From 80d59c9331f50e85ce955d4da9489045ecb99293 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9F=A5=E5=BE=AE?= Date: Fri, 17 Jul 2026 23:12:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E2=80=94=E2=80=94=E6=89=80=E6=9C=89=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E6=96=87=E4=BB=B6=E5=BD=92=E5=85=A5MoFin=20r?= =?UTF-8?q?epo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deploy/bot/ — XMPP bot核心(xmpp_agent_core + xmpp_zhiwei_bot) - deploy/profile-scripts/ — cron脚本(price_monitor等) - 运行时文件已替换为指向MoFin的符号链接 - 改代码只需改MoFin,系统自动生效 --- deploy/bot/xmpp_agent_core.py | 385 +++ deploy/bot/xmpp_zhiwei_bot.py | 25 + deploy/profile-scripts/300308_monitor.py | 196 ++ deploy/profile-scripts/_watchdog_report.py | 35 + .../profile-scripts/accumulation_scanner.py | 247 ++ .../profile-scripts/advice_reconciliation.py | 245 ++ deploy/profile-scripts/batch_reassess.py | 369 +++ deploy/profile-scripts/branch_evaluator.py | 148 + deploy/profile-scripts/branch_scanner.py | 124 + deploy/profile-scripts/candidate_filter.py | 352 +++ .../profile-scripts/capital_flow_collector.py | 188 ++ deploy/profile-scripts/check-prompt-deps.py | 194 ++ deploy/profile-scripts/chip_factors.py | 256 ++ deploy/profile-scripts/clean_watchlist.py | 125 + deploy/profile-scripts/closing_brief.py | 36 + .../collect_evaluation_data.py | 407 +++ deploy/profile-scripts/cron_health_monitor.py | 296 ++ deploy/profile-scripts/cron_to_xmpp.py | 358 +++ deploy/profile-scripts/data_flow_audit.py | 115 + deploy/profile-scripts/data_governance.py | 93 + deploy/profile-scripts/divergence_detector.py | 356 +++ deploy/profile-scripts/fix_gateway.py | 93 + deploy/profile-scripts/fix_gateway_port.py | 100 + deploy/profile-scripts/gateway_health.py | 56 + deploy/profile-scripts/generate_report.py | 154 + deploy/profile-scripts/get_realtime_prices.py | 94 + deploy/profile-scripts/hardcode_scanner.py | 113 + deploy/profile-scripts/hk_rate.py | 124 + .../holdings_reconciliation.py | 142 + deploy/profile-scripts/import_full_stocks.py | 107 + deploy/profile-scripts/import_holding_xls.py | 199 ++ .../profile-scripts/intraday_health_check.py | 273 ++ deploy/profile-scripts/intraday_monitor.py | 32 + .../profile-scripts/json_failure_monitor.py | 168 ++ .../macro_context_collector.py | 266 ++ .../profile-scripts/macro_signal_consumer.py | 122 + deploy/profile-scripts/market_insight.py | 203 ++ deploy/profile-scripts/market_scanner.py | 194 ++ deploy/profile-scripts/market_screener.py | 309 ++ deploy/profile-scripts/market_watch.py | 221 ++ deploy/profile-scripts/memory_guardian.py | 83 + deploy/profile-scripts/meta_growth.py | 250 ++ deploy/profile-scripts/mo_alphasift_bridge.py | 263 ++ deploy/profile-scripts/mo_bridge.py | 323 ++ deploy/profile-scripts/mo_config.py | 236 ++ deploy/profile-scripts/mo_data.py | 316 ++ deploy/profile-scripts/mo_dsa_opinion.py | 75 + deploy/profile-scripts/mo_models.py | 227 ++ deploy/profile-scripts/mo_provider.py | 353 +++ deploy/profile-scripts/mofin_collect.py | 272 ++ deploy/profile-scripts/mofin_db.py | 1303 ++++++++ deploy/profile-scripts/mofin_health.py | 925 ++++++ deploy/profile-scripts/mofin_news.py | 129 + deploy/profile-scripts/mofin_query.py | 169 ++ deploy/profile-scripts/monitor_300308.py | 116 + .../profile-scripts/morning_health_check.py | 900 ++++++ deploy/profile-scripts/multi_timeframe.py | 660 ++++ deploy/profile-scripts/ocr_client.py | 68 + deploy/profile-scripts/opening_brief.py | 31 + deploy/profile-scripts/per_stock_reassess.py | 741 +++++ deploy/profile-scripts/pre-flight-check.py | 231 ++ deploy/profile-scripts/preflight_verify.py | 250 ++ .../profile-scripts/premarket_full_review.py | 40 + .../profile-scripts/prepare_recommendation.py | 211 ++ deploy/profile-scripts/price_monitor.py | 742 +++++ deploy/profile-scripts/promote_candidates.py | 130 + deploy/profile-scripts/prune_branches.py | 117 + .../profile-scripts/refresh_macro_context.py | 76 + deploy/profile-scripts/refresh_mtf_cache.py | 103 + .../profile-scripts/review_needed_watchdog.py | 106 + deploy/profile-scripts/run_all_tests.py | 206 ++ deploy/profile-scripts/self_todo_executor.py | 119 + deploy/profile-scripts/server.py | 1071 +++++++ .../profile-scripts/session_to_cron_bridge.py | 216 ++ deploy/profile-scripts/stale_detector.py | 389 +++ deploy/profile-scripts/stale_push_wlin.py | 1037 +++++++ deploy/profile-scripts/stock_profile.py | 420 +++ deploy/profile-scripts/stock_quote.py | 438 +++ deploy/profile-scripts/stock_scorer.py | 151 + deploy/profile-scripts/stock_sector_enrich.py | 189 ++ .../strategy-staleness-check.py | 251 ++ deploy/profile-scripts/strategy_evaluator.py | 535 ++++ deploy/profile-scripts/strategy_feedback.py | 258 ++ deploy/profile-scripts/strategy_lifecycle.py | 2677 +++++++++++++++++ deploy/profile-scripts/strategy_review.py | 293 ++ deploy/profile-scripts/strategy_summary.py | 85 + deploy/profile-scripts/strategy_tree.py | 443 +++ deploy/profile-scripts/sync_cron_prompts.py | 82 + deploy/profile-scripts/sync_dashboard.py | 78 + .../profile-scripts/sync_decisions_to_db.py | 92 + deploy/profile-scripts/system_audit.py | 295 ++ deploy/profile-scripts/system_health_check.py | 323 ++ deploy/profile-scripts/technical_analysis.py | 650 ++++ deploy/profile-scripts/trend_detector.py | 303 ++ deploy/profile-scripts/vacuum_state_db.py | 30 + .../verify_reassess_pipeline.py | 192 ++ deploy/profile-scripts/watchlist_auto_exit.py | 106 + .../profile-scripts/xiaoguo_news_processor.py | 266 ++ deploy/profile-scripts/xiaoguo_scanner.py | 350 +++ .../xiaoguo_sentiment_bridge.py | 74 + .../xiaoguo_signal_consumer.py | 298 ++ deploy/profile-scripts/xmpp_agent_core.py | 385 +++ deploy/profile-scripts/xmpp_zhiwei_bot.py | 25 + 103 files changed, 29293 insertions(+) create mode 100644 deploy/bot/xmpp_agent_core.py create mode 100644 deploy/bot/xmpp_zhiwei_bot.py create mode 100644 deploy/profile-scripts/300308_monitor.py create mode 100644 deploy/profile-scripts/_watchdog_report.py create mode 100644 deploy/profile-scripts/accumulation_scanner.py create mode 100644 deploy/profile-scripts/advice_reconciliation.py create mode 100644 deploy/profile-scripts/batch_reassess.py create mode 100644 deploy/profile-scripts/branch_evaluator.py create mode 100644 deploy/profile-scripts/branch_scanner.py create mode 100644 deploy/profile-scripts/candidate_filter.py create mode 100644 deploy/profile-scripts/capital_flow_collector.py create mode 100644 deploy/profile-scripts/check-prompt-deps.py create mode 100644 deploy/profile-scripts/chip_factors.py create mode 100644 deploy/profile-scripts/clean_watchlist.py create mode 100644 deploy/profile-scripts/closing_brief.py create mode 100644 deploy/profile-scripts/collect_evaluation_data.py create mode 100644 deploy/profile-scripts/cron_health_monitor.py create mode 100644 deploy/profile-scripts/cron_to_xmpp.py create mode 100644 deploy/profile-scripts/data_flow_audit.py create mode 100644 deploy/profile-scripts/data_governance.py create mode 100644 deploy/profile-scripts/divergence_detector.py create mode 100644 deploy/profile-scripts/fix_gateway.py create mode 100644 deploy/profile-scripts/fix_gateway_port.py create mode 100644 deploy/profile-scripts/gateway_health.py create mode 100644 deploy/profile-scripts/generate_report.py create mode 100644 deploy/profile-scripts/get_realtime_prices.py create mode 100644 deploy/profile-scripts/hardcode_scanner.py create mode 100644 deploy/profile-scripts/hk_rate.py create mode 100644 deploy/profile-scripts/holdings_reconciliation.py create mode 100644 deploy/profile-scripts/import_full_stocks.py create mode 100644 deploy/profile-scripts/import_holding_xls.py create mode 100644 deploy/profile-scripts/intraday_health_check.py create mode 100644 deploy/profile-scripts/intraday_monitor.py create mode 100644 deploy/profile-scripts/json_failure_monitor.py create mode 100644 deploy/profile-scripts/macro_context_collector.py create mode 100644 deploy/profile-scripts/macro_signal_consumer.py create mode 100644 deploy/profile-scripts/market_insight.py create mode 100644 deploy/profile-scripts/market_scanner.py create mode 100644 deploy/profile-scripts/market_screener.py create mode 100644 deploy/profile-scripts/market_watch.py create mode 100644 deploy/profile-scripts/memory_guardian.py create mode 100644 deploy/profile-scripts/meta_growth.py create mode 100644 deploy/profile-scripts/mo_alphasift_bridge.py create mode 100644 deploy/profile-scripts/mo_bridge.py create mode 100644 deploy/profile-scripts/mo_config.py create mode 100644 deploy/profile-scripts/mo_data.py create mode 100644 deploy/profile-scripts/mo_dsa_opinion.py create mode 100644 deploy/profile-scripts/mo_models.py create mode 100644 deploy/profile-scripts/mo_provider.py create mode 100644 deploy/profile-scripts/mofin_collect.py create mode 100644 deploy/profile-scripts/mofin_db.py create mode 100644 deploy/profile-scripts/mofin_health.py create mode 100644 deploy/profile-scripts/mofin_news.py create mode 100644 deploy/profile-scripts/mofin_query.py create mode 100644 deploy/profile-scripts/monitor_300308.py create mode 100644 deploy/profile-scripts/morning_health_check.py create mode 100644 deploy/profile-scripts/multi_timeframe.py create mode 100644 deploy/profile-scripts/ocr_client.py create mode 100644 deploy/profile-scripts/opening_brief.py create mode 100644 deploy/profile-scripts/per_stock_reassess.py create mode 100644 deploy/profile-scripts/pre-flight-check.py create mode 100644 deploy/profile-scripts/preflight_verify.py create mode 100644 deploy/profile-scripts/premarket_full_review.py create mode 100644 deploy/profile-scripts/prepare_recommendation.py create mode 100644 deploy/profile-scripts/price_monitor.py create mode 100644 deploy/profile-scripts/promote_candidates.py create mode 100644 deploy/profile-scripts/prune_branches.py create mode 100644 deploy/profile-scripts/refresh_macro_context.py create mode 100644 deploy/profile-scripts/refresh_mtf_cache.py create mode 100644 deploy/profile-scripts/review_needed_watchdog.py create mode 100644 deploy/profile-scripts/run_all_tests.py create mode 100644 deploy/profile-scripts/self_todo_executor.py create mode 100644 deploy/profile-scripts/server.py create mode 100644 deploy/profile-scripts/session_to_cron_bridge.py create mode 100644 deploy/profile-scripts/stale_detector.py create mode 100644 deploy/profile-scripts/stale_push_wlin.py create mode 100644 deploy/profile-scripts/stock_profile.py create mode 100644 deploy/profile-scripts/stock_quote.py create mode 100644 deploy/profile-scripts/stock_scorer.py create mode 100644 deploy/profile-scripts/stock_sector_enrich.py create mode 100644 deploy/profile-scripts/strategy-staleness-check.py create mode 100644 deploy/profile-scripts/strategy_evaluator.py create mode 100644 deploy/profile-scripts/strategy_feedback.py create mode 100644 deploy/profile-scripts/strategy_lifecycle.py create mode 100644 deploy/profile-scripts/strategy_review.py create mode 100644 deploy/profile-scripts/strategy_summary.py create mode 100644 deploy/profile-scripts/strategy_tree.py create mode 100644 deploy/profile-scripts/sync_cron_prompts.py create mode 100644 deploy/profile-scripts/sync_dashboard.py create mode 100644 deploy/profile-scripts/sync_decisions_to_db.py create mode 100644 deploy/profile-scripts/system_audit.py create mode 100644 deploy/profile-scripts/system_health_check.py create mode 100644 deploy/profile-scripts/technical_analysis.py create mode 100644 deploy/profile-scripts/trend_detector.py create mode 100644 deploy/profile-scripts/vacuum_state_db.py create mode 100644 deploy/profile-scripts/verify_reassess_pipeline.py create mode 100644 deploy/profile-scripts/watchlist_auto_exit.py create mode 100644 deploy/profile-scripts/xiaoguo_news_processor.py create mode 100644 deploy/profile-scripts/xiaoguo_scanner.py create mode 100644 deploy/profile-scripts/xiaoguo_sentiment_bridge.py create mode 100644 deploy/profile-scripts/xiaoguo_signal_consumer.py create mode 100644 deploy/profile-scripts/xmpp_agent_core.py create mode 100644 deploy/profile-scripts/xmpp_zhiwei_bot.py diff --git a/deploy/bot/xmpp_agent_core.py b/deploy/bot/xmpp_agent_core.py new file mode 100644 index 00000000..e5e6e8a4 --- /dev/null +++ b/deploy/bot/xmpp_agent_core.py @@ -0,0 +1,385 @@ +#!/usr/bin/env python3 +""" +Core XMPP Agent — shared logic for zhiwei / mohe / xxm bots. +Imports by xmpp_zhiwei_bot.py / xmpp_mohe_bot.py with --agent flag. +""" +import os, sys, json, time, logging, threading, traceback +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import urlparse, parse_qs +from hashlib import md5 + +import slixmpp +from slixmpp import JID +import asyncio + +# ── Per-agent configuration ── +PER_AGENT = { + "mohe": { + "jid": "mohe@yoin.fun", + "password": "hermes123", + "nick": "mohe", + "http_port": 5808, + "gateway_url": "http://localhost:8642/v1/chat/completions", + "gateway_api_key": "hermes123", + "session_id": "xmpp-mohe-v2", + "name_cn": "莫荷", + "mention": "@mohe/@莫荷", + }, + "zhiwei": { + "jid": "zhiwei@yoin.fun", + "password": "2nw4psra", + "nick": "zhiwei", + "http_port": 5805, + "gateway_url": "http://localhost:8643/v1/chat/completions", + "gateway_api_key": "hermes123", + "session_id": "xmpp-zhiwei-v3", + "name_cn": "知微", + "mention": "@知微/zhiwei", + }, +} +_DEFAULT_AGENT = "mohe" + +# ── Module-level config (populated by _apply_config after agent detection) ── +AGENT_NAME = "" +XMPP_JID = "" +XMPP_PASSWORD = "" +MUC_ROOM = "coregroup@conference.yoin.fun" +MUC_NICK = "" +AGENT_MENTION = "" +HTTP_PORT = 5808 +AGENT_NICK = "" +ACK_DELAY = 15 +GATEWAY_URL = "" +GATEWAY_API_KEY = "" +GATEWAY_SESSION_ID = "" +GATEWAY_DEADLINE_SECONDS = 180 +CALL_HERMES_TIMEOUT = 180 +FALLBACK_REPLY = "请稍等,我在处理..." + +# ── 全局队列 ── +_outbound_queue = [] +_outbound_lock = threading.Lock() +_inbound_queue = [] +_inbound_lock = threading.Lock() + +RECENT_SENT_MAX = 50 + + +def _rs(p): + """Parse agent arg from sys.argv, returns agent name string.""" + agent = _DEFAULT_AGENT + skip_next = False + for i, a in enumerate(sys.argv[1:]): + if skip_next: + skip_next = False + continue + if a.startswith('--agent='): + agent = a.split('=', 1)[1] + elif a == '--agent' and i + 1 < len(sys.argv[1:]): + agent = sys.argv[i + 2] + skip_next = True + return agent + + +agent, is_mohe = _rs(None), None + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s %(levelname)s %(message)s', + stream=sys.stdout, +) +log = logging.getLogger('xmpp_agent') + + +def _apply_config(agent_name): + """Set module-level config variables from PER_AGENT dict + env overrides.""" + global AGENT_NAME, XMPP_JID, XMPP_PASSWORD, MUC_NICK, AGENT_MENTION + global HTTP_PORT, AGENT_NICK, GATEWAY_URL, GATEWAY_API_KEY, GATEWAY_SESSION_ID + cfg = PER_AGENT.get(agent_name, PER_AGENT.get(_DEFAULT_AGENT, {})) + AGENT_NAME = agent_name + XMPP_JID = os.environ.get('XMPP_JID', cfg.get('jid', '')) + XMPP_PASSWORD = os.environ.get('XMPP_PASSWORD', cfg.get('password', '')) + MUC_NICK = os.environ.get('MUC_NICK', cfg.get('nick', agent_name)) + AGENT_MENTION = os.environ.get('AGENT_MENTION', cfg.get('mention', '')) + HTTP_PORT = int(os.environ.get('HTTP_PORT', cfg.get('http_port', 5808))) + AGENT_NICK = os.environ.get('AGENT_NICK', cfg.get('nick', agent_name)) + GATEWAY_URL = os.environ.get('GATEWAY_URL', cfg.get('gateway_url', '')) + GATEWAY_API_KEY = os.environ.get('GATEWAY_API_KEY', cfg.get('gateway_api_key', '')) + GATEWAY_SESSION_ID = os.environ.get('GATEWAY_SESSION_ID', cfg.get('session_id', '')) + + +# ── Periodic ACK task ── +class AckManager: + def __init__(self): + self._active = {} + self._lock = threading.Lock() + + def start(self, session_id, to_jid, msg_body): + """Record an active LLM analysis and schedule the ACK.""" + with self._lock: + self._active[session_id] = { + 'to_jid': to_jid, + 'body': msg_body[:80], + 'started': time.time(), + 'acked': False, + } + + def ack(self, session_id): + with self._lock: + self._active.pop(session_id, None) + + def tick(self, bot): + now = time.time() + to_send = [] + with self._lock: + for sid, info in list(self._active.items()): + if not info['acked'] and now - info['started'] >= ACK_DELAY: + info['acked'] = True + to_send.append((info['to_jid'], FALLBACK_REPLY)) + for jid, msg in to_send: + try: + bot.send_message(mto=jid, mbody=msg, mtype='chat') + except Exception: + pass + + +ack_mgr = AckManager() + + +# ── Slixmpp Bot ── +class XmppAgent(slixmpp.ClientXMPP): + def __init__(self, jid, password, room, nick): + super().__init__(jid, password) + self._room = room + self._nick = nick + self._muc_joined = False + self._recent_sent = [] + self.add_event_handler('session_start', self.on_start) + self.add_event_handler('message', self.on_msg) + self.add_event_handler('disconnected', self.on_disconnect) + self.register_plugin('xep_0030') + self.register_plugin('xep_0045') + self.register_plugin('xep_0199') + + async def on_start(self, event): + self.send_presence() + await self.get_roster() + try: + await self.plugin['xep_0045'].join_muc(self._room, self._nick) + self._muc_joined = True + log.info(f"{AGENT_NAME} XMPP 就绪 (已加入 {self._room})") + except Exception as e: + log.error(f"{AGENT_NAME} MUC加入失败: {e}") + + def on_disconnect(self, event): + self._muc_joined = False + log.info(f"{AGENT_NAME} XMPP 断开") + # 自动重连:slixmpp 1.15.0 没有 auto_reconnect 属性,需手动 + try: + self.reconnect(wait=5.0, reason="断线自动重连") + except Exception as e: + log.warning(f"{AGENT_NAME} 重连失败: {e}") + + def on_msg(self, msg): + if msg['type'] in ('chat', 'groupchat'): + body = str(msg['body']).strip() + if not body: + return + log.info(f"📩 收到: from={msg['from']} type={msg['type']} body={body[:60]}") + if ('[executor]' in body and 'gateway_zhiwei' in body): + log.info(f"过滤 executor 消息: {body[:80]}...") + return + sender = str(msg['from']) + msg_type = msg['type'] + for s in self._recent_sent: + if body[:50] in s or s in body[:50]: + return + if msg_type == 'groupchat': + nick = sender.split('/')[-1] if '/' in sender else '' + if nick == AGENT_NICK: + return + mention_list = AGENT_MENTION.replace('@', '').split('/') + is_for_me = any(m in body for m in ['@' + m for m in mention_list] + mention_list) + if not is_for_me: + return + with _inbound_lock: + _inbound_queue.append((sender, body, msg_type)) + + def mark_sent(self, body: str): + self._recent_sent.append(body[:80]) + if len(self._recent_sent) > RECENT_SENT_MAX * 2: + self._recent_sent = self._recent_sent[-RECENT_SENT_MAX:] + + +# ── Deliver loop ── +def _deliver_loop(bot): + global _outbound_queue + while True: + try: + items = [] + with _outbound_lock: + items, _outbound_queue = _outbound_queue[:], [] + for target, text, msg_type in items: + try: + async def _send(to, body, mtype): + bot.send_message(mto=to, mbody=body, mtype=mtype) + asyncio.run(_send(target, text, msg_type)) + bot.mark_sent(text) + log.info(f" 已发送到 {target}: {text[:80]}") + except Exception as e: + log.error(f" 发送到 {target} 失败: {e}") + time.sleep(0.3) + except Exception as e: + log.error(f"_deliver_loop error: {e}") + time.sleep(1) + + +# ── Inbound processing loop ── +def _inbound_loop(bot): + global _inbound_queue + while True: + try: + time.sleep(0.2) + with _inbound_lock: + if not _inbound_queue: + continue + sender, body, msg_type = _inbound_queue.pop(0) + log.info(f"🔄 inbound处理: {body[:40]}") + ack_mgr.start(body[:40], sender, body) + reply = call_hermes(body) + ack_mgr.ack(body[:40]) + if reply: + with _outbound_lock: + _outbound_queue.append((sender, reply, 'chat')) + except Exception as e: + log.error(f"_inbound_loop error: {e}") + time.sleep(1) + + +# ── HTTP SendHandler ── +class SendHandler(BaseHTTPRequestHandler): + def do_POST(self): + content_len = int(self.headers.get('Content-Length', 0)) + post_body = self.rfile.read(content_len) + try: + data = json.loads(post_body) + target = data.get('to', '') + text = data.get('body', '') + if not target or not text: + self.send_response(400) + self.end_headers() + self.wfile.write(b'{"error":"missing to or body"}') + return + if '修复失败' in text and 'gateway_zhiwei' in text: + self.send_response(200) + self.end_headers() + self.wfile.write(b'{"ok":true,"filtered":true}') + return + msg_type = data.get('type', 'chat') + if text: + _outbound_queue.append((target, text, msg_type)) + self.send_response(200) + self.end_headers() + self.wfile.write(b'{"ok":true}') + log.info(f"SendHandler: enqueued -> {target}: {text[:80]}") + except Exception as e: + traceback.print_exc() + self.send_response(500) + self.end_headers() + self.wfile.write(str({'error': str(e)}).encode()) + + def log_message(self, format, *args): + pass + + +def _run_http_server(): + server = HTTPServer(('127.0.0.1', HTTP_PORT), SendHandler) + log.info(f"HTTP SendHandler listening on 127.0.0.1:{HTTP_PORT}") + server.serve_forever() + + +# ── call_hermes ── +def call_hermes(content: str, session_id=None) -> str: + now_str = time.strftime("[%Y-%m-%d %H:%M %A]", time.localtime()) + timed_content = f"{now_str}\n{content}" + payload = { + 'model': 'hermes-agent', + 'messages': [ + {'role': 'user', 'content': timed_content}, + ], + 'stream': False, + } + if session_id is None: + session_id = GATEWAY_SESSION_ID + headers = { + 'Content-Type': 'application/json', + 'X-Hermes-Session-Id': session_id, + } + if GATEWAY_API_KEY: + headers['Authorization'] = f'Bearer {GATEWAY_API_KEY}' + + import urllib.request + data_bytes = json.dumps(payload).encode('utf-8') + req = urllib.request.Request(GATEWAY_URL, data=data_bytes, headers=headers, method='POST') + try: + resp = urllib.request.urlopen(req, timeout=CALL_HERMES_TIMEOUT) + resp_data = json.loads(resp.read().decode('utf-8')) + reply = '' + if 'choices' in resp_data and len(resp_data['choices']) > 0: + choice = resp_data['choices'][0] + if 'message' in choice and 'content' in choice['message']: + reply = choice['message']['content'] + elif 'delta' in choice and 'content' in choice['delta']: + reply = choice['delta']['content'] + if not reply: + reply = resp_data.get('response', '') + if not reply: + reply = str(resp_data) + return reply.strip() + except urllib.request.HTTPError as e: + err_body = e.read().decode('utf-8', errors='replace') + log.error(f"call_hermes HTTP {e.code}: {err_body[:200]}") + return '' + except Exception as e: + log.error(f"call_hermes error: {type(e).__name__}: {e}") + return '' + + +# ── ACK manager tick ── +def _ack_tick(bot): + while True: + try: + ack_mgr.tick(bot) + time.sleep(5) + except Exception: + time.sleep(5) + + +# ── Main ── +def main(): + global is_mohe + agent_name = _rs(None) + _apply_config(agent_name) + is_mohe = (agent_name == 'mohe') + + log.info(f"Starting XMPP Agent: {agent_name} (mohe={is_mohe})") + log.info(f" JID={XMPP_JID} HTTP_PORT={HTTP_PORT}") + log.info(f" GATEWAY={GATEWAY_URL}") + log.info(f" SESSION_ID={GATEWAY_SESSION_ID}") + + bot = XmppAgent(XMPP_JID, XMPP_PASSWORD, MUC_ROOM, MUC_NICK) + bot.connect(host='127.0.0.1', port=5222) + t_deliver = threading.Thread(target=_deliver_loop, args=(bot,), daemon=True) + t_deliver.start() + t_inbound = threading.Thread(target=_inbound_loop, args=(bot,), daemon=True) + t_inbound.start() + t_ack = threading.Thread(target=_ack_tick, args=(bot,), daemon=True) + t_ack.start() + t_http = threading.Thread(target=_run_http_server, daemon=True) + t_http.start() + + bot.loop.run_forever() + + +if __name__ == '__main__': + main() diff --git a/deploy/bot/xmpp_zhiwei_bot.py b/deploy/bot/xmpp_zhiwei_bot.py new file mode 100644 index 00000000..774812e5 --- /dev/null +++ b/deploy/bot/xmpp_zhiwei_bot.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Wrapper for xmpp_agent_core.py --agent zhiwei""" +import sys, os, signal + +PID_FILE = "/tmp/xmpp_zhiwei_bot.pid" + +if os.path.exists(PID_FILE): + with open(PID_FILE) as f: + try: + old_pid = int(f.read().strip()) + os.kill(old_pid, 0) + print(f"xmpp_zhiwei_bot already running (PID {old_pid}), exiting.") + sys.exit(0) + except (ValueError, ProcessLookupError): + pass + +with open(PID_FILE, "w") as f: + f.write(str(os.getpid())) + +sys.argv = [sys.argv[0], '--agent', 'zhiwei'] +try: + exec(open(os.path.join(os.path.dirname(__file__), 'xmpp_agent_core.py')).read()) +finally: + if os.path.exists(PID_FILE): + os.remove(PID_FILE) diff --git a/deploy/profile-scripts/300308_monitor.py b/deploy/profile-scripts/300308_monitor.py new file mode 100644 index 00000000..03fce85b --- /dev/null +++ b/deploy/profile-scripts/300308_monitor.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""300308 午后紧盯监控 (no_agent) +每2分钟运行一次。在条件满足时输出买入信号,否则静默。 +支持策略动态调整——输出状态变化。 +""" + +import json, os, sys +from pathlib import Path +from datetime import datetime +from mo_data import get_price as md_get_price + +STATE_PATH = "/home/hmo/.hermes/300308_monitor_state.json" + +BUY_ZONE_LOW = 1307.22 +BUY_ZONE_HIGH = 1358.94 +STOP_LOSS = 1293.88 +TAKE_PROFIT = 1456.53 + +# 三档触发条件(按优先级) +LEVEL_STRONG = {"label": "★ 强信号", "price_min": 1330, "price_breach": 1325, "desc": "前3根5分K站稳1330+量放"} +LEVEL_MID = {"label": "◎ 中信号", "price_min": 1315, "price_breach": 1307, "desc": "回踩1315企稳+买盘放量"} +LEVEL_WEAK = {"label": "○ 弱信号", "price_min": 1307, "price_breach": 1298, "desc": "在买入区下沿附近企稳"} + + +def get_price(): + """从统一入口获取实时价""" + price, change_pct = md_get_price('300308') + return { + "price": price or 0, + "high": 0, + "low": 0, + "change_pct": change_pct or 0, + "volume": 0, + "buy_vol": 0, + "sell_vol": 0, + } + + +def load_state(): + try: + with open(STATE_PATH) as f: + return json.load(f) + except: + return {"phase": "waiting", "triggered_levels": [], "last_report": None, "afternoon_low": 99999} + + +def save_state(s): + os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True) + with open(STATE_PATH, "w") as f: + json.dump(s, f, ensure_ascii=False, indent=2) + + +def main(): + now = datetime.now() + # 只在13:00-15:00运行 + if now.hour < 13 or now.hour >= 15: + # After 15:00, send final summary if not already sent + state = load_state() + if state.get("phase") not in ("done", "final"): + # Market closed, output final status + state["phase"] = "final" + save_state(state) + print("【300308紧盯结束】15:00收盘。") + # Final check + price = get_price() + if price["price"] >= STOP_LOSS: + print(f"收盘价{price['price']:.2f},未触及止损{STOP_LOSS},策略有效。") + else: + print(f"⚠️ 收盘价{price['price']:.2f}已跌破止损{STOP_LOSS}!") + return + + state = load_state() + + # If already in done phase (signal already sent), stay silent + if state.get("phase") == "done": + return + + price_data = get_price() + p = price_data["price"] + vol = price_data["volume"] + buy_vol = price_data["buy_vol"] + sell_vol = price_data["sell_vol"] + + # Track afternoon low + if p < state["afternoon_low"]: + state["afternoon_low"] = p + + # Get volume since last check to calculate relative activity + state.setdefault("last_vol", 0) + vol_delta = vol - state["last_vol"] + state["last_vol"] = vol + vol_active = vol_delta > 0 # new trades happened + + # Determine buy/sell pressure (active buying as proportion) + total_trade = buy_vol + sell_vol + buy_ratio = buy_vol / total_trade if total_trade > 0 else 0.5 + + # Minutes into afternoon session + afternoon_minutes = (now.hour - 13) * 60 + now.minute + candle_num = afternoon_minutes // 5 + 1 # Which 5-min candle we're in + + # ---------------------------------------------------------------- + # Level checking - try from strong to weak + # ---------------------------------------------------------------- + triggered = state.get("triggered_levels", []) + + # Check levels from weak to strong + for level_key, level in [("weak", LEVEL_WEAK), ("mid", LEVEL_MID), ("strong", LEVEL_STRONG)]: + if level_key in triggered: + continue # already triggered, skip + + if p >= level["price_min"] and state["afternoon_low"] >= level["price_breach"]: + # Basic price conditions met — check volume + # Volume should show active buying (buy_vol > sell_vol * 0.8 = not too one-sided) + volume_ok = buy_ratio >= 0.45 # at least 45% buying + if volume_ok: + triggered.append(level_key) + state["triggered_levels"] = triggered + + if level_key == "strong": + msg = ( + f"【300308买入信号】★ 强信号触发\n" + f"现价{p:.2f},已在1330上方站稳,午后最低{state['afternoon_low']:.2f}未破1325\n" + f"主动买比{buy_ratio:.0%},买盘积极\n" + f"距止损{(p-STOP_LOSS)/STOP_LOSS*100:.1f}%,距止盈{(TAKE_PROFIT-p)/p*100:.1f}%\n" + f"操作:买入1手(100股),止损{STOP_LOSS},止盈{TAKE_PROFIT}" + ) + state["phase"] = "done" + save_state(state) + print(msg) + return + + if level_key == "mid": + msg = ( + f"【300308买入信号】◎ 中信号触发\n" + f"现价{p:.2f},回踩1315企稳,午后最低{state['afternoon_low']:.2f}\n" + f"主动买比{buy_ratio:.0%},买盘回温\n" + f"距止损{(p-STOP_LOSS)/STOP_LOSS*100:.1f}%,距止盈{(TAKE_PROFIT-p)/p*100:.1f}%\n" + f"操作:可买入1手(100股),止损{STOP_LOSS}(-{(p-STOP_LOSS)/p*100:.1f}%),止盈{TAKE_PROFIT}" + ) + state["phase"] = "done" + save_state(state) + print(msg) + return + + # ---------------------------------------------------------------- + # Check for failure conditions + # ---------------------------------------------------------------- + if state["afternoon_low"] < 1300: # Breaking towards stop loss + if not state.get("warned_low"): + state["warned_low"] = True + save_state(state) + print(f"【300308风险警告】午后最低{state['afternoon_low']:.2f},逼近止损{STOP_LOSS}(-4.4%)。" + f"现价{p:.2f}。建议取消今日买入。") + return + + if state["afternoon_low"] < STOP_LOSS: + state["phase"] = "done" # stop monitoring + save_state(state) + print(f"【300308止损触发】午后最低{state['afternoon_low']:.2f}<止损{STOP_LOSS}。" + f"策略已失效,取消今日买入。等明日重新评估。") + return + + # ---------------------------------------------------------------- + # Periodically report status (every ~10 mins) + # ---------------------------------------------------------------- + last_report = state.get("last_report_min", -999) + report_interval = 10 # minutes + this_minute = now.hour * 60 + now.minute + + if this_minute - last_report >= report_interval: + state["last_report_min"] = this_minute + # Check what condition we're closest to + if p >= LEVEL_STRONG["price_min"]: + closest = "强信号(1330)" + elif p >= LEVEL_MID["price_min"]: + closest = f"中信号(1315),差{LEVEL_STRONG['price_min']-p:.1f}到强信号" + else: + closest = f"弱信号(1307),差{LEVEL_MID['price_min']-p:.1f}到中信号" + + msg = ( + f"【300308午后监控】第{candle_num}根5分K线\n" + f"现价{p:.2f} | 午后最低{state['afternoon_low']:.2f}\n" + f"买比{buy_ratio:.0%} | 距目标{closest}\n" + f"仍有效,继续监控。" + ) + save_state(state) + print(msg) + return + + save_state(state) + # Silent - nothing to report + + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/_watchdog_report.py b/deploy/profile-scripts/_watchdog_report.py new file mode 100644 index 00000000..f825265e --- /dev/null +++ b/deploy/profile-scripts/_watchdog_report.py @@ -0,0 +1,35 @@ +import sys; sys.path.insert(0, '/home/hmo/MoFin') +from mo_data import * + +# Portfolio +pf = read_portfolio() +ta = pf.get('total_assets',0) +ca = pf.get('cash',0) +pp = pf.get('position_pct',0) +print(f"总资产: {ta:.0f} 现金: {ca:.0f} 仓位: {pp:.1f}%") +for h in pf.get('holdings', []): + c = h.get('currency','CNY') + price = h['price'] + cost = h['cost'] + profit_pct = (price/cost - 1)*100 if cost and cost else 0 + ps = f"{price:.2f}{' HKD' if c=='HKD' else ''}" + pp_h = h.get('position_pct') + if pp_h is None: pp_h = 0 + print(f" {h['code']} {h['name']} 价{ps} 仓{pp_h:.1f}% 盈{profit_pct:.1f}%") + +# Watchlist +print() +wl = read_watchlist() +for s in wl.get('stocks',[]): + try: + c = s.get('currency','CNY') + price = s.get('price') + if price is None: price = 0 + ps = f"{price:.2f}{' HKD' if c=='HKD' else ''}" + el = s.get('entry_low') + eh = s.get('entry_high') + enl = f"{el:.2f}" if el is not None else '?' + enh = f"{eh:.2f}" if eh is not None else '?' + print(f" 自选 {s['code']} {s.get('name','')} 价{ps} 入{enl}~{enh}") + except Exception as e: + print(f" ERROR {s.get('code','?')}: {e}") diff --git a/deploy/profile-scripts/accumulation_scanner.py b/deploy/profile-scripts/accumulation_scanner.py new file mode 100644 index 00000000..0600e9c5 --- /dev/null +++ b/deploy/profile-scripts/accumulation_scanner.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +"""accumulation_scanner.py — 主力建仓期股票扫描 + +逻辑: + 1. 从所有可获取行情的股票中,检测量价行为异常 + 2. 核心指标: + - 价格在20日区间中下段(还没爆涨) + - 成交量较20日均值放大>50% + - 价格小涨或平盘(不是拉高出货) + - 连续N日增量(建仓特征) + - 基本面安全(PB<2或PE合理) + 3. 输出候选到 candidates 表 + +数据源:腾讯批量行情API(日K线+实时价) +""" +import sys, json, urllib.request, re, time, os +from pathlib import Path +from datetime import datetime, timedelta +from collections import defaultdict + +DB_PATH = Path("/home/hmo/MoFin/data/mofin.db") +UA = "Mozilla/5.0" + +def fetch_qq_batch(symbols): + """腾讯批量实时行情""" + if not symbols: return {} + results = {} + # 分批,每批100个(腾讯推荐上限) + for i in range(0, len(symbols), 100): + batch = symbols[i:i+100] + url = f"http://qt.gtimg.cn/q={','.join(batch)}" + try: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + proxy = urllib.request.ProxyHandler({}) + opener = urllib.request.build_opener(proxy) + with opener.open(req, timeout=15) as r: + text = r.read().decode("gbk") + for line in text.strip().split("\n"): + if "~" not in line: continue + parts = line.split("~") + if len(parts) < 40: continue + m = re.search(r'_(\w+)=', parts[0]) + market = m.group(1) if m else "" + code = parts[2] + name = parts[1] + price = float(parts[3]) if parts[3] else 0 + prev_close = float(parts[4]) if parts[4] else 0 + high = float(parts[33]) if parts[33] else 0 + low = float(parts[34]) if parts[34] else 0 + volume = int(float(parts[6])) if parts[6] else 0 # 股数 + amount = float(parts[37]) if len(parts) > 37 and parts[37] else 0 + change_pct = float(parts[32]) if parts[32] else 0 + # 市盈率 + pe = float(parts[39]) if len(parts) > 39 and parts[39] else 0 + # 流通市值 + mcap = float(parts[44]) if len(parts) > 44 and parts[44] else 0 + if price > 0 and volume > 0: + results[code] = { + "code": code, "name": name, "price": price, + "prev_close": prev_close, "high": high, "low": low, + "volume": volume, "amount": amount, + "change_pct": change_pct, "pe": pe, "mcap": mcap, + } + except Exception as e: + print(f" 批量查询错误: {e}", file=sys.stderr) + time.sleep(0.15) # 批次间隔150ms(腾讯建议100ms以上,留余量) + return results + +def get_stock_pool(): + """获取待扫描股票池""" + import sqlite3 + conn = sqlite3.connect(str(DB_PATH)) + + # 从holding_strategies拿已有策略股 + existing = set() + for r in conn.execute("SELECT code FROM holding_strategies WHERE status='active'"): + existing.add(r[0]) + for r in conn.execute("SELECT code FROM holdings WHERE is_active=1"): + existing.add(r[0]) + + # 从stocks表拿所有代码 + all_stocks = [r[0] for r in conn.execute("SELECT code FROM stocks").fetchall()] + + conn.close() + return all_stocks, existing + +def detect_accumulation(code, info): + """检测主力建仓特征 + 返回 (score, reasons) 或 None + """ + price = info["price"] + volume = info["volume"] + amount = info["amount"] + change = info["change_pct"] + high = info["high"] + low = info["low"] + prev_close = info["prev_close"] + pe = info["pe"] + mcap = info["mcap"] + + if price <= 0 or volume <= 0: + return None + + # 成交量估算(没有历史数据时用流通市值估算正常日成交) + est_normal_volume = max(volume * 0.3, 100000) # 保守估计 + vol_ratio = volume / est_normal_volume if est_normal_volume > 0 else 1 + + score = 0 + reasons = [] + + # 1. 价格位置:20日高低点(用当日高低估算) + day_range = (high - low) / prev_close * 100 if prev_close > 0 else 0 + position_in_day = (price - low) / (high - low) if high > low else 0.5 + + # 价格没有爆涨(在日内中下段=还没到顶) + if position_in_day < 0.7: + score += 1 + else: + return None # 已经到日内高位,可能是拉高出货 + + # 2. 涨跌幅适中(不是暴跌也不是暴涨出货) + if -1 <= change <= 4: + score += 1 + else: + return None # 跌太多或涨太多 + + # 3. 成交量放大(有资金活动) + if vol_ratio > 1.5: + score += 1 + reasons.append(f"量增{vol_ratio:.0f}倍") + else: + return None # 没量没意义 + + # 4. 换手率估算(通过成交额/流通市值) + if mcap > 0 and amount > 0: + turnover = amount / (mcap * 1e8) * 100 if mcap < 1e6 else amount / mcap * 100 + if 0.5 <= turnover <= 10: + score += 1 + elif turnover > 10: + return None # 换手太高可能是出货 + + # 5. PE合理(基本面安全) + if 0 < pe < 100: + score += 1 + + # 6. 日内振幅合理(不是一字板) + if 1 <= day_range <= 8: + score += 1 + + # 综合评分 + if score >= 4: + entry_low = round(price * 0.95, 2) + entry_high = round(price * 1.02, 2) + stop_loss = round(price * 0.92, 2) + take_profit = round(price * 1.15, 2) + + return { + "score": score, + "reasons": "; ".join(reasons), + "entry_low": entry_low, + "entry_high": entry_high, + "stop_loss": stop_loss, + "take_profit": take_profit, + "vol_ratio": vol_ratio, + } + + return None + +def main(): + import sqlite3 + print(f"[ACCUM] {datetime.now().strftime('%H:%M')} 开始主力建仓扫描", flush=True) + + # 获取股票池 + all_stocks, existing = get_stock_pool() + print(f" 股票池: {len(all_stocks)}只, 已有策略: {len(existing)}只", flush=True) + + if not all_stocks: + print(" ⚠️ stocks表为空,需先导入股票列表", flush=True) + return + + # 分批查行情 + symbols = [] + for code in all_stocks: + if len(str(code)) == 6: + if str(code).startswith(("5", "6", "9")): + symbols.append(f"sh{code}") + else: + symbols.append(f"sz{code}") + else: + symbols.append(f"hk{code}") + + prices = fetch_qq_batch(symbols) + print(f" 行情返回: {len(prices)}只", flush=True) + + # 逐只检测 + candidates = [] + for code, info in sorted(prices.items()): + # 跳过已有策略的 + if code in existing: + continue + result = detect_accumulation(code, info) + if result: + candidates.append((result["score"], code, info, result)) + + # 按评分排序 + candidates.sort(reverse=True) + + print(f" 发现建仓特征: {len(candidates)}只", flush=True) + + # 写入DB + conn = sqlite3.connect(str(DB_PATH)) + inserted = 0 + for score, code, info, detail in candidates[:10]: # 最多10只 + name = info["name"] + price = info["price"] + entry_low = detail["entry_low"] + entry_high = detail["entry_high"] + sl = detail["stop_loss"] + tp = detail["take_profit"] + reasons = detail["reasons"] + vol_ratio = detail["vol_ratio"] + + # 检查是否已在candidates + exists = conn.execute( + "SELECT code FROM candidates WHERE code=? AND (promoted IS NULL OR promoted=0)", + (code,) + ).fetchone() + if exists: + continue + + conn.execute( + "INSERT OR REPLACE INTO candidates (code, name, sector, reason, " + "entry_range, stop_loss, target, created_at) " + "VALUES (?,?,?,?,?,?,?,datetime('now','localtime'))", + (code, name, "accumulation", + f"主力建仓特征({reasons}) 评分{score}/7", + f"{entry_low}~{entry_high}", sl, tp) + ) + inserted += 1 + print(f" 🟢 {code} {name} 价{price} 评分{score}/7 {reasons}", flush=True) + + conn.commit() + conn.close() + print(f" ✅ 新增{inserted}只候选", flush=True) + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/advice_reconciliation.py b/deploy/profile-scripts/advice_reconciliation.py new file mode 100644 index 00000000..d9483d9f --- /dev/null +++ b/deploy/profile-scripts/advice_reconciliation.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""advice_reconciliation.py — 建议对账脚本 + +每周运行一次,对比 decisions.json 的 advice_timeline 与 portfolio.json +的实际持仓变化,统计准确率。 + +用法: + python3 advice_reconciliation.py # 正常对账 + python3 advice_reconciliation.py --force # 强制重新对账所有建议 +""" +import json +import sys +from datetime import datetime, timedelta +from pathlib import Path + +from mo_data import read_decisions, read_portfolio +from mofin_db import get_conn, write_holding_strategy + +ACCURACY_PATH = Path(__file__).parent.parent / "data" / "accuracy_stats.json" + +def load_json(path, default=None): + try: + with open(path, encoding="utf-8") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} if default is None else default + +def save_json(path, data): + Path(path).parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + +def get_holding_change(portfolio, code): + """获取某只股票的当前持仓信息""" + holdings = portfolio.get("holdings", []) + for h in holdings: + if h["code"] == code: + return { + "code": code, + "name": h.get("name", ""), + "shares": h.get("shares", 0), + "cost": h.get("cost", 0), + "price": h.get("price", 0), + "position_pct": h.get("position_pct", 0), + } + return None # 已清仓 + +def evaluate_advice(advice, current_holding): + """评估一条建议是否正确 + + Returns: 'correct', 'partial', 'wrong', 'pending', 'unknown' + """ + direction = advice.get("direction", "") + status = advice.get("status", "pending") + + if status == "ignored": + return "ignored" + if status == "pending": + return "pending" + + if not current_holding: + # 股票已清仓 + if direction in ("卖出", "清仓", "减仓"): + return "correct" + elif direction in ("买入", "加仓", "补仓"): + return "wrong" + else: + return "unknown" + + shares = current_holding.get("shares", 0) + cost = current_holding.get("cost", 0) + price = current_holding.get("price", 0) + pnl_pct = (price - cost) / cost * 100 if cost > 0 else 0 + + if direction in ("买入", "加仓", "补仓"): + # 如果建议买入时价格低于现价,或浮盈为正 → 正确 + try: + advised_price = float(advice.get("price", 0)) + if advised_price > 0 and price > 0: + if price >= advised_price * 0.95: # 允许5%误差 + return "correct" + else: + return "wrong" + else: + return "unknown" + except: + return "unknown" + + elif direction in ("卖出", "清仓", "减仓"): + # 如果建议卖出时价格高于现价 → 正确(规避了下跌) + try: + advised_price = float(advice.get("price", 0)) + if advised_price > 0 and price > 0: + if price <= advised_price * 1.05: + return "correct" + else: + return "wrong" + else: + return "unknown" + except: + return "unknown" + + elif direction in ("持有", "观望"): + # 持有建议 → 看后续是否涨 + try: + advised_price = float(advice.get("price", 0)) + if advised_price > 0 and price > 0: + change = (price - advised_price) / advised_price * 100 + if change > -5: # 没跌超过5% + return "correct" + else: + return "wrong" + else: + return "unknown" + except: + return "unknown" + + elif direction == "自选": + # 自选建议无法直接对账 + return "unknown" + + return "unknown" + + +def run(): + force = "--force" in sys.argv + + decisions = read_decisions() + portfolio = read_portfolio() + old_stats = load_json(ACCURACY_PATH, {}) + + results = [] + total = {"correct": 0, "wrong": 0, "partial": 0, "unknown": 0, "pending": 0, "ignored": 0} + + for entry in decisions.get("decisions", []): + code = entry["code"] + name = entry.get("name", code) + timeline = entry.get("advice_timeline", []) + + if not timeline: + continue + + current_holding = get_holding_change(portfolio, code) + + for i, advice in enumerate(timeline): + # 跳过已评估过的(除非 --force) + if not force and advice.get("evaluated"): + # 计数已有结果 + result = advice.get("result", "unknown") + total[result] = total.get(result, 0) + 1 + continue + + result = evaluate_advice(advice, current_holding) + advice["evaluated"] = True + advice["result"] = result + advice["evaluated_at"] = datetime.now().isoformat() + total[result] = total.get(result, 0) + 1 + + results.append({ + "code": code, + "name": name, + "date": advice.get("date", ""), + "direction": advice.get("direction", ""), + "summary": advice.get("summary", ""), + "result": result, + }) + + # 保存更新后的 decisions 到 DB(含评估标记) + conn = get_conn() + for entry in decisions.get("decisions", []): + code = entry.get("code", "") + name = entry.get("name", code) + write_holding_strategy(conn, code, name, entry) + # 写入 advice_timeline 评估标记 + for adv in entry.get("advice_timeline", []): + conn.execute( + """INSERT OR REPLACE INTO advice_timeline + (id, code, date, direction, price, summary, status, + evaluated, result, evaluated_at, report_id) + VALUES ( + (SELECT id FROM advice_timeline WHERE code=? AND date=? AND direction=? AND summary=?), + ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + )""", + (code, adv.get("date", ""), adv.get("direction", ""), adv.get("summary", ""), + code, adv.get("date", ""), adv.get("direction", ""), + adv.get("price"), adv.get("summary", ""), adv.get("status", ""), + 1 if adv.get("evaluated") else 0, + adv.get("result", ""), adv.get("evaluated_at", ""), + adv.get("report_id", ""))) + conn.commit() + conn.close() + + # 计算准确率 + evaluated = total["correct"] + total["wrong"] + total["partial"] + accuracy = round(total["correct"] / evaluated * 100, 1) if evaluated > 0 else 0 + + stats = { + "updated_at": datetime.now().isoformat(), + "period_start": old_stats.get("period_start", (datetime.now() - timedelta(days=7)).isoformat()), + "period_end": datetime.now().isoformat(), + "total_advice": sum(total.values()), + "correct": total["correct"], + "wrong": total["wrong"], + "partial": total["partial"], + "unknown": total["unknown"], + "pending": total["pending"], + "ignored": total["ignored"], + "evaluated": evaluated, + "accuracy_pct": accuracy, + "details": results, + # 累计统计 + "cumulative": { + "total": old_stats.get("cumulative", {}).get("total", 0) + evaluated, + "correct": old_stats.get("cumulative", {}).get("correct", 0) + total["correct"], + "wrong": old_stats.get("cumulative", {}).get("wrong", 0) + total["wrong"], + }, + } + + cum = stats["cumulative"] + cum_accuracy = round(cum["correct"] / cum["total"] * 100, 1) if cum["total"] > 0 else 0 + stats["cumulative_accuracy_pct"] = cum_accuracy + + save_json(ACCURACY_PATH, stats) + + # 输出摘要 + print(f"📊 建议对账报告") + print(f" 周期: {stats['period_start'][:10]} ~ {stats['period_end'][:10]}") + print(f" 总建议: {stats['total_advice']}") + print(f" ✅ 正确: {stats['correct']}") + print(f" ❌ 错误: {stats['wrong']}") + print(f" ⏳ 待确认: {stats['pending']}") + print(f" ✗ 已忽略: {stats['ignored']}") + print(f" ❓ 无法判断: {stats['unknown']}") + print(f" 📈 本期准确率: {accuracy}%") + print(f" 📈 累计准确率: {cum_accuracy}%") + + if results: + print(f"\n 详情:") + for r in results[:20]: + icon = {"correct": "✅", "wrong": "❌", "partial": "🟡", "unknown": "❓", "pending": "⏳", "ignored": "✗"} + print(f" {icon.get(r['result'], '?')} {r['name']}({r['code']}) {r['direction']} → {r['result']}") + + +if __name__ == "__main__": + run() diff --git a/deploy/profile-scripts/batch_reassess.py b/deploy/profile-scripts/batch_reassess.py new file mode 100644 index 00000000..196dab1b --- /dev/null +++ b/deploy/profile-scripts/batch_reassess.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +"""batch_reassess.py — 批量补全九维分析(逐只处理,间隔防限流) + +用法: python3 batch_reassess.py [--all] [--code XXXXXX] + +流程:收集最新数据 → 调LLM(gateway)写九维分析+策略 → 保存到DB +""" +import sys, json, subprocess, sqlite3, re, time +from datetime import datetime + +DB = "/home/hmo/MoFin/data/mofin.db" +GATEWAY = "http://127.0.0.1:8643/v1/chat/completions" +COOLDOWN_HOURS = 1 + +def has_llm_analysis(code): + """检查是否为LLM生成的九维分析(>500字)""" + conn = sqlite3.connect(DB) + r = conn.execute("SELECT LENGTH(full_analysis) FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + conn.close() + return r and r[0] and r[0] > 500 + +def in_cooldown(code): + """冷却期检查""" + conn = sqlite3.connect(DB) + r = conn.execute("SELECT reassessed_at FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + conn.close() + if not r or not r[0]: + return False + try: + last = datetime.fromisoformat(r[0]) + diff = (datetime.now() - last).total_seconds() / 3600 + return diff < COOLDOWN_HOURS + except: + return False + +def collect_data(code): + """收集最新数据""" + data = {"code": code} + + # 从DB读策略 + conn = sqlite3.connect(DB) + r = conn.execute("SELECT name, entry_low, entry_high, stop_loss, take_profit, timing_signal, action, rr_ratio, tech_snapshot, sector_context, stock_category FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + if r: + data["name"] = r[0] + data["entry_low"] = r[1] or 0 + data["entry_high"] = r[2] or 0 + data["stop_loss"] = r[3] or 0 + data["take_profit"] = r[4] or 0 + data["timing_signal"] = r[5] or "" + data["action"] = r[6] or "" + data["rr_ratio"] = r[7] or 0 + data["tech_snapshot"] = r[8] or "" + data["sector_context"] = r[9] or "" + data["stock_category"] = r[10] or "" + conn.close() + + # 从腾讯API拉最新价和基本面 + prefix = "sh" if str(code).startswith(("6","9")) else "sz" + try: + r = subprocess.run(["curl", "-s", f"http://qt.gtimg.cn/q={prefix}{code}"], capture_output=True, timeout=10) + parts = r.stdout.decode("gbk", errors="ignore").split("~") + data["price"] = float(parts[3]) if len(parts) > 3 and parts[3] else 0 + data["pe"] = parts[39] if len(parts) > 39 and parts[39] else "" + data["mcap"] = parts[44] if len(parts) > 44 and parts[44] else "" + data["change_pct"] = parts[32] if len(parts) > 32 and parts[32] else "0" + except: + data["price"] = 0 + + # 大盘 + try: + conn = sqlite3.connect(DB) + mr = conn.execute("SELECT structure FROM macro_context_log ORDER BY id DESC LIMIT 1").fetchone() + if mr and mr[0]: + s = json.loads(mr[0]) + data["macro"] = s.get("description", "大盘震荡") + conn.close() + except: + data["macro"] = "大盘震荡" + + return data + +def build_prompt(data): + """构建LLM prompt,要求输出完整策略""" + cash = 321271 # 可用现金(从DB读取) + total = 952879 # 总资产 + + # 拉取资金流数据 + _flow_note = "暂无资金流数据" + try: + import sqlite3 as _sq, json as _j + _db = _sq.connect("/home/hmo/MoFin/data/mofin.db") + _fr = _db.execute("SELECT cache_json FROM capital_flow_cache ORDER BY id DESC LIMIT 1").fetchone() + if _fr and _fr[0]: + _fc = _j.loads(_fr[0]) + _stocks = _fc.get("stocks", {}) + _s = _stocks.get(data['code'], {}) + if _s and _s.get("analysis"): + _a = _s["analysis"] + _net = _a.get("net_flow", 0) + _main = _a.get("main_force", 0) + _retail = _a.get("retail_flow", 0) + _trend = _a.get("trend", "中性") + _flow_note = f"净流入{_net:.0f}万 主力{_main:.0f}万 散户{_retail:.0f}万 趋势{_trend}" + _db.close() + except: + pass + + # 拉取近期消息面 + _news_note = "暂无近期消息" + try: + import sqlite3 as _sq + _db = _sq.connect("/home/hmo/MoFin/data/mofin.db") + _nr = _db.execute( + "SELECT summary, overall_sentiment, created_at FROM signal_news " + "WHERE (code=? OR sector LIKE ?) AND overall_sentiment IN ('利好','利空') " + "ORDER BY id DESC LIMIT 3", + (data['code'], f'%{data.get("name","")[:4]}%') + ).fetchall() + if _nr: + _news_note = " | ".join([f"{r[2][:10]} {r[1]} {r[0][:40]}" for r in _nr]) + _db.close() + except: + pass + + return f"""你是一个资深A股分析师。请对{data['code']} {data.get('name','')}做一个完整的九维矩阵分析,并输出策略参数。 + +⚠️ 重要:以下9个维度不是独立分析的,你必须交叉对比后给出综合结论。 +例如:如果消息面利好但资金流在流出,说明利好可能是出货;如果基本面强但技术面破位,说明估值可能还没到底。 + +当前数据(以下数据均来自实时API,每条标注时间窗口,禁止使用模型内部训练数据): +大盘:{data.get('macro','震荡')}(当日实时) +最新价:{data.get('price',0)} 涨跌:{data.get('change_pct','0')}%(当日实时) +PE={data.get('pe','?')}(最新财报) 市值={data.get('mcap','?')}亿 +行业:{data.get('sector_context','?')}(当日实时) +技术面:{data.get('tech_snapshot','')[:300]}(MA=5/10/20/60日 支撑阻力=近20日 量价=当日+近5日趋势) +资金流:{_flow_note}(近5日累计) +消息面:{_news_note}(最近3条,自动标注抓取时间) +当前信号:{data.get('timing_signal','?')} 分类:{data.get('stock_category','?')} +原策略:{(data.get('action','') or '')[:200]} + +我的总资产={total}元,可用现金={cash}元。 + +请严格按以下格式输出: + +【交叉分析】用2-3句话说明哪些维度出现矛盾/共振,最关键的信号是什么 +① 大盘×基本面 [一句话,说明矛盾关系] +② 大盘×消息面 [一句话] +③ 大盘×技术面 [一句话] +④ 大盘×资金面 [一句话] +⑤ 行业×基本面 [一句话] +⑥ 行业×消息面 [一句话] +⑦ 行业×技术面 [一句话] +⑧ 行业×资金面 [一句话] +⑨ 个股×基本面 [一句话] +⑩ 个股×消息面 [一句话] +⑪ 个股×技术面 [一句话] +⑫ 个股×资金面 [一句话] + +【综合结论】(买入/关注/观望/卖出) +【操作建议】具体操作建议 +【买入区间】最低价~最高价 +【建议止损】数字 +【建议止盈】数字 + +【建议仓位】只有综合结论为"买入"时才输出此项。仓位计算公式: +基础仓位按RR确定:RR<1.5→不推荐,RR1.5~3→8%,RR3~5→12%,RR5+→15% +大盘偏弱×0.8,大盘偏强×1.15 +蓝筹/白马×1.2,成长×0.85,题材/短线×0.6 +最终仓位范围:5%~20% +同时考虑:现金{cash}元足够买多少手。 +输出格式:"X%(理由:一句话说明为什么这个仓位)""" +def parse_response(text): + """从LLM回复中提取策略参数""" + result = {"signal": "", "entry_low": 0, "entry_high": 0, "stop_loss": 0, "take_profit": 0, "position": ""} + + # 信号 + sl = [l for l in text.split("\n") if "综合结论" in l] + if sl: + for kw in ["买入","关注","观望","卖出"]: + if kw in sl[0]: + result["signal"] = kw + break + + # 买入区间 + zl = [l for l in text.split("\n") if "买入区间" in l] + if zl: + nums = re.findall(r'[\d.]+', zl[0]) + if len(nums) >= 2: + result["entry_low"] = float(nums[0]) + result["entry_high"] = float(nums[1]) + + # 止损 + for l in text.split("\n"): + if "建议止损" in l: + nums = re.findall(r'[\d.]+', l) + if nums: result["stop_loss"] = float(nums[0]) + + # 止盈 + for l in text.split("\n"): + if "建议止盈" in l: + nums = re.findall(r'[\d.]+', l) + if nums: result["take_profit"] = float(nums[0]) + + # 仓位:只有买入信号才需要,提取百分比数字 + result["position"] = "" + if result["signal"] == "买入": + for l in text.split("\n"): + if "建议仓位" in l: + nums = re.findall(r'[\d.]+', l) + for n in nums: + f = float(n) + if 1 <= f <= 30: # 合理的仓位范围 + result["position"] = f"{f:.0f}%" + break + break + + return result + +def save_result(code, full_text, parsed): + """保存LLM结果到DB""" + conn = sqlite3.connect(DB) + now = datetime.now().isoformat() + + updates = ["full_analysis=?", "reassessed_at=?"] + params = [full_text, now] + + if parsed["signal"]: + updates.append("timing_signal=?") + params.append(parsed["signal"]) + if parsed["entry_low"] > 0: + updates.append("entry_low=?") + params.append(parsed["entry_low"]) + if parsed["entry_high"] > 0: + updates.append("entry_high=?") + params.append(parsed["entry_high"]) + if parsed["stop_loss"] > 0: + updates.append("stop_loss=?") + params.append(parsed["stop_loss"]) + if parsed["take_profit"] > 0: + updates.append("take_profit=?") + params.append(parsed["take_profit"]) + if parsed["position"]: + updates.append("position_advice=?") + params.append(parsed["position"]) + + params.append(code) + sql = f"UPDATE holding_strategies SET {', '.join(updates)} WHERE code=? AND status='active'" + conn.execute(sql, params) + conn.commit() + + # 买入信号→推XMPP通知(在conn close前执行) + if parsed.get("signal") == "买入": + try: + _nr = conn.execute("SELECT name, price FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + _name = _nr[0] if _nr else code + _p = _nr[1] if _nr else 0 + _el = parsed.get("entry_low", 0) + _eh = parsed.get("entry_high", 0) + _sl = parsed.get("stop_loss", 0) + _tp = parsed.get("take_profit", 0) + _pos = parsed.get("position", "") + _msg = f"📈 {_name}({code}) 价{_p}→12维分析生成买入信号!区间{_el}~{_eh} 损{_sl} 盈{_tp} 仓位{_pos}" + import urllib.request, json as _jj + _req = urllib.request.Request("http://127.0.0.1:5805/", + data=_jj.dumps({"body": _msg, "to": "hmo@yoin.fun", "type": "chat"}).encode(), + headers={"Content-Type": "application/json"}) + urllib.request.urlopen(_req, timeout=5) + print(f" 📨 XMPP推送成功: {_msg[:60]}") + except Exception as _e: + print(f" ⚠️ XMPP推送失败: {_e}") + + conn.close() + +def process_stock(code): + """处理单只股票""" + print(f"\n{'='*50}") + print(f"处理: {code}") + print(f"{'='*50}") + + if has_llm_analysis(code): + print(f" ⏭ 已有LLM九维分析,跳过") + return False + + if in_cooldown(code): + print(f" ⏭ 冷却期内,跳过") + return False + + print(f" 收集数据...", flush=True) + data = collect_data(code) + if not data.get("price"): + print(f" ⚠️ 无价格数据,跳过") + return False + + print(f" 调LLM生成九维分析...", flush=True) + prompt = build_prompt(data) + + try: + r = subprocess.run(["curl", "-s", "--max-time", "300", + "-H", "Content-Type: application/json", + "-H", "Authorization: Bearer hermes123", + "-d", json.dumps({"model":"deepseek-v4-flash","messages":[{"role":"user","content":prompt}],"max_tokens":2048}), + GATEWAY], capture_output=True, timeout=310) + + if r.returncode != 0: + print(f" ❌ curl失败: {r.stderr.decode()[:100]}") + return False + + resp = json.loads(r.stdout) + if "choices" not in resp: + print(f" ❌ API异常: {str(resp)[:200]}") + return False + + full_text = resp["choices"][0]["message"]["content"] + print(f" ✅ LLM返回({len(full_text)}字)", flush=True) + + parsed = parse_response(full_text) + print(f" 信号={parsed['signal']} 区间={parsed['entry_low']}~{parsed['entry_high']} 损={parsed['stop_loss']} 盈={parsed['take_profit']} 仓位={parsed['position']}") + + save_result(code, full_text, parsed) + print(f" ✅ 已保存到DB") + return True + + except subprocess.TimeoutExpired: + print(f" ❌ 超时") + return False + except Exception as e: + print(f" ❌ 错误: {e}") + return False + +def main(): + codes = [] + if "--code" in sys.argv: + idx = sys.argv.index("--code") + codes = [sys.argv[idx+1]] + else: + # 所有自选策略 + conn = sqlite3.connect(DB) + rows = conn.execute("SELECT code FROM holding_strategies WHERE status='active' AND decision_type='自选策略' ORDER BY code").fetchall() + conn.close() + codes = [r[0] for r in rows] + + print(f"待处理: {len(codes)}只") + + ok = 0 + fail = 0 + skip = 0 + for i, code in enumerate(codes): + if has_llm_analysis(code): + print(f" [{i+1}/{len(codes)}] ⏭ {code} 已有LLM分析") + skip += 1 + continue + + print(f" [{i+1}/{len(codes)}] ", end="", flush=True) + if process_stock(code): + ok += 1 + else: + fail += 1 + + # 间隔15秒(防gateway过载) + if i < len(codes) - 1: + print(f" 等待15秒...", flush=True) + time.sleep(15) + + print(f"\n{'='*50}") + print(f"完成: {ok}成功, {fail}失败, {skip}跳过") + print(f"{'='*50}") + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/branch_evaluator.py b/deploy/profile-scripts/branch_evaluator.py new file mode 100644 index 00000000..a6aa5497 --- /dev/null +++ b/deploy/profile-scripts/branch_evaluator.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +""" +branch_evaluator.py — 分支自成长引擎 + +每30分钟评估所有策略树的当前适用性: + 1. 读取 decisions.json 中所有 strategy_tree.branches + 2. 获取当前宏观情景(detect_scenario) + 3. 对每只股票获取实时价,评估哪些分支条件命中 + 4. 命中的分支 → trigger_count+1, last_triggered=now + 5. 后续跟进:成功/失败取决于该分支被选中后5日盈亏(由price_monitor回填success_rate) + 6. 触发≥3次且成功率<30% → 标记 pruning_candidate + 7. 写回 decisions.json + +设计为 no_agent cron 脚本:非空输出→推送到XMPP,空输出→静默 +""" + +import json, sys, os, re +from datetime import datetime, date +from mo_data import read_portfolio, read_decisions +from mofin_db import get_conn, write_holding_strategy + +# 引入 strategy_tree 模块 +sys.path.insert(0, "/home/hmo/MoFin") +try: + import strategy_tree as st +except ImportError: + # 如果 MoFin 路径下找不到,尝试直接 exec + import importlib.util + spec = importlib.util.spec_from_file_location("st", "/home/hmo/MoFin/strategy_tree.py") + st = importlib.util.module_from_spec(spec) + spec.loader.exec_module(st) + + +def get_live_prices(): + """从 portfolio.json 读取实时价格""" + prices = {} + try: + pf = read_portfolio() + for h in pf.get("holdings", []): + code = str(h.get("code", "")) + prices[code] = h.get("price", 0) + except Exception: + pass + return prices + + +def evaluate_all(): + """评估所有已触发策略树的分支""" + try: + data = read_decisions() + except Exception as e: + print(f"[错误] 读 decisions.json 失败: {e}", file=sys.stderr) + return + + # 当前情景 + scenario = st.detect_scenario() + scenario_id = scenario.get("id", "") + scenario_label = scenario.get("label", "未知") + + prices = get_live_prices() + decisions = data.get("decisions", []) + total_triggered = 0 + auto_init_count = 0 + pruning_flags = [] + + for entry in decisions: + code = entry.get("code", "") + tree = entry.get("strategy_tree") + if not tree: + # 自初始化:无决策树的股票自动生成默认分支 + try: + branches = st.init_default_branches( + code=code, + name=entry.get("name", ""), + entry_low=entry.get("entry_low", 0), + entry_high=entry.get("entry_high", 0), + stop_loss=entry.get("stop_loss", 0), + take_profit=entry.get("take_profit", 0), + ) + tree = {"branches": branches, "initialized_at": datetime.now().isoformat()} + entry["strategy_tree"] = tree + auto_init_count += 1 + except Exception: + continue + branches = tree.get("branches", []) + if not branches: + continue + + price = prices.get(code, 0) or entry.get("price", 0) + shares = entry.get("shares", 0) + cost = entry.get("cost", 0) + + # 评估所有分支 + results = st.evaluate_branches(code, scenario_id, price, shares, cost) + now_ts = datetime.now().isoformat() + + updated = False + for result in results: + br_id = result.get("branch_id", "") + # 找到对应分支更新trigger_count + for br in branches: + if br.get("id") == br_id: + if result.get("applicable"): + # 分支命中 → 增加触发计数 + br["trigger_count"] = br.get("trigger_count", 0) + 1 + br["last_triggered"] = now_ts + total_triggered += 1 + updated = True + # 检查是否需要标记剪枝候补 + tc = br["trigger_count"] + sr = br.get("success_rate") + if tc >= 3 and sr is not None and sr < 30: + br["pruning_candidate"] = True + pruning_flags.append(f"{code}/{br_id}(触发{tc}次/成功率{sr}%)") + break + + if updated: + # 回写 strategy_tree + entry["strategy_tree"] = tree + # 标记评估时间 + tree["last_evaluated"] = now_ts + + # 写回 — DB 优先 + try: + conn = get_conn() + for d in data.get("decisions", []): + write_holding_strategy(conn, d.get("code", ""), d.get("name", ""), d) + conn.close() + except Exception: + pass + # 输出摘要(空 = 静默) + lines = [] + init_note = f" | 自动初始化{auto_init_count}只" if auto_init_count else "" + lines.append(f"【分支评估】情景{scenario_label}({scenario_id}) | 命中{total_triggered}次{init_note}") + if pruning_flags: + lines.append(f"需剪枝{len(pruning_flags)}个分支:") + for f in pruning_flags: + lines.append(f" ⚠ {f}") + else: + lines.append("无需剪枝的分支") + + out = "\n".join(lines) + print(out) + return out + + +if __name__ == "__main__": + evaluate_all() diff --git a/deploy/profile-scripts/branch_scanner.py b/deploy/profile-scripts/branch_scanner.py new file mode 100644 index 00000000..ae04a33b --- /dev/null +++ b/deploy/profile-scripts/branch_scanner.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +branch_scanner.py — 分支自成长数据采集器(全静默) + +核心功能(三件事,全部后台静默执行): +1. 每轮扫描42只股票,评估当前情景下各分支的适用性 +2. 适用分支 → trigger_count + 1,记录 last_triggered +3. 保存当前状态到 scanner_state.json 供下次对比 + +无输出 → 静默运行。触发数据积累在 decisions.json。 +操作信号由 stale_push_wlin / price_monitor / 开盘收盘简报 另路输出。 + +数据流向(自成长):每15分钟branch_scanner积累trigger_count → + 每日prune_branches评估低效分支 → decisions.json修剪 → 分支越来越有效 +""" + +import json, sys, re +from datetime import datetime +from mo_data import read_decisions +from mo_data import get_price as md_get_price +from mofin_db import get_conn, write_holding_strategy + +SCANNER_STATE = "/home/hmo/web-dashboard/data/scanner_state.json" + + +def get_price(code): + # 统一走 mo_data.get_price(含 DB 优先 + API 兜底) + try: + p, _ = md_get_price(code) + return p if p else 0 + except: + try: from mofin_db import get_price_from_db; p, _ = get_price_from_db(code); return p if p else 0 + except: return None + + +def get_scenario(): + try: + sys.path.insert(0, "/home/hmo/MoFin") + from strategy_tree import detect_scenario + return detect_scenario() + except Exception: + return {"id": "unknown", "label": "未知", "confidence": 0} + + +def check_condition(branch, scenario_id, price): + cond = branch.get("condition", {}) + required_scenario = cond.get("scenario", "") + if required_scenario and required_scenario != scenario_id: + return False + price_cond = cond.get("price", "") + if price_cond and price: + ops = re.findall(r"([<>=!]+)\s*([\d.]+)", price_cond) + for op, val_str in ops: + val = float(val_str) + if op == "<" and not (price < val): return False + if op == ">" and not (price > val): return False + if op == "<=" and not (price <= val): return False + if op == ">=" and not (price >= val): return False + price_lower = cond.get("price_lower", "") + if price_lower and price: + ops = re.findall(r"([<>=!]+)\s*([\d.]+)", price_lower) + for op, val_str in ops: + val = float(val_str) + if op == "<" and not (price < val): return False + if op == ">" and not (price > val): return False + if op == "<=" and not (price <= val): return False + if op == ">=" and not (price >= val): return False + return True + + +def main(): + now = datetime.now() + if now.hour < 9 or now.hour > 16: + return 0 + + scenario = get_scenario() + sid = scenario.get("id", "unknown") + + data = read_decisions() + decisions = data.get("decisions", []) + + for entry in decisions: + code = entry.get("code", "") + tree = entry.get("strategy_tree", {}) + branches = tree.get("branches", []) + if not branches: + continue + price = get_price(code) + if not price: + continue + for br in sorted(branches, key=lambda b: b.get("priority", 999)): + if check_condition(br, sid, price): + br["trigger_count"] = br.get("trigger_count", 0) + 1 + br["last_triggered"] = now.strftime("%Y-%m-%d") + break + + # DB 写入(替代 json.dump) + try: + conn = get_conn() + for d in data.get("decisions", []): + write_holding_strategy(conn, d.get("code", ""), d.get("name", ""), d) + conn.close() + except Exception: + pass + # 更新状态快照 + state = {"scenario": sid, "updated_at": now.isoformat(), "branches": {}} + for e in decisions: + code = e.get("code", "") + tree = e.get("strategy_tree", {}) + for br in sorted(tree.get("branches", []), key=lambda b: b.get("priority", 999)): + if check_condition(br, sid, get_price(code)): + state["branches"][code] = br.get("id", "") + break + try: + with open(SCANNER_STATE, "w") as f: + json.dump(state, f, indent=2) + except Exception: + pass + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/deploy/profile-scripts/candidate_filter.py b/deploy/profile-scripts/candidate_filter.py new file mode 100644 index 00000000..e33ab5b3 --- /dev/null +++ b/deploy/profile-scripts/candidate_filter.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 +"""candidate_filter.py — 候选股多级过滤管道 + +从 candidates 表读取未过滤的候选,逐级执行过滤: + Stage 2: 多日K线确认(量价连续性) + Stage 3: 技术位分析(MA位置) + Stage 4: 资金性质(大单流向) + Stage 5: 基本面(PE/PB/行业) + +用法: python3 candidate_filter.py [--stage 2|3|4|5] [--code XXXXXX] +""" +import sys, json, urllib.request, sqlite3, re, time +from pathlib import Path +from datetime import datetime + +DB_PATH = Path("/home/hmo/MoFin/data/mofin.db") +UA = "Mozilla/5.0" + +def get_conn(): + return sqlite3.connect(str(DB_PATH)) + +def log_candidate(conn, code, stage, passed, detail): + """记录过滤日志""" + conn.execute( + "UPDATE candidates SET log = COALESCE(log, '[]')" + ) + # SQLite JSON操作 + existing = conn.execute("SELECT log FROM candidates WHERE code=?", (code,)).fetchone() + if existing and existing[0]: + try: + logs = json.loads(existing[0]) + except: + logs = [] + else: + logs = [] + logs.append({"stage": stage, "passed": passed, "detail": detail, "time": datetime.now().strftime("%m-%d %H:%M")}) + conn.execute("UPDATE candidates SET log=? WHERE code=?", (json.dumps(logs, ensure_ascii=False), code)) + + +# ── Stage 2: 多日K线确认 ── + +def fetch_daily_klines(code): + """拉取近10日日K线(Sina 240分钟线=日K)""" + raw = str(code).strip() + if raw.startswith(("6", "9")): + prefix = "sh" + elif raw.startswith(("0", "3")): + prefix = "sz" + else: + return None + + import subprocess as _sp, json as _json + url = f"http://money.finance.sina.com.cn/quotes_service/api/json_v2.php/CN_MarketData.getKLineData?symbol={prefix}{raw}&scale=240&ma=5&datalen=10" + try: + r = _sp.run(["curl", "-s", "--noproxy", "*", url], capture_output=True, timeout=10) + data = _json.loads(r.stdout) + if not data: + return None + result = [] + for k in data: + result.append({ + "date": k.get("day", "")[:10], + "open": float(k["open"]), + "close": float(k["close"]), + "high": float(k["high"]), + "low": float(k["low"]), + "volume": int(k["volume"]), + "price": float(k["close"]), + "change_pct": 0, + }) + # 计算涨跌幅 + for i in range(1, len(result)): + prev = result[i-1]["close"] + if prev > 0: + result[i]["change_pct"] = (result[i]["close"] / prev - 1) * 100 + return result + except Exception as e: + return None + return None + + +def stage2_confirm(code, name, klines): + """第二关:多日K线确认 + 检查:多日量价配合、建仓特征 + """ + if not klines or len(klines) < 3: + return False, 0, "K线不足3日" + + recent = klines[-5:] # 最近5日 + score = 0 + checks = [] + + # 1. 成交量连续递增 + vols = [k["volume"] for k in recent] + vol_rising = sum(1 for i in range(len(vols)-1) if vols[i] < vols[i+1]) + if vol_rising >= 3: + score += 2 + checks.append(f"量增{vol_rising}/4日") + elif vol_rising >= 2: + score += 1 + checks.append(f"量微增{vol_rising}/4日") + + # 2. 涨放量、跌缩量 + up_vol = sum(k["volume"] for k in recent if k["change_pct"] >= 0) + down_vol = sum(k["volume"] for k in recent if k["change_pct"] < 0) + if down_vol > 0 and up_vol / down_vol > 1.5: + score += 2 + checks.append(f"涨量/跌量={up_vol/down_vol:.1f}") + elif down_vol > 0 and up_vol / down_vol > 1: + score += 1 + + # 3. 价格趋势 + closes = [k["close"] for k in recent] + up_days = sum(1 for i in range(1, len(closes)) if closes[i] > closes[i-1]) + if up_days >= 3: + score += 2 + checks.append(f"涨{up_days}/4日") + elif up_days >= 2: + score += 1 + + # 4. 无异常放量(单日>3倍均量=可能出货) + avg_vol = sum(vols) / len(vols) if vols else 1 + max_ratio = max(v / avg_vol for v in vols) if avg_vol > 0 else 1 + if max_ratio < 2.5: + score += 1 + else: + checks.append(f"异常量{max_ratio:.0f}倍") + + passed = score >= 4 + detail = f"评分{score}/7 | {'; '.join(checks)}" + return passed, score, detail + + +# ── Stage 3: 技术位分析 ── + +def stage3_technical(code, name, klines): + """第三关:技术位(当日数据估算)""" + if not klines or len(klines) == 0: + return False, 0, "无数据" + + today = klines[-1] + price = today.get("price", 0) + high = today.get("high", 0) + low = today.get("low", 0) + + score = 0 + checks = [] + + if price <= 0: + return False, 0, "价格无效" + + # 日内位置(在高低点中下段还有空间) + if high > low: + pos = (price - low) / (high - low) + if pos < 0.7: + score += 1 + checks.append(f"日内位置{pos:.0%}") + + # 有明确支撑(今日低点作为参考支撑) + if low > 0 and price > low: + score += 1 + checks.append(f"支撑{low:.2f}") + + # 有上涨空间(今日高点作为参考阻力) + if high > price: + upside = (high / price - 1) * 100 + if upside > 2: + score += 1 + checks.append(f"空间{upside:.0f}%") + + passed = score >= 2 + return passed, score, "; ".join(checks) if checks else "基础通过" + + +# ── Stage 4: 资金性质分析 ── + +def stage4_capital_flow(code, name): + """第四关:资金性质(从腾讯实时行情提取外盘/内盘比)""" + raw = str(code).strip() + if raw.startswith(("6", "9")): + prefix = "sh" + elif raw.startswith(("0", "3")): + prefix = "sz" + else: + return False, 0, "非A股" + + import subprocess as _sp + url = f"http://qt.gtimg.cn/q={prefix}{raw}" + try: + r = _sp.run(["curl", "-s", url], capture_output=True, timeout=10) + text = r.stdout.decode("gbk", errors="ignore") + parts = text.split("~") + if len(parts) < 40: + return False, 0, "数据不足" + + # 腾讯字段:[7]=外盘(主动买,股),[8]=内盘(主动卖,股) + try: + outer = int(float(parts[7])) if parts[7] else 0 # 外盘 + inner = int(float(parts[8])) if parts[8] else 0 # 内盘 + except: + return False, 0, "解析失败" + + if outer <= 0 or inner <= 0: + return False, 0, "无盘口数据" + + score = 0 + ratio = outer / inner if inner > 0 else 1 + checks = [] + + if ratio > 1.3: + score += 2 + checks.append(f"外/内={ratio:.2f}") + elif ratio > 1.0: + score += 1 + checks.append(f"买稍强{ratio:.2f}") + else: + checks.append(f"卖稍强{ratio:.2f}") + + # 绝对量也说明资金活跃度 + total = outer + inner + if total > 50000000: # >5000万股 + score += 1 + checks.append(f"活跃{total/10000:.0f}万") + + return score >= 1, score, "; ".join(checks) + except: + return False, 0, "接口失败" + + +# ── Stage 5: 基本面 ── + +def stage5_fundamental(code, name, price): + """第五关:基本面 + 从已有数据判断,不调外部API + """ + conn = get_conn() + score = 0 + checks = [] + + # PE(从stocks表或live_prices) + r = conn.execute("SELECT 1 FROM holdings WHERE code=? AND is_active=1", (code,)).fetchone() + is_holding = r is not None + if is_holding: + checks.append("已持仓") + else: + score += 1 # 新标的加分 + + # 检查是否已被其他候选覆盖 + r2 = conn.execute("SELECT code FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + if r2: + checks.append("已有策略") + else: + score += 1 + + conn.close() + return score >= 1, score, "; ".join(checks) if checks else "新标的" + + +# ── 主流程 ── + +def main(): + stage_filter = None + single_code = None + for i, arg in enumerate(sys.argv[1:]): + if arg == "--stage" and i+1 < len(sys.argv): + stage_filter = int(sys.argv[i+2]) + if arg == "--code" and i+1 < len(sys.argv): + single_code = sys.argv[i+2] + + conn = get_conn() + + # 读待过滤的候选 + query = "SELECT code, name, reason FROM candidates WHERE 1=1" + params = [] + if single_code: + query += " AND code=?" + params.append(single_code) + else: + query += " AND (pass_final IS NULL OR pass_final=0)" + + rows = conn.execute(query, params).fetchall() + print(f"[FILTER] 待处理候选: {len(rows)}只", flush=True) + + stages = [(2, stage2_confirm, "多日K线"), (3, stage3_technical, "技术位"), + (4, stage4_capital_flow, "资金流"), (5, stage5_fundamental, "基本面")] + + for code, name, reason in rows: + current_score = 0 + print(f" {code} {name}", flush=True) + + # 获取K线(多关需要) + klines = None + + for stage_num, stage_fn, stage_name in stages: + if stage_filter and stage_num != stage_filter: + continue + + # 检查是否已通过此关 + col = f"pass_s{stage_num}" + existing = conn.execute(f"SELECT {col} FROM candidates WHERE code=?", (code,)).fetchone() + if existing and existing[0]: + continue + + if stage_num in (2, 3) and klines is None: + klines = fetch_daily_klines(code) + + if stage_num == 2: + passed, sscore, detail = stage_fn(code, name, klines) + conn.execute("UPDATE candidates SET score_2nd=?, pass_s2=?, reason=? WHERE code=?", + (sscore, 1 if passed else 0, detail, code)) + log_candidate(conn, code, 2, passed, detail) + print(f" S2:{'✅' if passed else '❌'} {detail}", flush=True) + + elif stage_num == 3: + passed, sscore, detail = stage_fn(code, name, klines) + conn.execute("UPDATE candidates SET score_3rd=?, pass_s3=?, reason=? WHERE code=?", + (sscore, 1 if passed else 0, detail, code)) + log_candidate(conn, code, 3, passed, detail) + print(f" S3:{'✅' if passed else '❌'} {detail}", flush=True) + + elif stage_num == 4: + passed, sscore, detail = stage_fn(code, name) + conn.execute("UPDATE candidates SET score_4th=?, pass_s4=?, reason=? WHERE code=?", + (sscore, 1 if passed else 0, detail, code)) + log_candidate(conn, code, 4, passed, detail) + print(f" S4:{'✅' if passed else '❌'} {detail}", flush=True) + + elif stage_num == 5: + price = 0 # 从live_prices获取 + r = conn.execute("SELECT price FROM live_prices WHERE code=?", (code,)).fetchone() + if r: price = r[0] + passed, sscore, detail = stage_fn(code, name, price) + conn.execute("UPDATE candidates SET score_5th=?, pass_s5=?, reason=? WHERE code=?", + (sscore, 1 if passed else 0, detail, code)) + log_candidate(conn, code, 5, passed, detail) + print(f" S5:{'✅' if passed else '❌'} {detail}", flush=True) + + # 计算综合评分 + s2 = conn.execute("SELECT score_2nd FROM candidates WHERE code=?", (code,)).fetchone()[0] or 0 + s3 = conn.execute("SELECT score_3rd FROM candidates WHERE code=?", (code,)).fetchone()[0] or 0 + s4 = conn.execute("SELECT score_4th FROM candidates WHERE code=?", (code,)).fetchone()[0] or 0 + s5 = conn.execute("SELECT score_5th FROM candidates WHERE code=?", (code,)).fetchone()[0] or 0 + final = current_score + s2 + s3 + s4 + s5 + conn.execute("UPDATE candidates SET score_final=?, pass_final=1 WHERE code=?", + (final, code)) + + conn.commit() + conn.close() + print(f"[FILTER] 完成", flush=True) + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/capital_flow_collector.py b/deploy/profile-scripts/capital_flow_collector.py new file mode 100644 index 00000000..22477485 --- /dev/null +++ b/deploy/profile-scripts/capital_flow_collector.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""capital_flow_collector.py — 个股资金流数据采集器 + +每30分钟拉一次持仓+自选的超大单/大单/中单/小单资金流向。 +输出到 capital_flow_cache.json 供 price_monitor 和报告使用。 + +API: push2his.eastmoney.com 个股资金流日线 +""" +import json, os, sys, time, urllib.request +from datetime import datetime +from urllib.request import urlopen, Request +from concurrent.futures import ThreadPoolExecutor, as_completed +from threading import Semaphore +from mofin_db import get_conn, write_capital_flow_cache + +DATA_DIR = "/home/hmo/web-dashboard/data" +CACHE_PATH = f"{DATA_DIR}/capital_flow_cache.json" + +UA = "Mozilla/5.0" +# 限速器:最多5个并发,每请求后强制间隔0.3s +RATE_LIMIT = Semaphore(5) +MIN_INTERVAL = 0.3 +_last_req = 0 + +def _rate_limited_request(url): + """带速率限制的HTTP GET,用Semaphore控制并发数""" + global _last_req + with RATE_LIMIT: + elapsed = time.time() - _last_req + if elapsed < MIN_INTERVAL: + time.sleep(MIN_INTERVAL - elapsed) + proxy_handler = urllib.request.ProxyHandler({}) + opener = urllib.request.build_opener(proxy_handler) + req = Request(url, headers={"User-Agent": UA, "Referer": "https://data.eastmoney.com/"}) + try: + resp = opener.open(req, timeout=8) + _last_req = time.time() + return json.loads(resp.read().decode("utf-8")) + except Exception: + return None + +# eastmoney secid: 1=上海 0=深圳 +def secid(code): + code = str(code).strip() + if code.startswith(("6", "9")): + return f"1.{code}" + return f"0.{code}" + +def fetch_flow(code, days=5): + """拉取个股近N日资金流(带限速+代理绕过)""" + sid = secid(code) + url = f"http://push2his.eastmoney.com/api/qt/stock/fflow/daykline/get?secid={sid}&fields1=f1,f2,f3,f7&fields2=f51,f52,f53,f54,f55,f56,f57&lmt={days}" + data = _rate_limited_request(url) + if not data: + return None + klines = data.get("data") or {} + if isinstance(klines, dict): + klines = klines.get("klines", []) + if not klines: + return None + result = [] + for k in klines: + p = k.split(",") + if len(p) >= 7: + result.append({ + "date": p[0], + "main_net": float(p[1]), # 主力净流入(元) + "super_large": float(p[2]), # 超大单净流入(元) + "large": float(p[3]), # 大单净流入(元) + "medium": float(p[4]), # 中单净流入(元) + "small": float(p[5]), # 小单净流入(元) + }) + return result + +def fetch_flow_intraday(code): + """拉取当日分时资金流(用于盘中判断)""" + sid = secid(code) + url = f"http://push2.eastmoney.com/api/qt/stock/fflow/kline/get?secid={sid}&fields1=f1,f2,f3,f7&fields2=f51,f52,f53,f54,f55,f56,f57&klt=1&lmt=120" + try: + resp = urlopen(url, timeout=5) + data = json.loads(resp.read().decode("utf-8")) + klines = data.get("data", {}).get("klines", []) + if not klines: + return None + latest = klines[-1].split(",") + return { + "main_net": float(latest[1]), + "super_large": float(latest[2]), + "large": float(latest[3]), + } + except: + return None + +def analyze_flow(flow_data): + """分析资金流模式""" + if not flow_data or len(flow_data) < 2: + return {} + + result = {"alerts": [], "pattern": ""} + + # 最近两日对比 + d1 = flow_data[-1] # 最新日 + d2 = flow_data[-2] # 前一日 + + # 超大单信号 + sl1 = d1["super_large"] + sl2 = d2["super_large"] + + # 连续形态判断 + main_trend = sum(d["main_net"] for d in flow_data[-3:]) + sl_trend = sum(d["super_large"] for d in flow_data[-3:]) + + # 1. 主力连续流入 + if main_trend > 50000000 and sl1 > 0 and sl2 > 0: + result["pattern"] = "主力持续流入" + result["alerts"].append("主力连续3日净流入") + + # 2. 超大单突然转向(连续流入→流出 或 流出→流入) + if sl1 * sl2 < 0: # 方向反转 + if sl1 > 0 and sl2 < 0: + result["pattern"] = "超大单由出转入" + result["alerts"].append("超大单转为净买入(暗示消息即将落地)") + elif sl1 < 0 and sl2 > 0: + result["pattern"] = "超大单由入转出" + result["alerts"].append("超大单转为净卖出(利好出货嫌疑)") + + # 3. 价格与资金流背离(缺当前价格作比较,在主脚本中完成) + # 4. 单日暴量 + max_sl = max(abs(d["super_large"]) for d in flow_data) + if max_sl == abs(sl1) and abs(sl1) > 100000000: + result["pattern"] = "单日资金暴量" + result["alerts"].append(f"今日超大单异常: {sl1/100000000:.2f}亿") + + return result + +def main(): + codes = set() + # 读取持仓+自选(从DB直接读,替代已删除的mo_data) + try: + import sqlite3 + _db = sqlite3.connect("/home/hmo/MoFin/data/mofin.db") + for row in _db.execute("SELECT DISTINCT code FROM holdings WHERE is_active=1").fetchall(): + if row[0]: codes.add(row[0]) + for row in _db.execute("SELECT DISTINCT code FROM holding_strategies WHERE status='active' AND decision_type='自选策略'").fetchall(): + if row[0]: codes.add(row[0]) + _db.close() + except: + pass + + all_flows = {} + + # 并行抓取:ThreadPoolExecutor + 内置限速器(Semaphore 5 + 0.3s间隔) + code_list = sorted(codes) + if not code_list: + print("[capital_flow] 无代码需要采集") + return + + def fetch_one(code): + flow = fetch_flow(code, days=5) + if flow: + analysis = analyze_flow(flow) + return (code, { + "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M"), + "flow": flow, + "analysis": analysis, + }) + return (code, None) + + with ThreadPoolExecutor(max_workers=5) as pool: + futures = {pool.submit(fetch_one, c): c for c in code_list} + for f in as_completed(futures): + code, result = f.result() + if result: + all_flows[code] = result + + # 写缓存 + cache = { + "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M"), + "stocks": all_flows, + } + # 写 DB(替代 capital_flow_cache.json) + conn = get_conn() + write_capital_flow_cache(conn, cache) + conn.close() + print(f"[capital_flow] {len(all_flows)}/{len(code_list)}只更新完成") + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/check-prompt-deps.py b/deploy/profile-scripts/check-prompt-deps.py new file mode 100644 index 00000000..1861dcc1 --- /dev/null +++ b/deploy/profile-scripts/check-prompt-deps.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +""" +检查 MoFin prompt-manager 的跨提示词依赖一致性。 + +功能: +1. 对每个有 depends_on 的 prompt,验证上游 prompt 的 current_version 匹配 +2. 对每个有 impacts 的 prompt,验证下游 prompt 的 depends_on.version 是否已正确更新 +3. 输出状态:✅ 全部一致 / ⚠️ 有差异(列出差异详情) + +用法: + python3 check-prompt-deps.py + python3 check-prompt-deps.py --registry /path/to/registry.json + +集成方式: + 每次做策略分析前,或每次更新提示词后,运行一次确认一致性。 +""" + +import json +import sys +import os +from pathlib import Path + +DEFAULT_REGISTRY = "/home/hmo/projects/MoFin/data/prompts/registry.json" + +def load_registry(path: str) -> dict: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + +def prompt_map(registry: dict) -> dict: + """将 prompts 数组转为 id -> prompt 的字典""" + return {p["id"]: p for p in registry.get("prompts", [])} + +def check_depends_on(pm: dict) -> list: + """检查所有 depends_on 声明是否匹配上游的 current_version""" + issues = [] + for p in pm.values(): + deps = p.get("depends_on") + if not deps: + continue + target_id = deps.get("prompt") + expected_ver = deps.get("version") + target = pm.get(target_id) + if not target: + issues.append({ + "type": "MISSING_UPSTREAM", + "prompt": p["id"], + "detail": f"依赖的上游提示词 '{target_id}' 在 registry 中不存在" + }) + continue + actual_ver = target.get("current_version") + if actual_ver != expected_ver: + issues.append({ + "type": "VERSION_MISMATCH", + "prompt": p["id"], + "detail": ( + f"{p['id']}.depends_on → {target_id}@{expected_ver}, " + f"但 {target_id}.current_version = {actual_ver}" + ) + }) + return issues + +def check_impacts_fulfilled(pm: dict) -> list: + """检查有 impacts 声明的 prompt,其下游是否已更新 depends_on 到当前版本""" + issues = [] + for p in pm.values(): + impacted = p.get("impacts") + if not impacted: + continue + my_id = p["id"] + my_ver = p.get("current_version") + for downstream_id in impacted: + downstream = pm.get(downstream_id) + if not downstream: + issues.append({ + "type": "MISSING_DOWNSTREAM", + "prompt": my_id, + "detail": f"impacts 中列出的 '{downstream_id}' 在 registry 中不存在" + }) + continue + dd = downstream.get("depends_on", {}) + expected_ver = dd.get("version") if dd.get("prompt") == my_id else None + if expected_ver is None: + issues.append({ + "type": "MISSING_DEPENDS_ON", + "prompt": my_id, + "detail": ( + f"{my_id}.impacts 声明了 {downstream_id}, " + f"但 {downstream_id} 没有 depends_on → {my_id}" + ) + }) + elif expected_ver != my_ver: + issues.append({ + "type": "STALE_DEPENDENCY", + "prompt": my_id, + "detail": ( + f"{my_id}.current_version = {my_ver}, " + f"但 {downstream_id}.depends_on.version = {expected_ver}" + ) + }) + return issues + +def check_integrity(pm: dict) -> list: + """基本格式校验""" + issues = [] + for p in pm.values(): + deps = p.get("depends_on") + if deps: + if not deps.get("prompt") or not deps.get("version"): + issues.append({ + "type": "INVALID_DEPENDS_ON", + "prompt": p["id"], + "detail": "depends_on 结构不完整,需包含 prompt 和 version" + }) + if not deps.get("reason"): + issues.append({ + "type": "MISSING_REASON", + "prompt": p["id"], + "detail": "depends_on 缺少 reason 字段(说明为什么依赖)" + }) + impacts = p.get("impacts") + if impacts and not isinstance(impacts, list): + issues.append({ + "type": "INVALID_IMPACTS", + "prompt": p["id"], + "detail": "impacts 必须是数组" + }) + return issues + +def main(): + reg_path = DEFAULT_REGISTRY + if len(sys.argv) > 2 and sys.argv[1] == "--registry": + reg_path = sys.argv[2] + if not os.path.exists(reg_path): + print(f"❌ 找不到 registry 文件: {reg_path}") + sys.exit(1) + + registry = load_registry(reg_path) + pm = prompt_map(registry) + + all_issues = [] + all_issues.extend(check_integrity(pm)) + all_issues.extend(check_depends_on(pm)) + all_issues.extend(check_impacts_fulfilled(pm)) + + print(f"📋 MoFin 提示词版本依赖检查 ({registry.get('updated_at', '?')})") + print(f" 共 {len(pm)} 个提示词") + print() + + if not all_issues: + print("✅ 全部一致,无依赖问题") + # 打印当前依赖状态一览 + print() + for p in pm.values(): + deps = p.get("depends_on") + impacts = p.get("impacts") + status = p["id"] + if deps: + status += f" ← 依赖 {deps['prompt']}@{deps['version']}" + if impacts: + status += f" → 影响 {', '.join(impacts)}" + print(f" {status}") + sys.exit(0) + + # 按类型分组输出 + categories = { + "MISSING_UPSTREAM": "上游提示词缺失", + "VERSION_MISMATCH": "依赖版本不匹配", + "MISSING_DOWNSTREAM": "下游提示词缺失", + "MISSING_DEPENDS_ON": "下游缺少 depends_on 声明", + "STALE_DEPENDENCY": "下游依赖版本未同步更新", + "INVALID_DEPENDS_ON": "depends_on 结构不完整", + "MISSING_REASON": "缺少依赖原因说明", + "INVALID_IMPACTS": "impacts 格式错误", + } + + has_error = False + for issue in all_issues: + cat = categories.get(issue["type"], issue["type"]) + marker = "❌" if issue["type"] in ("VERSION_MISMATCH", "STALE_DEPENDENCY", "MISSING_UPSTREAM") else "⚠️" + if marker == "❌": + has_error = True + print(f"{marker} [{issue['prompt']}] {cat}") + print(f" {issue['detail']}") + + print() + if has_error: + print("❗ 存在必须修复的依赖问题,请在更新提示词前先处理。") + else: + print("💡 无版本冲突,但上面有信息缺失/规范问题,建议补充。") + + sys.exit(1 if has_error else 0) + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/chip_factors.py b/deploy/profile-scripts/chip_factors.py new file mode 100644 index 00000000..6ee95c0e --- /dev/null +++ b/deploy/profile-scripts/chip_factors.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""chip_factors.py — 筹码因子计算模块 + +基于中信建投《筹码分布因子系统构建》研报,实现四大类因子。 +用日线数据就够了,分钟数据用于当日穿透率增强。 + +用法: + from chip_factors import ChipFactors + cf = ChipFactors() + + # 计算单只股票的筹码乖离率 + result = cf.calc_all("600519") + print(result["bias"], result["ptr"], result["ptr_today"]) + + # 批量计算所有持仓/自选 + results = cf.batch_calc(["600519", "00700", "000700"]) +""" + +import json, os, sqlite3, time, urllib.request +from datetime import datetime, timedelta +from mo_data import read_decisions, get_price +from pathlib import Path + +DB_PATH = Path("/home/hmo/MoFin/data/mofin.db") +MOFIN_ROOT = Path("/home/hmo/MoFin") +CACHE_DIR = MOFIN_ROOT / "data" / "chip_cache" + +# ── 分钟数据限流 ── +_last_minute_call = 0 + +def _fetch_quote(code): + """拉实时价,统一走 mo_data.get_price""" + try: + price, _ = get_price(code) + return price or 0 + except: + return 0 + + +def _fetch_minute_kline(code, count=60): + """拉1分钟K线(带限流)""" + global _last_minute_call + now = time.time() + if now - _last_minute_call < 1.0: + time.sleep(1.0 - (now - _last_minute_call)) + secid = f"1.{code}" if code.startswith(('6','5')) else f"0.{code}" + url = (f"https://push2.eastmoney.com/api/qt/stock/kline/get" + f"?secid={secid}&fields1=f1,f2,f3&fields2=f51,f52,f53,f54,f55,f56,f57" + f"&klt=1&fqt=1&end=20500101&lmt={min(count, 240)}") + try: + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + resp = urllib.request.urlopen(req, timeout=8) + data = json.loads(resp.read())["data"]["klines"] + _last_minute_call = time.time() + return [line.split(",") for line in data] + except: + return None + + +class ChipFactors: + """筹码因子计算器""" + + def __init__(self): + CACHE_DIR.mkdir(parents=True, exist_ok=True) + self._cache = {} # code → {last_chip, winner, bias} + self._load_cache() + + def _load_cache(self): + """加载缓存的筹码状态""" + for f in CACHE_DIR.glob("*.json"): + code = f.stem + try: + with open(f) as fp: + self._cache[code] = json.load(fp) + except: + pass + + def _save_cache(self, code): + """保存筹码状态""" + if code in self._cache: + path = CACHE_DIR / f"{code}.json" + with open(path, "w") as fp: + json.dump(self._cache[code], fp, ensure_ascii=False) + + # ── 筹码分布估算(用日线OHLCV) ── + def _build_chip_distribution(self, code): + """从日线K线估算筹码分布。 + + 原理:假设每日成交量在OHLC区间内均匀分布, + 每根K线的成交量按价格区间分配,累积成筹码分布。 + """ + for k in list(os.environ.keys()): + if 'proxy' in k.lower(): + os.environ.pop(k) + # 从腾讯API取60日K线 + prefix = "sh" if code.startswith(('60','68','51')) else "sz" if code.startswith(('00','30','15')) else "hk" + url = f"http://ifzq.gtimg.cn/appstock/app/fqkline/get?param={prefix}{code},day,,,640,qfq" + try: + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + resp = opener.open(req, timeout=8).read().decode('utf-8') + data = json.loads(resp) + day_key = 'qfqday' if prefix != 'hk' else 'day' + bars = data.get('data', {}).get(f'{prefix}{code}', {}).get(day_key, []) + except: + return {} + + # 估算筹码分布:价格区间 → 累积量 + chip_dist = {} # price_level → accumulated_volume + decay = 0.97 # 每日衰减因子(老筹码逐步换手) + + for bar in bars: + try: + if len(bar) < 6: + continue + high = float(bar[3]) # index 3 = high + low = float(bar[4]) # index 4 = low + volume = float(bar[5]) if len(bar) > 5 else 0 # index 5 = volume + if high <= low or volume <= 0: + continue + # 在OHLC区间均匀分配成交量 + step = max(round((high - low) / 5, 2), 0.01) + level = round(low, 2) + vol_per_level = volume / max(int((high - low) / step) + 1, 1) + while level <= high: + chip_dist[level] = chip_dist.get(level, 0) + vol_per_level + level = round(level + step, 2) + except: + continue + + # 衰减老筹码 + total = sum(chip_dist.values()) + if total > 0: + for k in chip_dist: + chip_dist[k] *= decay + + return chip_dist + + # ── 三大因子计算 ── + def calc_all(self, code, name="", price=None): + """计算全部筹码因子,返回dict""" + result = {"code": code, "name": name, "price": price} + + # 获取当前价(如果没传) + if not price: + price = _fetch_quote(code) + if not price: + return result + result["price"] = price + + # 构建筹码分布 + chip_dist = self._build_chip_distribution(code) + if not chip_dist or price <= 0: + return result + + # 计算盈利/亏损筹码占比 + total_vol = sum(chip_dist.values()) + if total_vol <= 0: + return result + + winner_vol = sum(v for k, v in chip_dist.items() if k <= price) # 盈利筹码(cost≤现价) + loser_vol = total_vol - winner_vol # 亏损筹码(cost>现价) + + winner_pct = winner_vol / total_vol + loser_pct = loser_vol / total_vol + + # 获取前日状态 + prev = self._cache.get(code, {}) + prev_winner = prev.get("winner_pct", winner_pct) + prev_bias = prev.get("bias", 0) + + # 估算换手率(近10日均量/总流通股) + turnover = 0.02 # 默认2% + try: + prefix2 = "sh" if code.startswith(('60','68','51','56','50')) else "sz" if code.startswith(('00','30','15')) else "hk" + url2 = f"http://ifzq.gtimg.cn/appstock/app/fkline/get?param={prefix2}{code},day,,,10,qfq" + req2 = urllib.request.Request(url2, headers={"User-Agent": "Mozilla/5.0"}) + resp2 = urllib.request.urlopen(req2, timeout=5).read().decode('utf-8') + data2 = json.loads(resp2) + dk = 'qfqday' if prefix2 != 'hk' else 'day' + bars2 = data2.get('data', {}).get(f'{prefix2}{code}', {}).get(dk, []) + if len(bars2) > 5: + avg_vol = sum(float(b[5]) for b in bars2[-10:] if len(b)>5) / min(len(bars2), 10) + # 用近60日最高量估算总流通股 + max_vol = avg_vol * 50 # 估算值 + turnover = min(avg_vol / max(max_vol, 1), 0.3) + except: + pass + + # 1. 筹码穿透率 PTR = (winner_pct - prev_winner) / turnover + ptr = (winner_pct - prev_winner) / max(turnover, 0.001) + + # 2. 当日筹码穿透率(简化版) = 今日量 / 总筹码 / turnover + ptr_today = 0 + minute_data = _fetch_minute_kline(code, count=30) + if minute_data: + today_vol = sum(float(m[5]) for m in minute_data if len(m) > 5) + ptr_today = today_vol / max(total_vol, 1) / max(turnover, 0.001) + + # 3. 筹码乖离率(亏损版本—按文章发现,亏损筹码版反而最强) + # bias = loser_pct * turnover + prev_bias * (1 - turnover) + bias = loser_pct * turnover + prev_bias * (1 - turnover) + + # 更新缓存 + self._cache[code] = { + "winner_pct": winner_pct, + "loser_pct": loser_pct, + "bias": bias, + "updated_at": datetime.now().isoformat() + } + self._save_cache(code) + + return { + "code": code, + "name": name, + "price": price, + "winner_pct": round(winner_pct, 4), + "loser_pct": round(loser_pct, 4), + "ptr": round(ptr, 4), + "ptr_today": round(ptr_today, 4), + "bias": round(bias, 4), + "turnover": round(turnover, 4), + } + + def batch_calc(self, stocks): + """批量计算多只股票""" + results = [] + for i, (code, name) in enumerate(stocks): + if i > 0: + time.sleep(1.5) # 限流 + result = self.calc_all(code, name) + results.append(result) + return results + + +# ── 主入口 ── +if __name__ == "__main__": + import sys + cf = ChipFactors() + + # 从decisions.json获取持仓+自选 + dec = read_decisions() + stocks = [(s["code"], s.get("name","")) for s in dec.get("decisions", []) if s.get("status") != "closed"] + + results = cf.batch_calc(stocks) + + # 按bias排序显示(亏损筹码占比最高的排前面) + results.sort(key=lambda r: r.get("bias", 0), reverse=True) + print(f"{'股票':16} {'亏损筹码%':>10} {'PTR':>8} {'乖离率':>8} {'换手率':>8}") + print("-" * 60) + for r in results: + if r.get("price"): + print(f"{r['name']:8}({r['code']:6}) {r['loser_pct']*100:>8.1f}% {r['ptr']:>8.4f} {r['bias']:>8.4f} {r['turnover']*100:>6.1f}%") + + print(f"\n共计算{len(results)}只股票") + print(f"筹码缓存目录: {CACHE_DIR}") diff --git a/deploy/profile-scripts/clean_watchlist.py b/deploy/profile-scripts/clean_watchlist.py new file mode 100644 index 00000000..bf24afa5 --- /dev/null +++ b/deploy/profile-scripts/clean_watchlist.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Remove held stocks from watchlist""" + +import json, os, sys + +# 确保 MoFin 根目录在模块搜索路径中(兼容 cron 环境) +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from mo_data import read_portfolio, read_decisions, read_watchlist +from mofin_db import get_conn, write_watchlist_stock, write_holding_strategy + +WL = "/home/hmo/web-dashboard/data/watchlist.json" # 路径保留用于历史备份兼容,数据实际走DB +# 决策数据全部从DB读取,json文件已移除 +DEC = "/home/hmo/web-dashboard/data/decisions.json" # 保留常量但不再使用,防止引用报错 + +holding_codes = set() +pf = read_portfolio() +for h in pf.get("holdings", []): + c = h.get("code", "") + if c: + holding_codes.add(c) + +print(f"持仓 codes: {sorted(holding_codes)}") + +# Load watchlist +wl = read_watchlist() +stocks = wl.get("stocks", []) +before = len(stocks) + +# Remove held stocks +new_stocks = [s for s in stocks if s.get("code") not in holding_codes] +removed = [s for s in stocks if s.get("code") in holding_codes] + +after = len(new_stocks) +wl["stocks"] = new_stocks + +# Backup — DB 版,不再碰JSON文件 +# DB 写入 +conn = get_conn() +for s in wl.get("stocks", []): + s.setdefault("currency", "CNY") + write_watchlist_stock(conn, s) +conn.close() +# [migrated to DB] — cold backup removed +# json.dump(wl, open(WL, "w"), indent=2, ensure_ascii=False) + +print(f"\n自选: {before} → {after} 只") +print(f"移除 {len(removed)} 只:") +for r in removed: + print(f" {r['code']} {r.get('name','')}") + +# Also update decisions.json - set them to "managed_by_holdings" or remove watchlist-only fields +dec = read_decisions() +dec_changed = 0 +for d in dec.get("decisions", []): + code = d.get("code", "") + if code in [r["code"] for r in removed]: + # Remove watchlist-specific tags + if d.get("tag") == "watchlist": + d["tag"] = "managed_by_holdings" + dec_changed += 1 + +if dec_changed: + # DB 写入(不再碰JSON文件) + conn = get_conn() + for d in dec.get("decisions", []): + write_holding_strategy(conn, d.get("code", ""), d.get("name", ""), d) + conn.close() + # [migrated to DB] — cold backup removed + # json.dump(dec, open(DEC, "w"), indent=2, ensure_ascii=False) + print(f"\ndecisions数据: {dec_changed} 只更新标签") +else: + print(f"\ndecisions数据: 无需更新") + +# ── 反过程:清仓股自动加回自选 ── +# 找出曾持仓但现已不在 portfolio 的股票 +prev_held = {} # code → last_execution info +for d in dec.get("decisions", []): + code = d.get("code", "") + exec_info = d.get("execution", {}) + if exec_info and exec_info.get("status") in ("executing", "partial_exit"): + # 当前仍持仓但不在 portfolio?说明 portfolio 数据落后,跳过 + pass + elif exec_info and exec_info.get("status") in ("sold", "closed") and code not in holding_codes: + prev_held[code] = { + "name": d.get("name", code), + "entry_low": d.get("entry_low", 0), + "entry_high": d.get("entry_high", 0), + } + +if prev_held: + wl_stock_codes = set(s.get("code", "") for s in new_stocks) + added = 0 + for code, info in sorted(prev_held.items()): + if code not in wl_stock_codes and code not in holding_codes: + # 确保买入区有值 + entry_low = info.get("entry_low", 0) or 0 + entry_high = info.get("entry_high", 0) or 0 + new_stocks.append({ + "code": code, + "name": info["name"], + "entry_low": entry_low, + "entry_high": entry_high, + "stop_loss": 0, + "tag": "recovered_from_sold", + }) + added += 1 + print(f" ← 已清仓→加回自选: {code} {info['name']}") + if added: + wl["stocks"] = new_stocks + # DB 写入 + conn = get_conn() + for s in wl.get("stocks", []): + s.setdefault("currency", "CNY") + write_watchlist_stock(conn, s) + conn.close() + # [migrated to DB] — cold backup removed + # json.dump(wl, open(WL, "w"), indent=2, ensure_ascii=False) + print(f"\n反过程: {added} 只清仓股已加回自选") + else: + print("\n反过程: 无清仓股需加回") +else: + print("\n反过程: 无已清仓记录") + +print("\nDONE") diff --git a/deploy/profile-scripts/closing_brief.py b/deploy/profile-scripts/closing_brief.py new file mode 100644 index 00000000..88ae6320 --- /dev/null +++ b/deploy/profile-scripts/closing_brief.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Wrapper script: generates closing brief report. +Calls generate_report.py with the 'closing_brief' argument. +Hermes cron runner does not support script arguments, so this wrapper +is needed to work around that limitation. +""" +import sys +import subprocess +from pathlib import Path + +script_dir = Path(__file__).parent.resolve() +target = script_dir / "generate_report.py" + +if not target.exists(): + print(f"ERROR: generate_report.py not found at {target}", file=sys.stderr) + sys.exit(1) + +result = subprocess.run( + [sys.executable, str(target), "closing_brief"], + capture_output=True, + text=True, + timeout=120, +) + +if result.stdout: + print(result.stdout.strip()) + +if result.returncode != 0: + error_msg = f"Script exited with code {result.returncode}" + if result.stderr: + error_msg += f"\nstderr:\n{result.stderr}" + print(error_msg, file=sys.stderr) + sys.exit(result.returncode) + +if result.stderr: + print(f"stderr:\n{result.stderr}", file=sys.stderr) diff --git a/deploy/profile-scripts/collect_evaluation_data.py b/deploy/profile-scripts/collect_evaluation_data.py new file mode 100644 index 00000000..293ca5f8 --- /dev/null +++ b/deploy/profile-scripts/collect_evaluation_data.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python3 +"""collect_evaluation_data.py — 六维评估原始数据采集 + +纯数据收集脚本(no_agent),不做任何评估/判断/RR计算。 +输出:data/evaluation_input.json — 供 21:00 LLM cron 使用。 + +采集内容: +D1 宏观环境 — 五大指数(上证/深证/恒生/恒科/A50) +D2 行业表现 — 持仓+自选按行业分组 +D3 技术面(当前) — 今开/今高/今低/昨收/现价/成交量 +D4 基本面 — PE/PB/总市值/52周高/52周低 +D5 消息面 — (此脚本不采集,LLM cron web_search) +D6 资金面 — 成交额/换手率/量比 + +日期:2026-06-18 v1 — 初始版本 +""" + +import json +import urllib.request +import os +import sys +import re +from datetime import datetime +from pathlib import Path + +DATA_DIR = Path(__file__).parent.parent / "data" +PROFILES_PATH = DATA_DIR / "stock_profiles.json" +OUTPUT_PATH = DATA_DIR / "evaluation_input.json" + +UA = "Mozilla/5.0" + + +def load_json(path, default=None): + try: + with open(path, encoding="utf-8") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} if default is None else default + + +def save_json(path, data): + Path(path).parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def fetch_tencent_data(symbols): + """批量拉行情。DB 优先,腾讯 API fallback""" + if not symbols: + return {} + # DB 优先 + try: + from mofin_db import get_prices_batch_from_db + db = get_prices_batch_from_db(symbols) + if db: + return {code: {"name": "", "price": p, "prev_close": 0, "change_pct": chg or 0, + "high": 0, "low": 0} for code, (p, chg) in db.items()} + except: pass + # Fallback: 腾讯 + code_map = {} + query_symbols = [] + for c in symbols: + sym = f"hk{c}" if len(c) == 5 else f"sh{c}" if c.startswith(("5", "6", "9")) else f"sz{c}" + query_symbols.append(sym) + code_map[sym] = c + url = f"http://qt.gtimg.cn/q={','.join(query_symbols)}" + try: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + resp = urllib.request.urlopen(req, timeout=15) + text = resp.read().decode("gbk") + except Exception as e: + print(f"行情拉取失败: {e}", file=sys.stderr) + return {} + result = {} + for line in text.strip().split("\n"): + line = line.strip() + if not line or "=" not in line: + continue + raw = line.split("=", 1)[1].strip().strip('"').strip(";") + fields = raw.split("~") + if len(fields) < 35: + continue + sym = line.split("=", 1)[0].strip().lstrip("v_") + orig = code_map.get(sym) + if not orig: + continue + # 统一格式(A股和港股字段长度不同) + result[orig] = fields + return result + + +def fetch_indices(): + """拉五大指数""" + index_codes = { + "sh000001": "上证指数", + "sz399001": "深证成指", + "sz399006": "创业板指", + "hkHSI": "恒生指数", + "hkHSTECH": "恒生科技", + } + idx_map = {} + for c, n in index_codes.items(): + sym = c # 已经是完整符号 + idx_map[sym] = n + url = f"http://qt.gtimg.cn/q={','.join(index_codes.keys())}" + try: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + resp = urllib.request.urlopen(req, timeout=10) + text = resp.read().decode("gbk") + except Exception as e: + print(f"指数拉取失败: {e}", file=sys.stderr) + return {} + result = {} + for line in text.strip().split("\n"): + line = line.strip() + if not line or "=" not in line: + continue + raw = line.split("=", 1)[1].strip().strip('"').strip(";") + fields = raw.split("~") + if len(fields) < 33: + continue + sym = line.split("=", 1)[0].strip().lstrip("v_") + name = idx_map.get(sym, sym) + result[name] = { + "price": safe_float(fields[3]), + "prev_close": safe_float(fields[4]), + "change_pct": safe_float(fields[32]), + "high": safe_float(fields[33]), + "low": safe_float(fields[34]), + "timestamp": fields[30] if len(fields) > 30 else "", + } + return result + + +def safe_float(v): + try: + return float(v) if v else None + except (ValueError, TypeError): + return None + + +def parse_stock_data(code, fields, is_hk=False): + """从腾讯 API 字段解析为结构化数据""" + # fields可能是dict(已解析)或list(原始split) + if isinstance(fields, dict): + data = { + "code": code, + "name": fields.get("name", code), + "price": safe_float(fields.get("price", 0)), + "prev_close": safe_float(fields.get("prev_close", 0)), + "open": safe_float(fields.get("open", 0)) if not is_hk else None, + "change_pct": safe_float(fields.get("change_pct", 0)), + "high": safe_float(fields.get("high", 0)), + "low": safe_float(fields.get("low", 0)), + "volume": safe_float(fields.get("volume", 0)), + } + else: + data = { + "code": code, + "name": fields[1] if len(fields) > 1 else code, + "price": safe_float(fields[3]), + "prev_close": safe_float(fields[4]), + "open": safe_float(fields[5]) if not is_hk else None, + "change_pct": safe_float(fields[32]), + "high": safe_float(fields[33]), + "low": safe_float(fields[34]), + "volume": safe_float(fields[6]), + } + # A股特有字段 (index 35+) + if not is_hk: + if isinstance(fields, dict): + data["turnover_rate"] = safe_float(fields.get("turnover_rate", 0)) + data["amplitude"] = safe_float(fields.get("amplitude", 0)) + data["pe"] = safe_float(fields.get("pe", 0)) + data["market_cap"] = safe_float(fields.get("market_cap", 0)) + else: + data["turnover_rate"] = safe_float(fields[38]) if len(fields) > 38 else 0 + data["amplitude"] = safe_float(fields[39]) if len(fields) > 39 else 0 + data["pe"] = safe_float(fields[39]) if len(fields) > 39 else 0 + data["market_cap"] = safe_float(fields[45]) if len(fields) > 45 else 0 + # 港股特有 + if is_hk: + data["market"] = "HK" + if isinstance(fields, dict): + data["high_52w"] = safe_float(fields.get("high_52w", 0)) + data["low_52w"] = safe_float(fields.get("low_52w", 0)) + data["amplitude"] = safe_float(fields.get("amplitude", 0)) + else: + data["high_52w"] = safe_float(fields[48]) if len(fields) > 48 else 0 + data["low_52w"] = safe_float(fields[49]) if len(fields) > 49 else 0 + data["amplitude"] = safe_float(fields[43]) if len(fields) > 43 else 0 + # 港股特有字段 + if is_hk: + if isinstance(fields, dict): + data["pe"] = safe_float(fields.get("pe", 0)) + data["total_market_cap"] = safe_float(fields.get("total_market_cap", 0)) + data["high_52w"] = safe_float(fields.get("high_52w", 0)) + data["low_52w"] = safe_float(fields.get("low_52w", 0)) + else: + if len(fields) > 70: + data["pe"] = safe_float(fields[71]) + data["total_market_cap"] = safe_float(fields[69]) + data["high_52w"] = safe_float(fields[48]) + data["low_52w"] = safe_float(fields[49]) + return data + + +def get_sector_mapping(profiles, decisions): + """ + 从 stock_profiles.json 和 decisions.json 建立 + {code: {name, sector, business, market, type}} 映射 + """ + mapping = {} + # 先读 stock_profiles + profile_list = profiles.get("profiles", []) if isinstance(profiles, dict) else profiles + if isinstance(profile_list, list): + for p in profile_list: + code = p.get("code", "") + if code: + mapping[code] = { + "name": p.get("name", ""), + "sector": p.get("sector", ""), + "business": p.get("business", ""), + "market": p.get("market", ""), + "type": p.get("type", ""), + } + # 再补全 decisions.json 中的信息 + for d in decisions.get("decisions", []): + code = d.get("code", "") + if code and code not in mapping: + trig = d.get("trigger", {}) + mapping[code] = { + "name": d.get("name", code), + "sector": trig.get("sector_name", d.get("sector_name", "")), + "business": "", + "market": "港股" if len(code) == 5 else "A股", + "type": d.get("type", "持仓策略"), + } + return mapping + + +def get_portfolio_info(portfolio): + """建立 {code: {cost, shares, position_pct}} 映射""" + result = {} + for h in portfolio.get("holdings", []): + code = h.get("code", "") + result[code] = { + "cost": h.get("cost", 0), + "shares": h.get("shares", 0), + "position_pct": h.get("position_pct", 0), + } + return result + + +def get_decisions_info(decisions): + """提取 decisions.json 中的策略参数""" + return decisions.get("decisions", []) + + +def run(): + # 加载数据 + from mo_data import read_decisions, read_portfolio + decisions = read_decisions() + portfolio = read_portfolio() + profiles = load_json(PROFILES_PATH, {"profiles": []}) + + # 获取行业映射 + sector_mapping = get_sector_mapping(profiles, decisions) + + # 获取持仓信息 + portfolio_info = get_portfolio_info(portfolio) + + # 收集所有代码 + all_codes = set() + for d in decisions.get("decisions", []): + code = d.get("code", "") + if code: + all_codes.add(code) + for h in portfolio.get("holdings", []): + code = h.get("code", "") + if code: + all_codes.add(code) + + # 区分 A/H 股 + a_codes = [c for c in all_codes if len(c) != 5] + hk_codes = [c for c in all_codes if len(c) == 5] + + # 拉行情 + a_prices = fetch_tencent_data(a_codes) if a_codes else {} + hk_prices = fetch_tencent_data(hk_codes) if hk_codes else {} + + # 拉指数 + index_data = fetch_indices() + + # 解析个股数据 + stock_data = {} + for code in a_codes: + if code in a_prices: + stock_data[code] = parse_stock_data(code, a_prices[code], is_hk=False) + for code in hk_codes: + if code in hk_prices: + stock_data[code] = parse_stock_data(code, hk_prices[code], is_hk=True) + + # 组装输出 + stocks = [] + all_codes_sorted = sorted(all_codes) + + for code in all_codes_sorted: + raw = stock_data.get(code, {}) + sector_info = sector_mapping.get(code, {}) + port = portfolio_info.get(code, {}) + strategy = None + for d in decisions.get("decisions", []): + if d.get("code") == code: + trig = d.get("trigger", {}) + strategy = { + "action": trig.get("action", d.get("action", "")), + "entry_zone": trig.get("entry_zone", ""), + "stop_loss": trig.get("stop_loss", d.get("stop_loss", "")), + "take_profit": trig.get("take_profit", d.get("take_profit", "")), + "type": d.get("type", "持仓策略"), + "tech_snapshot": trig.get("tech_snapshot", d.get("tech_snapshot", "")), + } + break + + stock_entry = { + "code": code, + "name": raw.get("name", sector_info.get("name", code)), + "market": "港股" if len(code) == 5 else "A股", + "type": sector_info.get("type", "持仓策略"), + "sector": sector_info.get("sector", ""), + "business": sector_info.get("business", ""), + # 当天行情 + "price": raw.get("price"), + "prev_close": raw.get("prev_close"), + "open": raw.get("open"), + "high": raw.get("high"), + "low": raw.get("low"), + "change_pct": raw.get("change_pct"), + "volume": raw.get("volume"), + # 基本面 + "pe": raw.get("pe"), + "total_market_cap": raw.get("total_market_cap"), + "high_52w": raw.get("high_52w"), + "low_52w": raw.get("low_52w"), + "turnover_rate": raw.get("turnover_rate"), + "amplitude": raw.get("amplitude"), + # 持仓 + "cost": port.get("cost", 0), + "shares": port.get("shares", 0), + "position_pct": port.get("position_pct", 0), + # 现策略 + "strategy": strategy, + } + # 浮亏% + cost = port.get("cost", 0) + price = raw.get("price", 0) + if cost > 0 and price > 0: + stock_entry["pnl_pct"] = round((price - cost) / cost * 100, 2) + else: + stock_entry["pnl_pct"] = None + + stocks.append(stock_entry) + + # 按行业分组统计 + sector_groups = {} + for s in stocks: + sector = s.get("sector", "未分类") + if sector not in sector_groups: + sector_groups[sector] = [] + sector_groups[sector].append({ + "code": s["code"], + "name": s["name"], + "change_pct": s["change_pct"], + "pnl_pct": s["pnl_pct"], + "type": s["type"], + }) + + # 汇总 + total = len(stocks) + up_count = sum(1 for s in stocks if s["change_pct"] is not None and s["change_pct"] > 0) + down_count = sum(1 for s in stocks if s["change_pct"] is not None and s["change_pct"] < 0) + deep_loss = sum(1 for s in stocks if s["pnl_pct"] is not None and s["pnl_pct"] < -20) + + output = { + "collected_at": datetime.now().isoformat(), + "total_stocks": total, + "summary": { + "up_count": up_count, + "down_count": down_count, + "deep_loss_count": deep_loss, + "holdings_count": len(portfolio_info), + "watchlist_count": total - len(portfolio_info), + }, + "index_data": index_data, + "sector_groups": sector_groups, + "stocks": stocks, + } + + save_json(OUTPUT_PATH, output) + print(f"数据收集完成: {total}只股票, {len(index_data)}个指数, {len(sector_groups)}个行业分组") + print(f" 上涨{up_count} 下跌{down_count} 深套{deep_loss}") + print(f" 输出: {OUTPUT_PATH}") + + +if __name__ == "__main__": + run() diff --git a/deploy/profile-scripts/cron_health_monitor.py b/deploy/profile-scripts/cron_health_monitor.py new file mode 100644 index 00000000..fc26a35d --- /dev/null +++ b/deploy/profile-scripts/cron_health_monitor.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +"""cron_health_monitor.py — 全局cron job健康监控 v2 + +每10分钟跑一次,检查: +1. 所有启用cron job的last_status是否为failed +2. 关键job的last_run_at是否超过预期空闲时间 +3. price_monitor等核心管道是否活着 +4. 自己是否正常跑(自检) + +发现新问题 → 推XMPP告警(同类问题2小时内不重复推) +""" +import json, os, sys, re +from pathlib import Path +from datetime import datetime, timezone, timedelta +from urllib.request import Request, urlopen + +XMPP_BRIDGE = "http://127.0.0.1:5805/" +XMPP_USER = "hmo@yoin.fun" +STATE_FILE = Path.home() / ".hermes" / ".cron_health_state.json" +COOLDOWN_HOURS = 2 # 同类告警2小时内不重复 + +# 关键job及其最大允许空闲分钟 +# 只监控实时管道(每分~每小时跑的关键任务) +# 日/周任务不在此列(它们不耽误实时推荐也不需要秒级响应) +CRITICAL_JOBS = { + "price_monitor": {"max_idle_min": 15}, # 每2分 + "MoFin盘前中监控": {"max_idle_min": 90}, # 每25分(盘中) + "自选买入区提醒": {"max_idle_min": 90}, # 每30分 + "自选买入区提醒-盘前午间尾盘": {"max_idle_min": 185}, # 每3小时(9/12/15点),允许185分 + "宏观风险信号消费": {"max_idle_min": 40}, # 每15分 + "重评管道审计": {"max_idle_min": 90}, # 每30分 + "全局cron健康监控": {"max_idle_min": 30}, # 每10分 ← 自己 + "实时消息中继": {"max_idle_min": 20}, + "宏观上下文刷新": {"max_idle_min": 90}, # 每5分 +} + +def xmpp_push(text): + try: + payload = json.dumps({"to": XMPP_USER, "body": text, "type": "chat"}).encode() + req = Request(XMPP_BRIDGE, data=payload, headers={"Content-Type": "application/json"}) + urlopen(req, timeout=5) + return True + except Exception as e: + print(f"[XMPP推送失败] {e}", file=sys.stderr) + return False + + +def _create_cron_todo(tname, desc): + """为故障cron创建TODO,fix_action带自动重试+修复逻辑""" + try: + # 找这个cron对应的脚本 + script_path = None + try: + import json as _j + with open("/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json") as _f: + _crons = _j.load(_f).get("jobs", []) + for _c in _crons: + if _c.get("name") == tname: + _s = _c.get("script", "") + if _s: + _sp = f"/home/hmo/.hermes/profiles/position-analyst/scripts/{_s.split()[0]}" + if __import__('os').path.exists(_sp): + script_path = _sp + break + except Exception: + pass + + if script_path: + fix = ( + f"cd /home/hmo/MoFin && timeout 60 python3 {script_path} 2>&1 " + f"| tail -5; if [ $? -eq 0 ]; then " + f"python3 -c \"import json; p='/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json'; " + f"d=json.load(open(p)); " + f"for j in d['jobs']: " + f" if j.get('name')=='{tname}': j['last_status']='ok'; " + f"json.dump(d,open(p,'w'),ensure_ascii=False,indent=2)\" " + f"&& echo 'AUTO_FIXED'; " + f"else echo 'AUTO_FAIL'; fi" + ) + else: + fix = ( + f"cd /home/hmo/MoFin && echo 'check cron: {tname}' && " + f"python3 scripts/preflight_verify.py --cron-check" + ) + + _c = __import__('sqlite3').connect("/home/hmo/MoFin/data/mofin.db", timeout=10) + _c.execute( + "INSERT OR REPLACE INTO todos (id, title, description, status, priority, source, fix_action, created_at) " + "VALUES (?, ?, ?, 'pending', 'high', 'cron_health', ?, datetime('now','localtime'))", + (f"cron_err_{tname.replace(' ','_')}", f"[CRON_ERROR] {tname}", desc, fix) + ) + _c.commit() + _c.close() + except Exception: + pass + + +def parse_time(ts_str): + if not ts_str: + return None + try: + dt = datetime.fromisoformat(ts_str) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + except Exception: + return None + +def get_idle_minutes(last_run_str): + """计算上次运行到现在过了多少分钟""" + last = parse_time(last_run_str) + if not last: + return None + now = datetime.now(timezone.utc) + return (now - last).total_seconds() / 60 + +def scan_jobs_files(): + """扫描所有cron jobs.json,合并返回全量job列表""" + jobs = [] + seen = set() + for jf in [ + "/home/hmo/.hermes/cron/jobs.json", + "/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json", + ]: + try: + data = json.load(open(jf)) + for job in data.get("jobs", []): + jid = job.get("id", "") + if jid in seen: + continue + seen.add(jid) + jobs.append({ + "id": jid, + "name": job.get("name", jid[:12]) or jid[:12], + "status": job.get("last_status", ""), + "last_run": job.get("last_run_at", ""), + "enabled": job.get("enabled", True), + "no_agent": job.get("no_agent", False), + "schedule": job.get("schedule", {}), + }) + except Exception: + pass + return jobs + +def check_idle(jobs): + """检查关键job是否超过最大空闲时间""" + stale = [] + for j in jobs: + if not j["enabled"]: + continue + name = j["name"] + rule = None + for key, r in CRITICAL_JOBS.items(): + if key.lower() in name.lower(): + rule = r + break + if not rule: + continue + idle = get_idle_minutes(j["last_run"]) + if idle is None: + stale.append((j, "无运行记录")) + elif idle > rule["max_idle_min"]: + idle_str = f"{idle:.0f}分" + stale.append((j, f"超过{rule['max_idle_min']}分未运行(已{idle_str})")) + return stale + +def load_state(): + try: + with open(STATE_FILE) as f: + return json.load(f) + except Exception: + return {} + +def save_state(state): + STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(STATE_FILE, "w") as f: + json.dump(state, f, indent=2) + +def is_new_alert(alert_key, state): + """检查同类告警是否在cooldown期内""" + now = datetime.now().timestamp() + last = state.get(alert_key, {}).get("last_alerted", 0) + return (now - last) > COOLDOWN_HOURS * 3600 + +def main(): + now_str = datetime.now().strftime("%H:%M") + jobs = scan_jobs_files() + state = load_state() + alerts = [] + + # ── 检查1:last_status=failed ── + failed_jobs = [j for j in jobs if j["status"] == "failed" and j["enabled"]] + if failed_jobs: + for j in failed_jobs: + tag = "no_agent" if j["no_agent"] else "LLM" + key = f"failed_{j['id']}" + if is_new_alert(key, state): + alerts.append(f"❌ {j['name']}({j['id'][:8]}) [{tag}] {j['status']} @ {j['last_run'][:19]}") + state[key] = {"last_alerted": datetime.now().timestamp()} + # 同时写TODO供自愈系统处理 + _create_cron_todo(j['name'], f"[CRON_ERROR] {j['name']} 状态={j['status']} 最后运行={j['last_run'][:19]}") + + # ── 检查2:关键job空闲超时 ── + stale_jobs = check_idle(jobs) + if stale_jobs: + for j, reason in stale_jobs: + key = f"stale_{j['id']}" + if is_new_alert(key, state): + alerts.append(f"⏰ {j['name']}({j['id'][:8]}) {reason}") + state[key] = {"last_alerted": datetime.now().timestamp()} + + # ── 检查3:XMPP bridge是否在线(TCP端口检测,不发消息到Dad) ─ + bridge_key = "bridge_down" + try: + import socket + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(3) + result = sock.connect_ex(("127.0.0.1", 5805)) + sock.close() + bridge_ok = (result == 0) + if not bridge_ok and is_new_alert(bridge_key, state): + alerts.append("🔴 XMPP bridge(5805)端口无响应") + state[bridge_key] = {"last_alerted": datetime.now().timestamp()} + except Exception as e: + if is_new_alert(bridge_key, state): + alerts.append(f"🔴 XMPP bridge检测失败: {e}") + state[bridge_key] = {"last_alerted": datetime.now().timestamp()} + + # ── 检查4:gateway LLM推理是否正常(发真实请求测响应时间) ─ + gateway_key = "gateway_down" + try: + import urllib.request, json, time + payload = json.dumps({ + "model": "position-analyst", + "messages": [{"role": "user", "content": "ok"}], + "max_tokens": 5 + }).encode() + req = urllib.request.Request("http://127.0.0.1:8643/v1/chat/completions", + data=payload, + headers={"Authorization": "Bearer hermes123", "Content-Type": "application/json"}) + t0 = time.time() + resp = urllib.request.urlopen(req, timeout=30) + elapsed = time.time() - t0 + data = json.loads(resp.read()) + usage = data.get("usage", {}) + tok = usage.get("total_tokens", 0) + if elapsed > 25 and is_new_alert(gateway_key, state): + alerts.append(f"🔴 gateway响应慢: {elapsed:.0f}s ({tok}tok)") + state[gateway_key] = {"last_alerted": datetime.now().timestamp()} + except Exception as e: + if is_new_alert(gateway_key, state): + alerts.append(f"🔴 gateway推理失败: {e}") + state[gateway_key] = {"last_alerted": datetime.now().timestamp()} + + # ── 检查5:price_monitor数据新鲜度(直接查DB,不依赖job记录)─ + price_key = "price_stale" + try: + from mofin_db import get_conn + conn = get_conn() + lp = conn.execute("SELECT MAX(updated_at) FROM live_prices").fetchone()[0] + conn.close() + if lp: + lp_dt = datetime.fromisoformat(lp) if isinstance(lp, str) else lp + if hasattr(lp_dt, 'tzinfo') and lp_dt.tzinfo is None: + mins = (datetime.now() - lp_dt).total_seconds() / 60 + if mins > 15 and is_new_alert(price_key, state): + alerts.append(f"🔴 price_monitor {mins:.0f}分未更新数据") + state[price_key] = {"last_alerted": datetime.now().timestamp()} + except Exception as e: + if is_new_alert(price_key, state): + alerts.append(f"🔴 price_monitor查询失败: {e}") + state[price_key] = {"last_alerted": datetime.now().timestamp()} + + # ── 本脚本自检:检查自己是否在job列表里且有正常last_run ── + my_name = "全局cron健康监控" + myself = [j for j in jobs if my_name in j["name"]] + if myself: + m = myself[0] + idle = get_idle_minutes(m["last_run"]) + if idle is None or idle > 30: + # 自己异常但可能还在跑?记录但不推(否则死循环) + print(f"[SELF_CHECK] 自身last_run={m.get('last_run','?')} {idle:.0f}分前", file=sys.stderr) + + save_state(state) + + if not alerts: + print("[SILENT]") + return + + # 推告警 + text = f"🔴 {len(alerts)}条健康告警 ({now_str}):\n" + "\n".join(alerts) + print(text, file=sys.stderr) + xmpp_push(text) + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/cron_to_xmpp.py b/deploy/profile-scripts/cron_to_xmpp.py new file mode 100644 index 00000000..3fa286f0 --- /dev/null +++ b/deploy/profile-scripts/cron_to_xmpp.py @@ -0,0 +1,358 @@ +#!/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中继自身输出 + "a231e9c39b4e", # 知识研究-日常(由莫荷负责推送) + "7bda62d24d22", # 梦境循环-知识库归并(由莫荷负责推送) + "0cbf6c317c60", # evolution-pulse 跨文档连接(由莫荷负责推送) + "1160671067e0", # wiki-self-growth 自修复(由莫荷负责推送) + "health", # 健康检查输出 + "b9fa4482dc1a", # 自成长知识库-22:10中继推送(莫荷的通道) +} + +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) + + if not body or len(body) < 20: + return None + + # 只过滤内容是纯[SILENT]的报告 + if body.strip() == "[SILENT]": + return None + + # 正文中混了[SILENT]标记(LLM写了报告又在末尾加了这个)— 去掉标记保留正文 + body = body.replace("[SILENT]", "").strip() + if len(body) < 20: + 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, "sent": False} + stats["silent"] += n_silent + stats["short"] += n_short + stats["script"] += n_script + save_silent_stats(stats) + + # 收盘后16:35发送一次当日汇总(不重复发) + now = datetime.now() + hhmm = now.hour * 60 + now.minute + if hhmm == 995 and not stats.get("sent"): # 16:35,仅一次 + send_silent_summary(stats) + stats["sent"] = True + save_silent_stats(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/deploy/profile-scripts/data_flow_audit.py b/deploy/profile-scripts/data_flow_audit.py new file mode 100644 index 00000000..666aa147 --- /dev/null +++ b/deploy/profile-scripts/data_flow_audit.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""data_flow_audit.py — 数据流架构审计 + +对比实际读写关系 vs 设计意图(预期读写方),标记违规。 +输出给 mofin_health.py 消费,在 Dashboard 数据流 Tab 显示。 +""" +import re, os, json, subprocess +from pathlib import Path + +PROFILE_SCRIPTS = Path("/home/hmo/.hermes/profiles/position-analyst/scripts") +MOFIN_SCRIPTS = Path("/home/hmo/MoFin/scripts") +SCRIPTS_DIR = PROFILE_SCRIPTS if PROFILE_SCRIPTS.exists() else MOFIN_SCRIPTS + +# ── 设计意图注册表 ── +# 每张核心表的预期写入方(谁应该写)+ 预期用途 +DESIGN_INTENT = { + "live_prices": { + "expected_writers": ["price_monitor"], + "policy": "single_writer", + "desc": "实时价格缓存 — 仅price_monitor写入,其他一律读", + "remarks": "违规: 自行拉API会导致并发写+重复请求" + }, + "holdings": { + "expected_writers": ["import_holding_xls", "dad_asset_update"], + "policy": "restricted", + "desc": "持仓数据 — 仅通过成交截图导入或Dad确认更新", + "remarks": "" + }, + "portfolio_summary": { + "expected_writers": ["import_holding_xls", "dad_asset_update", "price_monitor"], + "policy": "restricted", + "desc": "组合汇总 — 持仓导入+价格更新", + "remarks": "" + }, + "cash_log": { + "expected_writers": ["import_holding_xls", "dad_asset_update"], + "policy": "restricted", + "desc": "资金流水 — 仅截图导入或Dad确认", + "remarks": "" + }, + "holding_strategies": { + "expected_writers": ["strategy_review", "strategy_evaluator", "per_stock_reassess"], + "policy": "multi_writer", + "desc": "策略数据 — 多个分析流程可写", + "remarks": "" + }, + "price_events": { + "expected_writers": ["price_monitor"], + "policy": "single_writer", + "desc": "价格触发事件 — 仅price_monitor写入", + "remarks": "" + }, + "sector_snapshots": { + "expected_writers": ["market_watch"], + "policy": "single_writer", + "desc": "板块快照 — 仅market_watch写入", + "remarks": "" + }, +} + +# 直接拉API的价格违规扫描 +API_PATTERNS = [ + r"qt\.gtimg\.cn", + r"tencent.*quote", + r"get_quote", + r"fetch.*price", + r"stock_quote\.", +] + +def scan_price_violations(): + """扫描不走live_prices直接拉API的脚本""" + violations = [] + for py_file in sorted(SCRIPTS_DIR.glob("*.py")): + name = py_file.stem + if name in ("price_monitor", "stock_quote", "mofin_db", "mo_data", "deploy_sync"): + continue # 这些是基础设施/被允许的 + content = py_file.read_text() + for pat in API_PATTERNS: + if re.search(pat, content): + # 找具体行号 + lines = content.split("\n") + for i, line in enumerate(lines, 1): + if re.search(pat, line): + violations.append({ + "script": name, + "line": i, + "code": line.strip()[:80], + }) + break + return violations + + +def audit(): + """读取当前数据流扫描结果,叠加设计意图""" + from mo_data import get_flow + # 这里应该读mofin_health输出的entities + + violations = scan_price_violations() + + result = { + "design_intent": DESIGN_INTENT, + "price_api_violations": violations, + "summary": { + "total_violations": len(violations), + "violating_scripts": list(set(v["script"] for v in violations)), + } + } + + return result + + +if __name__ == "__main__": + import json + result = audit() + print(json.dumps(result, ensure_ascii=False, indent=2)) diff --git a/deploy/profile-scripts/data_governance.py b/deploy/profile-scripts/data_governance.py new file mode 100644 index 00000000..42041628 --- /dev/null +++ b/deploy/profile-scripts/data_governance.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""data_governance.py — MoFin 数据治理 (no_agent) + +1. holding_strategies 去重归档 +2. 检查缺失策略的持仓 +3. 报告数据健康状况 +""" + +import json, sqlite3 +from pathlib import Path +from datetime import datetime +from mo_data import read_portfolio, read_decisions, read_watchlist + +BASE = Path("/home/hmo/MoFin") +DATA = BASE / "data" +DB_PATH = DATA / "mofin.db" + + +def clean_holding_strategies(conn): + """归档旧策略,只保留每只股票最新一条""" + codes = conn.execute( + "SELECT code, COUNT(*) as cnt, MAX(created_at) as latest " + "FROM holding_strategies GROUP BY code HAVING cnt > 1" + ).fetchall() + + total_archived = 0 + for code, cnt, latest in codes: + # 标记除了最新一条以外的所有记录为已归档 + conn.execute( + "UPDATE holding_strategies SET superseded_at=? " + "WHERE code=? AND created_at 0 and price > 0: + loss = (price - cost) / cost * 100 + if loss < -25: + deep.append((d.get("name",""), d["code"], loss, d.get("stop_loss",0))) + if deep: + print(f"\n🔴 {len(deep)}只深套(>-25%):") + for name, code, loss, sl in deep: + print(f" {name}({code}): {loss:.0f}% 止损={sl}") + else: + print("\n✅ 无深套持仓") + + conn.close() + + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/divergence_detector.py b/deploy/profile-scripts/divergence_detector.py new file mode 100644 index 00000000..b146aa53 --- /dev/null +++ b/deploy/profile-scripts/divergence_detector.py @@ -0,0 +1,356 @@ +#!/usr/bin/env python3 +""" +divergence_detector.py — 跨市场背离监测器(no_agent) + +每30分钟检测: + 1. 科创50 vs 恒指 → 科技股超买/超卖信号 + 2. A/H 隐含溢价 → 内外资分歧度 + 3. 上证50 vs 创业板 → 风格轮动信号 + 4. 恒指 vs 国企指数 → 离岸市场情绪 + 5. 指数连涨/连跌天数 → 趋势延续/衰竭 + +输出: + - HIGH/medium divergence → 写入 signal_news (source=divergence_watch) + - 状态文件 macro_divergence_state.json + - no_agent: 有信号才出声 +""" +import sys, json, re, datetime, os, requests +from pathlib import Path + +BASE = Path("/home/hmo/MoFin") +DATA = BASE / "data" +DB_PATH = DATA / "mofin.db" +STATE_PATH = DATA / "macro_divergence_state.json" + +# ── 监测的指数 ── +INDEX_CODES = { + "上证指数": "sh000001", + "深证成指": "sz399001", + "创业板指": "sz399006", + "科创50": "sh000688", + "上证50": "sh000016", + "沪深300": "sh000300", + "恒生指数": "hkHSI", + "国企指数": "hkHSCEI", +} + +# ── 背离阈值 ── +DIVERGENCE_STRONG = 5.0 # >5% → strong信号 +DIVERGENCE_MODERATE = 3.0 # >3% → moderate信号 +STREAK_DAYS = 3 # 连涨/连跌3天 → 信号 + +def fetch_indices(): + """获取所有指数实时数据(直接调用新浪API,mo_data不支持指数代码)""" + import requests + sina_map = { + "sh000001": "s_sh000001", + "sz399001": "s_sz399001", + "sz399006": "s_sz399006", + "sh000688": "s_sh000688", + "sh000016": "s_sh000016", + "sh000300": "s_sh000300", + "hkHSI": "rt_hkHSI", + "hkHSCEI": "rt_hkHSCEI", + } + url = "http://hq.sinajs.cn/list=" + ",".join(sina_map.values()) + headers = {"Referer": "https://finance.sina.com.cn"} + try: + r = requests.get(url, headers=headers, timeout=10) + r.encoding = "gbk" + except Exception as e: + print(f"[DIVERGE] 采集失败: {e}", file=sys.stderr) + return {} + + indices = {} + for line in r.text.strip().split("\n"): + line = line.strip() + if not line: + continue + # Parse: var hq_str_XXXX="fields,..."; + try: + var_name = line.split('"')[0].rsplit("_", 1)[-1].rstrip("=") + fields = line.split('"')[1].split(",") + except (IndexError, ValueError): + continue + + # Find the symbol + sym = None + for s, sn in sina_map.items(): + if sn.endswith(var_name): + sym = s + break + if not sym: + continue + + if sym.startswith("hk"): + # HK: name,price,open,high,low,prev_close,change,change_pct,... + name = fields[1] + price = float(fields[2]) if fields[2] else 0 + change_pct = float(fields[8]) if len(fields) > 8 and fields[8] else 0 + else: + # A-share: name,price,change,change_pct,... + name = fields[0] + price = float(fields[1]) if fields[1] else 0 + change_pct = float(fields[3]) if len(fields) > 3 and fields[3] else 0 + + indices[sym] = { + "name": name, + "price": price, + "close": 0, + "change_pct": change_pct, + "high": 0, + "low": 0, + "timestamp": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + } + + return indices + +def load_history(): + """从MACRO_CONTEXT_LOG加载前几天的指数数据""" + try: + import sqlite3 + conn = sqlite3.connect(str(DB_PATH)) + rows = conn.execute( + "SELECT indices, created_at FROM macro_context_log " + "WHERE has_valid_data=1 ORDER BY created_at DESC LIMIT 5" + ).fetchall() + conn.close() + history = [] + for row in rows: + idx_data = json.loads(row[0]) if row[0] else {} + history.append({ + "indices": idx_data, + "timestamp": row[1], + }) + return history + except Exception: + return [] + +def detect_divergences(indices, history): + """ + 检测跨市场背离信号 + 返回: list of signal dicts {type, level, desc, pairs} + """ + signals = [] + + def get(name): + """按中文名找指数""" + sym = INDEX_CODES.get(name) + if sym and sym in indices: + return indices[sym] + # 模糊匹配 + for s, idx in indices.items(): + if name in idx.get("name", ""): + return idx + return None + + sh_comp = get("上证指数") + sz_comp = get("深证成指") + cyb = get("创业板指") + kc = get("科创50") + sz50 = get("上证50") + hs300 = get("沪深300") + hsi = get("恒生指数") + hscei = get("国企指数") + + # ── 信号1: 科创50 vs 恒指(科技股vs国际资本)── + if kc and hsi: + kc_chg = kc["change_pct"] + hsi_chg = hsi["change_pct"] + divergence = abs(kc_chg - hsi_chg) + if divergence > DIVERGENCE_STRONG: + signals.append({ + "type": "a_h_tech_divergence", + "level": "high", + "desc": f"科创50({kc_chg:+.1f}%) vs 恒指({hsi_chg:+.1f}%) 背离{divergence:.1f}个百分点", + "pairs": [kc, hsi], + "direction": "risk" if kc_chg > hsi_chg else "opportunity", + }) + elif divergence > DIVERGENCE_MODERATE: + signals.append({ + "type": "a_h_tech_divergence", + "level": "medium", + "desc": f"科创50({kc_chg:+.1f}%) vs 恒指({hsi_chg:+.1f}%) 背离{divergence:.1f}个百分点", + "pairs": [kc, hsi], + "direction": "risk" if kc_chg > hsi_chg else "opportunity", + }) + + # ── 信号2: 上证50 vs 创业板(价值vs成长)── + if sz50 and cyb: + sz50_chg = sz50["change_pct"] + cyb_chg = cyb["change_pct"] + divergence = abs(sz50_chg - cyb_chg) + if divergence > DIVERGENCE_STRONG: + direction = "opportunity" if sz50_chg > cyb_chg else "risk" + signals.append({ + "type": "value_growth_divergence", + "level": "high", + "desc": f"上证50({sz50_chg:+.1f}%) vs 创业板({cyb_chg:+.1f}%) 背离{divergence:.1f}个百分点", + "pairs": [sz50, cyb], + "direction": direction, + }) + elif divergence > DIVERGENCE_MODERATE: + direction = "opportunity" if sz50_chg > cyb_chg else "risk" + signals.append({ + "type": "value_growth_divergence", + "level": "medium", + "desc": f"上证50({sz50_chg:+.1f}%) vs 创业板({cyb_chg:+.1f}%) 背离{divergence:.1f}个百分点", + "pairs": [sz50, cyb], + "direction": direction, + }) + + # ── 信号3: 恒指 vs 国企指数(国际资本流向)── + if hsi and hscei: + hsi_chg = hsi["change_pct"] + hscei_chg = hscei["change_pct"] + if hsi_chg < 0 and hscei_chg < hsi_chg: + # 国企跌得比恒指多 → 外资恐慌性卖出H股 + signals.append({ + "type": "hk_panic_selling", + "level": "high", + "desc": f"国企指数({hscei_chg:+.1f}%)跌幅大于恒指({hsi_chg:+.1f}%)→外资恐慌抛售H股", + "pairs": [hscei, hsi], + "direction": "risk", + }) + + # ── 信号4: A/H 价格背离(用历史数据检测趋势延续)── + if history and len(history) >= 2: + latest = history[0]["indices"] + prev = history[1]["indices"] + + # 科创50连涨检测 + if kc: + kc_now = kc["change_pct"] + kc_prev = prev.get("科创50", {}).get("change_pct", 0) if "科创50" in prev else 0 + kc_yest = latest.get("科创50", {}).get("change_pct", 0) + + # 检测连涨 + if isinstance(kc_yest, (int, float)) and isinstance(kc_prev, (int, float)): + streak = 0 + if kc_yest > 0: streak += 1 + if kc_prev > 0: streak += 1 + if kc_now > 0: streak += 1 + if streak >= STREAK_DAYS and kc_now > 0: + signals.append({ + "type": "tech_streak", + "level": "medium", + "desc": f"科创50连涨{streak}日({kc_prev:+.1f}%→{kc_yest:+.1f}%→{kc_now:+.1f}%)→超买风险", + "pairs": [kc], + "direction": "risk", + }) + + # ── 信号5: 大盘宽度 + 季节效应 ── + now = datetime.datetime.now() + is_month_end = now.day >= 25 # 月末最后一周 + is_friday = now.weekday() == 4 + + # 总体判断 + risk_count = sum(1 for s in signals if s["direction"] == "risk") + opp_count = sum(1 for s in signals if s["direction"] == "opportunity") + + # 月末+周五叠加 → 脆弱性增强 + if is_month_end and is_friday and risk_count >= 2: + signals.append({ + "type": "time_window_risk", + "level": "high", + "desc": f"月末({now.day}日)+周五效应+{risk_count}个风险信号叠加→市场脆弱性高", + "pairs": [], + "direction": "risk", + }) + elif is_month_end and risk_count >= 1: + signals.append({ + "type": "time_window_risk", + "level": "medium", + "desc": f"月末窗口({now.day}日)+{risk_count}个风险信号→注意控制仓位", + "pairs": [], + "direction": "risk", + }) + + return signals + +def write_state(signals, indices): + """写入状态文件,供监控 cron 消费""" + levels = [s["level"] for s in signals] + highest = "high" if "high" in levels else ("medium" if "medium" in levels else "none") + directions = [s["direction"] for s in signals] + bias = "risk" if directions.count("risk") > directions.count("opportunity") else "opportunity" + + state = { + "level": highest, + "bias": bias, + "signal_count": len(signals), + "signals": signals, + "indices": {k: v["change_pct"] for k, v in indices.items()}, + "created_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + } + STATE_PATH.write_text(json.dumps(state, ensure_ascii=False, indent=2)) + +def write_to_signal_news(signals): + """HIGH signal → signal_news""" + if not signals: + return + import sqlite3 + conn = sqlite3.connect(str(DB_PATH)) + + high_signals = [s for s in signals if s["level"] == "high"] + if high_signals: + for s in high_signals: + conn.execute( + "INSERT INTO signal_news (signal_id, sector, overall_sentiment, summary, key_articles, searched_stocks, source, processed) VALUES (?, ?, ?, ?, ?, ?, ?, 1)", + (0, "跨市场", f"背离-{s['direction'].upper()}", s["desc"], json.dumps(s, ensure_ascii=False), "", "divergence_watch") + ) + conn.commit() + + med_signals = [s for s in signals if s["level"] == "medium"] + if med_signals: + summary = "\n".join([s["desc"] for s in med_signals]) + conn.execute( + "INSERT INTO signal_news (signal_id, sector, overall_sentiment, summary, key_articles, searched_stocks, source, processed) VALUES (?, ?, ?, ?, ?, ?, ?, 1)", + (0, "跨市场", "背离-MEDIUM", summary, json.dumps(med_signals, ensure_ascii=False), "", "divergence_watch") + ) + conn.commit() + + conn.close() + +def print_report(signals, indices): + """no_agent 输出""" + if not signals: + return # SILENT + + # 有信号就输出 + high = [s for s in signals if s["level"] == "high"] + med = [s for s in signals if s["level"] == "medium"] + + lines = [] + if high: + for s in high: + icon = "\u26a0\ufe0f" if s["direction"] == "risk" else "\u2b06\ufe0f" + lines.append(f"[DIVERGE] {icon} {s['level'].upper()} {s['type']}: {s['desc']}") + if med: + for s in med[:3]: # 最多3条 + icon = "\u26a0\ufe0f" if s["direction"] == "risk" else "\u2b06\ufe0f" + lines.append(f"[DIVERGE] {icon} {s['level'].upper()} {s['type']}: {s['desc']}") + + # 输出指数全景 + idx_line = " | ".join([f"{n}: {indices.get(s, {}).get('change_pct', 0):+.1f}%" for n, s in INDEX_CODES.items() if s in indices]) + lines.append(f"[DIVERGE] 指数全景: {idx_line}") + + print("\n".join(lines)) + +def main(): + indices = fetch_indices() + if not indices: + return + + history = load_history() + signals = detect_divergences(indices, history) + + # 写入 state + signal_news + write_state(signals, indices) + write_to_signal_news(signals) + + # no_agent 输出 + print_report(signals, indices) + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/fix_gateway.py b/deploy/profile-scripts/fix_gateway.py new file mode 100644 index 00000000..85a7c5b6 --- /dev/null +++ b/deploy/profile-scripts/fix_gateway.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""fix_gateway.py — 网关健康检查+自愈脚本。 + +检查 gateway_zhiwei (position-analyst profile) 是否存活,端口8643是否开放。 +如果挂了则重启。 + +执行器调用方式: python3 fix_gateway.py +""" +import sys, os, json, subprocess, socket + +GATEWAY_PORT = 8643 +GATEWAY_PROFILE = "position-analyst" +HERMES_BIN = "/home/hmo/hermes-agent/.venv/bin/python" +GATEWAY_WD = "/home/hmo/hermes-agent" + +def tcp_check(host="127.0.0.1", port=8643, timeout=2): + """TCP端口检查""" + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + result = s.connect_ex((host, port)) + s.close() + return result == 0 + except Exception: + return False + +def check_gateway_process(): + """检查对应profile的网关进程""" + import subprocess + r = subprocess.run( + ["pgrep", "-f", f"position-analyst.*gateway.*--replace"], + capture_output=True, text=True, timeout=5 + ) + pids = [p.strip() for p in r.stdout.strip().split("\n") if p.strip()] + return pids + +def restart_gateway(): + """重启网关""" + pids = check_gateway_process() + if pids: + for pid in pids: + try: + os.kill(int(pid), 15) # SIGTERM + except: + pass + import time + time.sleep(2) + + # 启动新网关(后台) + subprocess.Popen( + [HERMES_BIN, "-m", "hermes_cli.main", "-p", GATEWAY_PROFILE, + "gateway", "run", "--replace"], + cwd=GATEWAY_WD, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True + ) + return True + +def main(): + component = sys.argv[1] if len(sys.argv) > 1 else "gateway_zhiwei" + + # 1. TCP端口检查 + port_ok = tcp_check(port=GATEWAY_PORT) + + # 2. 进程检查 + pids = check_gateway_process() + proc_ok = len(pids) > 0 + + if port_ok and proc_ok: + print(f"[OK] {component}: TCP端口{GATEWAY_PORT}开放, 进程={','.join(pids)}") + return 0 + + # 异常情况 + print(f"[DOWN] {component}: TCP端口={port_ok}, 进程={proc_ok} pids={pids}") + print(f"[FIX] 正在重启网关...") + + if restart_gateway(): + import time + time.sleep(5) + port_ok = tcp_check(port=GATEWAY_PORT) + pids = check_gateway_process() + if port_ok and pids: + print(f"[OK] {component}: 重启成功 端口={port_ok} pids={pids}") + return 0 + else: + print(f"[FAIL] {component}: 重启后仍未恢复 port={port_ok} pids={pids}") + return 1 + + return 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/deploy/profile-scripts/fix_gateway_port.py b/deploy/profile-scripts/fix_gateway_port.py new file mode 100644 index 00000000..2d7b0996 --- /dev/null +++ b/deploy/profile-scripts/fix_gateway_port.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""fix_gateway_port.py — 自愈系统调用的网关/XMPP Bot修复脚本 +v2: 新增 session 健康检查,检测卡死的 session 自动重启 +""" +import subprocess, sys, time, socket, json, urllib.request + +GATEWAY_PORT = 8643 +BOT_PORT = 5805 +BOT_SCRIPT = "/home/hmo/xmpp_zhiwei_bot.py" +API_KEY = "hermes123" +SESSION_ID = "xmpp-zhiwei" +GATEWAY_URL = f"http://127.0.0.1:{GATEWAY_PORT}/v1/chat/completions" + +def port_open(port, host="127.0.0.1"): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(2) + try: + r = s.connect_ex((host, port)) + return r == 0 + finally: + s.close() + +def check_session_health(): + """调gateway API,检测session是否卡死。超过15s无响应→不健康""" + try: + payload = json.dumps({ + "model": "hermes-agent", + "messages": [{"role": "user", "content": "ping"}] + }).encode() + req = urllib.request.Request(GATEWAY_URL, 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", SESSION_ID) + t0 = time.time() + with urllib.request.urlopen(req, timeout=25) as r: + data = json.loads(r.read()) + reply = data.get("choices", [{}])[0].get("message", {}).get("content", "") + elapsed = time.time() - t0 + if reply: + print(f"Session {SESSION_ID} 健康 ✓ ({elapsed:.1f}s)") + return True + else: + print(f"Session {SESSION_ID} 返回空", file=sys.stderr) + return False + except urllib.request.HTTPError as e: + print(f"Session {SESSION_ID} HTTP错误: {e.code}", file=sys.stderr) + return False + except Exception as e: + print(f"Session {SESSION_ID} 不健康: {e}", file=sys.stderr) + return False + +def restart_gateway(): + """通过systemd重启gateway""" + print(f"Gateway 端口{GATEWAY_PORT} 异常 → 重启中...") + subprocess.run(["sudo", "systemctl", "restart", "hermes-gateway-zhiwei.service"], + timeout=30, capture_output=True) + time.sleep(5) + if port_open(GATEWAY_PORT): + print(f"Gateway 已恢复 ✓") + return True + else: + print(f"Gateway 重启后仍不可达", file=sys.stderr) + return False + +target = sys.argv[1] if len(sys.argv) > 1 else "all" + +if target in ("all", "bot", "xmpp_bot"): + if not port_open(BOT_PORT): + print(f"XMPP bot port {BOT_PORT} CLOSED → 启动") + subprocess.run(["pkill", "-f", "xmpp_zhiwei_bot.py"], timeout=5, capture_output=True) + time.sleep(1) + subprocess.Popen(["python3", BOT_SCRIPT], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + time.sleep(5) + if port_open(BOT_PORT): + print(f"XMPP bot 端口{BOT_PORT} 已打开 ✓") + else: + print(f"XMPP bot 修复后仍不可达", file=sys.stderr) + sys.exit(1) + else: + print(f"XMPP bot 端口{BOT_PORT} 正常 ✓") + +if target in ("all", "gateway", "session"): + if not port_open(GATEWAY_PORT): + restart_gateway() + elif target in ("all", "gateway") or target == "session": + # 端口通了 → 进一步检查session健康 + if not check_session_health(): + print(f"Session {SESSION_ID} 不健康 → 重启gateway") + restart_gateway() + else: + print(f"Gateway 端口{GATEWAY_PORT} 正常 ✓") + +if target in ("all", "session"): + # 仅session检查 + if port_open(GATEWAY_PORT): + if not check_session_health(): + print(f"Session {SESSION_ID} 不健康 → 重启gateway") + restart_gateway() + +sys.exit(0) diff --git a/deploy/profile-scripts/gateway_health.py b/deploy/profile-scripts/gateway_health.py new file mode 100644 index 00000000..bfa1eb11 --- /dev/null +++ b/deploy/profile-scripts/gateway_health.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""gateway_health.py — 知微网关/XMPP Bot 自愈脚本""" +import subprocess, sys, time, os, json, urllib.request, socket + +GATEWAY_URL = "http://127.0.0.1:8643/health" +BOT_PORT = 5805 +BOT_SCRIPT = "/home/hmo/xmpp_zhiwei_bot.py" + +def check_port(port): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(3) + try: + r = s.connect_ex(('127.0.0.1', port)) + return r == 0 + finally: + s.close() + +def fix(what): + if what == "gateway": + r = subprocess.run(["python3", "-m", "hermes_cli.main", "-p", "position-analyst", "gateway", "run", "--replace"], + cwd="/home/hmo/hermes-agent", timeout=60, capture_output=True, text=True) + time.sleep(5) + return check_port(8643) and r.returncode == 0 + elif what == "xmpp_bot": + # Kill existing if any + subprocess.run(["pkill", "-f", "xmpp_zhiwei_bot.py"], timeout=5) + time.sleep(2) + subprocess.run(["python3", BOT_SCRIPT], timeout=30) + time.sleep(5) + return check_port(BOT_PORT) + return False + +if __name__ == "__main__": + target = sys.argv[1] if len(sys.argv) > 1 else "all" + ok = True + if target in ("all", "gateway"): + if not check_port(8643): + print("[HEALTH] gateway 8643 CLOSED → 修复中") + if fix("gateway"): + print("[HEALTH] gateway 修复成功") + else: + print("[HEALTH] ❌ gateway 修复失败", file=sys.stderr) + ok = False + else: + print("[HEALTH] gateway 8643 正常") + if target in ("all", "xmpp_bot"): + if not check_port(BOT_PORT): + print(f"[HEALTH] xmpp_bot {BOT_PORT} CLOSED → 修复中") + if fix("xmpp_bot"): + print("[HEALTH] xmpp_bot 修复成功") + else: + print("[HEALTH] ❌ xmpp_bot 修复失败", file=sys.stderr) + ok = False + else: + print(f"[HEALTH] xmpp_bot {BOT_PORT} 正常") + sys.exit(0 if ok else 1) diff --git a/deploy/profile-scripts/generate_report.py b/deploy/profile-scripts/generate_report.py new file mode 100644 index 00000000..0ee6b445 --- /dev/null +++ b/deploy/profile-scripts/generate_report.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +""" +generate_report.py — 模板报告生成器 v2 +流程: 代码采集数据 → 构建所有数据段 → 填入模板 → 输出预填模板+参数表 +用法: python3 generate_report.py + +LLM只写分析文本,所有数字由代码保障。 +""" + +import sys, json, subprocess, re +from pathlib import Path + +SCRIPTS_DIR = Path(__file__).parent +TEMPLATES_DIR = SCRIPTS_DIR.parent / "templates" + +def get_report_data(): + r = subprocess.run(["python3", str(SCRIPTS_DIR / "prepare_report_data.py")], capture_output=True, text=True, timeout=30) + if r.returncode != 0: + print(f"ERROR: {r.stderr}", file=sys.stderr) + sys.exit(1) + return json.loads(r.stdout) + +def fill_template(text: str, data: dict) -> str: + def replacer(m): + full_key = m.group(1) + # 分离key和format spec + if ":" in full_key: + key, fmt = full_key.split(":", 1) + else: + key, fmt = full_key, "" + parts = key.split(".") + val = data + try: + for p in parts: + val = val[p] + if val is None: + return "N/A" + if fmt: + try: + return format(val, fmt) + except: + return str(val) + if isinstance(val, float): + return f"{val:.2f}" if abs(val) < 10000 else f"{val:.0f}" + return str(val) + except (KeyError, TypeError): + return f"【缺失:{full_key}】" + return re.sub(r'\{([^}]+)\}', replacer, text) + +def build_holdings_table(data) -> str: + """生成持仓明细表""" + h = data["portfolio"]["holdings"] + if not h: + return "(空仓)" + lines = [] + for stk in h: + lines.append(f" {stk['code']} {stk['name']} {stk['shares']}股 {stk['price_display']} {stk['mv_display']} {stk['pnl_pct']:+.2f}%") + return "\n".join(lines) + +def build_risk_holdings(data) -> str: + """浮亏>20%的持仓""" + h = data["portfolio"]["holdings"] + risk = [s for s in h if s["pnl_pct"] < -20] + if not risk: + return "无" + lines = [] + for s in risk: + lines.append(f"🔴 {s['code']} {s['name']} {s['pnl_pct']:+.2f}% {s['price_display']}") + return "\n".join(lines) + +def build_cash_source(data) -> str: + """现金来源追溯""" + logs = data.get("cash_history", []) + if not logs: + return "无记录" + latest = logs[0] + return f"{latest['note']} (验证:{'✅' if latest['verified'] else '❌'})" + +def build_sections(data) -> dict: + """构建所有动态段""" + return { + "HOLDINGS_TABLE": build_holdings_table(data), + "HOLDINGS_RISK": build_risk_holdings(data), + "CASH_SOURCE": build_cash_source(data), + "CASH_AMOUNT": f"{data['portfolio']['cash']:.0f}", + "TOTAL_ASSETS": f"{data['portfolio']['total_assets']:.0f}", + "POSITION_PCT": f"{data['portfolio']['position_pct']}", + "STOCK_VALUE": f"{data['portfolio']['stock_value_cny']:.0f}", + "HOLDINGS_COUNT": str(data['portfolio']['holdings_count']), + "GENERATED_AT": data['_meta']['generated_at'], + "HK_RATE": str(data['portfolio']['hk_rate']), + } + +def main(): + report_type = sys.argv[1] if len(sys.argv) > 1 else "intraday_monitor" + + # ★ 前置重评:对 holding_strategies 中信号为买入/卖出的股票,先触发重评 + try: + import sqlite3 + _conn = sqlite3.connect(str(SCRIPTS_DIR.parent / "data" / "mofin.db")) + _actionable = _conn.execute( + "SELECT hs.code, lp.price, hs.entry_low, hs.entry_high FROM holding_strategies hs " + "LEFT JOIN live_prices lp ON hs.code = lp.code " + "WHERE hs.status='active' " + "AND hs.timing_signal IN ('买入','可买入','可加仓','卖出','止盈')" + ).fetchall() + _conn.close() + for _code, _price, _el, _eh in _actionable: + # 价格必须在买入区内或附近(不高于上沿20%),否则不触发重评 + if _price and _el and _eh and _price > 0 and _el > 0 and _eh > 0: + if _price > _eh * 1.20: + print(f" ⏭️ {_code}: 价{_price}超买入区上沿+{((_price/_eh)-1)*100:.0f}%,跳过重评") + continue + try: + subprocess.run( + ["python3", str(SCRIPTS_DIR / "per_stock_reassess.py"), _code], + capture_output=True, timeout=30 + ) + except: + pass + except Exception: + pass + + data = get_report_data() + sections = build_sections(data) + + # 读模板 + template_path = TEMPLATES_DIR / f"{report_type}.txt" + if not template_path.exists(): + print(f"ERROR: 模板 {template_path} 不存在", file=sys.stderr) + sys.exit(1) + template = template_path.read_text(encoding="utf-8") + + # 先填大段占位符 {SECTION_NAME}(全大写),再填简单占位符 {xxx.yyy} + filled = template + for key, val in sections.items(): + filled = filled.replace(f"{{{key}}}", val) + filled = fill_template(filled, data) + + # 输出 + print(filled) + print() + # 参数表附在最后供LLM参考 + print("【参 | 代码采集 | LLM不得修改】") + param_table = { + "portfolio": {k: data["portfolio"][k] for k in ["total_assets","cash","frozen_cash","stock_value_cny","position_pct","holdings_count","hk_rate"]}, + "market": {k: data["market"][k] for k in ["sh_index","sz_index","sh_change","sz_change","advance_decline_ratio","mood"]}, + "cash_source": sections["CASH_SOURCE"], + "data_integrity": data["_meta"]["data_integrity"], + } + print(json.dumps(param_table, ensure_ascii=False, indent=2)) + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/get_realtime_prices.py b/deploy/profile-scripts/get_realtime_prices.py new file mode 100644 index 00000000..02812b37 --- /dev/null +++ b/deploy/profile-scripts/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/deploy/profile-scripts/hardcode_scanner.py b/deploy/profile-scripts/hardcode_scanner.py new file mode 100644 index 00000000..65863d14 --- /dev/null +++ b/deploy/profile-scripts/hardcode_scanner.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +hardcode_scanner.py — 自成长扫描器 + +检测脚本中可能已过时的硬编码数值,写审计 JSON 供 system_audit 调用。 + +扫描规则: +1. 财务类硬编码(cash/金额/仓位)— 应来自 data/*.json +2. 汇率类硬编码(0.86xx, 0.87xx, 0.93等)— 应来自 hk_rate 模块 +3. 数字 fallback(return X, fallback=X)— 应来自实时数据源 +4. 每手股数硬编码(500, 1000 等)— 应来自 Tencent API field[60] + +输出:/home/hmo/web-dashboard/data/hardcode_audit.json +""" +import re, ast, json, os, sys + +SCAN_DIRS = [ + "/home/hmo/.hermes/profiles/position-analyst/scripts", + "/home/hmo/MoFin", + "/home/hmo/web-dashboard", +] + +SAFE_FALLBACK_PATTERNS = [ + # Known valid fallbacks where network data is genuinely optional + "rate = 0.87", # hk_rate module's own fallback + "retry_for_secs=5", # timeouts + "timeout=5", + "timeout=10", + "timeout=30", + "timeout=60", + "port 5805", + "127.0.0.1:5805", +] + +SUSPICIOUS_NUMBERS = [ + # (pattern, reason) + (r'return\s+\d{4,}\b', '可能的硬编码现金/金额'), + (r'=\s*\d{5,}\b', '可能的硬编码大额数字'), + (r'0\.8[5-9]\d{1,3}', '可能的硬编码汇率值'), + (r'0\.9[0-5]\d{1,3}', '可能的硬编码汇率值'), + (r'1手\s*[:=]\s*\d{3,}', '可能的每手股数硬编码'), + (r'[><=]\s*0\.[0-9]+', '可能的百分比阈值硬编码'), + (r'仓位\s*[:=]\s*\d{3,}', '可能的仓位金额硬编码'), + (r"['\"](?!http|~|\./|\.\./)/home/[^'\"]+['\"]", '可能的文件路径硬编码(应使用环境变量或配置)'), + # 扩展点 — meta_growth 在此追加新规则 +] + +def scan_file(filepath): + findings = [] + try: + with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + except Exception: + return [] + + lines = content.split('\n') + for i, line in enumerate(lines, 1): + stripped = line.strip() + # Skip comments and empty lines + if not stripped or stripped.startswith('#') or '"""' in stripped: + continue + + for pat, reason in SUSPICIOUS_NUMBERS: + if re.search(pat, stripped): + # Check if it's a safe fallback + if any(safe in stripped for safe in SAFE_FALLBACK_PATTERNS): + continue + findings.append({ + "file": filepath, + "line": i, + "code": stripped[:120], + "reason": reason, + "suggestion": "考虑从 data/*.json 或 API 实时读取,不使用硬编码值" + }) + break # one finding per line + return findings + + +def main(): + all_findings = [] + for directory in SCAN_DIRS: + if not os.path.isdir(directory): + continue + for root, _, files in os.walk(directory): + for f in files: + if f.endswith('.py'): + path = os.path.join(root, f) + findings = scan_file(path) + all_findings.extend(findings) + + # Only output to stdout for cron + if all_findings: + print(f"[HARDCODE_SCAN] 发现 {len(all_findings)} 处可能硬编码:") + for f in all_findings: + rel = f['file'].replace('/home/hmo/', '') + print(f" ⚠ {rel}:L{f['line']} {f['reason']}") + print(f" {f['code']}") + print(f" → {f['suggestion']}") + else: + print("[HARDCODE_SCAN] 未发现可疑硬编码") + + # Write audit log + os.makedirs(os.path.dirname(AUDIT_PATH), exist_ok=True) + json.dump({ + "timestamp": __import__('datetime').datetime.now().isoformat(), + "findings": all_findings, + "count": len(all_findings), + }, open(AUDIT_PATH, 'w'), ensure_ascii=False, indent=2) + + +if __name__ == '__main__': + AUDIT_PATH = "/home/hmo/web-dashboard/data/hardcode_audit.json" if 'AUDIT_PATH' not in dir() else AUDIT_PATH + main() diff --git a/deploy/profile-scripts/hk_rate.py b/deploy/profile-scripts/hk_rate.py new file mode 100644 index 00000000..e880991e --- /dev/null +++ b/deploy/profile-scripts/hk_rate.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +hk_rate.py — 每日刷新HKD/CNY汇率 + +用法: + from hk_rate import hkd_to_cny, refresh_rate + + rate = hkd_to_cny() # 自动使用缓存,过期则刷新 + refresh_rate() # 强制刷新 + +缓存文件:~/.cache/hk_exchange_rate.json +有效期:24小时 +""" + +import json, os, time, sys +from datetime import date + +CACHE_PATH = os.path.expanduser("~/.cache/hk_exchange_rate.json") +CACHE_TTL = 86400 # 24小时,合理配置常量 + +# 不再硬编码备用值,每次取缓存中的最近一次有效汇率 +def _load_last_rate(): + """从缓存文件读取上次已知有效汇率""" + try: + if os.path.exists(CACHE_PATH): + with open(CACHE_PATH) as f: + data = json.load(f) + rate = data.get("rate", 0) + if 0.7 < rate < 1.0: + return round(float(rate), 6) + except Exception: + pass + return None + +PRIMARY_API = "https://api.exchangerate-api.com/v4/latest/HKD" +BACKUP_API = "https://api.exchangerate-api.com/v4/latest/USD" + +def _fetch_rate(): + """从API获取 HKD/CNY 汇率""" + import urllib.request + ua = "Mozilla/5.0" + + # 主API:直接用HKD→CNY + try: + req = urllib.request.Request(PRIMARY_API, headers={"User-Agent": ua}) + with urllib.request.urlopen(req, timeout=8) as r: + data = json.loads(r.read()) + cny = data.get("rates", {}).get("CNY") + if cny and 0.7 < cny < 1.0: # 合理性检查 + return round(float(cny), 6) + except Exception: + pass + + # 备用:USD→HKD + USD→CNY 间接计算 + try: + req = urllib.request.Request(BACKUP_API, headers={"User-Agent": ua}) + with urllib.request.urlopen(req, timeout=8) as r: + data = json.loads(r.read()) + rates = data.get("rates", {}) + hkd = rates.get("HKD") + cny = rates.get("CNY") + if hkd and cny: + rate = cny / hkd + if 0.7 < rate < 1.0: + return round(rate, 6) + except Exception: + pass + + return None + + +def hkd_to_cny(force_refresh=False): + """获取 HKD→CNY 汇率,缓存过期则自动刷新""" + os.makedirs(os.path.dirname(CACHE_PATH), exist_ok=True) + now = time.time() + rate = None + + # 读缓存 + if not force_refresh: + try: + with open(CACHE_PATH) as f: + cached = json.load(f) + cache_date = cached.get("date", "") + rate = cached.get("rate") + cached_at = cached.get("cached_at", 0) + # 同一天且未过期 + if (cache_date == date.today().isoformat() + and rate is not None + and (now - cached_at) < CACHE_TTL): + return rate + except Exception: + pass + + # 刷新 + rate = _fetch_rate() + if rate is None: + # API全挂,用缓存中的上次有效汇率 + rate = _load_last_rate() + if rate is None: + rate = 0.87 # 极限兜底,纯预防 + print(f"[hk_rate] API不可达,使用 {rate} (fallback)", file=sys.stderr) + else: + # 写缓存 + try: + with open(CACHE_PATH, "w") as f: + json.dump({ + "rate": rate, + "date": date.today().isoformat(), + "cached_at": now, + "source": "exchangerate-api.com", + }, f) + except Exception: + pass + + return rate + + +def refresh_rate(): + return hkd_to_cny(force_refresh=True) + + +if __name__ == "__main__": + r = hkd_to_cny() + print(f"HKD/CNY = {r}") diff --git a/deploy/profile-scripts/holdings_reconciliation.py b/deploy/profile-scripts/holdings_reconciliation.py new file mode 100644 index 00000000..60fa70c3 --- /dev/null +++ b/deploy/profile-scripts/holdings_reconciliation.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""holdings_reconciliation.py — 每日持仓数据一致性校验 + +在 decisions.json 和 portfolio.json 之间做双向核对: +1. 股数不一致 → 以 portfolio.json 为准(券商导入为源头真理) +2. 股票存在一个文件但不存在另一个 → 同步到双方一致 +3. 总资产重新计算并写入双方 + +24小时内禁止修改策略参数(止盈/止损/买入区),只修股数和总资产。 +""" +import json, sys +from datetime import datetime +from mo_data import read_portfolio, read_decisions +from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_holding_strategy + +DECISIONS = "/home/hmo/web-dashboard/data/decisions.json" +PORTFOLIO = "/home/hmo/web-dashboard/data/portfolio.json" + +def main(): + dec = read_decisions() + pf = read_portfolio() + + # Build maps + dmap = {d["code"]: d for d in dec.get("decisions", [])} + pmap = {h["code"]: h for h in pf.get("holdings", []) if h.get("shares", 0) > 0} + + changes = [] + + # 1. Remove from decisions if not in portfolio (ghost holdings) + for code in list(dmap.keys()): + d = dmap[code] + in_portfolio = code in pmap + if not in_portfolio: + if d.get("shares", 0) > 0: + old = d["shares"] + d["shares"] = 0 + d["type"] = "自选策略" + d.setdefault("changelog", []).append({ + "time": datetime.now().strftime("%Y-%m-%d %H:%M"), + "from": old, + "to": 0, + "reason": "reconciliation: 不在券商持仓" + }) + changes.append(f" {d.get('name','')}({code}): 清仓{old}→0股(不在portfolio)") + continue + # Same stock in both: sync share count (portfolio is source of truth) + p_shares = pmap[code]["shares"] + if d.get("shares", 0) != p_shares: + old = d.get("shares", 0) + d["shares"] = p_shares + d["type"] = "持仓策略" + d.setdefault("changelog", []).append({ + "time": datetime.now().strftime("%Y-%m-%d %H:%M"), + "from": old, + "to": p_shares, + "reason": "reconciliation: 股数与券商一致" + }) + changes.append(f" {d.get('name','')}({code}): 股数{old}→{p_shares}(对齐portfolio)") + + # 2. Add to decisions if in portfolio but not in decisions + for code in pmap: + h = pmap[code] + if code not in dmap: + # Stock is in portfolio but not in decisions → add stub + stub = { + "code": code, + "name": h.get("name", f"STOCK_{code}"), + "shares": h["shares"], + "price": h.get("price", 0), + "stop_loss": 0, + "take_profit": 0, + "entry_low": 0, + "entry_high": 0, + "cost": h.get("cost", 0), + "type": "持仓策略", + "status": "active", + "timing_signal": "持有", + "action": "持仓策略 | 等待技术分析完善", + "tech_snapshot": "", + "action_note": "reconciliation: 自动补充", + "reassessed_at": datetime.now().strftime("%Y-%m-%d %H:%M"), + "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M"), + "changelog": [{ + "time": datetime.now().strftime("%Y-%m-%d %H:%M"), + "reason": "reconciliation: 券商持仓→自动补充策略" + }], + "trigger": {}, + "analysis": {}, + "currency": "CNY" + } + dec["decisions"].append(stub) + changes.append(f" {stub['name']}({code}): decisions新增持仓({h['shares']}股,来自portfolio)") + + # 3. Recalculate total_assets in portfolio (use mo_models for unified formula) + import sys, os + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + from mo_models import calc_total_assets + total_assets = calc_total_assets(pf) + dec_total = 0 + for d in dec.get("decisions", []): + if d.get("shares", 0) > 0 and d.get("price", 0) > 0: + dec_total += d["shares"] * d["price"] + + old_total = pf.get("total_assets", 0) + pf["total_assets"] = total_assets + pf["updated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M") + + # 4. Report + now = datetime.now().strftime("%Y-%m-%d %H:%M") + print(f"【持仓一致性校验】{now}") + print(f"") + if changes: + print(f"修正项 ({len(changes)}):") + for c in changes: + print(c) + else: + print("无差异,全部一致 ✅") + print(f"") + print(f"portfolio stock_value: {stock_value:.2f}") + print(f"portfolio cash: {cash:.2f}") + print(f"portfolio total_assets: {old_total} → {total_assets}") + print(f"decisions stock_value: {dec_total:.2f}") + print(f"decisions count(shares>0): {len([d for d in dec['decisions'] if d.get('shares',0)>0])}") + + # Write — DB 优先,JSON 冷备已移除 + dec["total"] = len(dec["decisions"]) + try: + conn = get_conn() + write_holdings_batch(conn, pf.get('holdings', [])) + write_portfolio_summary(conn, pf) + for d in dec.get('decisions', []): + write_holding_strategy(conn, d.get('code', ''), d.get('name', ''), d) + conn.close() + except Exception as e: + print(f" [DB写入失败] {e}") + # [migrated to DB] — cold backup removed; DB writes above + # json.dump(dec, open(DECISIONS, "w"), ensure_ascii=False, indent=2) + # json.dump(pf, open(PORTFOLIO, "w"), ensure_ascii=False, indent=2) + print(f"done") + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/import_full_stocks.py b/deploy/profile-scripts/import_full_stocks.py new file mode 100644 index 00000000..ef6c3e72 --- /dev/null +++ b/deploy/profile-scripts/import_full_stocks.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""import_full_stocks.py — 导入全量A股+港股列表到stocks表 + +数据来源:深交所/上交所公开列表(通过akshare或腾讯API) +运行:python3 import_full_stocks.py +""" +import sys, json, time, urllib.request +from pathlib import Path + +DB_PATH = Path("/home/hmo/MoFin/data/mofin.db") + +def fetch_tencent_batch(codes): + """腾讯批量查询股票名称""" + url = f"http://qt.gtimg.cn/q={','.join(codes)}" + try: + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + proxy = urllib.request.ProxyHandler({}) + opener = urllib.request.build_opener(proxy) + with opener.open(req, timeout=15) as r: + text = r.read().decode("gbk") + results = {} + for line in text.strip().split("\n"): + if "~" not in line: + continue + parts = line.split("~") + name_part = parts[0] if parts else "" + code = "" + m = __import__('re').search(r'_(sh|sz|hk)(\d+)', name_part) + if m: + code = m.group(2) + name = parts[1] if len(parts) > 1 else "" + market = parts[2] if len(parts) > 2 else "" + if code and name: + results[code] = (name, market) + return results + except Exception as e: + print(f" 腾讯API错误: {e}", file=sys.stderr) + return {} + +def main(): + import sqlite3 + conn = sqlite3.connect(str(DB_PATH)) + + # 获取已有代码 + existing = set(r[0] for r in conn.execute("SELECT code FROM stocks").fetchall()) + print(f"当前stocks表已有: {len(existing)}只") + + # 生成待查询的A股代码范围(深市000/001/002/003/300/301,沪市600/601/603/605/688/689) + prefixes = { + "深市A": [f"{i:03d}" for i in range(0, 10)], # 000-009 + "深市中小": [f"{i:03d}" for i in range(10, 50)], # 010-049→实际用001/002 + "深市创业": [f"{i:03d}" for i in range(300, 302)], # 300-301→实际用300 + "沪市A": [f"{i:03d}" for i in range(600, 606)], # 600-605 + "沪市科创": [f"{i:03d}" for i in range(688, 690)], # 688-689 + } + + # 实际代码规则:深市000/001/002/003/300/301,沪市600/601/603/605/688 + code_ranges = [] + for prefix in ["000", "001", "002", "003", "300", "301"]: + for suffix in range(1, 1000): + code_ranges.append(f"{prefix}{suffix:03d}") + for prefix in ["600", "601", "603", "605", "688"]: + for suffix in range(1, 1000): + code_ranges.append(f"{prefix}{suffix:03d}") + + print(f"待查代码总量: {len(code_ranges)}") + + # 分批查询(每批30个) + batch_size = 30 + new_count = 0 + for i in range(0, len(code_ranges), batch_size): + batch = code_ranges[i:i+batch_size] + # 过滤已存在的 + batch = [c for c in batch if c not in existing] + if not batch: + continue + + symbols = [] + for c in batch: + if c.startswith(("5", "6", "9")): + symbols.append(f"sh{c}") + else: + symbols.append(f"sz{c}") + + results = fetch_tencent_batch(symbols) + for code, (name, market) in results.items(): + if code not in existing: + try: + conn.execute( + "INSERT OR IGNORE INTO stocks (code, name) VALUES (?, ?)", + (code, name) + ) + new_count += 1 + existing.add(code) + except Exception: + pass + + if (i // batch_size) % 50 == 0: + print(f" 进度: {i}/{len(code_ranges)}, 新增{new_count}") + + conn.commit() + total = conn.execute("SELECT COUNT(*) FROM stocks").fetchone()[0] + print(f"\n完成: 新增{new_count}, 总{total}只") + conn.close() + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/import_holding_xls.py b/deploy/profile-scripts/import_holding_xls.py new file mode 100644 index 00000000..4d8cd64a --- /dev/null +++ b/deploy/profile-scripts/import_holding_xls.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +""" +import_holding_xls.py — 从 holding.xls 导入持仓到全系统 + +用法: + python3 import_holding_xls.py [--cash 现金] [--total 总资产] [--mv 市值] + +--cash 必传!holding文件不含现金行,不传则现金=0。 + +不传 --total/--mv 则从 holding.xls 计算(可能有价格时差误差)。 +建议传截图上的真实数字。 + +示例: + python3 import_holding_xls.py --cash 73758.0 --total 874598.90 --mv 800840.90 +""" +import csv, json, sys, subprocess, sqlite3, os +from datetime import datetime +from mo_data import read_decisions +from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_holding_strategy + +STOCKS_FILE = "/home/hmo/stocks/holding.xls" +DB_PATH = "/home/hmo/web-dashboard/data/mofin.db" + + +def clean_cell(v): + v = v.strip() + if v.startswith('="') and v.endswith('"'): + v = v[2:-1] + elif v.startswith('='): + v = v[1:] + return v.strip() + + +def main(): + # Parse args + # ⚠️ 现金不从holding文件读取。holding只有股票持仓,现金必须单独提供(截图)。 + # 不传 --cash 则默认为0,会在后面警告。 + cash = 0.0 + total_assets = 0 + market_value = 0 + frozen_cash = 0.0 + + args = sys.argv[1:] + for i, a in enumerate(args): + if a == '--cash' and i + 1 < len(args): + cash = float(args[i + 1]) + elif a == '--total' and i + 1 < len(args): + total_assets = float(args[i + 1]) + elif a == '--mv' and i + 1 < len(args): + market_value = float(args[i + 1]) + elif a == '--frozen' and i + 1 < len(args): + frozen_cash = float(args[i + 1]) + + with open(STOCKS_FILE, 'r', encoding='gbk') as f: + reader = csv.reader(f, delimiter='\t') + rows = list(reader) + + print(f"读取 {STOCKS_FILE}: {len(rows)-1} 条记录") + + holdings = [] + total_mv_cny = 0 + + for r in rows[1:]: + code = clean_cell(r[0]) + name = r[1].strip() + shares = int(clean_cell(r[2])) + # 跳过0股(已清仓的残留条目) + if shares <= 0: + continue + price_raw = r[4].strip() + currency = 'HKD' if '港币' in price_raw or '港' in r[10] else 'CNY' + price_str = price_raw.replace('港币', '').replace('港元', '').replace('港', '').strip() + price = float(price_str) + cost_price_raw = float(clean_cell(r[5])) + pl = float(clean_cell(r[6])) if r[6].strip() else 0 + mkt_val_raw = float(clean_cell(r[11])) + cost_amount_raw = float(clean_cell(r[15])) if r[15].strip() and r[15].strip() != '--' else 0 + rate_str = clean_cell(r[16]) + rate = float(rate_str) if rate_str and rate_str != '--' else 0.8664 + + # 港股:所有金额必须转为 CNY 再存储(mo_models 设计规范:portfolio 应全部存 CNY) + if currency == 'HKD': + cost_price = round(cost_price_raw * rate, 2) + mv_cny = round(mkt_val_raw * rate, 2) + price_cny = round(price * rate, 2) + else: + cost_price = round(cost_price_raw, 2) + mv_cny = mkt_val_raw + price_cny = price + total_mv_cny += mv_cny + + holdings.append({ + 'code': code, 'name': name, 'shares': shares, + 'price': price_cny, 'cost_price': cost_price, + 'currency': 'CNY', 'market_val': mv_cny, + 'cost_amount_raw': cost_amount_raw, 'exchange_rate': rate, + }) + + if cash <= 0: + print("⚠️ 警告:未提供现金(--cash),现金默认=0。Dad可能给了截图现金数!") + print(" holding文件不含现金行,必须手动提供。可以用:") + print(f" python3 import_holding_xls.py --cash 73758.0") + + # Use provided values or calculate (unified formula includes frozen_cash) + if total_assets <= 0: + total_assets = total_mv_cny + cash + frozen_cash + if market_value <= 0: + market_value = round(total_mv_cny, 2) + + position_pct = round(market_value / total_assets * 100, 2) if total_assets > 0 else 0 + + # Step 1: Update SQLite (regenerate_all reads from here) + print("\n→ 更新 SQLite holdings 表...") + conn = sqlite3.connect(DB_PATH) + c = conn.cursor() + c.execute('DELETE FROM holdings') + c.execute('DELETE FROM portfolio_summary') + for h in holdings: + c.execute(''' + INSERT INTO holdings (code, name, shares, cost, currency, position_pct, added_at, is_active) + VALUES (?, ?, ?, ?, ?, ?, ?, 1) + ''', (h['code'], h['name'], h['shares'], h['cost_price'], 'CNY', + round(h['market_val'] / total_assets * 100, 2), + datetime.now().strftime('%Y-%m-%d'))) + c.execute(''' + INSERT INTO portfolio_summary (total_assets, stock_value, cash, position_pct, total_pnl, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ''', (round(total_assets, 2), round(market_value, 2), cash, + position_pct, 0, datetime.now().strftime('%Y-%m-%d %H:%M'))) + conn.commit() + conn.close() + print(f" OK - {len(holdings)} 只持仓") + + # Step 2: Run full reassessment (reads SQLite, writes decisions.json + portfolio.json) + print("\n→ 全量策略重评...") + subprocess.run( + ["python3", "/home/hmo/.hermes/profiles/position-analyst/scripts/per_stock_reassess.py"], + capture_output=True, text=True, timeout=120 + ) + print(f" 完成") + + # Step 3: Overwrite portfolio.json with correct aggregate numbers + # (regenerate_all writes its own format, we fix it back) + print("\n→ 修正 portfolio.json 汇总数据...") + portfolio = { + 'holdings': holdings, + 'cash': cash, + 'total_market_value': market_value, + 'total_assets': round(total_assets, 2), + 'total_pl': 0, + 'position_pct': position_pct, + 'updated_at': datetime.now().strftime('%Y-%m-%d %H:%M'), + 'source': STOCKS_FILE, + } + # DB 写入 + try: + conn = get_conn() + write_holdings_batch(conn, portfolio.get('holdings', [])) + write_portfolio_summary(conn, portfolio) + conn.close() + except Exception as e: + print(f" [DB写入失败] {e}") + + # Step 4: Rebuild decision trees + print("\n→ 重建决策树...") + sys.path.insert(0, '/home/hmo/web-dashboard') + from strategy_tree import init_default_branches + data = read_decisions() + ok = 0 + for e in data.get('decisions', []): + branches = init_default_branches( + e.get('code', ''), e.get('name', ''), + e.get('entry_low', 0), e.get('entry_high', 0), + e.get('stop_loss', 0), e.get('take_profit', 0)) + e['strategy_tree'] = {'branches': branches, 'created_at': datetime.now().strftime('%Y-%m-%d')} + ok += 1 + # DB 写入(替代 json.dump) + try: + conn = get_conn() + for d in data.get('decisions', []): + _whs(conn, d.get('code', ''), d.get('name', ''), d) + conn.close() + except Exception: + pass + # [migrated to DB] — cold backup removed + # with open('/home/hmo/web-dashboard/data/decisions.json', 'w') as f: + # json.dump(data, f, indent=2, ensure_ascii=False) + + print(f"\n{'='*50}") + print(f"导入完成:{len(holdings)}只持仓") + print(f"总资产: {round(total_assets):,.0f}元") + print(f"市值: {market_value:,.0f}元") + print(f"现金: {cash:,.0f}元") + print(f"仓位: {position_pct}%") + print(f"决策树: {ok}/{len(data.get('decisions',[]))}") + + +if __name__ == '__main__': + main() diff --git a/deploy/profile-scripts/intraday_health_check.py b/deploy/profile-scripts/intraday_health_check.py new file mode 100644 index 00000000..ea93960e --- /dev/null +++ b/deploy/profile-scripts/intraday_health_check.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +"""intraday_health_check.py — 盘中高频轻量自检 (no_agent) + +每15分钟检查最关键的活动组件,只查会直接影响盘中运行的。 +发现问题→写TODO(消费管道与每日体检共享)。 +""" + +import json, os, subprocess, urllib.request, sys, socket +from pathlib import Path +from datetime import datetime, timedelta +from mofin_db import get_conn + +# ── MoFin path ───────────────────────────────────────────────────── +sys.path.insert(0, "/home/hmo/MoFin") +from mo_data import read_portfolio, read_decisions, read_watchlist + +BASE = Path("/home/hmo/MoFin") +DATA = BASE / "data" +DB_PATH = DATA / "mofin.db" +CRON_JOBS = Path("/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json") +GATEWAY_URL = "http://localhost:8643/v1/chat/completions" +GATEWAY_KEY = "hermes123" + +ISSUES = [] +OK_COUNT = 0 + + +def log(ok, msg): + global OK_COUNT + if ok: + OK_COUNT += 1 + else: + ISSUES.append(msg) + + +def check_port(port): + try: + r = subprocess.run(["ss", "-tlnp"], capture_output=True, text=True, timeout=5) + return f":{port}" in r.stdout + except: + return False + + +def check_http(url, timeout=5): + """检查HTTP可达性,5秒超时防止hang住""" + try: + for k in list(os.environ.keys()): + if 'proxy' in k.lower(): + os.environ.pop(k) + req = urllib.request.Request(url, method="GET") + urllib.request.urlopen(req, timeout=timeout) + return True + except: + return False + + +def db_today_count(table, date_col): + today = datetime.now().strftime("%Y-%m-%d") + try: + conn = get_conn() + r = conn.execute(f"SELECT COUNT(*) FROM {table} WHERE date({date_col}) = ?", (today,)).fetchone() + conn.close() + return r[0] + except: + return -1 + + +def check_xiaoguo(): + """小果管道:进程/scanner有数据/API可达(降级不报错)""" + # 进程 — 不一定有常驻进程(no_agent cron模式) + # 数据 — 今日有扫描记录 + scans_today = db_today_count("xiaoguo_scan_tracker", "last_scanned_at") + if scans_today <= 0: + # 可能是小果离线了,不报严重,记录即可 + return + # API — 用socket快速检测可达性(3s超时) + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(3) + s.connect(("node122", 18003)) + s.close() + except: + pass + + +def check_price_monitor(): + """价格监控:检查price_monitor cron最近是否运行 + 数据是否更新 + + 注意:price_events 存储的是区间偏离事件(价格穿过买入区/止损/止盈边界), + 不是心跳信号。横盘期/无操作信号时自然不会有新事件。因此不检查event数, + 改为检查 cron 最后运行时间和 DB 数据新鲜度(read_portfolio() 从 mofin.db 读取)。 + """ + # 检查cron最近运行记录 + cron_ok = False + try: + with open(str(CRON_JOBS)) as f: + data = json.load(f) + jobs_list = data.get("jobs", []) if isinstance(data.get("jobs"), list) else [] + if not jobs_list: + jobs_list = list(data.get("jobs", {}).values()) + for job in jobs_list: + if not job: + continue + script = job.get("script") or "" + name = job.get("name") or "" + if "price_monitor" in script or "价格监控" in name: + last_run = job.get("last_run_at") + if last_run: + last_dt = datetime.fromisoformat(last_run) + # 兼容带时区和无时区两种格式 + ref_now = datetime.now(last_dt.tzinfo) if last_dt.tzinfo else datetime.now() + elapsed = (ref_now - last_dt).total_seconds() + if elapsed < 600: # 10分钟内运行过 + cron_ok = True + break + except Exception: + pass + + if not cron_ok: + log(False, "价格监控cron无最近运行记录(>10分钟未运行)") + return + + # 检查 DB 数据新鲜度(read_portfolio() 从 mofin.db 读取) + # 兼容 '2026-07-02 10:43'(price_monitor写入,无秒)和 '2026-07-02 10:43:53'(DB写入,有秒) + def _parse_updated_at(ts: str) -> datetime | None: + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"): + try: + return datetime.strptime(ts, fmt) + except ValueError: + continue + return None + + try: + pf = read_portfolio() + pf_updated = pf.get("updated_at", "") + if pf_updated: + pf_dt = _parse_updated_at(pf_updated) + if pf_dt is None: + log(False, f"价格数据updated_at格式无法解析: {pf_updated}") + else: + seconds_ago = (datetime.now() - pf_dt).total_seconds() + if seconds_ago < 600: # 10分钟内 + log(True, f"价格监控运行正常,数据{int(seconds_ago//60)}分钟前更新") + else: + log(False, f"价格数据{int(seconds_ago)}秒未更新(来自DB)") + else: + log(False, "DB 价格数据缺少updated_at字段") + except Exception as e: + log(False, f"价格数据新鲜度检查失败: {e}") + + +def check_bots(): + zhiwei = subprocess.run(["systemctl", "is-active", "xmpp-zhiwei.service"], + capture_output=True, text=True, timeout=5).stdout.strip() == "active" + xiaoguo = subprocess.run(["systemctl", "is-active", "xmpp-xiaoguo.service"], + capture_output=True, text=True, timeout=5).stdout.strip() == "active" + log(zhiwei, "知微XMPP Bot离线") + log(xiaoguo, "小果XMPP Bot离线") + + +def check_gateways(): + log(check_port(8643), "知微Gateway :8643 未监听") + log(check_port(8645), "小果Gateway :8645 未监听") + + +def check_signal_pipeline(): + """信号从xiaoguo_scanner→signal_news→consumer是否通畅""" + unproc = 0 + total_unproc = 0 + try: + conn = get_conn() + # xiaoguo 信号堆积(4h以内时效) + r = conn.execute("SELECT COUNT(*) FROM signal_news WHERE source LIKE 'xiaoguo%' AND (processed=0 OR processed IS NULL) AND created_at > datetime('now', '-4 hours')").fetchone() + unproc = r[0] + # 全量未处理信号(跨来源) + r2 = conn.execute("SELECT COUNT(*) FROM signal_news WHERE (processed=0 OR processed IS NULL)").fetchone() + total_unproc = r2[0] + conn.close() + except: + pass + log(unproc < 30, f"xiaoguo信号堆积: {unproc}条未处理(需<30)") + # 其他来源信号积压预警 + other = total_unproc - unproc + if other > 50: + log(False, f"其它来源信号积压: {other}条未处理(divergence_watch/trend等无consumer)") + + # 宏观风险状态检查 + try: + risk_path = DATA / "macro_risk_state.json" + if risk_path.exists(): + risk = json.loads(risk_path.read_text()) + level = risk.get("level", "none") + expired = risk.get("expired", False) + # 提取摘要做原因描述(state.json用signals数组,不是reason字段) + signals = risk.get("signals", []) + reason = "" + if signals and isinstance(signals, list) and len(signals) > 0: + first_sig = signals[0] + summary = first_sig.get("summary", "") + if summary: + reason = summary[:80].replace("\n", " ") + if level == "high" and not expired: + reason_clean = reason.replace("【高风险】", "").strip()[:60] + log(False, f"🔴 宏观风险HIGH: {reason_clean}") + elif level == "high" and expired: + log(True, f"⏳ 宏观风险HIGH已过期(无新信号超过15分钟)") + elif level == "medium": + log(True, f"⚠️ 宏观风险MEDIUM: {reason}") + else: + log(True, "无宏观风险状态文件(可能未生成)") + except: + pass + + +def write_todos(): + if not ISSUES: + return + for msg in ISSUES: + title = f"[盘中自检] {msg}" + try: + conn = get_conn() + # 宏观风险HIGH去重:只要有pending/in_progress的宏观风险TODO,不再新增 + if "宏观风险HIGH" in msg: + exist = conn.execute( + "SELECT id FROM todos WHERE title LIKE '%宏观风险HIGH%' AND status IN ('pending','in_progress') LIMIT 1" + ).fetchone() + else: + exist = conn.execute( + "SELECT id FROM todos WHERE title=? AND status IN ('pending','in_progress')", (title,) + ).fetchone() + if not exist: + conn.execute( + "INSERT INTO todos (title, description, priority, source, status, fix_action) " + "VALUES (?, ?, 'high', 'intraday_check', 'pending', NULL)", + (title, f"盘中自动发现: {msg}")) + conn.commit() + conn.close() + except: + pass + + +def main(): + now = datetime.now() + # 只在交易时段运行 + if now.weekday() >= 5 or now.hour < 9 or now.hour >= 15: + print("[SILENT] 非交易时段") + return + + check_bots() + check_gateways() + check_xiaoguo() + if 9 <= now.hour < 16: + # 开盘前10分钟(9:00-9:10)跳过价格新鲜度检查 + # price_monitor 从 09:00 才开始启动,09:01 检查时数据还未更新(前一天收盘数据) + # 给 price_monitor 足够时间完成第一轮数据拉取更新 + if now.hour == 9 and now.minute < 10: + log(True, "开盘初期,价格监控grace period(跳过新鲜度检查)") + else: + check_price_monitor() + check_signal_pipeline() + + write_todos() + + if ISSUES: + print(f"盘中自检 | {now.strftime('%H:%M')} | {len(ISSUES)}项异常:") + for i in ISSUES: + print(f" ⚠️ {i}") + else: + print(f"[SILENT] 盘中自检通过 | {OK_COUNT}项正常") + + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/intraday_monitor.py b/deploy/profile-scripts/intraday_monitor.py new file mode 100644 index 00000000..f016a3f6 --- /dev/null +++ b/deploy/profile-scripts/intraday_monitor.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Wrapper script: generates intraday monitor report. +Hermes cron runner does not support script arguments, so this wrapper +calls generate_report.py with the 'intraday_monitor' argument. +Used by both 盘前中监控 and 午后监控 cron jobs. +""" +import sys +import subprocess +from pathlib import Path + +script_dir = Path(__file__).parent.resolve() +target = script_dir / "generate_report.py" + +if not target.exists(): + print(f"ERROR: generate_report.py not found at {target}", file=sys.stderr) + sys.exit(1) + +result = subprocess.run( + [sys.executable, str(target), "intraday_monitor"], + capture_output=True, text=True, timeout=120, +) + +if result.stdout: + print(result.stdout.strip()) +if result.returncode != 0: + error_msg = f"Script exited with code {result.returncode}" + if result.stderr: + error_msg += f"\nstderr:\n{result.stderr}" + print(error_msg, file=sys.stderr) + sys.exit(result.returncode) +if result.stderr: + print(f"stderr:\n{result.stderr}", file=sys.stderr) diff --git a/deploy/profile-scripts/json_failure_monitor.py b/deploy/profile-scripts/json_failure_monitor.py new file mode 100644 index 00000000..4e623f35 --- /dev/null +++ b/deploy/profile-scripts/json_failure_monitor.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""json_failure_monitor.py — JSON解析失败监控 v1 + +监控知微pipeline中LLM输出JSON解析失败事件,记录模型名+原始输出前200字符。 +配合 cron_health_monitor.py 使用。 + +运行方式: + python3 json_failure_monitor.py # 扫描agent.log最近的JSON解析失败 + python3 json_failure_monitor.py --watch # 持续跟踪新条目 + +输出:JSON解析失败事件写入 ~/.hermes/profiles/position-analyst/logs/json_failures.jsonl + 每行一个JSON对象,字段:timestamp, model, provider, session_id, raw_preview(前200字) +""" + +import json, os, re, sys, time +from pathlib import Path +from datetime import datetime, timezone + +PROFILE_DIR = Path.home() / ".hermes" / "profiles" / "position-analyst" +LOG_DIR = PROFILE_DIR / "logs" +FAILURES_LOG = LOG_DIR / "json_failures.jsonl" + +# 匹配JSON解析失败的模式(从 agent.log 中提取) +# 常见模式:json.loads/json.JSONDecodeError/SyntaxError/Invalid JSON +JSON_FAILURE_PATTERNS = [ + re.compile(r'json\.loads.*Error', re.I), + re.compile(r'JSONDecodeError', re.I), + re.compile(r'Expecting.*value.*char', re.I), + re.compile(r'Invalid control character', re.I), + re.compile(r'SyntaxError.*invalid syntax.*json', re.I), + re.compile(r'not (valid |)JSON', re.I), + re.compile(r'JSON schema.*mismatch', re.I), + re.compile(r'fail.*parse.*JSON', re.I), + re.compile(r'Failed to decode JSON', re.I), + re.compile(r'output.*not.*valid.*json', re.I), +] + +# 提取模型名 +MODEL_PATTERN = re.compile(r'model=([\w.-]+)') +PROVIDER_PATTERN = re.compile(r'provider=([\w.-]+)') +SESSION_PATTERN = re.compile(r'\[([\w_-]+)\]') + + +def scan_log(log_path, since_pos=0): + """扫描日志文件中的JSON解析失败事件""" + if not log_path.exists(): + return [], since_pos + + events = [] + size = log_path.stat().st_size + + with open(log_path, 'r', errors='replace') as f: + if since_pos > 0: + f.seek(since_pos) + + for line in f: + # 检查是否匹配JSON失败模式 + is_failure = any(p.search(line) for p in JSON_FAILURE_PATTERNS) + if not is_failure: + continue + + # 提取元数据 + model_m = MODEL_PATTERN.search(line) + provider_m = PROVIDER_PATTERN.search(line) + session_m = SESSION_PATTERN.search(line) + + event = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "model": model_m.group(1) if model_m else "unknown", + "provider": provider_m.group(1) if provider_m else "unknown", + "session_id": session_m.group(1) if session_m else "unknown", + "raw_preview": line[:200].strip(), + } + events.append(event) + + since_pos = f.tell() + + return events, since_pos + + +def write_events(events): + """写入JSON失败事件到jsonl文件""" + if not events: + return 0 + + with open(FAILURES_LOG, 'a') as f: + for ev in events: + f.write(json.dumps(ev, ensure_ascii=False) + '\n') + + return len(events) + + +def summarize_recent(hours=24): + """输出最近N小时的JSON解析失败统计""" + if not FAILURES_LOG.exists(): + return "暂无JSON解析失败记录" + + cutoff = time.time() - hours * 3600 + events = [] + + with open(FAILURES_LOG, 'r') as f: + for line in f: + line = line.strip() + if not line: + continue + try: + ev = json.loads(line) + events.append(ev) + except json.JSONDecodeError: + continue + + # 筛选近期 + recent = [] + for ev in events: + try: + t = datetime.fromisoformat(ev.get("timestamp", "")).timestamp() + if t >= cutoff: + recent.append(ev) + except (ValueError, TypeError): + continue + + if not recent: + return f"最近{hours}小时内无JSON解析失败事件" + + # 按模型聚合 + by_model = {} + for ev in recent: + model = ev.get("model", "unknown") + by_model.setdefault(model, {"count": 0, "sessions": set()}) + by_model[model]["count"] += 1 + by_model[model]["sessions"].add(ev.get("session_id", "")) + + lines = [f"最近{hours}h JSON解析失败统计:共{len(recent)}次"] + for model, info in sorted(by_model.items(), key=lambda x: -x[1]["count"]): + lines.append(f" {model}: {info['count']}次 (涉及{len(info['sessions'])}个session)") + + return "\n".join(lines) + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "--summarize": + print(summarize_recent()) + sys.exit(0) + + # 扫描所有agent.log* + log_files = sorted(LOG_DIR.glob("agent.log*"), reverse=True) + total = 0 + state_file = PROFILE_DIR / ".json_failure_monitor.state" + + since_pos = 0 + if state_file.exists(): + try: + since_pos = int(state_file.read_text().strip()) + except (ValueError, OSError): + since_pos = 0 + + for lf in log_files: + events, since_pos = scan_log(lf, since_pos) + n = write_events(events) + total += n + if n > 0: + print(f"[{datetime.now().isoformat()}] {lf.name}: 发现{n}个JSON解析失败") + + # 保存扫描位置 + state_file.write_text(str(since_pos)) + + if total == 0: + print(f"[{datetime.now().isoformat()}] 未发现新的JSON解析失败") diff --git a/deploy/profile-scripts/macro_context_collector.py b/deploy/profile-scripts/macro_context_collector.py new file mode 100644 index 00000000..73ff596e --- /dev/null +++ b/deploy/profile-scripts/macro_context_collector.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +""" +macro_context_collector.py — 宏观新闻采集器+实时红绿灯(no_agent) + +采集来源: + 1. akshare.stock_info_global_em() — 东方财富全球宏观新闻(200条实时) + +核心功能: + A. 采集新闻→macro_raw_news(去重入库) + B. 实时红绿灯检测——用关键词规则快速识别HIGH/MEDIUM风险 + - 不等LLM,采集完立刻判断 + - 检测到风险→写入signal_news+macro_risk_state.json + - 盘中的监控cron(每15-25分)读到后立刻调整策略 + +红绿灯规则: + HIGH(单条就触发): 全球巨头+核心产业负面/美联储意外/指数暴跌/地缘冲突 + MEDIUM(累计2条触发): 常规宏观事件/板块级/资金面 +""" + +import sys, json, hashlib, os, re +from datetime import datetime +from pathlib import Path + +DATA_DIR = Path("/home/hmo/MoFin/data") +DB_PATH = DATA_DIR / "mofin.db" +STATE_PATH = DATA_DIR / "macro_risk_state.json" + +# ── 红绿灯 关键词规则 ── +# HIGH: 任何一条匹配 → 立即 HIGH 预警 +HIGH_PATTERNS = [ + # 全球巨头+核心产业 + r"苹果[^。]*(?:涨价|降价|推迟|取消|禁|制裁|调查|召回|大跌|暴跌)", + r"openai[^。]*(?:推迟|取消|风险|调查|起诉|倒闭|ipo)", + r"(?:英伟达|nvidia)[^。]*(?:跌|调查|制裁|推迟|禁令)", + r"台积电[^。]*(?:跌|推迟|取消|地震|火灾|禁)", + r"特斯拉.*(?:暴跌|召回|调查|破产|禁)", + # 美联储/央行意外 + r"美联储.*(?:意外|紧急|缩表|风暴|警告|超预期|加息\s*50|降息\s*50|紧急\s*(?:会议|声明))", + r"美联储.*(?:利率|决议).*(?:超预期|意外|紧急)", + r"fed.*(?:emergency|unexpected|surprise|hike|cut)", + # 指数暴跌(需 ≥2% 跌幅或使用更强范围词) + r"指数[^。]*?(?:暴跌|熔断|闪崩|重挫)", + r"指数[^。]*?(?:跌幅[^。]{0,20}(?:扩大至|达|至|超|为|逾)[^。]*?(?= 2: + level = "medium" + matched = hits_medium[:5] + titles = [f"· {h['title'][:50]}" for h in hits_medium[:5]] + summary = f"【中风险】{len(hits_medium)}条预警信号:\n" + "\n".join(titles) + + return level, matched, summary + +def write_risk_signal(conn, level, matched, summary): + """写入 signal_news + macro_risk_state.json""" + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + sentiment_map = {"high": "宏观-WATCH_HIGH", "medium": "宏观-WATCH_MEDIUM"} + sentiment = sentiment_map.get(level, "") + + # 写入 signal_news + articles_json = json.dumps([{"title": a["title"][:80], "summary": a["summary"][:120]} for a in matched], ensure_ascii=False) + conn.execute( + "INSERT INTO signal_news (signal_id, sector, overall_sentiment, summary, key_articles, searched_stocks, source) VALUES (0, ?, ?, ?, ?, '', ?)", + ("宏观", sentiment, summary[:500], articles_json, "macro_watch") + ) + conn.commit() + + # 写入状态文件(供监控cron实时读取) + state = { + "level": level, + "signals": [{"sentiment": sentiment, "summary": summary[:300], "key_articles": articles_json, "created_at": now}], + "signal_count": len(matched), + "created_at": now, + "expired": False, + "source": "collector_realtime", + } + STATE_PATH.write_text(json.dumps(state, ensure_ascii=False, indent=2)) + +def main(): + conn = sqlite3.connect(str(DB_PATH)) + ensure_tables(conn) + + # 去重基础 + existing = set() + for row in conn.execute("SELECT title FROM macro_raw_news ORDER BY id DESC LIMIT 200"): + existing.add(title_hash(row[0])) + + items = fetch_news() + if not items: + conn.close() + return + + # 去重写入 + new_items = [] + now_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + for item in items: + if not item["title"].strip(): + continue + h = title_hash(item["title"]) + if h in existing: + continue + existing.add(h) + try: + conn.execute( + "INSERT INTO macro_raw_news (title, summary, url, source_ts, fetched_at) VALUES (?, ?, ?, ?, ?)", + (item["title"][:300], item["summary"][:500], item["url"][:500], item["source_ts"][:20], now_str) + ) + new_items.append(item) + except Exception: + pass + + conn.commit() + + # 红绿灯检测(只针对新采集的) + level, matched, summary = "none", [], "" + if new_items: + level, matched, summary = quick_risk_check(new_items) + if level in ("high", "medium"): + write_risk_signal(conn, level, matched, summary) + + conn.close() + + # no_agent 输出 + if new_items: + print(f"[MACRO] {len(new_items)}条新宏观新闻") + # HIGH风险:输出预警(会推送到XMPP) + if level == "high": + print(f"⚠️ HIGH风险预警 ({len(matched)}条): {summary[:200]}") + elif level == "medium": + print(f" MEDIUM风险: {len(matched)}条匹配") + +if __name__ == "__main__": + import sqlite3 + main() diff --git a/deploy/profile-scripts/macro_signal_consumer.py b/deploy/profile-scripts/macro_signal_consumer.py new file mode 100644 index 00000000..f13a585f --- /dev/null +++ b/deploy/profile-scripts/macro_signal_consumer.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +""" +macro_signal_consumer.py — 消费宏观风险信号,写入风险状态 + +no_agent 模式:有HIGH风险→输出风险摘要 | 无→静默 + +管道位置: + macro_risk_scanner (8:30/11:30) → signal_news(source=macro_watch) → 本脚本 + ↓ + macro_risk_state.json — 供所有监控 cron 读取 + ↓ + 如果 HIGH → 推送到 Dad +""" +import sqlite3, json, os, sys, time +from pathlib import Path +from datetime import datetime + +BASE = Path("/home/hmo/MoFin") +DATA = BASE / "data" +DB_PATH = DATA / "mofin.db" +STATE_PATH = DATA / "macro_risk_state.json" + +def db_update(conn, sql, params, max_retries=3): + """幂等DB更新,遇到锁自动重试""" + for attempt in range(max_retries): + try: + conn.execute(sql, params) + conn.commit() + return True + except sqlite3.OperationalError as e: + if "locked" in str(e).lower(): + if attempt < max_retries - 1: + time.sleep(1) + continue + raise + print(f"[MACRO-CONSUMER] DB更新失败(持续锁): {sql}", file=sys.stderr) + return False + +def main(): + conn = sqlite3.connect(str(DB_PATH), timeout=10) + conn.execute("PRAGMA busy_timeout=5000") + conn.row_factory = sqlite3.Row + + # 读取未处理的 macro_watch 信号 + rows = conn.execute( + "SELECT * FROM signal_news WHERE source='macro_watch' AND (processed=0 OR processed IS NULL)" + ).fetchall() + + if not rows: + # 无新信号时状态文件维持 15 分钟过期 + try: + state = json.loads(STATE_PATH.read_text()) + created = datetime.strptime(state.get("created_at", "2000-01-01"), "%Y-%m-%d %H:%M:%S") + if (datetime.now() - created).total_seconds() > 900: # 15 min + state["level"] = "none" + state["expired"] = True + STATE_PATH.write_text(json.dumps(state, ensure_ascii=False, indent=2)) + except: + pass + conn.close() + return # SILENT + + # 聚合风险等级(考虑修正覆盖信号) + levels = {"宏观-WATCH_HIGH": "high", "宏观-WATCH_MEDIUM": "medium", "宏观-WATCH_INFO": "info"} + highest = "info" + all_summaries = [] + + def _effective_level(sentiment, summary): + """修正覆盖信号取修正后的级别,不按原始sentiment算""" + if "修正覆盖" in (summary or ""): + s = (summary or "").lower() + # 明确说零风险/正面/利好 → info + if any(kw in s for kw in ["零风险", "正面利好", "正面进展", "非风险", "利好"]): + return "info" + # 明确说MEDIUM → medium + if "medium" in s or "中风险" in s: + return "medium" + # 修正覆盖HIGH → 默认降为medium(不保留原始HIGH) + return "medium" + return levels.get(sentiment, "info") + + for r in rows: + sentiment = r["overall_sentiment"] + lv = _effective_level(sentiment, r["summary"]) + if lv == "high": + highest = "high" + elif lv == "medium" and highest != "high": + highest = "medium" + all_summaries.append({ + "sentiment": sentiment, + "summary": r["summary"][:300], + "key_articles": r["key_articles"], + "created_at": r["created_at"], + }) + + # 写入风险状态文件 + state = { + "level": highest, + "signals": all_summaries, + "signal_count": len(rows), + "created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "expired": False, + } + STATE_PATH.write_text(json.dumps(state, ensure_ascii=False, indent=2)) + + # 标记为已处理(含重试) + for r in rows: + db_update(conn, "UPDATE signal_news SET processed=1 WHERE id=?", (r["id"],)) + conn.close() + + # no_agent 输出(有 HIGH 才主动出声) + if highest == "high": + print(f"[MACRO-RISK] ⚠️ HIGH: {len(rows)}条高风险信号") + for s in all_summaries: + print(f" {s['summary'][:100]}") + elif highest == "medium": + if len(os.environ.get("MACRO_VERBOSE", "")) > 0: + print(f"[MACRO-RISK] MEDIUM: {len(rows)}条中风险信号") + # HIGH 信号会通过 no_agent 推送到 XMPP + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/market_insight.py b/deploy/profile-scripts/market_insight.py new file mode 100644 index 00000000..2bb438c3 --- /dev/null +++ b/deploy/profile-scripts/market_insight.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""market_insight.py — 基于 market.json 数据生成基础洞察 + 潜力挖掘 + +输出:更新 data/market.json 中的 insights / potential_stocks 字段 + +策略: + 1. 行业热点 vs 持仓匹配 → 相关影响 + 2. 资金流向异常 → 关注信号 + 3. 市场情绪 → 每日研判 + 4. 潜力挖掘 → 强势行业中寻找持仓相关标的 +""" + +import json +import sys +from datetime import datetime +from pathlib import Path + +DATA_DIR = Path(__file__).parent.parent / "data" + +# ── 持仓股 → 行业映射(从 stock_profiles 自动提取) ── + +def load_holding_industry_map(): + """从 stock_profiles 和 portfolio 提取持仓→行业映射""" + try: + with open(DATA_DIR / "stock_profiles.json", "r", encoding="utf-8") as f: + profiles = json.load(f).get("profiles", []) + + # 优先从DB读取持仓(Dad铁律:禁用JSON直读) + from mo_data import read_portfolio + portfolio = read_portfolio() + except FileNotFoundError: + return {} + + # 构建 code→name 映射(从 portfolio) + code_to_name = {} + for item in portfolio.get("holdings", []): + code_to_name[item.get("code", "")] = item.get("name", "") + + # 构建行业→持仓列表 + industry_holdings = {} + for p in profiles: + code = p.get("code", "") + name = p.get("name", "") + sector = p.get("sector", "") + if not sector or sector == "待补全": + continue + # 提取一级行业(取斜杠前第一个) + primary = sector.split("/")[0].split("(")[0].strip() + if primary: + industry_holdings.setdefault(primary, []).append({ + "code": code, + "name": name, + "sector": sector, + }) + return industry_holdings + + +def generate(): + # market_path 在DB和fallback两个分支后都会用到,所以提前定义 + market_path = DATA_DIR / "market.json" + # 优先从 SQLite 读取市场数据 + try: + from mofin_db import get_conn, query_latest_market + conn = get_conn() + market = query_latest_market(conn) + conn.close() + if market and market.get("sectors"): + sectors = market["sectors"] + top_gainers = market.get("top_gainers", []) + top_losers = market.get("top_losers", []) + mood = market.get("mood", "unknown") + up_ratio = market.get("up_ratio", 0) + timestamp = market.get("timestamp", "") + # 字段名适配 + for s in sectors: + s["change"] = s.get("change_pct", 0) + for g in top_gainers: + g["change"] = g.get("change_pct", 0) + for l in top_losers: + l["change"] = l.get("change_pct", 0) + else: + raise Exception("no data") + except Exception: + market_path = DATA_DIR / "market.json" + with open(market_path, "r", encoding="utf-8") as f: + market = json.load(f) + sectors = market.get("sectors", []) + top_gainers = market.get("top_gainers", []) + top_losers = market.get("top_losers", []) + mood = market.get("mood", "unknown") + up_ratio = market.get("up_ratio", 0) + timestamp = market.get("timestamp", "") + + industry_holdings = load_holding_industry_map() + insights = [] + potentials = [] + + # ── 洞察1:市场情绪总览 ── + mood_cn = {"bullish": "偏强", "neutral": "中性", "bearish": "偏弱", "unknown": "未知"} + insights.append( + f"市场情绪{mood_cn.get(mood, '未知')},上涨占比{up_ratio}%" + ) + + # ── 洞察2:领涨行业 vs 持仓影响 ── + gainer_insights = [] + for g in top_gainers[:3]: + name = g.get("name", "") + change = g.get("change", 0) + # 看持仓中是否有该行业 + matched = [] + for industry, holdings in industry_holdings.items(): + if industry in name or name in industry: + matched.extend([h["name"] for h in holdings]) + if matched: + gainer_insights.append( + f"{name}+{change}%, 关联持仓{'/'.join(matched[:3])}受益" + ) + else: + gainer_insights.append(f"{name}+{change}%, 暂无持仓") + if gainer_insights: + insights.append("领涨板块: " + " | ".join(gainer_insights[:2])) + + # ── 洞察3:领跌行业 vs 持仓风险 ── + loser_insights = [] + for g in top_losers[:3]: + name = g.get("name", "") + change = g.get("change", 0) + matched = [] + for industry, holdings in industry_holdings.items(): + if industry in name or name in industry: + matched.extend([h["name"] for h in holdings]) + if matched: + loser_insights.append( + f"{name}{change}%, {'/'.join(matched[:2])}需关注" + ) + else: + loser_insights.append(f"{name}{change}%") + if loser_insights: + insights.append("风险板块: " + " | ".join(loser_insights[:3])) + + # ── 洞察4:资金流向异动 ── + big_inflow = [s for s in sectors if s.get("net_inflow", 0) > 50] + big_outflow = [s for s in sectors if s.get("net_inflow", 0) < -50] + if big_inflow: + top = max(big_inflow, key=lambda s: s["net_inflow"]) + insights.append( + f"资金流入最大: {top['name']} {top['net_inflow']}亿" + ) + if big_outflow: + top = min(big_outflow, key=lambda s: s["net_inflow"]) + insights.append( + f"资金流出最大: {top['name']} {top['net_inflow']}亿" + ) + + # ── 潜力股挖掘:从强势行业中找持仓或自选相关 ── + for g in top_gainers[:5]: + name = g.get("name", "") + change = g.get("change", 0) + if change < 2: + continue # 只关注涨>2%的 + + # 找该行业指数有没有关联持仓 + lead_stock = g.get("lead_stock", "") + if lead_stock: + potentials.append({ + "name": lead_stock, + "reason": f"{name}领涨股, 板块+{change}%", + }) + + # 看持仓中是否有该行业 + for industry, holdings in industry_holdings.items(): + if industry in name or name in industry: + for h in holdings: + potentials.append({ + "name": h["name"], + "reason": f"所在行业{name}涨{change}%", + }) + + # 去重(最多5条) + seen = set() + unique_potentials = [] + for p in potentials: + key = p["name"] + if key not in seen: + seen.add(key) + unique_potentials.append(p) + if len(unique_potentials) >= 5: + break + potentials = unique_potentials + + # ── 写入 market.json ── + market["insights"] = insights + market["potential_stocks"] = potentials + market["insight_timestamp"] = datetime.now().strftime("%Y-%m-%d %H:%M") + + with open(market_path, "w", encoding="utf-8") as f: + json.dump(market, f, ensure_ascii=False, indent=2) + + print(f"生成{len(insights)}条洞察 + {len(potentials)}条潜力挖掘") + + +if __name__ == "__main__": + generate() diff --git a/deploy/profile-scripts/market_scanner.py b/deploy/profile-scripts/market_scanner.py new file mode 100644 index 00000000..8f29454e --- /dev/null +++ b/deploy/profile-scripts/market_scanner.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""market_scanner.py — 全市场异动扫描(替代小果扫描线) + +每15分钟扫描: + 1. 板块轮动检测(从已采集的 sector_snapshots 读) + 2. 热门板块领涨股扫描(从腾讯API批量拉) + 3. 资金流向异常检测(从已有 capital_flow_cache 读) + 4. 输出候选股到 candidates 表 + +不依赖小果LLM,纯数据驱动。 +""" +import sys, json, sqlite3, urllib.request, re, time +from pathlib import Path +from datetime import datetime + +DB_PATH = Path("/home/hmo/MoFin/data/mofin.db") +CANDIDATES_FILE = Path("/home/hmo/web-dashboard/data/candidate_pool.json") + +UA = "Mozilla/5.0" + +def get_conn(): + return sqlite3.connect(str(DB_PATH)) + +def fetch_qq_batch(symbols): + """腾讯批量行情""" + if not symbols: + return {} + url = f"http://qt.gtimg.cn/q={','.join(symbols)}" + try: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + proxy = urllib.request.ProxyHandler({}) + opener = urllib.request.build_opener(proxy) + with opener.open(req, timeout=15) as r: + text = r.read().decode("gbk") + results = {} + for line in text.strip().split("\n"): + if "~" not in line: + continue + parts = line.split("~") + if len(parts) < 40: + continue + m = re.search(r'_(\w+)=', parts[0]) + market = m.group(1) if m else "" + code = parts[2] + name = parts[1] + price = float(parts[3]) if parts[3] else 0 + chg_pct = float(parts[32]) if parts[32] else 0 + high = float(parts[33]) if parts[33] else 0 + low = float(parts[34]) if parts[34] else 0 + volume = int(parts[6]) if parts[6] else 0 + amount = float(parts[37]) if len(parts) > 37 and parts[37] else 0 + if price > 0: + results[code] = {"code": code, "name": name, "price": price, + "change_pct": chg_pct, "high": high, "low": low, + "volume": volume, "amount": amount, + "market": "SH" if market == "sh" else "SZ" if market == "sz" else "HK"} + return results + except Exception as e: + print(f"[SCANNER] 腾讯API错误: {e}", file=sys.stderr) + return {} + +def scan_hot_sectors(): + """从DB读热门板块,返回板块名+领涨股""" + conn = get_conn() + latest = conn.execute("SELECT MAX(id) FROM market_snapshots").fetchone()[0] + if not latest: + conn.close() + return [] + sectors = conn.execute(""" + SELECT name, change_pct, lead_stock, lead_stock_code, up_count, down_count + FROM sector_snapshots WHERE snapshot_id=? + ORDER BY change_pct DESC LIMIT 15 + """, (latest,)).fetchall() + conn.close() + return [{"name": s[0], "change": s[1], "lead_stock": s[2], + "lead_code": s[3], "up": s[4], "down": s[5]} for s in sectors if s[1] > 2.0] + +def scan_candidates(): + """主扫描流程""" + print(f"[SCANNER] {datetime.now().strftime('%H:%M')} 开始扫描", flush=True) + + # 1. 热门板块领涨股 + hot = scan_hot_sectors() + print(f" 热门板块(涨幅>2%): {len(hot)}个", flush=True) + + candidates = {} + + # 从热门板块拉领涨股 + lead_codes = [] + for s in hot: + if s.get("lead_code"): + lc = str(s["lead_code"]).strip() + if lc and lc not in candidates: + lead_codes.append(lc) + candidates[lc] = {"source": f"板块:{s['name']}(+{s['change']:.1f}%)", "sector": s["name"]} + + print(f" 领涨股待查: {len(lead_codes)}只", flush=True) + + # 2. 腾讯API批量查行情 + symbols = [] + for c in lead_codes: + if len(c) == 6: + if c.startswith(("5", "6", "9")): + symbols.append(f"sh{c}") + else: + symbols.append(f"sz{c}") + else: + symbols.append(f"hk{c}") + + prices = fetch_qq_batch(symbols) + print(f" 行情返回: {len(prices)}只", flush=True) + + # 3. 评估候选 + new_candidates = [] + for code, info in prices.items(): + if code not in candidates: + continue + src = candidates[code] + price = info["price"] + chg = info["change_pct"] + name = info["name"] + vol = info["volume"] + amt = info["amount"] + + # 条件:涨幅>3%,有量 + if chg < 3.0: + continue + if vol <= 0: + continue + + score = min(10, round(3 + chg * 0.5 + (amt / 1e8 if amt > 0 else 0) * 0.1, 1)) + + entry_low = round(price * 0.95, 2) + entry_high = round(price, 2) + stop_loss = round(price * 0.92, 2) + take_profit = round(price * 1.15, 2) + + candidate = { + "code": code, + "name": name, + "price": price, + "change_pct": chg, + "score": score, + "entry_low": entry_low, + "entry_high": entry_high, + "stop_loss": stop_loss, + "take_profit": take_profit, + "source": src["source"], + "sector": src.get("sector", ""), + "reason": f"热门板块{src['sector']}领涨+{chg:.1f}%" + } + new_candidates.append(candidate) + print(f" ✅ {code} {name} 价{price} (+{chg:.1f}%) 评分{score}", flush=True) + + # 4. 写入candidates表 + if new_candidates: + conn = get_conn() + for c in new_candidates: + conn.execute( + "INSERT OR REPLACE INTO candidates (code, name, price, change_pct, score, " + "entry_low, entry_high, stop_loss, take_profit, source, sector, reason, created_at) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,datetime('now','localtime'))", + (c["code"], c["name"], c["price"], c["change_pct"], c["score"], + c["entry_low"], c["entry_high"], c["stop_loss"], c["take_profit"], + c["source"], c["sector"], c["reason"]) + ) + conn.commit() + conn.close() + print(f" ✅ 写入{len(new_candidates)}只候选", flush=True) + else: + print(f" ⚪ 无新候选", flush=True) + + # 5. 推送到Dad(只推高分) + high_score = [c for c in new_candidates if c["score"] >= 6] + if high_score: + lines = ["🔍 市场扫描发现潜在机会:"] + for c in high_score[:3]: + lines.append( + f" {c['name']}({c['code']}) 价{c['price']:.2f}(+{c['change_pct']:.1f}%) " + f"评分{c['score']}/10 | {c['reason']}" + ) + msg = "\n".join(lines) + # 推XMPP + try: + payload = json.dumps({"to": "hmo@yoin.fun", "body": msg, "type": "chat"}).encode() + req = urllib.request.Request("http://127.0.0.1:5805/", data=payload, + headers={"Content-Type": "application/json"}) + urllib.request.urlopen(req, timeout=5) + print(f" 📨 已推送{len(high_score)}只高评分候选", flush=True) + except Exception as e: + print(f" ⚠️ 推送失败: {e}", file=sys.stderr) + +if __name__ == "__main__": + scan_candidates() diff --git a/deploy/profile-scripts/market_screener.py b/deploy/profile-scripts/market_screener.py new file mode 100644 index 00000000..4cf35a09 --- /dev/null +++ b/deploy/profile-scripts/market_screener.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +"""market_screener.py — 小果本地LLM全市场筛选(分步少吃多餐版)""" + +import json, os, re, time, urllib.request, urllib.error +from datetime import datetime +from pathlib import Path + +WEB_DASHBOARD_DIR = Path(__file__).resolve().parent.parent.parent / "web-dashboard" if "hermes" in str(Path(__file__).resolve()) else Path(__file__).parent +DATA_DIR = WEB_DASHBOARD_DIR / "data" +POOL_JSON = DATA_DIR / "candidate_pool.json" # 筛选缓存(临时) +XIAOGUO_MODEL = "Qwen3.6-27B-OptiQ-4bit" +API_TIMEOUT = 60 +MAX_SECTORS = 5 +MAX_CANDIDATES_POOL = 60 +TENCENT_URL = "http://qt.gtimg.cn/q=" + +def _get_xiaoguo_url(): + try: + from mo_config import get_config + return get_config().xiaoguo_api_url + except Exception: + return "http://node122:18003/v1/chat/completions" # legacy fallback + + +def load_json(path): + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return None + + +def save_json(path, data): + 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 call_xiaoguo(messages, timeout=API_TIMEOUT): + """调小果本地LLM(通过node122直连,/etc/hosts自动走LAN或EasyTier)""" + payload = json.dumps({"model": XIAOGUO_MODEL, "messages": messages, + "temperature": 0.1, "max_tokens": 2048}).encode() + req = urllib.request.Request(_get_xiaoguo_url(), data=payload, + headers={"Content-Type": "application/json", + "Authorization": "Bearer hermes123"}, method="POST") + try: + resp = urllib.request.build_opener(urllib.request.ProxyHandler({})).open(req, timeout=timeout) + return json.loads(resp.read())["choices"][0]["message"]["content"] + except Exception as e: + print(f"⚠️ 小果API不可用: {e}", flush=True) + return None + +def xiaoguo_available(): + """快速探测小果是否在线(2秒超时)""" + try: + req = urllib.request.Request(_get_xiaoguo_url(), + data=b'{"model":"Qwen3.6-27B-OptiQ-4bit","messages":[{"role":"user","content":"ok"}],"max_tokens":1}', + headers={"Content-Type": "application/json", + "Authorization": "Bearer hermes123"}, method="POST") + urllib.request.build_opener(urllib.request.ProxyHandler({})).open(req, timeout=2) + return True + except Exception: + return False + + + +def extract_json(text): + """从回复中提取第一个完整JSON,跳过思考过程""" + # 优先找代码块 + m = re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", text) + if m: + try: + return json.loads(m.group(1)) + except json.JSONDecodeError: + pass + # 跳过思考过程行(Here's a thinking process / 数字列表项) + clean_lines = [] + for line in text.split("\n"): + s = line.strip() + if re.match(r"^\d+\.\s+\*\*", s) or "Here's a thinking" in s or "**Analyze" in s: + continue + if s.startswith("```"): + clean_lines.append(s) + continue + clean_lines.append(line) + clean = "\n".join(clean_lines) + # 找第一个完整JSON + start = clean.find("{") + if start < 0: + return None + for mode in ["直接", "跳过头部"]: + for pos in [start, clean.find("\n{", start), clean.find("{", start + 1)]: + if pos < 0: + continue + depth = 0 + for i in range(pos, len(clean)): + if clean[i] == "{": depth += 1 + elif clean[i] == "}": + depth -= 1 + if depth == 0: + try: + return json.loads(clean[pos:i+1]) + except json.JSONDecodeError: + break + return None + + +def tencent_quote(code): + try: + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + tc = f"sh{code}" if code.startswith("6") or code.startswith("5") else f"sz{code}" + resp = opener.open(TENCENT_URL + tc, timeout=5) + d = resp.read().decode("gbk").split('="')[1].split('"')[0].split("~") + if len(d) > 32: + return {"name": d[1], "price": float(d[3]) if d[3] else 0, + "change_pct": float(d[32]) if d[32] else 0} + except: + pass + return None + + +def load_pool(): + pool = load_json(POOL_JSON) + return pool if pool else {"last_updated": "", "total_candidates": 0, "candidates": []} + + +def save_pool(pool): + pool["last_updated"] = datetime.now().strftime("%Y-%m-%d %H:%M") + pool["total_candidates"] = len([c for c in pool.get("candidates", []) if not c.get("dropped")]) + save_json(POOL_JSON, pool) + + +def add_or_update(pool, code, name, sector, score, reason, entry_range, stop_loss, target, vprice, vchg): + now = datetime.now().strftime("%Y-%m-%d %H:%M") + for c in pool.setdefault("candidates", []): + if c["code"] == code and not c.get("dropped"): + c.update({"last_updated": now, "num_observations": c.get("num_observations", 1) + 1, + "xiaoguo_score": score, "xiaoguo_reason": reason, + "xiaoguo_strategy": {"entry_range": entry_range, "stop_loss": stop_loss, "target": target}, + "verified_price": vprice, "verified_change": vchg}) + c.setdefault("score_history", []).append({"date": now, "score": score}) + hist = c["score_history"] + if len(hist) >= 3: + recent = [h["score"] for h in hist[-3:]] + c["trend_warning"] = all(recent[i] > recent[i+1] for i in range(2)) + return False + pool["candidates"].append({"code": code, "name": name, "sector": sector, + "xiaoguo_score": score, "xiaoguo_reason": reason, + "xiaoguo_strategy": {"entry_range": entry_range, "stop_loss": stop_loss, "target": target}, + "verified_price": vprice, "verified_change": vchg, + "added_at": now, "last_updated": now, "num_observations": 1, + "score_history": [{"date": now, "score": score}], + "zhiwei_star": None, "zhiwei_reviewed": False, "zhiwei_reviewed_at": None, + "promoted": False, "promoted_at": None, "dropped": False, "drop_reason": None, + "trend_warning": False, "trend_note": ""}) + return True + + +def cleanup(pool): + now = datetime.now() + for c in pool.get("candidates", []): + if c.get("dropped"): continue + hist = c.get("score_history", []) + if len(hist) >= 3 and sum(h["score"] for h in hist[-3:]) / 3 < 5: + c.update({"dropped": True, "drop_reason": "平均评分<5"}); continue + if len(hist) >= 3: + recent = [h["score"] for h in hist[-3:]] + if all(recent[i] > recent[i+1] for i in range(2)): + if c.get("trend_warning"): c.update({"dropped": True, "drop_reason": "连续下降2轮"}) + else: c["trend_warning"] = True + if c.get("last_updated"): + try: + if (now - datetime.strptime(c["last_updated"], "%Y-%m-%d %H:%M")).days >= 7: + c.update({"dropped": True, "drop_reason": "超7天未更新"}) + except: pass + + +# ── 第1步:大盘分析 ── +def step1(sectors, source): + normalized = [] + for s in sectors: + cp = (s.get("change", 0) / 100) if source == "eastmoney" else s.get("change", 0) + parts = [f"{s['name']}({cp:+.2f}%)"] + if s.get("lead_stock"): parts.append(f"领涨:{s['lead_stock']}") + if s.get("net_inflow"): parts.append(f"资金:{s['net_inflow']}亿") + normalized.append((" | ".join(parts), cp)) + gainers = [x[0] for x in sorted(normalized, key=lambda x: x[1], reverse=True) if x[1] > 0][:10] + losers = [x[0] for x in sorted(normalized, key=lambda x: x[1]) if x[1] <= 0][:5] + text = "领涨板块:\n" + "\n".join(gainers) + if losers: text += "\n领跌板块:\n" + "\n".join(losers) + prompt = f"分析以下A股板块,选出3-5个值得关注的行业(只看趋势,排除一日游)。\n\n{text}\n\n输出JSON:{{\"market_verdict\":\"强势|中性|弱势\",\"hot_sectors\":[{{\"name\":\"板块名\",\"reason\":\"理由\"}}],\"danger_sectors\":[{{\"name\":\"板块名\",\"reason\":\"理由\"}}]}}" + print(f" 第1步:大盘分析", flush=True) + result = call_xiaoguo([{"role": "user", "content": prompt}]) + return extract_json(result) if result else None + + +# ── 第2步:个股分析 ── +def step2(sector, source): + name = sector.get("name", "") + cp = (sector.get("change", 0) / 100) if source == "eastmoney" else sector.get("change", 0) + detail = [] + if sector.get("up_count"): detail.append(f"上涨{sector['up_count']}/{sector['up_count']+sector.get('down_count',0)}家") + if sector.get("net_inflow"): detail.append(f"资金净流入{sector['net_inflow']}亿") + if sector.get("lead_stock"): detail.append(f"领涨{sector['lead_stock']}({sector.get('lead_stock_change',0):+.2f}%)") + d = " | ".join(detail) if detail else "无详细数据" + prompt = f"板块:{name}({cp:+.2f}%) | {d}\n推荐2-3只候选股,评分1-10,附入场区间/止损/目标。JSON:{{\"sector_judgment\":\"\",\"sector_reason\":\"\",\"candidates\":[{{\"code\":\"\",\"name\":\"\",\"score\":0,\"reason\":\"\",\"entry_range\":\"\",\"stop_loss\":\"\",\"target\":\"\"}}]}}" + print(f" 分析行业: {name}", flush=True) + result = call_xiaoguo([{"role": "user", "content": prompt}]) + if not result: return None + parsed = extract_json(result) + if not parsed: return None + valid = [] + for c in parsed.get("candidates", []): + q = tencent_quote(c.get("code", "")) + if q and q["price"] > 0: + c["verified_price"], c["verified_change"] = q["price"], q["change_pct"] + valid.append(c) + print(f" 候选{len(parsed.get('candidates',[]))}只, 验证通过{len(valid)}只", flush=True) + return valid + + +def main(): + # 从 DB 读大盘+板块数据(替代 market.json) + from mofin_db import get_conn + conn = get_conn() + market = {"sectors": [], "source": "db"} + try: + # latest snapshot + sr = conn.execute("SELECT * FROM market_snapshots ORDER BY id DESC LIMIT 1").fetchone() + if sr: + cols = [d[0] for d in conn.execute("SELECT * FROM market_snapshots LIMIT 0").description] + snap = dict(zip(cols, sr)) + market["up_ratio"] = snap.get("up_ratio", 0) + market["mood"] = snap.get("mood", "neutral") + market["source"] = snap.get("source", "db") + + # sector data + sectors = conn.execute(""" + SELECT s.name as name, s.change_pct as change_pct, s.lead_stock as lead_stock, + s.up_count, s.down_count, s.net_inflow + FROM sector_snapshots s + JOIN market_snapshots ms ON s.snapshot_id = ms.id + WHERE ms.id = (SELECT MAX(id) FROM market_snapshots) + ORDER BY s.change_pct DESC + """).fetchall() + if sectors: + cols = [d[0] for d in conn.execute("SELECT name, change_pct, lead_stock, up_count, down_count, net_inflow FROM sector_snapshots LIMIT 0").description] + market["sectors"] = [dict(zip(cols, r)) for r in sectors] + except Exception as e: + print(f"DB read error: {e}", flush=True) + conn.close() + + if not market.get("sectors"): + print("market 无数据", flush=True); return + sectors = market["sectors"] + source = market.get("source", "db") + print(f"板块: {len(sectors)}, 来源: {source}", flush=True) + + # 快速探测小果是否在线,不在线就降级 + if not xiaoguo_available(): + print("⚠️ 小果不在线,跳过LLM分析,仅保存板块数据", flush=True) + save_json(DATA_DIR / "market_scan_summary.json", { + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"), + "source": source, + "sector_count": len(sectors), + "xiaoguo_status": "offline", + "note": "小果不在线,未做LLM全市场筛选", + }) + return + + pool = load_pool() + existing = {c["code"] for c in pool.get("candidates", []) if not c.get("dropped")} + + # 第1步 + view = step1(sectors, source) + if not view: + print("第1步失败", flush=True); return + market["market_verdict"] = view.get("market_verdict", "中性") + market["verdict_reason"] = view.get("verdict_reason", "") + market["hot_sectors"] = view.get("hot_sectors", []) + market["danger_sectors"] = view.get("danger_sectors", []) + market["xiaoguo_scan_timestamp"] = datetime.now().strftime("%Y-%m-%d %H:%M") + market.setdefault("insights", []).append(f"[小果全市场] {view.get('market_verdict','中性')}") + hot_names = [s["name"] for s in view.get("hot_sectors", [])] + print(f"热门行业: {hot_names}", flush=True) + if not hot_names: + save_pool(pool) + return + + # 第2步 + hot_data = [s for s in sectors if s.get("name", "") in hot_names][:MAX_SECTORS] + new_count = 0 + for s in hot_data: + cands = step2(s, source) + if not cands: continue + for c in cands: + if add_or_update(pool, c["code"], c.get("name",""), s["name"], + c.get("score",5), c.get("reason",""), + c.get("entry_range",""), c.get("stop_loss",""), c.get("target",""), + c.get("verified_price",0), c.get("verified_change",0)): + new_count += 1 + print(f" + {c.get('name','')}({c['code']}) {c.get('score',0)}分", flush=True) + + cleanup(pool) + save_pool(pool) + print(f"完成: 新增{new_count}, 池内{pool['total_candidates']}活跃", flush=True) + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/market_watch.py b/deploy/profile-scripts/market_watch.py new file mode 100644 index 00000000..e6c60d67 --- /dev/null +++ b/deploy/profile-scripts/market_watch.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""market_watch.py — 行業熱點數據採集,寫入 dashboard data/market.json + +數據源優先級: + 後端A:東方財富 push2 API(首選,有板塊代碼+實時指數) + 後端B:同花順 THS / akshare(降級,有漲跌家數+資金流向) + +注意:當前服務器無法連通東方財富API(已被封禁/域名不可達), +實際運行時自動降級到同花順 THS 後端。THS 提供90+行業板塊的 +實時漲跌、上漲/下跌家數、淨流入資金等數據,足以滿足需求。 + +輸出:data/market.json → MoFin Dashboard 市場數據展示 +""" + +import json +from datetime import datetime +from pathlib import Path + +from mofin_db import get_conn, init_all_tables, write_market_snapshot + +DATA_DIR = Path(__file__).parent.parent / "data" + + +# ── 後端A:東方財富 push2 API(首選,有板塊代碼+實時指數) ── + +def _fetch_em(url): + """通用 EM API 請求""" + import urllib.request + 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=60&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: + return None + + +def fetch_concept_em(): + """東方財富概念板塊""" + try: + data = _fetch_em( + "https://push2.eastmoney.com/api/qt/clist/get?" + "pn=1&pz=30&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: + return None + + +# ── 後端B:同花順 THS / akshare(降級) ── + +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)), + "volume": float(r.get("总成交量", 0)), + "turnover": float(r.get("总成交额", 0)), + "net_inflow": float(r.get("净流入", 0)), + "up_count": int(r.get("上涨家数", 0)), + "down_count": int(r.get("下跌家数", 0)), + "avg_price": float(r.get("均价", 0)), + "lead_stock": r.get("领涨股", ""), + "lead_stock_change": float(r.get("领涨股-涨跌幅", 0)), + } for _, r in df.iterrows()] + except Exception as e: + print(f"THS行業失敗: {e}", flush=True) + 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.get("code", "")), + "change": 0, + } for _, r in df.iterrows()] + except Exception as e: + print(f"THS概念失敗: {e}", flush=True) + 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 get_market_verdict(up_ratio, mood, sectors): + """Return (verdict, reason) based on sector data.""" + if not sectors: + return "unknown", "数据不足" + if up_ratio < 25: + return "弱势", f"仅{up_ratio}%板块上涨,{mood}" + elif up_ratio < 40: + return "偏弱", f"{up_ratio}%板块上涨,结构分化" + elif up_ratio < 60: + return "均衡", f"{up_ratio}%板块上涨,涨跌均衡" + else: + return "强势", f"{up_ratio}%板块上涨,整体走强" + + +def get_hot_sectors(sectors, top_n=3): + """Return sectors with highest positive change as hot sectors.""" + hot = [s for s in sectors if s.get("change", 0) > 1.0] + hot.sort(key=lambda s: s.get("change", 0), reverse=True) + return [{ + "name": s["name"], + "change": s.get("change", 0), + "reason": f"板块涨{s.get('change',0):.1f}%" + } for s in hot[:top_n]] + + +def get_danger_sectors(sectors, top_n=3): + """Return sectors with lowest (negative) change as danger sectors.""" + danger = [s for s in sectors if s.get("change", 0) < -1.0] + danger.sort(key=lambda s: s.get("change", 0)) + return [{ + "name": s["name"], + "change": s.get("change", 0), + "reason": f"板块跌{s.get('change',0):.1f}%" + } for s in danger[:top_n]] + + +# ── 主流程 ── + +def main(): + # 行業板塊:EM → THS → 兜底 + sectors = fetch_sector_em() + source = "eastmoney" + if sectors is None: + sectors = fetch_sector_ths() + source = "ths" + + # 概念板塊:EM → THS → 空 + concepts = fetch_concept_em() + concept_source = "eastmoney" + if concepts is None: + concepts = fetch_concept_ths() + concept_source = "ths" + if not concepts: + concepts = [] + concept_source = "unavailable" + + # 排序 + 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] + + # 计算大盘数据 + 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) + verdict, verdict_reason = get_market_verdict(up_ratio, mood, sectors) + + market_data = { + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"), + "source": source, + "concept_source": concept_source, + "total_sectors": len(sectors), + "up_ratio": up_ratio, + "mood": mood, + "market_verdict": verdict, + "verdict_reason": verdict_reason, + "hot_sectors": get_hot_sectors(sectors), + "danger_sectors": get_danger_sectors(sectors), + "top_gainers": top_gainers, + "top_losers": top_losers, + "sectors": sectors, + "concepts": concepts, + } + + DATA_DIR.mkdir(parents=True, exist_ok=True) + + # ── SQLite 写入(替代 market.json)── + conn = get_conn() + init_all_tables(conn) + ok, msg, sid = write_market_snapshot(conn, market_data) + if ok: + print(f"[DB] {msg}", flush=True) + else: + print(f"[DB] 写入失败(JSON 不受影响): {msg}", flush=True) + conn.close() + + # 靜默:只寫文件,不輸出到stdout,避免cron推送 + + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/memory_guardian.py b/deploy/profile-scripts/memory_guardian.py new file mode 100644 index 00000000..21906736 --- /dev/null +++ b/deploy/profile-scripts/memory_guardian.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""memory_guardian.py — 记忆守卫 (no_agent) + +每日运行,按SOUL记忆规则检查并清理: +1. MEMORY.md 是否存在、不超容量 +2. 通过gateway自动清理共享memory中带[Agent:知微]的profile专属条目 +3. 报告结果给Dad + +有违规→输出报告 | 一切正常→SILENT +""" + +import json, subprocess, urllib.request +from pathlib import Path +from datetime import datetime + +HOME = Path.home() +PROFILE = HOME / ".hermes" / "profiles" / "position-analyst" +MEMORY_FILE = PROFILE / "MEMORY.md" +MEMORY_MAX = 3000 +MEMORY_WARN = 2400 +ISSUES = [] + + +def check_memory_md(): + if not MEMORY_FILE.exists(): + ISSUES.append("MEMORY.md 不存在!profile专属内容可能全部塞在共享memory中") + return + size = MEMORY_FILE.stat().st_size + if size > MEMORY_MAX: + ISSUES.append(f"MEMORY.md 超限: {size}/{MEMORY_MAX}字符 ({size-MEMORY_MAX}超出)") + elif size > MEMORY_WARN: + ISSUES.append(f"MEMORY.md 接近上限: {size}/{MEMORY_MAX}字符") + else: + print(f" ✅ MEMORY.md: {size}/{MEMORY_MAX}字符") + + +def cleanup_memory(): + """通过gateway自动清理共享memory中profile专属条目""" + try: + payload = json.dumps({ + "model": "default", + "messages": [{"role": "user", + "content": "清理共享memory中所有带[Agent:知微]标签或Session:2026的条目。" + "这些应该放在MEMORY.md而非共享memory。" + "用memory(action='remove', old_text='唯一子串')逐条删除。" + "完成后回复'清理完成'。"}], + "max_tokens": 100, + }).encode() + req = urllib.request.Request( + "http://localhost:8643/v1/chat/completions", + data=payload, + headers={"Content-Type": "application/json", + "Authorization": "Bearer hermes123", + "X-Hermes-Session-Id": "memory-guardian"} + ) + resp = urllib.request.urlopen(req, timeout=120) + result = json.loads(resp.read()) + reply = result["choices"][0]["message"]["content"] + return "清理完成" in reply, reply[:200] + except Exception as e: + return False, str(e)[:200] + + +def main(): + print(f"记忆守卫 | {datetime.now().strftime('%Y-%m-%d %H:%M')}") + print() + check_memory_md() + + ok, detail = cleanup_memory() + print(f" {'✅' if ok else '⚠️'} 共享memory清理: {'已完成' if ok else detail}") + + if ISSUES: + print() + print(f"⚠️ 发现 {len(ISSUES)} 个问题:") + for i in ISSUES: + print(f" • {i}") + else: + print() + print("[SILENT] 记忆系统正常") + + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/meta_growth.py b/deploy/profile-scripts/meta_growth.py new file mode 100644 index 00000000..49072b7d --- /dev/null +++ b/deploy/profile-scripts/meta_growth.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +""" +meta_growth.py — 自成长机制的元层 + +功能:读取近期 git log,识别修复模式,注入新扫描规则到 hardcode_scanner 的扩展点。 +让自成长机制本身也会成长——能自动发现新的问题类型并添加对应的扫描规则。 + +调度:交易日 12:45 和 00:45(no_agent 模式) +- 12:45: 上午盘发现的问题→下午17:25审计就能扫到 +- 00:45: 全天修复汇总→次日审计带新规则 + +输出:/home/hmo/web-dashboard/data/growth_registry.json +""" + +import subprocess +import re +import json +import os +import sys +import datetime + +SCANNER_PATH = "/home/hmo/MoFin/scripts/hardcode_scanner.py" +PROFILE_SCANNER = "/home/hmo/.hermes/profiles/position-analyst/scripts/hardcode_scanner.py" +REGISTRY_PATH = "/home/hmo/web-dashboard/data/growth_registry.json" +EXTENSION_MARKER = "# 扩展点 — meta_growth 在此追加新规则" + +# 已知问题类别 → 扫描规则模板 +# meta_growth 分析 git log 后,把新模式匹配到这里生成规则元组 +PATTERN_TEMPLATES = [ + { + "name": "cash_hardcode", + "desc": "现金/金额硬编码", + "regex": r"return\s+\d{4,}\b", + "reason": "可能的硬编码现金/金额", + "git_keywords": ["cash", "现金", "硬编码", "金额", "fallback.*\\d+"], + }, + { + "name": "exchange_rate", + "desc": "汇率硬编码", + "regex": r"0\.8[5-9]\d{1,3}", + "reason": "可能的硬编码汇率值", + "git_keywords": ["汇率", "rate", "HKD", "CNY", "0.8[5-9]"], + }, + { + "name": "lot_size_hardcode", + "desc": "港股每手股数硬编码", + "regex": r"1手\s*[:=]\s*\d{3,}", + "reason": "可能的每手股数硬编码", + "git_keywords": ["lot_size", "每手", "手数", "lot", "board lot", "f\\[60\\]"], + }, + { + "name": "percent_threshold", + "desc": "百分比阈值硬编码", + "regex": r"[><=]\s*0\.[0-9]+", + "reason": "可能的百分比阈值硬编码", + "git_keywords": ["threshold", "阈值", "止损", "stop_loss", "止盈", "百分比"], + }, + { + "name": "position_limit", + "desc": "仓位金额硬编码", + "regex": r"仓位\s*[:=]\s*\d{3,}", + "reason": "可能的仓位金额硬编码", + "git_keywords": ["仓位", "position", "持仓金额"], + }, + { + "name": "hardcoded_path", + "desc": "路径硬编码", + "regex": r"['\"](?!http|~|\./|\.\./)/home/[^'\"]+['\"]", + "reason": "可能的文件路径硬编码(应使用环境变量或配置)", + "git_keywords": ["路径", "path", "hardcoded path"], + }, +] + + +def get_recent_git_log(hours=8): + """获取最近 N 小时的 git log""" + try: + result = subprocess.run( + ["git", "log", f"--since={hours} hours ago", "--oneline"], + capture_output=True, text=True, cwd="/home/hmo/MoFin", timeout=10 + ) + return result.stdout + except Exception as e: + print(f"[meta_growth] git log 失败: {e}", file=sys.stderr) + return "" + + +def analyze_log(log_text): + """分析 git log,识别修复模式""" + found_patterns = [] + lines = log_text.strip().split("\n") + + for tmpl in PATTERN_TEMPLATES: + hit_count = 0 + for line in lines: + for kw in tmpl["git_keywords"]: + if re.search(kw, line, re.IGNORECASE): + hit_count += 1 + break + if hit_count > 0: + found_patterns.append({ + "name": tmpl["name"], + "desc": tmpl["desc"], + "regex": tmpl["regex"], + "reason": tmpl["reason"], + "hits": hit_count, + }) + + return found_patterns + + +def load_registry(): + """加载问题类别注册表""" + try: + if os.path.exists(REGISTRY_PATH): + with open(REGISTRY_PATH) as f: + return json.load(f) + except Exception: + pass + return { + "known_categories": [], + "injected_rules": [], + "last_run": None, + "last_findings": [], + } + + +def save_registry(registry): + """保存注册表""" + os.makedirs(os.path.dirname(REGISTRY_PATH), exist_ok=True) + with open(REGISTRY_PATH, "w") as f: + json.dump(registry, f, indent=2, ensure_ascii=False) + + +def rule_already_exists(registry, regex): + """检查规则是否已注入""" + for r in registry.get("injected_rules", []): + if r.get("regex") == regex: + return True + return False + + +def inject_rule(scanner_path, regex, reason, marker=EXTENSION_MARKER): + """在 hardcode_scanner.py 的扩展点后插入新规则""" + if not os.path.exists(scanner_path): + return False + + try: + with open(scanner_path, "r") as f: + content = f.read() + except Exception: + return False + + if regex in content: + return False # 已存在 + + new_rule = f" (r'{regex}', '{reason}'),\n {marker}" + if marker not in content: + return False # 没有扩展点 + + content = content.replace(marker, new_rule) + + try: + with open(scanner_path, "w") as f: + f.write(content) + return True + except Exception: + return False + + +def self_check(): + """自检:检查自成长系统本身的健康度""" + issues = [] + if not os.path.exists(SCANNER_PATH): + issues.append("hardcode_scanner.py 不存在") + if not os.path.exists(REGISTRY_PATH): + issues.append("growth_registry.json 不存在(首次运行正常)") + return issues + + +def main(): + now = datetime.datetime.now().isoformat() + period = "afternoon" if datetime.datetime.now().hour < 15 else "overnight" + + # 自检 + issues = self_check() + if issues: + for issue in issues: + print(f"[meta_growth] ⚠ {issue}", file=sys.stderr) + + # 读取 git log + hours = 8 # 过去8小时(覆盖一整个交易时段) + log = get_recent_git_log(hours=hours) + if not log: + print(f"[meta_growth] 无近期提交,跳过") + return + + print(f"[meta_growth] 分析 {period} 时段日志 ({len(log.strip().split(chr(10)))} 条提交)") + + # 分析修复模式 + patterns = analyze_log(log) + + # 加载注册表 + registry = load_registry() + registry["last_run"] = now + + if not patterns: + print(f"[meta_growth] 未发现新修复模式") + registry["last_findings"] = [] + save_registry(registry) + return + + # 去重注入 + injected_count = 0 + for p in patterns: + if rule_already_exists(registry, p["regex"]): + print(f"[meta_growth] 规则已存在: {p['name']} ({p['regex']})") + continue + + # 注入到 MoFin and profile 两个副本 + ok1 = inject_rule(SCANNER_PATH, p["regex"], p["reason"]) + ok2 = inject_rule(PROFILE_SCANNER, p["regex"], p["reason"]) + + if ok1 or ok2: + registry["injected_rules"].append({ + "name": p["name"], + "desc": p["desc"], + "regex": p["regex"], + "reason": p["reason"], + "injected_at": now, + "period": period, + "hits_in_log": p["hits"], + }) + injected_count += 1 + print(f"[meta_growth] 注入新规则: {p['name']} ({p['desc']})") + + # 记录到已知类别 + if p["name"] not in registry["known_categories"]: + registry["known_categories"].append(p["name"]) + + registry["last_findings"] = patterns + save_registry(registry) + + print(f"[meta_growth] 本次注入 {injected_count} 条新规则") + if injected_count > 0: + print(f"[meta_growth] 下次 hardcode_scanner 运行时将自动使用新规则") + + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/mo_alphasift_bridge.py b/deploy/profile-scripts/mo_alphasift_bridge.py new file mode 100644 index 00000000..fe4989bf --- /dev/null +++ b/deploy/profile-scripts/mo_alphasift_bridge.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +""" +mo_alphasift_bridge.py — AlphaSift 多策略并行选股 → MoFin 自选池 + +支持同时跑多个策略,合并去重后写入自选池。 +默认三策略: balanced_alpha + dual_low + quality_value + +用法: + python3 mo_alphasift_bridge.py # 默认三策略 + python3 mo_alphasift_bridge.py --strategy dual_low # 单策略 + python3 mo_alphasift_bridge.py --dry-run # 只看不写 + python3 mo_alphasift_bridge.py --strategy list # 列出所有策略 +""" + +import sys, os, json, argparse, urllib.request, time +from datetime import datetime +from pathlib import Path + +DSA_API = "http://127.0.0.1:8001" +MOFIN_DATA = Path("/home/hmo/web-dashboard/data") +# 已迁移到 DB — watchlist.json / portfolio.json 不再使用 +# 保留路径仅用于兼容 import,实际数据从 mofin.db 读取 + +DEFAULT_STRATEGIES = "balanced_alpha,dual_low,quality_value" +DEFAULT_MARKET = "cn" +DEFAULT_MAX = 15 +MIN_SCORE = 5 +MAX_ADD = 5 +POLL_INTERVAL = 5 +POLL_TIMEOUT = 300 + +# 全局开关: ALPHASIFT_ENABLED=true 才执行选股 +ALPHASIFT_ENABLED = os.environ.get("ALPHASIFT_ENABLED", "false").lower() == "true" + + +def load_json(path): + try: return json.loads(path.read_text(encoding="utf-8")) + except: return None + +def save_json(path, data): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + +def api(endpoint, method="GET", body=None): + url = f"{DSA_API}{endpoint}" + data = json.dumps(body).encode() if body else None + req = urllib.request.Request(url, data=data, method=method, + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=30) as r: + return json.loads(r.read()) + except Exception as e: + print(f" API错误: {e}") + return None + +def get_existing_codes(): + """从 DB 读取已有持仓+自选。JSON 已废弃。""" + codes = set() + try: + import sqlite3 + db = sqlite3.connect(str(MOFIN_DATA / "mofin.db")) + for row in db.execute("SELECT code FROM watchlist_stocks WHERE is_active=1"): + codes.add(str(row[0]).strip()) + for row in db.execute("SELECT code FROM holdings"): + codes.add(str(row[0]).strip()) + db.close() + except Exception as e: + print(f"WARN: DB读取失败: {e}") + return codes + + +def run_one_strategy(strategy, market, max_results): + """跑单个策略,返回候选股列表""" + print(f"\n{'='*50}") + print(f"策略: {strategy}") + print(f"{'='*50}") + + task = api("/api/v1/alphasift/screen/tasks", "POST", { + "strategy": strategy, "market": market, "max_results": max_results + }) + if not task or not task.get("task_id"): + print(f" FAIL: 提交失败") + return [] + + task_id = task["task_id"] + print(f" 任务: {task_id[:12]}...", flush=True) + + waited = 0 + while waited < POLL_TIMEOUT: + time.sleep(POLL_INTERVAL) + waited += POLL_INTERVAL + status = api(f"/api/v1/alphasift/screen/tasks/{task_id}") + if not status: continue + s = status.get("status", "") + if s == "completed": + print(f" 完成 ({waited}s)") + result = status.get("result", {}) + candidates = result.get("candidates", []) + print(f" 候选: {len(candidates)} 只") + if candidates: + for c in candidates[:3]: + print(f" {c.get('code','?')} {c.get('name',c.get('title','?'))} 评分{c.get('score','?'):.1f}") + return candidates + elif s == "failed": + print(f" FAIL: {status.get('error','')}") + return [] + else: + if waited % 60 == 0: + print(f" ...{s} ({status.get('progress',0)}%)", flush=True) + + print(f" FAIL: 超时") + return [] + + +def run_all(strategies_str, market, max_results, dry_run=False): + """多策略并行 → 合并去重 → MoFin 自选池""" + if not ALPHASIFT_ENABLED and not dry_run: + print("AlphaSift 已禁用。设置 ALPHASIFT_ENABLED=true 或 --enable 启用。") + return + strategies = [s.strip() for s in strategies_str.split(",") if s.strip()] + now = datetime.now() + date_str = now.strftime("%Y-%m-%d") + time_str = now.strftime("%Y-%m-%d %H:%M") + + print(f"AlphaSift 多策略选股: {', '.join(strategies)}") + print(f"开始: {time_str}") + + # 逐个跑策略,汇总 + all_candidates = [] + seen = set() + for strategy in strategies: + candidates = run_one_strategy(strategy, market, max_results) + for c in candidates: + code = str(c.get("code", "")).strip() + if code in seen: continue + seen.add(code) + c["_strategy"] = strategy + all_candidates.append(c) + + if not all_candidates: + print("\n无候选股") + return + + print(f"\n汇总: {len(all_candidates)} 只候选股 (去重后)") + for s in strategies: + cnt = sum(1 for c in all_candidates if c.get("_strategy") == s) + print(f" {s}: {cnt} 只") + + # 过滤 + existing = get_existing_codes() + new_stocks = [] + skipped_score = 0 + skipped_dup = 0 + + for c in all_candidates: + code = str(c.get("code", "")).strip() + score = c.get("score", 0) or c.get("llm_score", 0) or 0 + if score < MIN_SCORE: + skipped_score += 1; continue + if code in existing: + skipped_dup += 1; continue + + name = c.get("name", "") or c.get("title", "") or code + reason = c.get("reason", "") or c.get("llm_thesis", "") + src = c.get("_strategy", "unknown") + + factors = c.get("factor_scores", {}) + factor_note = ", ".join(f"{k}={v:.0f}" for k,v in list(factors.items())[:3]) if factors else "" + + notes = f"AlphaSift/{src} 评分{score:.0f}" + if factor_note: notes += f" [{factor_note}]" + if reason: notes += f" | {reason[:120]}" + + new_stocks.append({ + "code": code, + "name": name, + "price": c.get("price", 0), + "source": "alpha_sift", + "source_detail": { + "strategy": src, + "strategies_run": strategies, + "score": score, + "factor_scores": factors, + "date": date_str, + "reason": reason[:300], + }, + "notes": notes, + "added_at": time_str, + "added_by": "AlphaSift", + "analysis": {}, + }) + existing.add(code) + + print(f"\n过滤: {len(new_stocks)} 新标的 (评分不足{skipped_score} + 重复{skipped_dup})") + + if not new_stocks: + print("无符合条件的新标的") + return + + new_stocks.sort(key=lambda s: s["source_detail"]["score"], reverse=True) + new_stocks = new_stocks[:MAX_ADD] + + print(f"\n新增 {len(new_stocks)} 只到自选池:") + for s in new_stocks: + sd = s["source_detail"] + print(f" {s['code']} {s['name']} ({sd['strategy']} 评分{sd['score']:.0f})") + + if dry_run: + print("\n[DRY RUN] 未写入") + return + + # 写入 DB + try: + sys.path.insert(0, str(MOFIN_DATA.parent)) + from mofin_db import get_conn, write_watchlist_stock + conn = get_conn() + for s in new_stocks: + s.setdefault('currency', 'CNY') + write_watchlist_stock(conn, s) + conn.close() + print(f"\n已写入 {len(new_stocks)} 只到 DB watchlist_stocks") + except Exception as e: + print(f"WARN: DB写入失败: {e}") + return + + # 策略生成 + print("\n调用 regenerate_all()...") + try: + sys.path.insert(0, str(MOFIN_DATA.parent)) + from strategy_lifecycle import regenerate_all + r = regenerate_all(stdout=True) + if r: print(f"完成: {r.get('ok',0)}/{r.get('total',0)} 只策略已生成") + except Exception as e: + print(f"WARN: {e}") + + +def list_strategies(): + r = api("/api/v1/alphasift/strategies") + if r and r.get("strategies"): + for s in r["strategies"]: + print(f" {s['id']:22s} {s.get('name','?'):10s} {s.get('description','')[:60]}") + + +def main(): + global MIN_SCORE + p = argparse.ArgumentParser(description="AlphaSift → MoFin") + p.add_argument("--strategy", default=DEFAULT_STRATEGIES) + p.add_argument("--market", default=DEFAULT_MARKET) + p.add_argument("--max", type=int, default=DEFAULT_MAX) + p.add_argument("--min-score", type=int, default=MIN_SCORE) + p.add_argument("--enable", action="store_true", help="覆盖 ALPHASIFT_ENABLED 开关") + p.add_argument("--dry-run", action="store_true") + args = p.parse_args() + MIN_SCORE = args.min_score + if args.enable: + global ALPHASIFT_ENABLED + ALPHASIFT_ENABLED = True + if args.strategy == "list": list_strategies() + else: run_all(args.strategy, args.market, args.max, args.dry_run) + + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/mo_bridge.py b/deploy/profile-scripts/mo_bridge.py new file mode 100644 index 00000000..5acbe487 --- /dev/null +++ b/deploy/profile-scripts/mo_bridge.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +""" +mo_bridge.py — MoFin ↔ DSA 全功能集成桥 + +真正调用 DSA 的三大功能: +1. 新闻搜索 — SearchService.search_comprehensive_intel()(7 个搜索引擎) +2. 大盘复盘 — run_market_review()(A股/港股/美股) +3. 策略问股 — AgentExecutor.run()(DSA 的 15 种策略,作为 MoFin 的第二意见) + +用法: + from mo_bridge import enrich_analysis_context, get_stock_analysis + + # 在 MoFin 分析前注入 DSA 上下文(大盘 + 新闻) + ctx = enrich_analysis_context("00700", "腾讯控股", region="hk") + prompt += f"\n\n{ctx}" + + # 用 DSA 的策略做独立分析 + opinion = get_stock_analysis("600519", "贵州茅台", skills=["ma_golden_cross"]) +""" + +import sys +import os +import json +import logging +from pathlib import Path +from datetime import datetime + +logger = logging.getLogger(__name__) + +# ── DSA 路径 ───────────────────────────────────────────────────────── + +_DSA_CANDIDATES = [ + "/home/hmo/daily-stock-analysis", + str(Path(__file__).resolve().parent.parent / "daily-stock-analysis" / "ZhuLinsen-daily_stock_analysis-a448886"), +] + +_DSA_BASE = None +for _c in _DSA_CANDIDATES: + _p = Path(_c) + if _p.is_dir() and (_p / "data_provider" / "base.py").exists(): + _DSA_BASE = _p + break + +_HAS_DSA = _DSA_BASE is not None + +if _HAS_DSA: + sys.path.insert(0, str(_DSA_BASE)) + +# ── 懒加载 DSA 模块 ────────────────────────────────────────────────── + +_dsa_search_service = None +_dsa_config = None + + +def _ensure_dsa_search(): + global _dsa_search_service + if _dsa_search_service is not None: + return _dsa_search_service + if not _HAS_DSA: + return None + try: + from src.search_service import get_search_service + _dsa_search_service = get_search_service() + except Exception as e: + logger.warning("DSA SearchService 加载失败: %s", e) + return _dsa_search_service + + +def _ensure_dsa_config(): + global _dsa_config + if _dsa_config is not None: + return _dsa_config + if not _HAS_DSA: + return None + try: + from src.config import get_config + _dsa_config = get_config() + except Exception as e: + logger.warning("DSA Config 加载失败: %s", e) + return _dsa_config + + +# ── 1. 新闻搜索 ───────────────────────────────────────────────────── + +def get_stock_news(stock_code: str, stock_name: str = "", max_results: int = 5) -> str: + """获取股票相关新闻。优先 DSA 搜索引擎,失败则 fallback 到东方财富 akshare。 + + Args: + stock_code: 股票代码 (如 '600519', '00700', 'AAPL') + stock_name: 股票名称 (提高搜索精度) + max_results: 最多返回条数 + + Returns: + str: Markdown 格式新闻摘要,可直接注入分析 prompt。失败时返回 ''。 + """ + lines = [f"## 📰 {stock_name or stock_code} 最新情报"] + got_any = False + + # 主通道: DSA SearchService(7 个搜索引擎,需要 API Key) + service = _ensure_dsa_search() + if service: + try: + intel = service.search_comprehensive_intel( + stock_code, stock_name or stock_code, max_searches=2 + ) + if intel: + news = intel.get("latest_news") + if news and news.results: + lines.append("\n### 最新新闻") + for r in news.results[:max_results]: + date_str = f" ({r.published_date})" if r.published_date else "" + snippet = r.snippet[:150] if r.snippet else "" + lines.append(f"- **{r.title}**{date_str}: {snippet}") + got_any = True + + risk = intel.get("risk_check") + if risk and risk.results: + lines.append("\n### ⚠️ 风险关注") + for r in risk.results[:3]: + lines.append(f"- {r.title}") + except Exception as e: + logger.debug("DSA 搜索失败: %s", e) + + # Fallback: 东方财富 akshare(免费,国内直连,无需 API Key) + if not got_any: + try: + import sys, os + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from mofin_news import search_akshare_news + articles = search_akshare_news(stock_code, max_results) + if articles: + lines.append("\n### 最新新闻 (东方财富)") + for a in articles: + snippet = a.get('content', '')[:120] if a.get('content') else '' + lines.append(f"- **{a.get('title', '')}**: {snippet}") + got_any = True + except Exception as e: + logger.debug("akshare 新闻 fallback 失败: %s", e) + + return "\n".join(lines) if got_any else "" + + +# ── 2. 大盘复盘 ───────────────────────────────────────────────────── + +def get_market_review(region: str = "cn", force_refresh: bool = False) -> str: + """获取 DSA 的大盘复盘报告。 + + cron 场景:默认只读缓存(快,不阻塞)。每天第一次调用时 DSA 可能已经生成了缓存。 + 手动场景:force_refresh=True 实时调用 DSA 生成(慢,5-10秒)。 + + Args: + region: 'cn'=A股, 'hk'=港股, 'us'=美股 + force_refresh: 是否强制实时生成(默认 False,只读缓存) + + Returns: + str: Markdown 格式大盘复盘摘要 + """ + if not _HAS_DSA: + return "" + + # 先读缓存(优先,快) + cache_dirs = [ + Path(str(_DSA_BASE)) / "reports", # DSA 生成的市场报告目录 + Path(str(_DSA_BASE)) / "data" / "market_review", + ] + for cache_dir in cache_dirs: + if not cache_dir.exists(): + continue + try: + pattern = f"market_review_*.md" if "report" in str(cache_dir) else "*.md" + files = [] + for p in cache_dir.glob("market_review_*.md"): + files.append(p) + if not files: + for p in cache_dir.glob("*.md"): + if "market" in p.name.lower() or "review" in p.name.lower(): + files.append(p) + files.sort(key=os.path.getmtime, reverse=True) + if files: + if (datetime.now().timestamp() - os.path.getmtime(str(files[0]))) < 86400: + content = files[0].read_text(encoding="utf-8") + lines = [l for l in content.split("\n")[:30] if len(l.strip()) > 3] + return "## 📈 今日大盘背景\n" + "\n".join(lines) + except Exception: + pass + + # cron 场景不走实时(太慢),直接返回空 + if not force_refresh: + return "" + + # 手动场景:实时调用 DSA(慢,需要 LLM) + try: + from src.core.market_review import run_market_review + + class StubNotifier: + def is_available(self): return False + def send(self, *a, **kw): return True + def save_report_to_file(self, *a, **kw): return None + + config = _ensure_dsa_config() + result = run_market_review( + notifier=StubNotifier(), config=config, + override_region=region, send_notification=False, + save_report_file=True, persist_history=True, + trigger_source="mofin", + ) + if result and isinstance(result, str): + lines = [l for l in result.split("\n")[:25] if len(l.strip()) > 3] + return "## 📈 今日大盘复盘\n" + "\n".join(lines) + except Exception as e: + logger.warning("DSA 大盘复盘生成失败: %s", e) + + return "" + + +# ── 3. 策略问股(第二意见)─────────────────────────────────────────── + +def get_stock_analysis( + stock_code: str, + stock_name: str = "", + skills: list = None, +) -> dict | None: + """用 DSA 的 15 种内置策略独立分析一只股票。 + + Args: + stock_code: 股票代码 + stock_name: 股票名称 + skills: 策略列表,默认 ['ma_golden_cross', 'bull_trend'] + + Returns: + dict: {source, sentiment_score, operation_advice, trend_prediction, + analysis_summary, risk_warning, strategies_used, raw} + """ + if not _HAS_DSA: + return None + + if not skills: + skills = ["ma_golden_cross", "bull_trend"] + + try: + from src.agent.factory import build_agent_executor + + executor = build_agent_executor(skills=skills) + result = executor.run( + task=f"分析 {stock_code} {stock_name}", + context={"stock_code": stock_code, "stock_name": stock_name, "report_language": "zh"}, + ) + + if result.success and result.dashboard: + d = result.dashboard + return { + "source": "DSA", + "sentiment_score": d.get("sentiment_score", 0), + "operation_advice": d.get("operation_advice", ""), + "trend_prediction": d.get("trend_prediction", ""), + "analysis_summary": d.get("analysis_summary", ""), + "risk_warning": d.get("risk_warning", ""), + "strategies_used": skills, + "raw": result.content[:500] if result.content else "", + } + except Exception as e: + logger.warning("DSA Agent 分析 %s 失败: %s", e) + + return None + + +def get_strategy_opinion_text(opinion: dict) -> str: + """将 get_stock_analysis() 的结果格式化为可读文本""" + if not opinion: + return "" + return ( + f"## 🤖 DSA 策略参考\n" + f"- 评分: {opinion.get('sentiment_score', '?')}/100\n" + f"- 建议: {opinion.get('operation_advice', '?')}\n" + f"- 趋势: {opinion.get('trend_prediction', '?')}\n" + f"- 策略: {', '.join(opinion.get('strategies_used', []))}\n" + f"- 摘要: {opinion.get('analysis_summary', '')}\n" + f"- 风险: {opinion.get('risk_warning', '')}" + ) + + +# ── 4. 综合上下文(一键调用)───────────────────────────────────────── + +def enrich_analysis_context( + stock_code: str = "", + stock_name: str = "", + region: str = "cn", + include_news: bool = True, + include_market: bool = True, +) -> str: + """一键获取 DSA 全部分析上下文,注入 MoFin 的 LLM prompt。 + + 在 strategy_lifecycle.reassess_with_context() 或 Hermes cron job 的 prompt 前调用。 + + Returns: + str: 可直接拼接到 LLM prompt 的 Markdown 文本 + """ + parts = [] + + if include_market: + market = get_market_review(region) + if market: + parts.append(market) + + if include_news and stock_code: + news = get_stock_news(stock_code, stock_name) + if news: + parts.append(news) + + return "\n\n".join(parts) if parts else "" + + +# ── 自检 ───────────────────────────────────────────────────────────── + +if __name__ == "__main__": + print(f"DSA: {'available' if _HAS_DSA else 'NOT FOUND'} ({_DSA_BASE})") + if _HAS_DSA: + print("\n--- 新闻测试 (600519) ---") + n = get_stock_news("600519", "贵州茅台", max_results=2) + print(n[:300] if n else "(无结果)") + print("\n--- 大盘测试 ---") + m = get_market_review("cn") + print(m[:300] if m else "(无结果)") diff --git a/deploy/profile-scripts/mo_config.py b/deploy/profile-scripts/mo_config.py new file mode 100644 index 00000000..5ab6896e --- /dev/null +++ b/deploy/profile-scripts/mo_config.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +""" +mo_config.py — MoFin 统一配置管理(单例模式) + +替代 MoFin 中散落在各文件的硬编码路径和常量。 + +⚠️ 铁律:所有 MoFin 模块必须从此处获取路径和配置,严禁硬编码。 + 之前:DATA_DIR = "/home/hmo/web-dashboard/data" (散落在 10+ 文件中) + 现在:from mo_config import config; config.data_dir + +用法: + from mo_config import config + from mo_data import read_portfolio; data = read_portfolio() +""" + +import os +import json +from pathlib import Path +from dataclasses import dataclass, field +from typing import List + + +@dataclass +class MoConfig: + """MoFin 全局配置单例""" + + # ── 路径 ────────────────────────────────────────────────────── + # 项目根目录 + project_dir: Path = field(default_factory=lambda: Path(__file__).parent.resolve()) + + # 数据目录(mofin.db 等,所有数据只从 DB 读写) + data_dir: Path = field(default_factory=lambda: Path( + os.environ.get("MOFIN_DATA_DIR", "/home/hmo/web-dashboard/data") + )) + + # SQLite 数据库路径 + db_path: Path = field(default=None) + + # 缓存目录 + cache_dir: Path = field(default_factory=lambda: Path.home() / ".cache" / "mofin") + + # Hermes 状态目录 + hermes_dir: Path = field(default_factory=lambda: Path.home() / ".hermes") + + # ── 关键数据文件路径(已废弃,仅保留为检查逻辑。新代码勿用) ────── + + @property + def portfolio_path(self) -> Path: + """⚠️ DEPRECATED: 数据已迁至 mofin.db holdings + portfolio_summary 表。""" + import warnings + warnings.warn("portfolio_path is deprecated — use mo_data.read_portfolio() for DB data", DeprecationWarning, stacklevel=2) + return Path() + + @property + def decisions_path(self) -> Path: + """⚠️ DEPRECATED: 数据已迁至 mofin.db holding_strategies 表。""" + import warnings + warnings.warn("decisions_path is deprecated — use mo_data.read_decisions() for DB data", DeprecationWarning, stacklevel=2) + return Path() + + @property + def watchlist_path(self) -> Path: + """⚠️ DEPRECATED: 数据已迁至 mofin.db watchlist_stocks 表。""" + import warnings + warnings.warn("watchlist_path is deprecated — use mo_data.read_watchlist() for DB data", DeprecationWarning, stacklevel=2) + return Path() + + @property + def price_events_path(self) -> Path: + """⚠️ 已废弃!数据在 mofin.db price_events 表。""" + return self.data_dir / "price_events.json" + + @property + def live_prices_path(self) -> Path: + """⚠️ DEPRECATED: 实时价格已迁移到 mofin_db.live_prices 表。""" + return self.data_dir / "live_prices.json" + + @property + def evaluation_input_path(self) -> Path: + return self.data_dir / "evaluation_input.json" + + @property + def multi_tf_cache_path(self) -> Path: + """⚠️ DEPRECATED: 多周期缓存已迁移到 mofin_db.mtf_cache 表。""" + return self.data_dir / "multi_tf_cache.json" + + @property + def price_history_path(self) -> Path: + return self.data_dir / "price_history.json" + + # ── DB 路径(懒加载) ──────────────────────────────────────── + + def _get_db_path(self) -> Path: + if self.db_path is None: + self.db_path = self.data_dir / "mofin.db" + return self.db_path + + # ── 汇率 ────────────────────────────────────────────────────── + + hk_rate_fallback: float = 0.87 # 港币→人民币 fallback 汇率 + + # ── 小果 LLM 端点(用机器名,/etc/hosts 自动解析 LAN/EasyTier)─ + # node122 = 192.168.1.122 (LAN) / 10.144.144.2 (EasyTier) + xiaoguo_host: str = "node122" + xiaoguo_port: int = 18003 + + @property + def xiaoguo_url(self) -> str: + return f"http://{self.xiaoguo_host}:{self.xiaoguo_port}" + + @property + def xiaoguo_api_url(self) -> str: + return f"{self.xiaoguo_url}/v1/chat/completions" + + port: int = field(default_factory=lambda: int(os.environ.get("PORT", "8899"))) + + tdx_relay_url: str = field( + default_factory=lambda: os.environ.get("TDX_RELAY_URL", "http://localhost:8080") + ) + + xmpp_agent_host: str = field( + default_factory=lambda: os.environ.get("XMPP_AGENT_HOST", "localhost") + ) + + xmpp_agent_port: int = field( + default_factory=lambda: int(os.environ.get("XMPP_AGENT_PORT", "5801")) + ) + + # ── DSA 集成 ────────────────────────────────────────────────── + + dsa_enabled: bool = field( + default_factory=lambda: os.environ.get("DSA_ENABLED", "false").lower() == "true" + ) + + dsa_base_dir: Path = field(default_factory=lambda: Path( + os.path.normpath(os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "..", "daily-stock-analysis", + "ZhuLinsen-daily_stock_analysis-a448886" + )) + )) + + # ── 数据新鲜度 ──────────────────────────────────────────────── + + market_hours_max_stale_min: int = 5 # 盘中最大过期时间(分钟) + off_hours_max_stale_min: int = 120 # 盘后最大过期时间(分钟) + + # ── 验证 ────────────────────────────────────────────────────── + + def validate(self) -> List[str]: + """验证配置,返回问题列表""" + issues = [] + + if not self.data_dir.exists(): + issues.append(f"数据目录不存在: {self.data_dir}") + + if not self.portfolio_path.exists(): + issues.append(f"portfolio_path 不存在(已废弃): {self.portfolio_path}") + + if not self.decisions_path.exists(): + issues.append(f"decisions_path 不存在(已废弃): {self.decisions_path}") + + return issues + + def ensure_dirs(self): + """确保必要的目录存在""" + self.data_dir.mkdir(parents=True, exist_ok=True) + self.cache_dir.mkdir(parents=True, exist_ok=True) + self.hermes_dir.mkdir(parents=True, exist_ok=True) + + # ── 输出 ────────────────────────────────────────────────────── + + def summary(self) -> str: + """打印配置摘要""" + lines = [ + "=== MoFin 配置 ===", + f"项目目录: {self.project_dir}", + f"数据目录: {self.data_dir} (存在: {self.data_dir.exists()})", + f"DB路径: {self._get_db_path()} (存在: {self._get_db_path().exists()})", + f"端口: {self.port}", + f"TDX Relay: {self.tdx_relay_url}", + f"DSA 集成: {'启用' if self.dsa_enabled else '关闭'}", + f"港币汇率 fallback: {self.hk_rate_fallback}", + ] + issues = self.validate() + if issues: + lines.append(f"\n⚠️ 配置问题 ({len(issues)}):") + for i in issues: + lines.append(f" - {i}") + return "\n".join(lines) + + +# ── 单例 ──────────────────────────────────────────────────────────── + +_config_instance: MoConfig | None = None + + +def get_config() -> MoConfig: + """获取全局配置单例""" + global _config_instance + if _config_instance is None: + _config_instance = MoConfig() + return _config_instance + + +# 便捷别名 +config = property(lambda self: get_config()) + + +# ── 模块级便捷访问 ────────────────────────────────────────────────── + +def data_dir() -> Path: + return get_config().data_dir + +def ensure_dirs(): + get_config().ensure_dirs() + + +# ── 向后兼容:导出已废弃的路由常量 ────────────────────────────────── +# PORTFOLIO_PATH / DECISIONS_PATH / WATCHLIST_PATH 均已废弃(数据在 DB)。 + +def _lazy(attr): + """懒加载属性,首次访问时从 config 获取""" + return getattr(get_config(), attr) + +# 为兼容旧代码导出以下变量 +PORTFOLIO_PATH = None # 改用 config.portfolio_path +DECISIONS_PATH = None # 改用 config.decisions_path +WATCHLIST_PATH = None # 改用 config.watchlist_path + + +# ── 自检 ──────────────────────────────────────────────────────────── + +if __name__ == "__main__": + cfg = get_config() + print(cfg.summary()) diff --git a/deploy/profile-scripts/mo_data.py b/deploy/profile-scripts/mo_data.py new file mode 100644 index 00000000..db6c7e01 --- /dev/null +++ b/deploy/profile-scripts/mo_data.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +""" +mo_data.py — MoFin 统一数据层(纯 DB) + +所有数据从 SQLite 读取。不做 JSON fallback。 +JSON 文件已弃用,仅保留为历史备份。 + +用法: + from mo_data import read_portfolio, read_decisions, read_watchlist + + pf = read_portfolio() # 返回和 portfolio.json 一样的 dict 结构 + dec = read_decisions() # 返回和 decisions.json 一样的 dict 结构 + wl = read_watchlist() # 返回和 watchlist.json 一样的 dict 结构 +""" + +import sqlite3, json, sys +from datetime import datetime +from pathlib import Path + +DB_PATH = '/home/hmo/MoFin/data/mofin.db' +SCRIPT_DIR = Path('/home/hmo/MoFin/scripts') + + +def _get_db(): + db = sqlite3.connect(DB_PATH) + db.row_factory = sqlite3.Row + return db + + +# ── portfolio ───────────────────────────────────────────────────── + +def read_portfolio(): + """返回 portfolio.json 等价 dict。纯 DB。""" + db = _get_db() + rows = db.execute( + "SELECT code, name, shares, cost, price, market_value, " + "change_pct, currency, position_pct " + "FROM holdings WHERE is_active=1" + ).fetchall() + holdings = [] + for r in rows: + h = dict(r) + h['_currency'] = h.get('currency', 'CNY') + holdings.append(h) + + sum_row = db.execute("SELECT * FROM portfolio_summary WHERE id=1").fetchone() + summary = dict(sum_row) if sum_row else {} + + db.close() + + return { + "holdings": holdings, + "total_assets": summary.get("total_assets", 0), + "total_mv": summary.get("total_mv", 0), + "stock_value": summary.get("stock_value", summary.get("total_mv", 0)), + "cash": summary.get("cash", 0), + "frozen_cash": summary.get("frozen_cash", 0), + "position_pct": summary.get("position_pct", 0), + "currency": summary.get("currency", "CNY"), + "updated_at": summary.get("updated_at", ""), + } + + +# ── decisions ───────────────────────────────────────────────────── + +def _parse_json(val, default): + if val: + try: return json.loads(val) + except: pass + return default + + +def read_decisions(): + """返回 decisions.json 等价 dict。纯 DB。""" + db = _get_db() + rows = db.execute( + "SELECT code, name, version, price, cost, shares, " + "stop_loss, take_profit, entry_low, entry_high, " + "currency, strategy_type, action, timing_signal, " + "rr_ratio, tech_snapshot, stock_category, sector_context, " + "status, trigger_json, changelog_json, source, reason, " + "created_at, updated_at, " + "avg_price, decision_timestamp, note, quality_check, " + "quality_checked_at, quality_issues_json, position_advice, " + "signal_factors_json, time_horizon, decision_type " + "FROM holding_strategies WHERE status IN ('active','updated') " + "ORDER BY code" + ).fetchall() + + decisions = [] + for r in rows: + d = dict(r) + d['trigger'] = _parse_json(r['trigger_json'], {}) + d['changelog'] = _parse_json(r['changelog_json'], []) + d['quality_issues'] = _parse_json(r['quality_issues_json'], {}) + d['signal_factors'] = _parse_json(r['signal_factors_json'], []) + d['timestamp'] = r['decision_timestamp'] or r['created_at'] or '' + d['type'] = r['decision_type'] or r['strategy_type'] or '持仓策略' + decisions.append(d) + + db.close() + + return { + "decisions": decisions, + "total": len(decisions), + "regenerated_at": datetime.now().strftime('%Y-%m-%d %H:%M'), + } + + +# ── watchlist ───────────────────────────────────────────────────── + +def read_watchlist(): + """返回 watchlist 等价 dict。纯 DB。 + 从 holding_strategies(自选策略)读取,watchlist_stocks 已废弃。""" + db = _get_db() + # 主数据源:holding_strategies 自选策略 + rows = db.execute( + "SELECT code, name, price, entry_low, entry_high, " + "stop_loss, currency, updated_at " + "FROM holding_strategies WHERE status='active' AND decision_type='自选策略'" + ).fetchall() + + stocks = [] + seen = set() + for r in rows: + code = str(r["code"]) + if code in seen: + continue + seen.add(code) + stocks.append({ + "code": code, + "name": r["name"] or "", + "price": r["price"] or 0, + "entry_low": r["entry_low"] or 0, + "entry_high": r["entry_high"] or 0, + "stop_loss": r["stop_loss"] or 0, + "currency": r["currency"] or "CNY", + "added_at": r["updated_at"] or "", + "analysis": {}, + }) + + return {"stocks": stocks, "total": len(stocks)} + + db.close() + + return { + "stocks": stocks, + "updated_at": datetime.now().strftime('%Y-%m-%d %H:%M'), + } + + +# ── 便捷别名 ─────────────────────────────────────────────────────── + +def read_portfolio_json(): + return read_portfolio() + +def read_decisions_json(): + return read_decisions() + +def read_watchlist_json(): + return read_watchlist() + + +# ── 统一价格获取(唯一入口,禁止各脚本自拉API)── + +def get_price(code, max_age_minutes=5, use_stale_fallback=True): + """获取单只股票最新价格。 + + 优先级: live_prices(DB) → stock_quote(API兜底) + - live_prices 有且不超过 max_age_minutes → 直接返回 + - 没有或过期 → 调 stock_quote 拉,写回 live_prices + - 都失败 → 返回 (None, None) + + 返回 (price, change_pct),两值都是 float 或 None。 + """ + from mofin_db import get_price_from_db + from datetime import datetime, timedelta + + # 1. 先读 DB + try: + db_price, db_chg = get_price_from_db(code) + if db_price is not None and db_price > 0: + # 检查时效性 + conn = __import__('sqlite3').connect(str(DB_PATH)) + row = conn.execute( + "SELECT updated_at FROM live_prices WHERE code=?", + (str(code).strip(),) + ).fetchone() + conn.close() + if row and row[0]: + try: + updated = datetime.strptime(row[0], "%Y-%m-%d %H:%M:%S") + age = (datetime.now() - updated).total_seconds() / 60 + if age <= max_age_minutes: + return (db_price, db_chg) + except: + pass + else: + return (db_price, db_chg) + except Exception: + pass + + # 2. DB 没有或过期 → 调 stock_quote + if not use_stale_fallback: + return (None, None) + + try: + import subprocess, json + r = subprocess.run( + [sys.executable, str(SCRIPT_DIR / "stock_quote.py"), str(code)], + capture_output=True, text=True, timeout=15 + ) + if r.returncode == 0: + data = json.loads(r.stdout.strip()) + price = float(data.get("price", 0)) + chg = float(data.get("change_pct", 0)) + if price > 0: + # 写回 live_prices + try: + conn = __import__('sqlite3').connect(str(DB_PATH)) + conn.execute(""" + INSERT OR REPLACE INTO live_prices (code, price, change_pct, updated_at) + VALUES (?, ?, ?, datetime('now','localtime')) + """, (str(code).strip(), price, chg)) + conn.commit() + conn.close() + except: + pass + return (price, chg) + except Exception: + pass + + return (None, None) + + +def get_prices_batch(codes, max_age_minutes=5): + """批量获取价格,返回 {code: (price, change_pct)}""" + from mofin_db import get_prices_batch_from_db + + result = {} + need_api = [] + + # 1. 批量读 DB + try: + db_prices = get_prices_batch_from_db(codes) + for code in codes: + cs = str(code).strip() + if cs in db_prices: + p, c = db_prices[cs] + if p and p > 0: + result[cs] = (p, c) + continue + need_api.append(cs) + except: + need_api = [str(c).strip() for c in codes] + + # 2. 缺失的调 API + if need_api: + try: + import subprocess, json + r = subprocess.run( + [sys.executable, str(SCRIPT_DIR / "stock_quote.py")] + need_api, + capture_output=True, text=True, timeout=30 + ) + if r.returncode == 0: + for line in r.stdout.strip().split("\n"): + if not line: + continue + try: + data = json.loads(line) + code = str(data.get("code", "")).strip() + price = float(data.get("price", 0)) + chg = float(data.get("change_pct", 0)) + if code and price > 0: + result[code] = (price, chg) + except: + pass + except: + pass + + return result + + +# ── cash_log 写入 ────────────────────────────────────────────────── + +def write_cash_log(cash_before, cash_after, frozen_before, frozen_after, + source, note, verified=0): + """记录现金变更到 cash_log 表。""" + change_amount = round(cash_after - cash_before, 2) if cash_after is not None and cash_before is not None else 0 + db = sqlite3.connect(DB_PATH) + try: + cur = db.execute( + """INSERT INTO cash_log + (timestamp, cash_before, cash_after, frozen_before, frozen_after, + change_amount, source, note, verified) + VALUES (datetime('now','localtime'), ?, ?, ?, ?, ?, ?, ?, ?)""", + (cash_before, cash_after, frozen_before, frozen_after, + change_amount, source, note, verified) + ) + db.commit() + return cur.lastrowid + finally: + db.close() + + +# ── 自检 ─────────────────────────────────────────────────────────── + +if __name__ == "__main__": + pf = read_portfolio() + print(f"portfolio: {len(pf.get('holdings',[]))} holdings, total_assets={pf.get('total_assets',0)}") + + dec = read_decisions() + print(f"decisions: {len(dec.get('decisions',[]))} entries") + + wl = read_watchlist() + print(f"watchlist: {len(wl.get('stocks',[]))} stocks") diff --git a/deploy/profile-scripts/mo_dsa_opinion.py b/deploy/profile-scripts/mo_dsa_opinion.py new file mode 100644 index 00000000..85407e2c --- /dev/null +++ b/deploy/profile-scripts/mo_dsa_opinion.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +""" +mo_dsa_opinion.py — DSA 策略问股(第二意见) + +用 DSA 的 15 种策略独立分析一只股票,和 MoFin 自己的分析做交叉验证。 +不替代 MoFin,只做参考。 + +用法: + # 分析一只股票 + python3 mo_dsa_opinion.py 00700 腾讯控股 + + # 指定策略 + python3 mo_dsa_opinion.py 600519 贵州茅台 --skills ma_golden_cross,chan_theory + + # 作为 cron job 调用(静默模式,输出到文件) + python3 mo_dsa_opinion.py 00700 腾讯控股 --quiet + +输出格式: + ## 🤖 DSA 策略参考 + - 评分: 72/100 + - 建议: 持有 + - 趋势: 看多 + - 策略: ma_golden_cross, bull_trend + - 摘要: ... + - 风险: ... +""" + +import sys, os, json, argparse + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +def main(): + parser = argparse.ArgumentParser(description="DSA 策略问股") + parser.add_argument("code", help="股票代码") + parser.add_argument("name", nargs="?", default="", help="股票名称") + parser.add_argument("--skills", default="ma_golden_cross,bull_trend", + help="策略列表,逗号分隔") + parser.add_argument("--quiet", action="store_true", help="静默模式") + parser.add_argument("--json", action="store_true", help="JSON 输出") + args = parser.parse_args() + + skills = [s.strip() for s in args.skills.split(",") if s.strip()] + + if not args.quiet: + print(f"🔍 DSA 分析 {args.code} {args.name}...", flush=True) + print(f" 策略: {', '.join(skills)}", flush=True) + + from mo_bridge import get_stock_analysis, get_strategy_opinion_text + + # 先获取新闻和大盘上下文 + from mo_bridge import enrich_analysis_context + ctx = enrich_analysis_context(args.code, args.name, region="cn") + + opinion = get_stock_analysis(args.code, args.name, skills=skills) + + if not opinion: + print("❌ DSA 分析失败(LLM 超时或 DSA 不可用)") + sys.exit(1) + + if args.json: + print(json.dumps(opinion, ensure_ascii=False, indent=2)) + else: + print() + print(get_strategy_opinion_text(opinion)) + + # MoFin 对比提示 + print() + print("---") + print("⚠️ 以上是 DSA 独立分析,仅供参考。") + print(" MoFin 的分析以 strategy_lifecycle 为准。") + print(" 如果两方结论一致 → 增强信心") + print(" 如果两方结论冲突 → 关注分歧点,人工判断") + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/mo_models.py b/deploy/profile-scripts/mo_models.py new file mode 100644 index 00000000..32fb72c6 --- /dev/null +++ b/deploy/profile-scripts/mo_models.py @@ -0,0 +1,227 @@ +# -*- coding: utf-8 -*- +""" +mo_models.py — MoFin 唯一数据模型(Single Source of Truth) + +⚠️ 铁律:MoFin 中所有以下操作必须走这个文件,严禁各自实现: + 1. 判断港股 — 用 is_hk_stock(code) + 2. 计算总资产 — 用 calc_total_assets(pf) + 3. 获取港币汇率 — 用 get_hk_rate() + 4. 币种转换 — 用 to_cny(price, code) + +创建日期: 2026-06-29 +原因: 之前 total_assets 在 6+ 文件中各自计算,公式不一致(3个漏了 frozen_cash); + is_hk_stock 有 3 种不同实现,存在误判风险; + hk_rate 在多个文件中硬编码不同值(0.866/0.87/0.8664/0.8700)。 +""" + +import sys +import os + +# ── 港股检测 ──────────────────────────────────────────────────────── + +def is_hk_stock(code): + """判断是否为港股。 + + 规则:港股代码为5位数字,以0或1开头。 + 例:00700(腾讯), 01888(建滔积层板), 00981(中芯国际) + + 排除: + - A股6位代码如 000657(中钨高新) — len==6 不会被误判 + - 美股字母代码如 AAPL + - 带前缀的代码如 hk00700 → 自动去前缀后判断 + """ + code = (str(code or '')).strip().upper() + # 去常见前缀 + for prefix in ('HK', 'SH', 'SZ', 'BJ'): + if code.startswith(prefix): + code = code[len(prefix):] + # 港股: 5位数字, 0或1开头 + return len(code) == 5 and code.isdigit() and code[0] in ('0', '1') + + +def is_a_stock(code): + """判断是否为A股(沪深京)""" + code = (str(code or '')).strip().upper() + for prefix in ('SH', 'SZ', 'BJ'): + if code.startswith(prefix): + code = code[len(prefix):] + if len(code) == 6 and code.isdigit(): + if code.startswith(('0', '3', '6')): + return True + if code.startswith(('4', '8', '9')): + return True # 北交所/科创板 + return False + + +def normalize_code(code): + """归一化股票代码:去市场前缀,去后缀,统一大写""" + code = (str(code or '')).strip().upper() + for prefix in ('HK', 'SH', 'SZ', 'BJ'): + if code.startswith(prefix): + code = code[len(prefix):] + return code + + +# ── 港币汇率 ────────────────────────────────────────────────────────── + +def get_hk_rate(): + """获取 HKD→CNY 汇率。优先用 hk_rate 模块(支持API+缓存),失败回退 0.87""" + try: + from hk_rate import hkd_to_cny + return hkd_to_cny() + except Exception: + pass + # 最后的兜底 + return 0.87 + + +def to_cny(price, code): + """如果 code 是港股,把 price 从 HKD 转为 CNY;否则原样返回""" + if price is None or price == 0: + return price + if is_hk_stock(code): + return round(float(price) * get_hk_rate(), 2) + return price + + +# ── 总资产计算(唯一公式) ──────────────────────────────────────────── + +def calc_total_mv(holdings): + """计算持仓总市值。港股 price 为 HKD,需 × 汇率转 CNY""" + total = 0 + rate = get_hk_rate() + for h in (holdings or []): + p = (h.get('price', 0) or 0) + s = (h.get('shares', 0) or 0) + if h.get('currency') == 'HKD' or (str(h.get('code','')).startswith(('0','1')) and len(str(h.get('code',''))) == 5): + total += s * p * rate + else: + total += s * p + return round(total, 2) + + +def calc_total_assets(pf): + """ + 计算总资产 = 持仓市值 + 可用现金 + 冻结资金 + + 这是 MoFin 中 total_assets 的 **唯一正确公式**。 + 所有文件必须调用此函数,严禁各自实现。 + + Args: + pf: dict,包含 holdings、cash、frozen_cash 字段 + + Returns: + float: 总资产(人民币) + """ + total_mv = calc_total_mv(pf.get('holdings', [])) + cash = float(pf.get('cash', 0) or 0) + frozen = float(pf.get('frozen_cash', 0) or 0) + return round(total_mv + cash + frozen, 2) + + +def calc_position_pct(pf): + """计算仓位百分比""" + total = calc_total_assets(pf) + if total > 0: + total_mv = calc_total_mv(pf.get('holdings', [])) + return round(total_mv / total * 100, 2) + return 0 + + +# ── 数据验证 ────────────────────────────────────────────────────────── + +def validate_portfolio(pf): + """验证 portfolio.json 数据一致性,返回 issues 列表""" + issues = [] + holdings = pf.get('holdings', []) + + # 1. 总资产校验 + stored = pf.get('total_assets', 0) + calculated = calc_total_assets(pf) + if stored > 0 and abs(stored - calculated) / max(stored, 1) > 0.01: + issues.append( + f"total_assets 不匹配: 存储{stored:.2f} ≠ 计算{calculated:.2f}" + f" (市值{calc_total_mv(holdings):.2f}+现金{pf.get('cash',0):.2f}+冻结{pf.get('frozen_cash',0):.2f})" + ) + + # 2. 币种一致性 + for h in holdings: + code = str(h.get('code', '')) + currency = h.get('currency', h.get('_currency', '')) + if is_hk_stock(code) and currency == 'HKD': + issues.append( + f"⚠️ 港股{code}({h.get('name','?')}) currency=HKD," + f"portfolio.json 应全部存 CNY" + ) + + # 3. 零股检查 + for h in holdings: + if (h.get('shares', 0) or 0) <= 0 and h.get('code'): + issues.append(f"持仓{h.get('code')}({h.get('name','?')}) 股数为0或负数") + + return issues + + +# ── 向后兼容别名 ────────────────────────────────────────────────────── + +# 让旧代码中散落的 is_hk_stock 引用也能正确工作 +__all__ = [ + 'is_hk_stock', + 'is_a_stock', + 'normalize_code', + 'get_hk_rate', + 'to_cny', + 'calc_total_mv', + 'calc_total_assets', + 'calc_position_pct', + 'validate_portfolio', +] + +# 模块自检 +if __name__ == '__main__': + # 测试 is_hk_stock + test_cases = [ + ('00700', True), # 腾讯 + ('01888', True), # 建滔积层板 + ('000657', False), # 中钨高新 A股 + ('600519', False), # 茅台 A股 + ('AAPL', False), # 苹果 美股 + ('hk00700', True), # 带前缀港股 + ('SH600519', False), # 带前缀A股 + ] + print("=== is_hk_stock 测试 ===") + all_ok = True + for code, expected in test_cases: + result = is_hk_stock(code) + status = '✅' if result == expected else '❌' + if result != expected: + all_ok = False + print(f" {status} is_hk_stock('{code}') = {result} (expected {expected})") + + # 测试 calc_total_assets + print("\n=== calc_total_assets 测试 ===") + pf = { + 'holdings': [ + {'code': '00700', 'shares': 100, 'price': 365.0}, + {'code': '600519', 'shares': 200, 'price': 1700.0}, + ], + 'cash': 50000.0, + 'frozen_cash': 10000.0, + } + expected_mv = 100 * 365.0 + 200 * 1700.0 # 36500 + 340000 = 376500 + expected_ta = expected_mv + 50000 + 10000 # 436500 + calc_ta = calc_total_assets(pf) + print(f" total_mv = {calc_total_mv(pf['holdings'])} (expected {expected_mv})") + print(f" total_assets = {calc_ta} (expected {expected_ta})") + print(f" {'✅' if abs(calc_ta - expected_ta) < 0.01 else '❌'} calc_total_assets") + + # 测试 validate + print("\n=== validate_portfolio 测试 ===") + issues = validate_portfolio(pf) + if issues: + for i in issues: + print(f" ⚠️ {i}") + else: + print(" ✅ 无问题") + + print(f"\n{'全部通过 ✅' if all_ok else '有失败 ❌'}") diff --git a/deploy/profile-scripts/mo_provider.py b/deploy/profile-scripts/mo_provider.py new file mode 100644 index 00000000..142e2505 --- /dev/null +++ b/deploy/profile-scripts/mo_provider.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +""" +mo_provider.py — MoFin 统一数据源适配器 + +封装 DSA (daily_stock_analysis) 的数据源层作为 MoFin 的备份/增强数据管道。 + +架构: + 主数据源:TDX Relay(通達信实时行情,走招商证券 7727 服务器) + 备份数据源:DSA DataFetcherManager(16 个 fetcher,自动 fallback) + +用法: + from mo_provider import MoDataProvider + provider = MoDataProvider() + + # 获取实时行情(TDX 优先,失败 → Tencent API → DSA fallback) + realtime = provider.get_realtime("00700") + + # 获取 K 线数据(优先本地缓存,失败 → DSA) + kline = provider.get_kline("600519", period="daily") + + # 新闻搜索(DSA 的 search_service) + news = provider.search_news("腾讯控股") + +依赖: + DSA 代码需在 ../../daily-stock-analysis/ZhuLinsen-daily_stock_analysis-a448886/ + 安装 DSA 依赖: pip install -r ../../daily-stock-analysis/...requirements.txt +""" + +import sys +import os +import json +import logging +from datetime import datetime + +logger = logging.getLogger(__name__) + +# ── 路径配置 ───────────────────────────────────────────────────────── + +# DSA 源码路径(按优先级尝试) +_DSA_CANDIDATES = [ + "/home/hmo/daily-stock-analysis", # 服务器部署路径 + os.path.normpath(os.path.join( # 本地开发路径 + os.path.dirname(os.path.abspath(__file__)), + "..", "daily-stock-analysis", "ZhuLinsen-daily_stock_analysis-a448886" + )), +] + +_DSA_BASE = None +for _c in _DSA_CANDIDATES: + if os.path.isdir(_c) and os.path.isfile(os.path.join(_c, "data_provider", "base.py")): + _DSA_BASE = _c + break + +_HAS_DSA = _DSA_BASE is not None + + +# ── MoDataProvider ─────────────────────────────────────────────────── + +class MoDataProvider: + """MoFin 统一数据获取门面。 + + - TDX Relay 作为实时行情的主源(港股接近实时) + - DSA 作为备份源(当 TDX 不可用时自动 fallback) + - 所有数据获取统一走此类,禁止各文件自己实现 + """ + + def __init__(self, tdx_url: str = None): + self._dsa_manager = None # 懒加载 DSA DataFetcherManager + self._tdx_url = tdx_url or "http://localhost:8080" # TDX relay 地址 + + # ── DSA 懒加载 ─────────────────────────────────────────────── + + @property + def has_dsa(self) -> bool: + """DSA 数据源是否可用""" + return _HAS_DSA + + def _ensure_dsa(self): + """懒加载 DSA DataFetcherManager""" + if self._dsa_manager is not None: + return self._dsa_manager + + if not _HAS_DSA: + logger.warning("DSA 源码不在 %s,无法使用备份数据源", _DSA_BASE) + return None + + try: + sys.path.insert(0, _DSA_BASE) + from data_provider.base import DataFetcherManager + self._dsa_manager = DataFetcherManager() + logger.info("DSA DataFetcherManager 已加载(16个数据源)") + except Exception as e: + logger.warning("加载 DSA DataFetcherManager 失败: %s", e) + self._dsa_manager = None + + return self._dsa_manager + + # ── 实时行情 ────────────────────────────────────────────────── + + def get_realtime(self, code: str) -> dict | None: + """获取实时行情。 + + 优先级:TDX → Tencent API → DSA fallback + + Returns: + dict with: price, change_pct, volume, name, currency + 或 None(所有源均失败) + """ + # 1. 尝试 TDX Relay + try: + import urllib.request + url = f"{self._tdx_url}/realtime/{code}" + req = urllib.request.Request(url) + with urllib.request.urlopen(req, timeout=3) as r: + data = json.loads(r.read()) + if data.get("price"): + logger.debug("TDX relay 返回 %s 行情: %.2f", code, data["price"]) + return data + except Exception: + pass + + # 2. 尝试 Tencent API + try: + return self._get_tencent_realtime(code) + except Exception: + pass + + # 3. DSA fallback + dsa = self._ensure_dsa() + if dsa: + try: + # DSA 的 get_realtime_quote + result = dsa.get_realtime_quote(code) + if result: + logger.info("DSA fallback 返回 %s 行情", code) + return {"price": result.price, "name": result.name} + except Exception as e: + logger.debug("DSA fallback 失败: %s", e) + + logger.warning("所有数据源均无法获取 %s 的行情", code) + return None + + def _get_tencent_realtime(self, code: str) -> dict | None: + """通过 mo_data.get_price 获取实时行情(替代原 Tencent API)""" + from mo_data import get_price + price, chg = get_price(code) + if price and price > 0: + return { + "code": code, + "name": "", + "price": price, + "change_pct": chg or 0.0, + "volume": 0, + "market": "", + } + return None + + # ── K 线数据 ────────────────────────────────────────────────── + + def get_kline(self, code: str, period: str = "daily", count: int = 60) -> list | None: + """获取 K 线数据。 + + Args: + code: 股票代码 + period: daily/weekly/monthly + count: 获取条数 + + Returns: + list of dict 或 None + """ + dsa = self._ensure_dsa() + if not dsa: + return None + + try: + df = dsa.get_daily_data(code, period=period, limit=count) + if df is not None and not df.empty: + return df.to_dict("records") + except Exception as e: + logger.warning("DSA get_kline 失败: %s", e) + + return None + + # ── 分钟级 K 线 ────────────────────────────────────────────── + + _last_minute_call = 0 # 限流时间戳 + + def get_minute_kline(self, code: str, count: int = 60) -> list | None: + """获取1分钟K线数据(东方财富 push2)。 + + 限流保护:每次调用间隔至少1秒,批量查询间隔2秒。 + + Args: + code: 股票代码(6位,如'600519') + count: 获取条数(最大240,约4小时) + + Returns: + [{"time":"09:31","open":xx,"close":xx,"high":xx,"low":xx,"volume":xx,"amount":xx}, ...] + 或 None + """ + import time, urllib.request + now = time.time() + elapsed = now - self._last_minute_call + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + + # A股secid: 1.上海 0.深圳 + secid = f"1.{code}" if code.startswith(('6','5')) else f"0.{code}" + url = (f"https://push2.eastmoney.com/api/qt/stock/kline/get" + f"?secid={secid}&fields1=f1,f2,f3&fields2=f51,f52,f53,f54,f55,f56,f57" + f"&klt=1&fqt=1&end=20500101&lmt={min(count, 240)}") + + try: + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + resp = urllib.request.urlopen(req, timeout=8) + data = json.loads(resp.read())["data"]["klines"] + result = [] + for line in data: + parts = line.split(",") + if len(parts) >= 6: + result.append({ + "time": parts[0][-5:], # "2026-07-01 09:31" → "09:31" + "open": float(parts[1]), + "close": float(parts[2]), + "high": float(parts[3]), + "low": float(parts[4]), + "volume": int(parts[5]), + "amount": float(parts[6]) if len(parts) > 6 else 0, + }) + self._last_minute_call = time.time() + return result + except Exception as e: + logger.warning("get_minute_kline(%s) 失败: %s", code, e) + return None + + # ── 新闻搜索 ────────────────────────────────────────────────── + + def search_news(self, query: str, max_results: int = 5) -> list: + """通过 DSA 的搜索服务获取新闻。 + + Args: + query: 搜索关键词 + max_results: 最多返回条数 + + Returns: + list of dict with: title, url, snippet, date + """ + dsa = self._ensure_dsa() + if not dsa: + return [] + + try: + from src.search_service import SearchService + service = SearchService() + results = service.search(query, limit=max_results) + return results[:max_results] if results else [] + except Exception as e: + logger.debug("DSA news search 失败: %s", e) + + return [] + + # ── 大盘分析 ────────────────────────────────────────────────── + + def get_market_context(self, region: str = "cn") -> str | None: + """获取 DSA 的市场复盘摘要。 + + Args: + region: cn/hk/us/both + + Returns: + 市场上下文文本 或 None + """ + dsa = self._ensure_dsa() + if not dsa: + return None + + try: + from src.core.market_review import run_market_review + from src.config import get_config + config = get_config() + result = run_market_review(config=config, send_notification=False) + if result and hasattr(result, 'report'): + return result.report + except Exception as e: + logger.debug("DSA market review 失败: %s", e) + + return None + + # ── 基本面 ──────────────────────────────────────────────────── + + def get_fundamentals(self, code: str) -> dict | None: + """获取股票基本面数据(PE/PB/ROE 等)""" + dsa = self._ensure_dsa() + if not dsa: + return None + + try: + from data_provider.fundamental_adapter import AkshareFundamentalAdapter + adapter = AkshareFundamentalAdapter() + return adapter.get_fundamentals(code) + except Exception: + pass + + return None + + +# ── 单例 ──────────────────────────────────────────────────────────── + +_provider_instance: MoDataProvider | None = None + + +def get_provider() -> MoDataProvider: + """获取 MoDataProvider 单例""" + global _provider_instance + if _provider_instance is None: + _provider_instance = MoDataProvider() + return _provider_instance + + +# ── 便捷函数 ──────────────────────────────────────────────────────── + +def get_realtime(code: str) -> dict | None: + """便捷函数:获取实时行情""" + return get_provider().get_realtime(code) + + +def get_market_context() -> str | None: + """便捷函数:获取大盘上下文""" + return get_provider().get_market_context() + + +def search_news(query: str) -> list: + """便捷函数:搜索新闻""" + return get_provider().search_news(query) + + +# ── 自检 ──────────────────────────────────────────────────────────── + +if __name__ == "__main__": + provider = MoDataProvider() + print(f"DSA 可用: {provider.has_dsa}") + print(f"DSA 路径: {_DSA_BASE}") + + if provider.has_dsa: + manager = provider._ensure_dsa() + print(f"DataFetcherManager: {'已加载' if manager else '加载失败'}") + + # 测试 Tencent API + try: + result = provider._get_tencent_realtime("00700") + print(f"\nTencent API 测试 (00700): {result}") + except Exception as e: + print(f"Tencent API 测试失败: {e}") diff --git a/deploy/profile-scripts/mofin_collect.py b/deploy/profile-scripts/mofin_collect.py new file mode 100644 index 00000000..904e7bdb --- /dev/null +++ b/deploy/profile-scripts/mofin_collect.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +"""mofin_collect.py — MoFin 数据采集链 + +每轮盯盘 cron 前运行,顺序执行: + 0. 策略新鲜度强制检查(代码级约束:推荐前必须先检查策略是否最新) + 1. market_watch — 拉90个行业板块数据(9:30前跳过,市场未开) + 2. trend_detector — 检测17种信号(依赖板块数据,同跳) + 3. mofin_news — 搜新闻+小果分析 + 4. stock_quote — 所有持仓最新行情(CRITICAL: LLM唯一价格源) +""" + +import subprocess, sys, time, json +from pathlib import Path +from datetime import datetime +from mo_data import get_price, get_prices_batch + +BASE = Path(__file__).parent.parent if "hermes" in str(Path(__file__).resolve()) else Path(__file__).parent + +now = datetime.now() +market_open = (now.hour >= 9 and now.minute >= 30) or now.hour >= 10 + +# ── 步骤0: 策略新鲜度强制检查(代码级约束)── +# 在LLM看到任何数据前,先确保所有持仓策略是新鲜的 +# 策略过期(>4h未更新) → 创建TODO触发重评 + 注入STALE标记到LLM context +# LLM看到STALE标记:禁止对该股给出任何操作建议 +print("--- strategy_freshness_check ---", flush=True) +try: + sys.path.insert(0, str(BASE)) + from mofin_db import get_conn + conn = get_conn() + cur = conn.cursor() + # 读所有活跃持仓及其最新策略更新时间 + rows = cur.execute(""" + SELECT h.code, h.name, h.price, h.cost, h.shares, h.position_pct, + hs.stop_loss, hs.take_profit, hs.entry_low, hs.entry_high, + hs.created_at, hs.action + FROM holdings h + LEFT JOIN ( + SELECT code, stop_loss, take_profit, entry_low, entry_high, + created_at, action, + ROW_NUMBER() OVER (PARTITION BY code ORDER BY id DESC) AS rn + FROM holding_strategies + ) hs ON h.code = hs.code AND hs.rn = 1 + WHERE h.is_active = 1 + ORDER BY h.code + """).fetchall() + + stale_count = 0 + fresh_count = 0 + no_strategy_count = 0 + + for r in rows: + code = r["code"] + name = r["name"] + last_update = r["created_at"] + has_strategy = last_update is not None + + if has_strategy: + try: + last_dt = datetime.fromisoformat(last_update) + hours_stale = (now - last_dt).total_seconds() / 3600 + except: + hours_stale = 999 + + if hours_stale > 4: + stale_count += 1 + # 强制代码级约束:立即执行重评,不等TODO异步处理 + # 在LLM看到数据前,策略必须是新鲜的 + try: + from strategy_lifecycle import reassess_with_context + result = reassess_with_context( + code, name, r["price"], + r["cost"] if r["cost"] else 0, r["shares"] if r["shares"] else 0, + r["action"] or "" + ) + if result and result.get("action"): + print(f" 🔄 FORCE_REASSESS {code} {name}: {hours_stale:.0f}h过期→已立即重评→{result['action'][:60]}", flush=True) + # 写strategy_evaluations(独立短连接,防锁冲突) + try: + eval_conn = sqlite3.connect(str(Path(__file__).parent.parent / "data" / "mofin.db"), timeout=10) + eval_conn.execute(""" + INSERT INTO strategy_evaluations + (code, eval_type, status, new_stop_loss, new_tp, reason, created_at) + VALUES (?, 'reassess', 'completed', ?, ?, ?, ?) + """, ( + code, + result.get("stop_loss"), + result.get("take_profit"), + f"{result.get('action','')} RR={result.get('rr_ratio','?')} 信号={result.get('timing_signal','')}", + now.isoformat() + )) + eval_conn.commit() + eval_conn.close() + except Exception: + pass + else: + print(f" ⚠️ FORCE_REASSESS {code} {name}: 重评返回空结果", flush=True) + except Exception as e: + print(f" ❌ FORCE_REASSESS {code} {name} 失败: {e}", flush=True) + # 创建TODO作为兜底 + todo_sql = """ + INSERT OR IGNORE INTO todos + (title, code, fix_action, source, priority, status, created_at) + VALUES (?, ?, 'reassess_strategy', 'freshness_check', 'high', 'pending', ?) + """ + cur.execute(todo_sql, ( + f"策略过期需重评: {code} {name} ({hours_stale:.0f}h未更新)", + code, + now.isoformat() + )) + print(f" ⚠️ STALE {code} {name}: {hours_stale:.0f}h未更新 → 已强制重评", flush=True) + else: + fresh_count += 1 + print(f" ✅ FRESH {code} {name}: {hours_stale:.1f}h前更新", flush=True) + else: + no_strategy_count += 1 + # 无策略→立即执行重评创建策略(代码级约束:不允许无策略就输出建议) + try: + from strategy_lifecycle import reassess_with_context + result = reassess_with_context( + code, name, r["price"], + r["cost"] if r["cost"] else 0, r["shares"] if r["shares"] else 0, + "" + ) + if result and result.get("action"): + print(f" 🔄 CREATE_STRATEGY {code} {name}: 无策略→已创建→{result['action'][:60]}", flush=True) + try: + eval_conn = sqlite3.connect(str(Path(__file__).parent.parent / "data" / "mofin.db"), timeout=10) + eval_conn.execute(""" + INSERT INTO strategy_evaluations + (code, eval_type, status, new_stop_loss, new_tp, reason, created_at) + VALUES (?, 'reassess', 'completed', ?, ?, ?, ?) + """, ( + code, + result.get("stop_loss"), + result.get("take_profit"), + f"{result.get('action','')} RR={result.get('rr_ratio','?')} 信号={result.get('timing_signal','')}", + now.isoformat() + )) + eval_conn.commit() + eval_conn.close() + except Exception: + pass + else: + print(f" ⚠️ CREATE_STRATEGY {code} {name}: 重评返回空", flush=True) + except Exception as e: + print(f" ❌ CREATE_STRATEGY {code} {name} 失败: {e}", flush=True) + + conn.commit() + + # === 自选股策略检查 + 强制重评 === + wl_fresh = 0 + wl_stale = 0 + wl_error = 0 + try: + for wr in conn.execute("SELECT code, name, price, entry_low, entry_high, stop_loss FROM watchlist_stocks WHERE is_active=1"): + code = wr["code"] + name = wr["name"] + wl_price = wr["price"] or 0 + # 自选股price可能为0(新加入未更新),从实时API获取 + if wl_price <= 0: + try: + p, _ = get_price(code) + if p and p > 0: + wl_price = p + except Exception: + pass + # 自选股无cost/shares,传0 + try: + from strategy_lifecycle import reassess_with_context + result = reassess_with_context( + code, name, wl_price, + 0, 0, "" + ) + if result and result.get("action"): + wl_stale += 1 + print(f" 📋 WATCHLIST_REASSESS {code} {name}: →{result['action'][:60]}", flush=True) + try: + eval_conn = sqlite3.connect(str(Path(__file__).parent.parent / "data" / "mofin.db"), timeout=10) + eval_conn.execute(""" + INSERT INTO strategy_evaluations + (code, eval_type, status, new_stop_loss, new_tp, reason, created_at) + VALUES (?, 'reassess', 'completed', ?, ?, ?, ?) + """, ( + code, + result.get("stop_loss"), + result.get("take_profit"), + f"自选:{result.get('action','')} RR={result.get('rr_ratio','?')} 信号={result.get('timing_signal','')}", + now.isoformat() + )) + eval_conn.commit() + eval_conn.close() + except Exception: + pass + else: + wl_fresh += 1 + except Exception as e: + wl_error += 1 + print(f" ❌ WATCHLIST_REASSESS {code} {name} 失败: {e}", flush=True) + except Exception as e: + print(f" ⚠️ 自选股检查跳过: {e}", flush=True) + + conn.close() + + total = len(rows) + wl_total = wl_fresh + wl_stale + wl_error + print(f"策略检查完成: {total}只持仓({fresh_count}新鲜/{stale_count}过期/{no_strategy_count}无策略) + {wl_total}只自选({wl_fresh}无需/{wl_stale}已重评/{wl_error}失败)", flush=True) + if stale_count > 0 or no_strategy_count > 0 or wl_stale > 0: + print(f"⚠️ 重评完成: {stale_count + no_strategy_count}只已强制刷新, LLM可基于最新策略给出建议", flush=True) +except Exception as e: + print(f"WARN: strategy_freshness_check跳过 ({e})", flush=True) + +# 步骤1-3: 行业/新闻数据 +SCRIPTS = [] +if market_open: + SCRIPTS.append(("market_watch.py", 60)) + SCRIPTS.append(("trend_detector.py", 60)) +else: + print(f"[{now.strftime('%H:%M')}] 市场未开盘(9:30),跳过板块采集", flush=True) + +SCRIPTS.append(("mofin_news.py", 50)) + +for script, timeout in SCRIPTS: + path = BASE / script + if not path.exists(): + path = Path("/home/hmo/MoFin") / script + print(f"--- {script} ---", flush=True) + start = time.time() + try: + result = subprocess.run( + [sys.executable, str(path)], + capture_output=True, text=True, timeout=timeout + ) + elapsed = time.time() - start + if result.returncode == 0: + print(f"OK ({elapsed:.0f}s)", flush=True) + if result.stdout.strip(): + for line in result.stdout.strip().split("\n")[-3:]: + print(f" {line}", flush=True) + else: + print(f"FAIL ({elapsed:.0f}s): {result.stderr[:200]}", flush=True) + except subprocess.TimeoutExpired: + print(f"TIMEOUT ({timeout}s)", flush=True) + except Exception as e: + print(f"ERROR: {e}", flush=True) + +# ── 步骤4: 个股行情注入(唯一权威价格源)── +# 所有持仓最新行情,注入到 LLM context +# LLM 禁止自行调用原始API解析价格 +PRICE_SCRIPT = BASE / "stock_quote.py" +if not PRICE_SCRIPT.exists(): + PRICE_SCRIPT = Path("/home/hmo/MoFin/scripts/stock_quote.py") +if PRICE_SCRIPT.exists(): + print("--- stock_quote.py ---", flush=True) + try: + result = subprocess.run( + [sys.executable, str(PRICE_SCRIPT), "--all-holdings"], + capture_output=True, text=True, timeout=30 + ) + if result.returncode == 0 and result.stdout.strip(): + lines = [l for l in result.stdout.strip().split("\n") if l.strip()] + print(f"OK ({len(lines)}只持仓)", flush=True) + for line in lines[:50]: + print(f" {line}", flush=True) + else: + print(f"WARN: stock_quote stderr={result.stderr[:100]}", flush=True) + except Exception as e: + print(f"WARN: stock_quote skipped ({e})", flush=True) +else: + print("WARN: stock_quote.py not found", flush=True) + +print("采集链完成", flush=True) diff --git a/deploy/profile-scripts/mofin_db.py b/deploy/profile-scripts/mofin_db.py new file mode 100644 index 00000000..20b313b3 --- /dev/null +++ b/deploy/profile-scripts/mofin_db.py @@ -0,0 +1,1303 @@ +#!/usr/bin/env python3 +"""mofin_db.py — MoFin 统一数据库访问层 + +所有脚本通过此模块访问 mofin.db,避免重复建表/连接逻辑。 + +用法: + from mofin_db import get_conn, write_market_snapshot, write_klines, ... + +设计原则: + - 幂等建表(CREATE TABLE IF NOT EXISTS) + - WAL 模式 + 外键约束 + - 所有写操作返回 (success: bool, detail: str) + - JSON 写入由调用方负责,本模块只写 SQLite +""" + +import sqlite3 +import json +import time +import functools +from datetime import datetime +from pathlib import Path +from typing import Optional, Callable + +DATA_DIR = Path(__file__).parent / "data" +DB_PATH = DATA_DIR / "mofin.db" + +# ═══════════════════════════════════════════════════════════ +# 连接管理 +# ═══════════════════════════════════════════════════════════ + +def get_conn() -> sqlite3.Connection: + """获取数据库连接(WAL 模式,外键约束,Row 工厂,30秒超时防并发锁,autocommit模式)""" + DATA_DIR.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(DB_PATH), timeout=30, isolation_level=None) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + conn.execute("PRAGMA busy_timeout=30000") + conn.execute("PRAGMA synchronous=NORMAL") + # 每次连接时清理WAL:防止被kill的进程留下残留事务导致后续全部卡死 + try: + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + except Exception: + pass + return conn + + +def execute_with_retry(conn: sqlite3.Connection, sql: str, params: tuple = (), + max_retries: int = 3, base_delay: float = 1.0) -> sqlite3.Cursor: + """执行SQL并自动重试(捕获 database is locked)""" + last_err = None + for attempt in range(max_retries + 1): + try: + return conn.execute(sql, params) + except sqlite3.OperationalError as e: + if "database is locked" not in str(e) and "cannot commit" not in str(e): + raise # 非锁错误直接抛 + last_err = e + if attempt < max_retries: + delay = base_delay * (2 ** attempt) # 指数退避: 1s, 2s, 4s + time.sleep(delay) + else: + raise sqlite3.OperationalError( + f"DB锁重试{max_retries}次仍失败: {e}" + ) + # unreachable -- both paths in loop either return or raise + if last_err: + raise last_err # type: ignore[misc] + + +def commit_with_retry(conn: sqlite3.Connection, max_retries: int = 3, + base_delay: float = 1.0) -> None: + """提交事务并自动重试""" + last_err = None + for attempt in range(max_retries + 1): + try: + conn.commit() + return + except sqlite3.OperationalError as e: + if "database is locked" not in str(e) and "cannot commit" not in str(e): + raise + last_err = e + if attempt < max_retries: + delay = base_delay * (2 ** attempt) + time.sleep(delay) + else: + raise sqlite3.OperationalError( + f"DB提交重试{max_retries}次仍失败: {e}" + ) + raise last_err + + +def retry_db_write(func: Callable) -> Callable: + """装饰器:为 DB 写函数自动添加重试""" + @functools.wraps(func) + def wrapper(*args, **kwargs): + max_retries = 3 + base_delay = 1.0 + last_err = None + for attempt in range(max_retries + 1): + try: + return func(*args, **kwargs) + except sqlite3.OperationalError as e: + if "database is locked" not in str(e) and "cannot commit" not in str(e): + raise + last_err = e + if attempt < max_retries: + delay = base_delay * (2 ** attempt) + time.sleep(delay) + else: + raise sqlite3.OperationalError( + f"DB写重试{max_retries}次仍失败({func.__name__}): {e}" + ) + raise last_err + return wrapper + + +# ═══════════════════════════════════════════════════════════ +# 建表(幂等) +# ═══════════════════════════════════════════════════════════ + +def init_all_tables(conn: sqlite3.Connection): + """创建全部表(幂等,已存在则跳过)""" + conn.executescript(""" + -- 市场快照 + CREATE TABLE IF NOT EXISTS market_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'ths', + up_ratio REAL, + mood TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + CREATE INDEX IF NOT EXISTS idx_snapshots_time ON market_snapshots(timestamp); + + -- 板块快照 + CREATE TABLE IF NOT EXISTS sector_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_id INTEGER NOT NULL REFERENCES market_snapshots(id), + name TEXT NOT NULL, + change_pct REAL, + up_count INTEGER, + down_count INTEGER, + net_inflow REAL, + lead_stock TEXT, + lead_stock_change REAL, + volume REAL, + turnover REAL + ); + CREATE INDEX IF NOT EXISTS idx_sector_name ON sector_snapshots(name); + CREATE INDEX IF NOT EXISTS idx_sector_snapshot ON sector_snapshots(snapshot_id); + CREATE INDEX IF NOT EXISTS idx_sector_name_time ON sector_snapshots(name, snapshot_id); + + -- 个股 + CREATE TABLE IF NOT EXISTS stocks ( + code TEXT PRIMARY KEY, + name TEXT NOT NULL, + exchange TEXT DEFAULT 'SH', + type TEXT DEFAULT 'A', + updated_at TEXT + ); + + -- K线(日/周/月) + CREATE TABLE IF NOT EXISTS stock_daily ( + code TEXT NOT NULL REFERENCES stocks(code), + date TEXT NOT NULL, + open REAL, close REAL, high REAL, low REAL, + volume REAL, amount REAL, + PRIMARY KEY (code, date) + ); + CREATE TABLE IF NOT EXISTS stock_weekly ( + code TEXT NOT NULL REFERENCES stocks(code), + date TEXT NOT NULL, + open REAL, close REAL, high REAL, low REAL, + volume REAL, + PRIMARY KEY (code, date) + ); + CREATE TABLE IF NOT EXISTS stock_monthly ( + code TEXT NOT NULL REFERENCES stocks(code), + date TEXT NOT NULL, + open REAL, close REAL, high REAL, low REAL, + volume REAL, + PRIMARY KEY (code, date) + ); + + -- 基本面 + CREATE TABLE IF NOT EXISTS stock_fundamentals ( + code TEXT PRIMARY KEY REFERENCES stocks(code), + pe REAL, pb REAL, eps REAL, + mcap_total REAL, mcap_flow REAL, + updated_at TEXT + ); + + -- 板块成分映射 + CREATE TABLE IF NOT EXISTS stock_sectors ( + code TEXT NOT NULL REFERENCES stocks(code), + sector_name TEXT NOT NULL, + source TEXT DEFAULT 'ths', + updated_at TEXT DEFAULT (datetime('now','localtime')), + PRIMARY KEY (code, sector_name) + ); + CREATE INDEX IF NOT EXISTS idx_stock_sector ON stock_sectors(sector_name); + + -- 持仓 + CREATE TABLE IF NOT EXISTS holdings ( + code TEXT PRIMARY KEY REFERENCES stocks(code), + name TEXT NOT NULL, + shares INTEGER NOT NULL, + cost REAL, + price REAL, -- 当前价格 (CNY) + market_value REAL, -- 市值 = shares * price + change_pct REAL, -- 涨跌幅 + currency TEXT NOT NULL DEFAULT 'CNY' CHECK(currency IN ('CNY','HKD')), + position_pct REAL, + added_at TEXT, + is_active INTEGER DEFAULT 1, + closed_at TEXT, + close_pnl REAL + ); + + -- 持仓策略(对应 decisions.json decisions[]) + CREATE TABLE IF NOT EXISTS holding_strategies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL REFERENCES holdings(code), + name TEXT, + version INTEGER DEFAULT 1, + price REAL, + cost REAL, + shares INTEGER DEFAULT 0, + stop_loss REAL, + take_profit REAL, + entry_low REAL, + entry_high REAL, + currency TEXT NOT NULL DEFAULT 'CNY' CHECK(currency IN ('CNY','HKD')), + strategy_type TEXT DEFAULT 'holding', + action TEXT, + timing_signal TEXT, + rr_ratio REAL, + tech_snapshot TEXT, + stock_category TEXT, + sector_context TEXT, + status TEXT DEFAULT 'active', + trigger_json TEXT, + changelog_json TEXT, + source TEXT, + reason TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')), + updated_at TEXT, + superseded_at TEXT, + -- 以下为 decisions.json→DB 迁移新增列 + avg_price REAL, + decision_timestamp TEXT, + note TEXT, + quality_check TEXT, + quality_checked_at TEXT, + quality_issues_json TEXT, + position_advice TEXT, + signal_factors_json TEXT, + time_horizon TEXT, + decision_type TEXT + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_strategy_code ON holding_strategies(code); + CREATE INDEX IF NOT EXISTS idx_strategy_status ON holding_strategies(status); + + -- 自选股 + CREATE TABLE IF NOT EXISTS watchlist_stocks ( + code TEXT PRIMARY KEY REFERENCES stocks(code), + name TEXT NOT NULL, + price REAL, -- 当前价格 + entry_low REAL, -- 买入区下限 + entry_high REAL, -- 买入区上限 + stop_loss REAL, -- 止损 + currency TEXT NOT NULL DEFAULT 'CNY' CHECK(currency IN ('CNY','HKD')), + source TEXT, -- 来源: alpha_sift/xiaoguo/manual + source_detail TEXT, -- 来源详情 JSON + notes TEXT, -- 备注 + added_by TEXT, -- 谁加的 + added_at TEXT DEFAULT (datetime('now','localtime')), + is_active INTEGER DEFAULT 1, + analysis_json TEXT -- 分析结果 JSON + ); + + -- 候选池 + CREATE TABLE IF NOT EXISTS candidates ( + code TEXT PRIMARY KEY REFERENCES stocks(code), + name TEXT NOT NULL, + sector TEXT, + reason TEXT, + entry_range TEXT, + stop_loss REAL, + target REAL, + zhiwei_star REAL, + zhiwei_reviewed INTEGER DEFAULT 0, + zhiwei_reviewed_at TEXT, + promoted INTEGER DEFAULT 0, + promoted_at TEXT, + dropped INTEGER DEFAULT 0, + drop_reason TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + + -- 候选评分历史 + CREATE TABLE IF NOT EXISTS candidate_score_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL REFERENCES candidates(code), + score REAL NOT NULL, + source TEXT NOT NULL, + reason TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + CREATE INDEX IF NOT EXISTS idx_candidate_history ON candidate_score_history(code, created_at); + + -- 价格事件 + CREATE TABLE IF NOT EXISTS price_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL REFERENCES stocks(code), + name TEXT, + event_type TEXT NOT NULL, + price REAL, + trigger_value TEXT, + event_label TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')), + date TEXT + ); + CREATE INDEX IF NOT EXISTS idx_events_code ON price_events(code); + CREATE INDEX IF NOT EXISTS idx_events_date ON price_events(date); + + -- 策略评估记录 + CREATE TABLE IF NOT EXISTS strategy_evaluations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL REFERENCES stocks(code), + eval_type TEXT NOT NULL, + status TEXT DEFAULT 'pending', + old_stop_loss REAL, + new_stop_loss REAL, + old_tp REAL, + new_tp REAL, + reason TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + + -- 持仓汇总(portfolio.json 顶层字段) + CREATE TABLE IF NOT EXISTS portfolio_summary ( + id INTEGER PRIMARY KEY CHECK (id = 1), + total_assets REAL, + total_mv REAL, -- 持仓总市值 + stock_value REAL, + cash REAL, -- 可用现金 + frozen_cash REAL DEFAULT 0, -- 冻结资金 + position_pct REAL, + total_pnl REAL, + currency TEXT NOT NULL DEFAULT 'CNY' CHECK(currency IN ('CNY','HKD')), + updated_at TEXT + ); + + -- 现金变更日志(每次买卖/出入金记录) + CREATE TABLE IF NOT EXISTS cash_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (datetime('now','localtime')), + cash_before REAL, -- 变更前可用现金 + cash_after REAL, -- 变更后可用现金 + frozen_before REAL, -- 变更前冻结资金 + frozen_after REAL, -- 变更后冻结资金 + change_amount REAL, -- 现金变动额(正=入金/卖股,负=出金/买股) + source TEXT NOT NULL, -- 来源: screenshot/manual/import_xls/trade + note TEXT, -- 备注: 例如 "卖出法拉电子 200股" + verified INTEGER DEFAULT 0 -- 是否已验证(0=未验证,1=Dad确认) + ); + + -- 建议时间线(decisions.json advice_timeline[]) + CREATE TABLE IF NOT EXISTS advice_timeline ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL REFERENCES stocks(code), + date TEXT, + direction TEXT, + price REAL, + summary TEXT, + status TEXT, + evaluated INTEGER DEFAULT 0, + result TEXT, + evaluated_at TEXT, + report_id TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + CREATE INDEX IF NOT EXISTS idx_advice_code ON advice_timeline(code); + + -- 准确率统计(accuracy_stats.json) + CREATE TABLE IF NOT EXISTS accuracy_stats ( + id INTEGER PRIMARY KEY CHECK (id = 1), + period_start TEXT, + period_end TEXT, + total_advice INTEGER DEFAULT 0, + correct INTEGER DEFAULT 0, + wrong INTEGER DEFAULT 0, + partial INTEGER DEFAULT 0, + unknown INTEGER DEFAULT 0, + pending INTEGER DEFAULT 0, + ignored INTEGER DEFAULT 0, + evaluated INTEGER DEFAULT 0, + accuracy_pct REAL, + phase1_correct INTEGER DEFAULT 0, + phase1_wrong INTEGER DEFAULT 0, + phase1_pending INTEGER DEFAULT 0, + phase1_accuracy REAL, + phase2_correct INTEGER DEFAULT 0, + phase2_wrong INTEGER DEFAULT 0, + phase2_pending INTEGER DEFAULT 0, + phase2_accuracy REAL, + total_evaluated INTEGER DEFAULT 0, + updated_at TEXT + ); + + -- 策略反馈(strategy_feedback.json) + CREATE TABLE IF NOT EXISTS strategy_feedback ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL REFERENCES stocks(code), + name TEXT, + evaluated_at TEXT, + phase1_completed INTEGER DEFAULT 0, + phase1_result TEXT, + phase1_completed_at TEXT, + phase1_price REAL, + phase2_completed INTEGER DEFAULT 0, + phase2_result TEXT, + phase2_completed_at TEXT, + days_in_phase1 INTEGER, + adjustments_json TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + CREATE INDEX IF NOT EXISTS idx_feedback_code ON strategy_feedback(code); + + -- 板块信号(trend_detector 产出) + CREATE TABLE IF NOT EXISTS sector_signals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + signal_type TEXT NOT NULL, + sector TEXT NOT NULL, + severity TEXT DEFAULT 'medium', + related_stocks TEXT, + holdings_in_sector TEXT, + watchlist_in_sector TEXT, + trigger_reason TEXT, + snapshot_id INTEGER, + processed INTEGER DEFAULT 0, + detected_at TEXT DEFAULT (datetime('now','localtime')) + ); + CREATE INDEX IF NOT EXISTS idx_signal_processed ON sector_signals(processed); + CREATE INDEX IF NOT EXISTS idx_signal_sector ON sector_signals(sector); + + -- 小果情报(xiaoguo_news_processor 产出) + CREATE TABLE IF NOT EXISTS signal_news ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + signal_id INTEGER REFERENCES sector_signals(id), + sector TEXT NOT NULL, + overall_sentiment TEXT, + summary TEXT, + key_articles TEXT, + searched_stocks TEXT, + created_at TEXT DEFAULT (datetime('now','localtime')) + ); + CREATE INDEX IF NOT EXISTS idx_signal_news_signal ON signal_news(signal_id); + + -- 小果扫描跟踪(去重用) + CREATE TABLE IF NOT EXISTS xiaoguo_scan_tracker ( + code TEXT PRIMARY KEY, + name TEXT, + last_scanned_at TEXT, + found_count INTEGER DEFAULT 0 + ); + + -- 实时价格快照(替代 live_prices.json) + CREATE TABLE IF NOT EXISTS live_prices ( + code TEXT PRIMARY KEY, + price REAL, + change_pct REAL, + updated_at TEXT DEFAULT (datetime('now','localtime')) + ); + + -- 多周期缓存(替代 multi_tf_cache.json) + CREATE TABLE IF NOT EXISTS mtf_cache ( + code TEXT PRIMARY KEY, + cache_json TEXT, + updated_at TEXT DEFAULT (datetime('now','localtime')) + ); + + -- 资金流缓存(替代 capital_flow_cache.json) + CREATE TABLE IF NOT EXISTS capital_flow_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cache_json TEXT, + updated_at TEXT DEFAULT (datetime('now','localtime')) + ); + + -- Self-TODO 自动化任务表 + CREATE TABLE IF NOT EXISTS todos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + status TEXT DEFAULT 'pending', + priority TEXT DEFAULT 'medium', + source TEXT DEFAULT 'manual', + fix_action TEXT, + retry_count INTEGER DEFAULT 0, + note TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + conn.commit() + + # 迁移:给 signal_news 加 source 字段(幂等) + try: + conn.execute("ALTER TABLE signal_news ADD COLUMN source TEXT DEFAULT 'trend'") + except sqlite3.OperationalError: + pass + + # cash_log migration (2026-07-01) + try: + conn.execute("ALTER TABLE cash_log ADD COLUMN frozen_before REAL") + except sqlite3.OperationalError: + pass + try: + conn.execute("ALTER TABLE cash_log ADD COLUMN frozen_after REAL") + except sqlite3.OperationalError: + pass + try: + conn.execute("ALTER TABLE cash_log ADD COLUMN verified INTEGER DEFAULT 0") + except sqlite3.OperationalError: + pass + + # ── 币种约束迁移(2026-06-30)──────────────────────────────── + _currency_migrations = [ + ("holdings", ["price REAL", "market_value REAL", "change_pct REAL", + "currency TEXT NOT NULL DEFAULT 'CNY'"]), + ("holding_strategies", ["name TEXT", "price REAL", "cost REAL", "shares INTEGER DEFAULT 0", + "currency TEXT NOT NULL DEFAULT 'CNY'", + "action TEXT", "timing_signal TEXT", "rr_ratio REAL", + "tech_snapshot TEXT", "stock_category TEXT", + "sector_context TEXT", "status TEXT DEFAULT 'active'", + "trigger_json TEXT", "changelog_json TEXT", + "updated_at TEXT"]), + ("portfolio_summary", ["total_mv REAL", "frozen_cash REAL DEFAULT 0", + "currency TEXT NOT NULL DEFAULT 'CNY'"]), + ("watchlist_stocks", ["price REAL", "entry_low REAL", "entry_high REAL", + "stop_loss REAL", "currency TEXT NOT NULL DEFAULT 'CNY'", + "source TEXT", "source_detail TEXT", "notes TEXT", + "added_by TEXT", "analysis_json TEXT"]), + ] + for table, columns in _currency_migrations: + for col_def in columns: + col_name = col_def.split()[0] + try: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {col_def}") + except sqlite3.OperationalError: + pass # column already exists + conn.commit() + + +# ═══════════════════════════════════════════════════════════ +# 市场快照写入 +# ═══════════════════════════════════════════════════════════ + +def write_market_snapshot(conn: sqlite3.Connection, market_data: dict) -> tuple[bool, str, Optional[int]]: + """写入一次市场采集到 market_snapshots + sector_snapshots + + Returns: (ok, message, snapshot_id) + """ + try: + cur = conn.execute( + "INSERT INTO market_snapshots (timestamp, source, up_ratio, mood) VALUES (?, ?, ?, ?)", + (market_data["timestamp"], market_data.get("source", "unknown"), + market_data.get("up_ratio", 0), market_data.get("mood", "unknown")), + ) + sid = cur.lastrowid + + sectors = market_data.get("sectors", []) + rows = [(sid, s.get("name", ""), s.get("change", 0), + s.get("up_count"), s.get("down_count"), s.get("net_inflow"), + s.get("lead_stock"), s.get("lead_stock_change"), + s.get("volume"), s.get("turnover")) for s in sectors] + if rows: + conn.executemany( + "INSERT INTO sector_snapshots (snapshot_id, name, change_pct, up_count, down_count, " + "net_inflow, lead_stock, lead_stock_change, volume, turnover) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", rows) + conn.commit() + return True, f"snapshot_id={sid}, sectors={len(rows)}", sid + except Exception as e: + try: + conn.rollback() + except Exception: + pass + return False, str(e), None + + +# ═══════════════════════════════════════════════════════════ +# K线写入 +# ═══════════════════════════════════════════════════════════ + +def write_klines(conn: sqlite3.Connection, code: str, name: str, + daily: list = None, weekly: list = None, monthly: list = None, + fundamentals: dict = None) -> bool: + """将个股K线数据双写 SQLite + + Args: + code: 股票代码 + name: 股票名称 + daily/weekly/monthly: [{date, open, close, high, low, volume}, ...] + fundamentals: {pe, pb, eps, mcap_total, mcap_flow} + """ + try: + # 判断交易所 + raw = str(code) + if len(raw) == 5 and raw.isdigit(): + exchange, stype = "HK", "H" + elif raw.startswith(("6", "5", "9")): + exchange, stype = "SH", "A" + else: + exchange, stype = "SZ", "A" + + # stocks 表(INSERT OR REPLACE) + conn.execute( + "INSERT OR REPLACE INTO stocks (code, name, exchange, type, updated_at) VALUES (?, ?, ?, ?, ?)", + (code, name, exchange, stype, datetime.now().isoformat())) + + # K线数据 + for period, table, data in [ + ("daily", "stock_daily", daily), + ("weekly", "stock_weekly", weekly), + ("monthly", "stock_monthly", monthly), + ]: + if not data: + continue + rows = [(code, d.get("date", ""), d.get("open"), d.get("close"), + d.get("high"), d.get("low"), d.get("volume"), + d.get("amount") if period == "daily" else None) for d in data] + if period == "daily": + conn.executemany( + f"INSERT OR REPLACE INTO {table} (code, date, open, close, high, low, volume, amount) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", rows) + else: + conn.executemany( + f"INSERT OR REPLACE INTO {table} (code, date, open, close, high, low, volume) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + [(r[0], r[1], r[2], r[3], r[4], r[5], r[6]) for r in rows]) + + # 基本面 + if fundamentals: + conn.execute( + "INSERT OR REPLACE INTO stock_fundamentals (code, pe, pb, eps, mcap_total, mcap_flow, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (code, fundamentals.get("pe"), fundamentals.get("pb"), + fundamentals.get("eps"), fundamentals.get("mcap_total"), + fundamentals.get("mcap_flow"), datetime.now().isoformat())) + + conn.commit() + return True + except Exception as e: + try: + conn.rollback() + except Exception: + pass + return False + + +# ═══════════════════════════════════════════════════════════ +# 价格事件写入 +# ═══════════════════════════════════════════════════════════ + +def write_price_event(conn: sqlite3.Connection, code: str, name: str, + event_type: str, price: float, trigger_value: str, + event_label: str = "") -> bool: + """写入一条价格事件""" + try: + now = datetime.now() + conn.execute( + "INSERT INTO price_events (code, name, event_type, price, trigger_value, event_label, date) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (code, name, event_type, round(price, 2), trigger_value, + event_label, now.strftime("%Y-%m-%d"))) + conn.commit() + return True + except Exception: + try: + conn.rollback() + except Exception: + pass + return False + + +# ═══════════════════════════════════════════════════════════ +# 板块成分迁移 +# ═══════════════════════════════════════════════════════════ + +def migrate_stock_sectors(conn: sqlite3.Connection) -> tuple[int, int]: + """从 stock_sector_map.json 迁移到 stock_sectors 表 + + Returns: (migrated_stocks, total_mappings) + """ + sector_map_path = DATA_DIR / "stock_sector_map.json" + if not sector_map_path.exists(): + return 0, 0 + + try: + with open(sector_map_path, encoding="utf-8") as f: + data = json.load(f) + except Exception: + return 0, 0 + + # 过滤元数据字段 + mappings = [(code, sectors) for code, sectors in data.items() + if not code.startswith("_") and isinstance(sectors, list)] + + total = 0 + for code, sectors in mappings: + for sector in sectors: + try: + conn.execute( + "INSERT OR IGNORE INTO stock_sectors (code, sector_name, source) VALUES (?, ?, 'ths')", + (code, sector)) + total += 1 + except Exception: + pass + conn.commit() + return len(mappings), total + + +# ═══════════════════════════════════════════════════════════ +# 查询辅助 +# ═══════════════════════════════════════════════════════════ + +def query_sector_trend(conn: sqlite3.Connection, name: str, limit: int = 5) -> list[dict]: + """板块最近N次趋势""" + rows = conn.execute(""" + SELECT s.timestamp, ss.change_pct, ss.net_inflow, + ss.up_count, ss.down_count, ss.lead_stock, ss.lead_stock_change + FROM sector_snapshots ss + JOIN market_snapshots s ON ss.snapshot_id = s.id + WHERE ss.name = ? ORDER BY s.timestamp DESC LIMIT ? + """, (name, limit)).fetchall() + return [dict(r) for r in rows] + + +def query_top_inflow(conn: sqlite3.Connection, limit: int = 5) -> list[dict]: + """最新一次资金净流入排行""" + rows = conn.execute(""" + SELECT ss.name, ss.change_pct, ss.net_inflow, ss.lead_stock, s.timestamp + FROM sector_snapshots ss + JOIN market_snapshots s ON ss.snapshot_id = s.id + WHERE s.id = (SELECT MAX(id) FROM market_snapshots) + AND ss.net_inflow IS NOT NULL + ORDER BY ss.net_inflow DESC LIMIT ? + """, (limit,)).fetchall() + return [dict(r) for r in rows] + + +def query_consecutive_inflow(conn: sqlite3.Connection, days: int = 3) -> list[dict]: + """连续N次净流入的板块""" + rows = conn.execute(""" + SELECT name, COUNT(*) as times, ROUND(AVG(net_inflow), 2) as avg_inflow, + ROUND(AVG(change_pct), 2) as avg_change + FROM sector_snapshots ss + JOIN market_snapshots s ON ss.snapshot_id = s.id + WHERE s.id > (SELECT MAX(id) - ? FROM market_snapshots) + AND net_inflow > 0 + GROUP BY name HAVING COUNT(*) >= ? + ORDER BY avg_inflow DESC + """, (days, days)).fetchall() + return [dict(r) for r in rows] + + +def query_market_mood(conn: sqlite3.Connection, limit: int = 10) -> list[dict]: + """市场情绪趋势""" + rows = conn.execute(""" + SELECT timestamp, source, up_ratio, mood + FROM market_snapshots ORDER BY timestamp DESC LIMIT ? + """, (limit,)).fetchall() + return [dict(r) for r in rows] + + +def query_db_stats(conn: sqlite3.Connection) -> dict: + """数据库概览""" + snap_count = conn.execute("SELECT COUNT(*) FROM market_snapshots").fetchone()[0] + sector_count = conn.execute("SELECT COUNT(*) FROM sector_snapshots").fetchone()[0] + stock_count = conn.execute("SELECT COUNT(*) FROM stocks").fetchone()[0] + kline_count = conn.execute("SELECT COUNT(*) FROM stock_daily").fetchone()[0] + event_count = conn.execute("SELECT COUNT(*) FROM price_events").fetchone()[0] + holding_count = conn.execute("SELECT COUNT(*) FROM holdings").fetchone()[0] + candidate_count = conn.execute("SELECT COUNT(*) FROM candidates").fetchone()[0] + latest = conn.execute( + "SELECT timestamp, source FROM market_snapshots ORDER BY id DESC LIMIT 1").fetchone() + return { + "snapshots": snap_count, "sector_rows": sector_count, + "stocks": stock_count, "daily_klines": kline_count, + "price_events": event_count, "holdings": holding_count, + "candidates": candidate_count, + "latest_snapshot": dict(latest) if latest else None, + } + + +# ═══════════════════════════════════════════════════════════ +# 持仓查询 +# ═══════════════════════════════════════════════════════════ + +def query_holdings(conn: sqlite3.Connection) -> list[dict]: + """持仓列表(含最新策略)""" + rows = conn.execute(""" + SELECT h.code, h.name, h.shares, h.cost, h.position_pct, h.is_active, + h.price, h.change_pct, h.currency, + hs.stop_loss, hs.take_profit, hs.entry_low, hs.entry_high, + hs.reason as action, hs.created_at as strategy_updated + FROM holdings h + LEFT JOIN holding_strategies hs ON h.code = hs.code + AND hs.id = (SELECT MAX(id) FROM holding_strategies WHERE code = h.code AND strategy_type = 'holding') + WHERE h.is_active = 1 + """).fetchall() + return [dict(r) for r in rows] + + +def query_holding_by_code(conn: sqlite3.Connection, code: str) -> dict | None: + """单只持仓""" + row = conn.execute(""" + SELECT h.*, hs.stop_loss, hs.take_profit, hs.entry_low, hs.entry_high, + hs.reason as action + FROM holdings h + LEFT JOIN holding_strategies hs ON h.code = hs.code + AND hs.id = (SELECT MAX(id) FROM holding_strategies WHERE code = h.code AND strategy_type = 'holding') + WHERE h.code = ? + """, (code,)).fetchone() + return dict(row) if row else None + + +def query_portfolio_summary(conn: sqlite3.Connection) -> dict: + """持仓汇总""" + row = conn.execute("SELECT * FROM portfolio_summary WHERE id = 1").fetchone() + return dict(row) if row else {} + + +# ═══════════════════════════════════════════════════════════ +# 自选股查询 +# ═══════════════════════════════════════════════════════════ + +def query_watchlist(conn: sqlite3.Connection) -> list[dict]: + """自选股列表(含策略)""" + rows = conn.execute(""" + SELECT w.code, w.name, w.added_at, + hs.stop_loss, hs.take_profit, hs.entry_low, hs.entry_high, + hs.reason as action + FROM watchlist_stocks w + LEFT JOIN holding_strategies hs ON w.code = hs.code + AND hs.id = (SELECT MAX(id) FROM holding_strategies WHERE code = w.code AND strategy_type = 'watch') + WHERE w.is_active = 1 + """).fetchall() + return [dict(r) for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 决策/策略查询 +# ═══════════════════════════════════════════════════════════ + +def query_strategies(conn: sqlite3.Connection, code: str = None) -> list[dict]: + """策略列表(按版本倒序)""" + if code: + rows = conn.execute( + "SELECT * FROM holding_strategies WHERE code = ? ORDER BY version DESC", (code,)).fetchall() + else: + rows = conn.execute( + "SELECT * FROM holding_strategies ORDER BY code, version DESC").fetchall() + return [dict(r) for r in rows] + + +def query_advice_timeline(conn: sqlite3.Connection, code: str = None, limit: int = 50) -> list[dict]: + """建议时间线""" + if code: + rows = conn.execute( + "SELECT * FROM advice_timeline WHERE code = ? ORDER BY date DESC LIMIT ?", + (code, limit)).fetchall() + else: + rows = conn.execute( + "SELECT * FROM advice_timeline ORDER BY date DESC LIMIT ?", (limit,)).fetchall() + return [dict(r) for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 候选池查询 +# ═══════════════════════════════════════════════════════════ + +def query_candidates(conn: sqlite3.Connection, active_only: bool = True) -> list[dict]: + """候选池列表(含最新评分)""" + where = "WHERE c.dropped = 0" if active_only else "" + rows = conn.execute(f""" + SELECT c.*, (SELECT score FROM candidate_score_history + WHERE code = c.code ORDER BY created_at DESC LIMIT 1) as latest_score + FROM candidates c {where} + ORDER BY c.zhiwei_star DESC NULLS LAST + """).fetchall() + return [dict(r) for r in rows] + + +def query_candidate_scores(conn: sqlite3.Connection, code: str) -> list[dict]: + """某候选的评分历史""" + rows = conn.execute( + "SELECT * FROM candidate_score_history WHERE code = ? ORDER BY created_at", + (code,)).fetchall() + return [dict(r) for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 价格事件查询 +# ═══════════════════════════════════════════════════════════ + +def query_price_events(conn: sqlite3.Connection, code: str = None, limit: int = 100) -> list[dict]: + """价格事件""" + if code: + rows = conn.execute( + "SELECT * FROM price_events WHERE code = ? ORDER BY created_at DESC LIMIT ?", + (code, limit)).fetchall() + else: + rows = conn.execute( + "SELECT * FROM price_events ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall() + return [dict(r) for r in rows] + + +def query_price_events_by_date(conn: sqlite3.Connection, date: str) -> list[dict]: + """某天的价格事件""" + rows = conn.execute( + "SELECT * FROM price_events WHERE date = ? ORDER BY created_at DESC", (date,)).fetchall() + return [dict(r) for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 板块成分查询 +# ═══════════════════════════════════════════════════════════ + +def query_stock_sectors(conn: sqlite3.Connection, code: str) -> list[str]: + """某只股票所属板块""" + rows = conn.execute( + "SELECT sector_name FROM stock_sectors WHERE code = ?", (code,)).fetchall() + return [r[0] for r in rows] + + +def query_sector_stocks(conn: sqlite3.Connection, sector_name: str) -> list[str]: + """某板块包含的股票""" + rows = conn.execute( + "SELECT code FROM stock_sectors WHERE sector_name = ?", (sector_name,)).fetchall() + return [r[0] for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 准确率统计查询 +# ═══════════════════════════════════════════════════════════ + +def query_accuracy_stats(conn: sqlite3.Connection) -> dict: + """准确率统计""" + row = conn.execute("SELECT * FROM accuracy_stats WHERE id = 1").fetchone() + return dict(row) if row else {} + + +# ═══════════════════════════════════════════════════════════ +# 策略反馈查询 +# ═══════════════════════════════════════════════════════════ + +def query_strategy_feedback(conn: sqlite3.Connection, code: str = None) -> list[dict]: + """策略反馈""" + if code: + rows = conn.execute( + "SELECT * FROM strategy_feedback WHERE code = ? ORDER BY evaluated_at DESC", (code,)).fetchall() + else: + rows = conn.execute( + "SELECT * FROM strategy_feedback ORDER BY evaluated_at DESC").fetchall() + return [dict(r) for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 策略评估查询 +# ═══════════════════════════════════════════════════════════ + +def query_strategy_evaluations(conn: sqlite3.Connection, code: str = None) -> list[dict]: + """策略评估记录""" + if code: + rows = conn.execute( + "SELECT * FROM strategy_evaluations WHERE code = ? ORDER BY created_at DESC", (code,)).fetchall() + else: + rows = conn.execute( + "SELECT * FROM strategy_evaluations ORDER BY created_at DESC").fetchall() + return [dict(r) for r in rows] + + +# ═══════════════════════════════════════════════════════════ +# 市场快照查询(最新) +# ═══════════════════════════════════════════════════════════ + +def query_latest_market(conn: sqlite3.Connection) -> dict: + """获取最新一次市场快照(含 sector 详情)""" + row = conn.execute( + "SELECT * FROM market_snapshots ORDER BY id DESC LIMIT 1").fetchone() + if not row: + return {} + snap = dict(row) + # 关联 sectors + sectors = conn.execute( + "SELECT * FROM sector_snapshots WHERE snapshot_id = ? ORDER BY change_pct DESC", + (snap["id"],)).fetchall() + snap["sectors"] = [dict(r) for r in sectors] + snap["top_gainers"] = [dict(r) for r in sectors[:5]] + snap["top_losers"] = [dict(r) for r in sectors[-3:]] + return snap + + +# ═══════════════════════════════════════════════════════════════════ +# 通用工具 +# ═══════════════════════════════════════════════════════════════════ + +def get_price_from_db(code: str) -> tuple[float | None, float | None]: + """从 DB 读取最新价格(price_monitor 维护)。 + 返回 (price, change_pct) 或 (None, None) + + 所有脚本应优先调用此函数,DB 无数据时才拉腾讯 API。 + """ + try: + import sqlite3 + db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') + db.row_factory = sqlite3.Row + row = db.execute( + "SELECT price, change_pct FROM holdings WHERE code=? AND is_active=1", (str(code),) + ).fetchone() + if not row: + row = db.execute( + "SELECT price FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (str(code),) + ).fetchone() + db.close() + if row: + return (row['price'], row['change_pct'] if 'change_pct' in row.keys() else None) + except Exception: + pass + return (None, None) + + +def get_prices_batch_from_db(codes: list[str]) -> dict: + """从 DB 批量读取价格。返回 {code: (price, change_pct)}""" + results = {} + if not codes: + return results + try: + import sqlite3 + db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') + db.row_factory = sqlite3.Row + for code in codes: + row = db.execute( + "SELECT price, change_pct FROM holdings WHERE code=? AND is_active=1", (str(code),) + ).fetchone() + if not row: + row = db.execute( + "SELECT price FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (str(code),) + ).fetchone() + if row and row['price']: + results[str(code)] = (row['price'], row['change_pct'] if 'change_pct' in row.keys() else 0) + db.close() + except Exception: + pass + return results + """最新一次市场快照(含板块数据)""" + snap = conn.execute( + "SELECT * FROM market_snapshots ORDER BY id DESC LIMIT 1").fetchone() + if not snap: + return {} + snap = dict(snap) + sectors = conn.execute( + "SELECT * FROM sector_snapshots WHERE snapshot_id = ? ORDER BY change_pct DESC", + (snap["id"],)).fetchall() + snap["sectors"] = [dict(r) for r in sectors] + # 计算 top_gainers / top_losers + snap["top_gainers"] = [dict(r) for r in sectors[:5]] + snap["top_losers"] = [dict(r) for r in sectors[-3:]] + return snap + + +# ═══════════════════════════════════════════════════════════════════ +# 核心写函数 — 替代 json.dump(),强制币种约束 +# ═══════════════════════════════════════════════════════════════════ + +def write_holding_strategy(conn, code: str, name: str, data: dict) -> tuple[bool, str]: + """写入持仓策略(替代 decisions.json 单条写入)。data 必须包含 currency。""" + try: + currency = data.get('currency', 'CNY') + # Serialize JSON fields + import json as _json + trigger_j = _json.dumps(data.get('trigger', {}), ensure_ascii=False) if isinstance(data.get('trigger'), dict) else str(data.get('trigger', '{}')) + changelog_j = _json.dumps(data.get('changelog', []), ensure_ascii=False) if isinstance(data.get('changelog'), list) else str(data.get('changelog', '[]')) + quality_issues_j = _json.dumps(data.get('quality_issues', {}), ensure_ascii=False) if isinstance(data.get('quality_issues'), dict) else data.get('quality_issues_json', '') + signal_factors_j = _json.dumps(data.get('signal_factors', []), ensure_ascii=False) if isinstance(data.get('signal_factors'), list) else data.get('signal_factors_json', '') + + # 在DELETE前保留现有的full_analysis和reassessed_at(防止被regenerate_all等清空) + _existing_fa = data.get('full_analysis', '') + _existing_ra = data.get('reassessed_at', '') + if not _existing_fa: + try: + _old = conn.execute("SELECT full_analysis, reassessed_at FROM holding_strategies WHERE code=? ORDER BY id DESC LIMIT 1", (code,)).fetchone() + if _old: + if _old[0]: _existing_fa = _old[0] + if _old[1]: _existing_ra = _old[1] + except: + pass + + # DELETE + INSERT + conn.execute("DELETE FROM holding_strategies WHERE code=?", (code,)) + conn.execute(""" + INSERT INTO holding_strategies + (code, name, version, price, cost, shares, stop_loss, take_profit, + entry_low, entry_high, currency, strategy_type, action, + timing_signal, rr_ratio, tech_snapshot, stock_category, + sector_context, status, trigger_json, changelog_json, + source, reason, updated_at, + avg_price, decision_timestamp, note, quality_check, + quality_checked_at, quality_issues_json, position_advice, + signal_factors_json, time_horizon, decision_type, + full_analysis, reassessed_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, + datetime('now','localtime'), + ?,?,?,?,?,?,?,?,?,?,?,?) + """, ( + code, name, + data.get('version', 1), data.get('price'), data.get('cost'), + data.get('shares', 0), data.get('stop_loss'), data.get('take_profit'), + data.get('entry_low'), data.get('entry_high'), currency, + data.get('strategy_type', 'holding'), data.get('action'), + data.get('timing_signal'), data.get('rr_ratio'), + data.get('tech_snapshot'), data.get('stock_category'), + data.get('sector_context'), data.get('status', 'active'), + trigger_j, changelog_j, + data.get('source'), data.get('reason'), + # new columns + data.get('avg_price', 0), + data.get('timestamp') or data.get('created_at', ''), + data.get('note', ''), + data.get('quality_check', ''), + data.get('quality_checked_at', ''), + quality_issues_j, + data.get('position_advice', ''), + signal_factors_j, + data.get('time_horizon', ''), + data.get('type', data.get('strategy_type', 'holding')), + # 保留full_analysis和reassessed_at + _existing_fa, + _existing_ra, + )) + conn.commit() + return True, f"策略 {code} 已写入" + except sqlite3.IntegrityError as e: + return False, f"币种约束: {e}" + except Exception as e: + return False, str(e) + + +def write_holdings_batch(conn, holdings: list[dict]) -> tuple[bool, str]: + """批量写入持仓(替代 portfolio.json holdings[])""" + try: + conn.execute("BEGIN IMMEDIATE") + for h in holdings: + currency = str(h.get('currency', 'CNY')).upper() + if currency not in ('CNY', 'HKD'): + return False, f"非法币种: {currency}(必须 CNY 或 HKD)" + conn.execute(""" + INSERT INTO holdings (code, name, shares, cost, price, market_value, + change_pct, currency, position_pct, added_at, is_active) + VALUES (?,?,?,?,?,?,?,?,?,datetime('now','localtime'),1) + ON CONFLICT(code) DO UPDATE SET + name=excluded.name, shares=excluded.shares, cost=excluded.cost, + price=excluded.price, market_value=excluded.market_value, + change_pct=excluded.change_pct, currency=excluded.currency, + position_pct=excluded.position_pct + """, ( + h.get('code'), h.get('name'), h.get('shares', 0), + h.get('cost'), h.get('price'), + h.get('market_value'), h.get('change_pct'), + h.get('currency', 'CNY'), h.get('position_pct'), + )) + conn.commit() + return True, f"已写入 {len(holdings)} 条持仓" + except sqlite3.IntegrityError as e: + conn.rollback() + return False, f"币种约束: {e}" + except sqlite3.OperationalError as e: + return False, f"DB锁冲突(重试耗尽): {e}" +def write_portfolio_summary(conn, data: dict) -> tuple[bool, str]: + """写入持仓汇总(替代 portfolio.json 顶层)""" + try: + conn.execute("BEGIN IMMEDIATE") + conn.execute(""" + INSERT INTO portfolio_summary (id, total_assets, total_mv, stock_value, + cash, frozen_cash, position_pct, total_pnl, currency, updated_at) + VALUES (1,?,?,?,?,?,?,?,?,datetime('now','localtime')) + ON CONFLICT(id) DO UPDATE SET + total_assets=excluded.total_assets, total_mv=excluded.total_mv, + stock_value=excluded.stock_value, cash=excluded.cash, + frozen_cash=excluded.frozen_cash, position_pct=excluded.position_pct, + total_pnl=excluded.total_pnl, currency=excluded.currency, + updated_at=datetime('now','localtime') + """, ( + data.get('total_assets'), data.get('total_mv'), data.get('stock_value'), + data.get('cash'), data.get('frozen_cash', 0), data.get('position_pct'), + data.get('total_pnl'), data.get('currency', 'CNY'), + )) + conn.commit() + return True, "汇总已写入" + except sqlite3.IntegrityError as e: + return False, f"约束: {e}" + except sqlite3.OperationalError as e: + return False, f"DB锁冲突: {e}" + + +def write_watchlist_stock(conn, stock: dict) -> tuple[bool, str]: + """写入自选股(写入 watchlist_stocks 表)""" + try: + conn.execute(""" + INSERT INTO watchlist_stocks (code, name, price, entry_low, entry_high, + stop_loss, currency, source, source_detail, notes, added_by, added_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,datetime('now','localtime')) + ON CONFLICT(code) DO UPDATE SET + name=excluded.name, price=excluded.price, entry_low=excluded.entry_low, + entry_high=excluded.entry_high, stop_loss=excluded.stop_loss, + currency=excluded.currency, source=excluded.source, + source_detail=excluded.source_detail, notes=excluded.notes, + added_by=excluded.added_by + """, ( + stock.get('code'), stock.get('name'), stock.get('price'), + stock.get('entry_low'), stock.get('entry_high'), stock.get('stop_loss'), + stock.get('currency', 'CNY'), stock.get('source'), stock.get('source_detail'), + stock.get('notes'), stock.get('added_by'), + )) + conn.commit() + return True, f"自选 {stock.get('code')} 已写入" + except sqlite3.IntegrityError as e: + return False, f"约束: {e}" + + +def write_cash_log(conn, data: dict) -> tuple[bool, str]: + """记录现金变更(替代手动改 portfolio.json cash 字段)""" + try: + conn.execute(""" + INSERT INTO cash_log (cash_before, cash_after, frozen_before, frozen_after, + change_amount, source, note) + VALUES (?,?,?,?,?,?,?) + """, ( + data.get('cash_before'), data.get('cash_after'), + data.get('frozen_before'), data.get('frozen_after'), + data.get('change_amount'), data.get('source', 'manual'), + data.get('note', ''), + )) + conn.commit() + return True, "现金变更已记录" + except Exception as e: + return False, str(e) + + +def query_cash_log(conn, limit: int = 20) -> list[dict]: + rows = conn.execute( + "SELECT * FROM cash_log ORDER BY id DESC LIMIT ?", (limit,) + ).fetchall() + return [dict(r) for r in rows] + + +# ═══ live_prices / mtf_cache / capital_flow_cache 写函数 ═══ + +def write_live_prices(conn, prices: dict): + """写入实时价格快照(替代 live_prices.json)""" + import json + for code, info in prices.items(): + conn.execute( + "INSERT OR REPLACE INTO live_prices (code, price, change_pct, updated_at) VALUES (?,?,?,datetime('now','localtime'))", + (code, info.get('price'), info.get('change_pct')) + ) + +def read_live_prices(conn) -> dict: + rows = conn.execute("SELECT code, price, change_pct FROM live_prices").fetchall() + return {r['code']: {'price': r['price'], 'change_pct': r['change_pct']} for r in rows} + + +def write_mtf_cache(conn, code: str, data: dict): + """写入多周期缓存(替代 multi_tf_cache.json 单条)""" + import json + conn.execute( + "INSERT OR REPLACE INTO mtf_cache (code, cache_json, updated_at) VALUES (?,?,datetime('now','localtime'))", + (code, json.dumps(data, ensure_ascii=False)) + ) + +def read_mtf_cache(conn, code: str) -> dict: + import json + r = conn.execute("SELECT cache_json FROM mtf_cache WHERE code=?", (code,)).fetchone() + return json.loads(r['cache_json']) if r else {} + + +def write_capital_flow_cache(conn, data: dict): + """写入资金流缓存(替代 capital_flow_cache.json)""" + import json + conn.execute("DELETE FROM capital_flow_cache") + conn.execute( + "INSERT INTO capital_flow_cache (cache_json, updated_at) VALUES (?,datetime('now','localtime'))", + (json.dumps(data, ensure_ascii=False),) + ) + +def read_capital_flow_cache(conn) -> dict: + import json + r = conn.execute("SELECT cache_json FROM capital_flow_cache ORDER BY id DESC LIMIT 1").fetchone() + return json.loads(r['cache_json']) if r else {} diff --git a/deploy/profile-scripts/mofin_health.py b/deploy/profile-scripts/mofin_health.py new file mode 100644 index 00000000..bc9198f8 --- /dev/null +++ b/deploy/profile-scripts/mofin_health.py @@ -0,0 +1,925 @@ +#!/usr/bin/env python3 +"""mofin_health.py — MoFin 健康监控数据采集 + +输出JSON供dashboard展示,三个view: + tab1: 功能树(逐级展开,每节点绿/黄/红) + tab2: 数据实体表(输入/输出流分析,孤立表报警) + tab3: 流程/cron映射(状态正常/异常) +""" +import json, os, sys, re +import sqlite3 +from pathlib import Path +from datetime import datetime, timezone +from mofin_db import get_conn + +DATA_DIR = Path("/home/hmo/MoFin/data") +WEB_DATA = Path("/home/hmo/web-dashboard/data") +STATIC_DIR = Path("/home/hmo/web-dashboard/static") +PROFILE_SCRIPTS = Path("/home/hmo/.hermes/profiles/position-analyst/scripts") +CRON_FILES = [ + "/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json", + "/home/hmo/.hermes/cron/jobs.json", +] + +# 数据实体作用说明 +TABLES_DESC = { + "holdings": "当前持仓(权威源)", + "holding_strategies": "每只股票的完整策略参数", + "portfolio_summary": "总资产/现金/仓位汇总", + "portfolio_state": "组合状态快照(只读派生)", + "strategy_evaluations": "策略重评历史记录", + "strategy_feedback": "策略效果反馈", + "watchlist_stocks": "自选股列表", + "candidates": "潜力股候选池(小果扫描产出)", + "live_prices": "所有持仓+自选最新实时价", + "price_events": "价格区间突破事件日志", + "market_snapshots": "大盘指数快照(每10分)", + "sector_snapshots": "行业板块数据", + "sector_signals": "行业信号(趋势检测产出)", + "signal_news": "信号相关新闻", + "macro_raw_news": "宏观新闻原始数据", + "macro_context_log": "宏观上下文(大盘偏向/指数)", + "stocks": "全量股票代码", + "stock_daily": "日线行情", + "stock_weekly": "周线行情", + "stock_monthly": "月线行情", + "stock_fundamentals": "基本面数据(PE/PB)", + "stock_sectors": "股票行业映射", + "capital_flow_cache": "资金流缓存", + "xiaoguo_scan_tracker": "小果扫描跟踪", + "advice_timeline": "建议执行时间线", + "accuracy_stats": "建议准确率统计", + "todos": "自愈任务队列", + "health_check_log": "健康检查日志", + "cash_log": "资金变动记录", + "mtf_cache": "多周期均线缓存", + "state_meta": "系统状态元数据", +} + +JSON_DESC = { + "decisions.json": "策略决策(DB→JSON同步,兼容层)", + "portfolio.json": "持仓汇总(兼容层)", + "market.json": "市场概况数据", + "xiaoguo_insights.json": "小果分析洞察", + "candidate_pool.json": "潜力股候选池完整数据", + "zone_breach.json": "价格区间突破状态", + "strategy_staleness_report.json": "策略过期报告", + "alerts.json": "告警列表", + "macro_risk_state.json": "宏观风险状态(采集器写入)", + "capital_flow_cache.json": "资金流缓存", + "multi_tf_cache.json": "多周期均线缓存", + "macro_context.json": "宏观上下文JSON(旧兼容层)", + "system_inventory.json": "全量系统清单", + "mofin_health.json": "健康监控数据", +} + +now = datetime.now() + +def load_cron_jobs(): + jobs = [] + seen = set() + for jf in CRON_FILES: + profile_tag = "position-analyst" if "position-analyst" in str(jf) else "default" + try: + for j in json.load(open(jf)).get("jobs", []): + jid = j.get("id", "") + if jid in seen: continue + seen.add(jid) + j["profile"] = profile_tag + jobs.append(j) + except: pass + return jobs + +def get_db_stats(): + conn = get_conn() + tables = conn.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").fetchall() + stats = {} + for (tname,) in tables: + cnt = conn.execute(f"SELECT COUNT(*) FROM \"{tname}\"").fetchone()[0] + stats[tname] = cnt + conn.close() + return stats + +def scan_data_flows(): + """对每个脚本,扫描它读/写了哪些DB表和JSON文件""" + flows = {"db_read": {}, "db_write": {}, "json_read": {}, "json_write": {}} + for py in sorted(PROFILE_SCRIPTS.glob("*.py")): + name = py.stem + content = py.read_text(encoding="utf-8", errors="ignore") + # DB reads: SELECT FROM + reads = set(re.findall(r'FROM\s+(\w+)', content, re.I)) + reads |= set(re.findall(r'join\s+(\w+)', content, re.I)) + # DB writes: INSERT INTO / UPDATE / DELETE FROM + writes = set(re.findall(r'INSERT\s+(?:OR\s+\w+\s+)?INTO\s+(\w+)', content, re.I)) + writes |= set(re.findall(r'UPDATE\s+(\w+)', content, re.I)) + writes |= set(re.findall(r'DELETE\s+FROM\s+(\w+)', content, re.I)) + # JSON reads: json.load/open + json_r = set(re.findall(r'(?:json\.load|open)\s*\(\s*["\']([^"\']+\.json)', content)) + json_w = set(re.findall(r'(?:json\.dump|json\.dumps)\s*\(', content)) + for t in reads: flows["db_read"].setdefault(t, set()).add(name) + for t in writes: flows["db_write"].setdefault(t, set()).add(name) + for f in json_r: + fname = os.path.basename(f) + flows["json_read"].setdefault(fname, set()).add(name) + if json_w: + flows["json_write"].setdefault(name, set()).add(name) + return {k: {kk: list(vv) for kk, vv in v.items()} for k, v in flows.items()} + +def check_scripts(): + """检查每个脚本是否有语法错误或明显问题""" + issues = {} + for py in sorted(PROFILE_SCRIPTS.glob("*.py")): + r = os.system(f"python3 -m py_compile {py} 2>/dev/null") + issues[py.stem] = "ok" if r == 0 else "syntax_error" + return issues + + +def match_cron(cron_jobs, name_keywords): + """匹配cron任务列表,返回匹配的cron信息列表(空格归一化后匹配)""" + matches = [] + for j in cron_jobs: + jname = j.get("name", "").replace(" ", "").replace("\u00a0", "") # 去空格再比 + if isinstance(name_keywords, str): + if name_keywords.replace(" ", "") in jname: + matches.append(j) + elif isinstance(name_keywords, (list, tuple)): + clean_kws = [k.replace(" ", "").replace("\u00a0", "") for k in name_keywords] + if any(kw in jname for kw in clean_kws): + matches.append(j) + elif callable(name_keywords): + if name_keywords(j): + matches.append(j) + # 去重(相同name只保留一条) + seen = set() + deduped = [] + for j in matches: + n = j.get("name", "") + if n not in seen: + seen.add(n) + deduped.append(j) + return deduped + + +# ── 功能树描述 ── +NODE_DESC = { + "数据采集": "从腾讯/东财/小果采集原始行情、新闻、资金流数据", + "策略分析": "策略评估、新鲜度检查、重评和成长分析", + "推荐推送": "生成简报、推荐并推送到XMPP", + "风险监控": "宏观风险信号、跨市场背离检测", + "自检/审计": "系统健康检查、监控采集、审计", + "执行/修复": "自愈系统、门禁跟进、清理修复", + "持仓复查": "持仓基本面复查和策略复盘", + "信号消费": "消费小果情感分析和宏观风险信号", + "系统服务": "系统维护(如DB真空整理)", + "持仓监控": "特定持仓(300308/芯碁微装)盘中监控", + "市场快照": "每10分钟采集全市场板块和指数快照", + "宏观新闻": "采集宏观新闻和财经资讯", + "价格监控": "每2分钟刷新持仓/自选实时价格→写入live_prices", + "小果扫描": "小果独立扫描潜在机会", + "资金流采集": "盘中采集板块资金流向", + "宏观上下文刷新": "刷新大盘指数/市场情绪", + "策略重评": "价格偏离买入区或策略过期时自动重评", + "持仓自选新鲜度检查": "检查策略是否过期或价格严重偏离", + "自选买入区提醒": "自选进入买入区时推送提醒", + "策略评估": "每日/每周策略效果评估", + "分支自成长": "策略分支探索和剪枝", + "元自成长": "系统元层级自我进化", + "MoFin盘前中监控": "上午盘中实时监控+推送", + "MoFin午后监控": "下午盘中实时监控+推送", + "cron报告推XMPP": "cron报告通过XMPP推送到手机", + "开盘简报": "每日开盘前市场简报", + "收盘简报": "每日收盘后市场简报", + "市场精选推荐": "每日全市场潜力股精推", + "宏观风险扫描": "从新闻中检测系统性风险", + "宏观风险信号消费": "消费宏观风险信号并生成建议", + "跨市场背离检测": "检测A股/港股/美股指数背离", + "系统全局审计": "7维度系统全面审计", + "全局cron健康监控": "监控所有cron的运行状态", + "重评管道审计": "审计策略重评管道完整性", + "健康监控数据采集": "采集健康数据供Dashboard展示", + "自愈执行器": "每10分钟自动处理TODO列表", + "策略质量门禁": "新策略必须通过9维验证才能写入", + "自选自动清理": "开盘前清理过期自选数据", + "建议对账": "每周对账校验建议准确性", + "持仓基本面复查": "每周持仓基本面深度复查", + "策略复盘": "每日策略执行复盘", + "小果情感分析": "收盘后对持仓/自选做新闻情感分析", + "宏观风险信号消费-盘中": "盘中消费宏观风险信号", + "小果市场筛选": "全市场扫描值得关注的板块和个股", + "芯碁微装": "芯碁微装午后价格监控", + "300308": "300308午后紧盯+入场信号监控", + "硬编码扫描": "扫描脚本中的硬编码参数", + "系统体检": "开盘前系统全面体检", + "盘中自检": "盘中高频自检", + "记忆守卫": "每日记忆清理和优化", + "数据治理": "每周数据清理和归档", + "自选股自动重评": "周末自动重评自选股策略", + "多周期缓存": "刷新MA5/MA20/MA60等技术指标缓存", + "数据同步": "同步数据到Dashboard", + "盘前热点扫描": "盘前扫描市场热点", + "宏观新闻采集": "采集宏观新闻", + "宏观新闻采集-周末": "周末宏观新闻采集", + "state.db真空整理": "DB真空整理维护", + "分支剪枝-每日": "修剪已失效的策略分支", + "自选股自动重评-周末": "周末批量重评自选股策略", + "系统健康检查-开盘前": "开盘前检查所有核心组件是否正常", + "多周期缓存刷新-开盘前": "开盘前刷新技术指标缓存", + "MoFin 系统常规体检-开盘前": "开盘前8:00全面系统体检", + "开盘前钉对钉验证": "开盘前15项验证(脚本同步/DB完整性/资产公式)", + "cron-推XMPP中继": "将cron输出通过XMPP中继推送", + "小果信号消费-盘中": "盘中消费小果扫描信号", + "硬编码扫描-每日": "扫描脚本中的硬编码参数", + "盘中自检-高频": "每15分钟盘中自检", + "数据治理-每周": "每周数据治理", + "记忆守卫-每日": "每日记忆优化", + "300308入场信号紧盯": "300308入场信号(13:00-14:00)", + "300308午后紧盯": "300308午后监控(13:00-15:00含止损)", + "多周期缓存刷新-盘中": "盘中刷新技术指标缓存", + "知识萃取-盘后": "盘后从分析报告中萃取可复用知识", + "区间维护": "每30分钟维护买入区", + "知微洞察生成": "生成每日市场洞察(15:35)", + "小果市场筛选-全市场": "小果筛选全市场关注板块", + "数据同步-dashboard": "同步数据到Dashboard", + "state.db真空整理-每周": "每周DB真空整理", + "未分类": "未被规则匹配的cron自动归入此", +} + +# ── 数据流详细描述 ── +# 每张表说明:存什么 + 谁写入(为什么+写什么) + 谁读取(为什么+读什么) + 综合总结 +FLOW_DETAIL = { + "signal_news": { + "summary": "全系统信号/新闻的统一存储表,所有宏观分析、风险扫描、小果分析的输出汇聚地,也是下游消费脚本的输入源。7个写入方汇聚不同来源信号,5个读取方按需消费。", + "writers": { + "macro_context_collector": "写入宏观新闻原始数据(标题+摘要+分类),供后续风险扫描消费", + "xiaoguo_news_processor": "写入小果LLM处理后的新闻情感分析结果", + "macro_signal_consumer": "写入宏观风险信号判定结果(等级+来源+建议)", + "divergence_detector": "写入跨市场背离检测信号(A股/港股/美股指数对)", + "xiaoguo_signal_consumer": "写入小果扫描发现的个股/板块信号", + "mofin_news": "写入外部财经常规新闻采集结果", + "xiaoguo_scanner": "写入小果独立扫描的市场机会信号", + }, + "readers": { + "macro_signal_consumer": "读取原始宏观新闻和信号,判定风险等级并生成建议", + "system_audit": "读取信号表行数/更新时间,审计数据管道是否畅通", + "intraday_health_check": "读取最新信号,检查盘中是否有新的风险信号到达", + "xiaoguo_signal_consumer": "读取小果相关信号,生成买入/卖出建议", + "server": "读取信号数据供Web Dashboard展示", + }, + }, + "holdings": { + "summary": "当前持仓表,是系统最核心的数据表之一。import_holding_xls从券商文件导入持仓,mofin_db在价格刷新时更新市值。下游脚本读取持仓做策略分析和推送。", + "writers": { + "mofin_db": "写入price_monitor刷新后的持仓最新市值(通过write_holdings_batch)", + "import_holding_xls": "从券商holding.xls导入最新持仓数量/成本/市值", + }, + "readers": { + "stale_push_wlin": "读取持仓列表+最新价格,检查是否进入买入区/触发止损", + "mofin_db": "内部读取(get_price_from_db等函数)", + "system_audit": "读取持仓总数/品种分布,审计持仓完整性", + "server": "读取持仓数据供Web Dashboard展示", + "prepare_report_data": "读取持仓数据用于生成分析报告", + "mo_data": "通过read_portfolio()读取持仓结构化数据", + }, + }, + "portfolio_summary": { + "summary": "组合汇总表(id=1单行),记录总资产=持股市值+可用资金+冻结资金。每笔导入或价格刷新后更新。", + "writers": { + "mofin_db": "价格监控刷新总市值后更新total_mv/total_assets", + "import_holding_xls": "导入持仓后更新cash/frozen/total_assets", + }, + "readers": { + "import_holding_xls": "读取当前汇总信息,验证导入后是否正确", + "mo_data": "通过read_portfolio()读取组合汇总", + "price_monitor": "读取当前现金/市值,计算总资产变动", + "prepare_report_data": "读取总资产/现金数据用于报告", + "server": "读取汇总数据供Dashboard展示", + }, + }, + "holding_strategies": { + "summary": "策略数据表,记录每只持仓/自选股的策略配置(买入价/止损/止盈/目标价/分析维度等)。多写入方按各自职责更新不同字段。", + "writers": { + "data_governance": "归档过期策略、修复异常策略数据", + "sync_decisions_to_db": "从JSON同步策略到DB", + "mofin_db": "策略写入(内部函数)", + "strategy_review": "策略复盘后更新执行结果和评级", + }, + "readers": { + "data_governance": "读取所有活跃策略,检查缺失和异常", + "per_stock_reassess": "读取个股策略配置,判断是否需要重评", + "mo_data": "通过read_decisions()读取策略数据", + "stale_push_wlin": "读取买入区/止损/止盈配置,检查价格触发", + }, + }, + "live_prices": { + "summary": "实时价格缓存表,price_monitor每2分钟写入全量持仓/自选价格。所有脚本必须通过mo_data.get_price()读取——先读此表,无数据才调API。单一写入、多方读取。", + "writers": { + "mofin_db": "price_monitor调用write_live_prices写入最新价格", + "mo_data": "get_price()兜底时从API拉取价格后写回此表", + }, + "readers": { + "mo_data": "get_price()/get_prices_batch()优先从此表读取价格", + "mofin_db": "内部读取(get_price_from_db)", + "system_audit": "读取价格更新时间和数据量", + "verify_reassess_pipeline": "验证重评管道是否有最新价格", + }, + }, + "price_events": { + "summary": "价格触发事件表,价格进入/离开买入区或触发止损止盈时记录事件。用于审计和重评触发。", + "writers": { + "mofin_db": "price_monitor检测到价格区间变化时写入事件记录", + }, + "readers": { + "mofin_db": "查询历史事件判断是否触发重评", + }, + }, + "cash_log": { + "summary": "资金流水表,每次资金变动(入金/出金/冻结/解冻)记录一条日志。审计用。", + "writers": { + "mofin_db": "通过write_cash_log记录资金变动", + "mo_data": "write_cash_log函数入口", + }, + "readers": { + "prepare_report_data": "读取现金变动历史用于报告", + "mofin_db": "内部查询最近流水", + }, + }, + "market_snapshots": { + "summary": "市场快照表,market_watch每10分钟采集全市场大盘指数+板块涨跌+上涨下跌家数。下游用于判断市场情绪。", + "writers": { + "mofin_db": "market_watch采集后写入快照数据", + }, + "readers": { + "market_screener": "读取最新板块快照,判断热点板块", + "prepare_report_data": "读取市场情绪数据用于报告", + "mofin_db": "内部查询最新快照", + "system_audit": "审计数据新鲜度", + }, + }, + "sector_snapshots": { + "summary": "板块快照表,market_watch按板块写入涨跌/领涨股/资金流向。market_screener据此判断行业热点。", + "writers": { + "mofin_db": "market_watch采集后写入各板块数据", + }, + "readers": { + "market_screener": "读取板块涨跌排名,筛选热点行业", + "strategy_lifecycle": "读取板块数据用于策略生命周期管理", + "mofin_db": "内部查询", + "trend_detector": "读取板块趋势数据用于趋势检测", + }, + }, + "sector_signals": { + "summary": "板块信号表,多源汇聚的板块级别信号(新闻情感+趋势+资金流向)。用于判断行业轮动。", + "writers": { + "mofin_news": "写入新闻分析得出的板块信号", + "xiaoguo_news_processor": "写入小果LLM分析的板块情感信号", + "trend_detector": "写入技术面趋势检测到的板块信号", + }, + "readers": { + "server": "读取供Dashboard展示", + "mofin_news": "读取已有信号做增量更新", + "xiaoguo_news_processor": "读取已有信号避免重复写入", + "trend_detector": "读取信号辅助趋势判定", + }, + }, + "macro_context_log": { + "summary": "宏观上下文日志,refresh_macro_context每30分钟采集大盘指数/市场情绪/资金面数据。下游多个脚本按需读取最新宏观状态。", + "writers": { + "refresh_macro_context": "每30分钟采集上证/深证/创业板/恒指等指数+情绪指标", + }, + "readers": { + "stale_push_wlin": "读取大盘情绪用于策略推送的宏观背景", + "divergence_detector": "读取多市场指数数据做背离检测", + "system_audit": "审计数据采集是否正常", + "xiaoguo_signal_consumer": "读取宏观情绪辅助信号判定", + }, + }, + "macro_raw_news": { + "summary": "宏观新闻原始数据表,macro_context_collector采集的未经处理的财经新闻。供后续清洗和分析。", + "writers": { + "macro_context_collector": "从财经网站采集原始新闻标题+URL+摘要", + }, + "readers": { + "macro_context_collector": "读取最近新闻hash避免重复采集", + "system_audit": "审计新闻采集量", + }, + }, + "accuracy_stats": { + "summary": "策略准确率统计表,strategy_review复盘后写入各策略的正确/错误/待定计数。", + "writers": { + "strategy_review": "策略复盘后更新准确率统计", + }, + "readers": { + "mofin_db": "读取统计结果用于报告", + }, + }, + "advice_timeline": { + "summary": "建议时间线表,记录每条推送建议的时间/内容/状态。用于审计和对账。", + "writers": { + "advice_reconciliation": "每周对账时写入对账结果", + }, + "readers": { + "advice_reconciliation": "读取历史建议做对账", + "mofin_db": "内部查询", + }, + }, + "candidate_score_history": { + "summary": "候选股评分历史表,记录每次全市场筛选时对候选股的评分。用于评分变化追踪。", + "writers": { + "mofin_db": "market_screener筛选结果写入评分记录", + }, + "readers": { + "mofin_db": "查询评分历史供展示", + }, + }, + "candidates": { + "summary": "候选股池表,market_screener筛选出的值得关注的个股。包含评分/买入区/止损/目标价。", + "writers": { + "mofin_db": "market_screener写入候选股", + "market_screener": "直接写入候选股列表", + }, + "readers": { + "mofin_db": "读取候选股数据供展示和后续处理", + }, + }, + "capital_flow_cache": { + "summary": "资金流向缓存表,capital_flow_collector采集的板块资金流入流出数据。", + "writers": { + "mofin_db": "写入板块资金流向数据", + }, + "readers": { + "mofin_db": "读取缓存数据", + }, + }, + "health_check_log": { + "summary": "健康检查日志表,morning_health_check每次运行记录检查结果。用于追踪系统健康历史。", + "writers": { + "morning_health_check": "每日开盘前体检后写入检查结果", + }, + "readers": { + "morning_health_check": "读取历史检查结果比较变化", + }, + }, + "mtf_cache": { + "summary": "多周期技术指标缓存,refresh_mtf_cache计算MA5/MA20/MA60/支撑阻力位等。下游技术分析脚本从缓存读取避免重复计算。", + "writers": { + "multi_timeframe": "计算并写入多周期MA/支撑阻力位", + "mofin_db": "内部写入函数", + }, + "readers": { + "multi_timeframe": "读取已有缓存判断是否需要刷新", + "technical_analysis": "读取MA/支撑阻力位用于技术分析", + "mofin_db": "内部读取", + }, + }, + "stock_fundamentals": { + "summary": "基本面数据表,存储PE/PB/ROE/市值等财务指标。", + "writers": { + "mofin_db": "基本面数据采集后写入", + }, + "readers": { + "strategy_lifecycle": "读取基本面数据用于策略评估", + }, + }, + "stock_sectors": { + "summary": "股票-板块映射表,记录每只股票所属行业板块。多脚本用于行业分类和板块归因。", + "writers": { + "mofin_db": "股票行业分类数据写入", + }, + "readers": { + "xiaoguo_news_processor": "按行业分类新闻", + "mofin_news": "按行业归类新闻", + "mofin_db": "内部查询", + "strategy_lifecycle": "读取行业信息用于策略决策", + }, + }, + "stocks": { + "summary": "全量股票代码表,所有A股/港股基础信息。供各脚本按code查询股票名称/市场。", + "writers": { + "mofin_db": "初始化时导入全量股票代码", + }, + "readers": { + "mofin_news": "按股票代码查找新闻", + "xiaoguo_news_processor": "按股票代码过滤新闻", + "mofin_db": "内部查询", + "trend_detector": "按股票代码获取数据", + }, + }, + "strategy_evaluations": { + "summary": "策略评估结果表,策略评估脚本每次运行记录评估得分/等级/评语。", + "writers": { + "mofin_collect": "策略评估前采集数据并写入评估结果", + }, + "readers": { + "verify_reassess_pipeline": "读取评估结果验证管道完整性", + "mofin_db": "内部查询", + "system_audit": "审计评估是否按时执行", + }, + }, + "strategy_feedback": { + "summary": "策略反馈表,记录用户对建议的反馈(采纳/忽略/修改)。用于策略自学习。", + "writers": { + "mofin_db": "写入反馈数据", + "server": "通过Web提交反馈后写入", + }, + "readers": { + "mofin_db": "读取反馈用于分析和展示", + }, + }, + "todos": { + "summary": "待办事项表,各脚本发现异常时写入TODO,self_todo_executor每10分钟执行修复。异常发现→自动修复的闭环。", + "writers": { + "morning_health_check": "体检发现异常写入TODO", + "intraday_health_check": "盘中自检发现异常写入TODO", + "strategy-staleness-check": "策略过期检测写入TODO", + "self_todo_executor": "执行完成后更新TODO状态", + "preflight_verify": "开盘前验证失败写入TODO", + }, + "readers": { + "morning_health_check": "读取待处理的TODO", + "self_todo_executor": "读取待处理的TODO并执行fix_action", + "strategy-staleness-check": "读取TODO避免重复写入", + "intraday_health_check": "读取TODO检查自愈进度", + }, + }, + "watchlist_stocks": { + "summary": "自选股表,系统自动维护的观察列表。与持仓表分离,用于跟踪潜在买入机会。", + "writers": { + "per_stock_reassess": "策略重评时更新自选状态", + "mofin_db": "内部写入函数", + }, + "readers": { + "per_stock_reassess": "读取自选列表做重评", + "stock_quote": "读取自选代码拉取行情", + "mo_alphasift_bridge": "读取自选供Alpha分析", + "mo_data": "通过read_watchlist()读取自选数据", + }, + }, + "xiaoguo_scan_tracker": { + "summary": "小果扫描追踪表,记录每次小果扫描的状态/耗时/结果数量。用于监控小果服务健康。", + "writers": { + "xiaoguo_scanner": "每次扫描完成后写入状态和统计", + }, + "readers": { + "server": "读取扫描状态供Dashboard展示", + "xiaoguo_scanner": "读取上次扫描时间判断是否需要全量扫描", + }, + }, + "state_meta": { + "summary": "状态元数据表,记录各服务的状态追踪信息(如扫描偏移量/最新处理ID)。", + "writers": { + "xiaoguo_scanner": "写入扫描进度偏移量", + }, + "readers": { + "xiaoguo_scanner": "读取上次处理位置继续增量处理", + }, + }, +} + + +def build_feature_tree(cron_jobs, db_stats): + # 硬编码分类规则:标签→匹配关键词 + rules = { + "市场快照": ["市场数据采集"], + "宏观新闻": ["宏观采集"], + "价格监控": ["价格监控"], + "小果扫描": ["小果独立扫描"], + "资金流采集": ["资金流"], + "宏观上下文刷新": ["宏观上下文刷新"], + "策略重评": ["策略重评"], + "持仓自选新鲜度检查": ["策略时效性检查"], + "自选买入区提醒": ["自选买入区提醒"], + "策略评估": ["策略评估"], + "分支自成长": ["分支自成长"], + "元自成长": ["元自成长"], + "MoFin盘前中监控": ["MoFin盘前中监控"], + "MoFin午后监控": ["MoFin午后监控"], + "cron报告推XMPP": ["cron报告推XMPP"], + "开盘简报": ["开盘简报"], + "收盘简报": ["收盘简报"], + "市场精选推荐": ["市场精选推荐"], + "小果情感分析": ["小果情感分析"], + "系统全局审计": ["系统全局审计"], + "全局cron健康监控": ["全局cron健康监控"], + "重评管道审计": ["重评管道审计"], + "健康监控数据采集": ["健康监控数据采集"], + "持仓基本面复查": ["分析师-持仓复查"], + "策略复盘": ["策略复盘"], + "宏观风险扫描": ["宏观风险扫描"], + "宏观风险信号消费": ["宏观风险信号消费"], + "跨市场背离检测": ["跨市场背离检测"], + "自愈执行器": ["自愈执行器"], + "策略质量门禁": ["策略质量门禁"], + "自选自动清理": ["自选自动清理"], + "建议对账": ["建议对账"], + "宏观新闻采集": ["宏观新闻采集"], + "数据治理": ["数据治理"], + "盘前热点扫描": ["盘前热点扫描"], + "数据同步": ["数据同步"], + "小果市场筛选": ["小果市场筛选"], + "芯碁微装": ["芯碁微装"], + "宏观新闻采集-周末": ["宏观新闻采集-周末"], + "硬编码扫描": ["硬编码扫描"], + "系统体检": ["系统体检"], + "盘中自检": ["盘中自检"], + "记忆守卫": ["记忆守卫"], + "数据治理": ["数据治理"], + "自选股自动重评": ["自选股自动重评"], + "state.db真空整理": ["真空整理"], + "300308": ["300308"], + "多周期缓存": ["多周期缓存"], + "元自成长": ["元自成长"], + } + # 自动归类:未被任何规则匹配的cron按名称关键词归入类别 + # 关键词必须够精确,避免误归类 + AUTO_CATEGORIES = [ + ("数据采集", ["市场数据", "宏观采集", "新闻采集", "价格监控", "资金流采集", "小果独立扫描", "上下文刷新"]), + ("策略分析", ["策略评估", "策略时效性", "重评", "买入区提醒", "自成长", "策略复盘", "分支"]), + ("推荐推送", ["简报", "推送", "推荐", "XMPP", "开盘", "收盘"]), + ("风险监控", ["宏观风险", "背离检测", "信号消费"]), + ("自检/审计", ["系统全局审计", "健康监控", "管道审计", "系统体检", "盘中自检", "记忆守卫", "硬编码扫描", "治理"]), + ("执行/修复", ["自愈执行", "门禁", "清理", "对账", "TODO"]), + ("持仓监控", ["300308", "芯碁微装", "多周期缓存", "自选股自动重评"]), + ("系统服务", ["真空整理"]), + ] + + matched_names = set() # 记录已匹配的cron name + + def attach_pipes(node, parent_cat=None): + nonlocal matched_names + label = node.get("label", "") + # 附加描述(自动带脚本名的节点去掉括号内容匹配) + desc_key = label.split(" (")[0] if " (" in label else label + if desc_key in NODE_DESC: + node["desc"] = NODE_DESC[desc_key] + keywords = rules.get(label) + pipes = [] + if keywords: + matched = match_cron(cron_jobs, keywords) + for j in matched: + n = j.get("name", "") + matched_names.add(n) + pipes = [{ + "name": j.get("name", ""), + "script": j.get("script", ""), + "schedule": j.get("schedule", {}).get("display", str(j.get("schedule", ""))), + "status": j.get("last_status", "unknown"), + "last_run": (j.get("last_run_at", "") or "")[:16] if j.get("last_run_at") else "", + "type": "no_agent" if j.get("no_agent") else "LLM", + "profile": j.get("profile", "?"), + } for j in matched] + if pipes: + node["pipes"] = pipes + if node.get("children"): + for c in node["children"]: + attach_pipes(c, parent_cat or label) + + def make_cron_node(j): + name = j.get("name", "?") + desc_key = name.split(" (")[0] if " (" in name else name + return { + "label": f"{name} ({j.get('script','LLM')})", + "desc": NODE_DESC.get(desc_key, ""), + "status": j.get("last_status", "unknown"), + "pipes": [{ + "name": j.get("name", ""), + "script": j.get("script", ""), + "schedule": j.get("schedule", {}).get("display", str(j.get("schedule", ""))), + "status": j.get("last_status", "unknown"), + "last_run": (j.get("last_run_at", "") or "")[:16] if j.get("last_run_at") else "", + "type": "no_agent" if j.get("no_agent") else "LLM", + "profile": j.get("profile", "?"), + }] + } + + tree = { + "label": "MoFin 系统", + "status": "ok", + "children": [ + {"label": "数据采集", "status": "ok", "children": [ + {"label": "市场快照", "status": "ok"}, + {"label": "宏观新闻", "status": "ok"}, + {"label": "价格监控", "status": "ok"}, + {"label": "小果扫描", "status": "ok"}, + {"label": "资金流采集", "status": "ok"}, + {"label": "宏观上下文刷新", "status": "ok"}, + ]}, + {"label": "策略分析", "status": "ok", "children": [ + {"label": "策略重评", "status": "ok"}, + {"label": "持仓自选新鲜度检查", "status": "ok"}, + {"label": "自选买入区提醒", "status": "ok"}, + {"label": "策略评估", "status": "ok"}, + {"label": "分支自成长", "status": "ok"}, + {"label": "元自成长", "status": "ok"}, + ]}, + {"label": "推荐推送", "status": "ok", "children": [ + {"label": "MoFin盘前中监控", "status": "ok"}, + {"label": "MoFin午后监控", "status": "ok"}, + {"label": "cron报告推XMPP", "status": "ok"}, + {"label": "开盘简报", "status": "ok"}, + {"label": "收盘简报", "status": "ok"}, + {"label": "市场精选推荐", "status": "ok"}, + ]}, + {"label": "风险监控", "status": "ok", "children": [ + {"label": "宏观风险扫描", "status": "ok"}, + {"label": "宏观风险信号消费", "status": "ok"}, + {"label": "跨市场背离检测", "status": "ok"}, + ]}, + {"label": "自检/审计", "status": "ok", "children": [ + {"label": "系统全局审计", "status": "ok"}, + {"label": "全局cron健康监控", "status": "ok"}, + {"label": "重评管道审计", "status": "ok"}, + {"label": "健康监控数据采集", "status": "ok"}, + ]}, + {"label": "执行/修复", "status": "ok", "children": [ + {"label": "自愈执行器", "status": "ok"}, + {"label": "策略质量门禁", "status": "ok"}, + {"label": "自选自动清理", "status": "ok"}, + {"label": "建议对账", "status": "ok"}, + ]}, + {"label": "持仓复查", "status": "ok", "children": [ + {"label": "持仓基本面复查", "status": "ok"}, + {"label": "策略复盘", "status": "ok"}, + ]}, + {"label": "信号消费", "status": "ok", "children": [ + {"label": "小果情感分析", "status": "ok"}, + {"label": "宏观风险信号消费-盘中", "status": "ok"}, + ]}, + ], + } + + attach_pipes(tree) + + # 收集所有未被任何规则匹配的cron,按名称自动归入类别 + unmatched = [j for j in cron_jobs if j.get("name", "") not in matched_names] + + # 按自动归类分组 + cat_map = {} + for j in unmatched: + name = j.get("name", "") + assigned = False + for cat_name, keywords in AUTO_CATEGORIES: + if any(kw in name for kw in keywords): + cat_map.setdefault(cat_name, []).append(j) + assigned = True + break + if not assigned: + cat_map.setdefault("未分类", []).append(j) + + # 将自动归类的cron追加到已有分类或创建新分类 + for cat_name, jobs in sorted(cat_map.items()): + # 如果该分类已存在于树中,追加到其children + found = None + for child in tree["children"]: + if child["label"] == cat_name: + found = child + break + if found: + existing_labels = {c["label"] for c in found.get("children", [])} + for j in jobs: + lbl = j.get("name", "?") + if lbl not in existing_labels: + found["children"].append(make_cron_node(j)) + existing_labels.add(lbl) + else: + tree["children"].append({ + "label": cat_name, + "status": "ok", + "children": [make_cron_node(j) for j in jobs], + }) + + return tree + +def build_report(): + cron_jobs = load_cron_jobs() + db_stats = get_db_stats() + flows = scan_data_flows() + script_health = check_scripts() + + # ── 功能树(只显示知微的cron)── + zhiwei_crons = [j for j in cron_jobs if j.get("profile") == "position-analyst" or j.get("name") in [ + "cron-推XMPP中继", "数据同步-dashboard", "记忆守卫-每日", "市场数据采集" + ]] + feature_tree = build_feature_tree(zhiwei_crons, db_stats) + # 递归计算节点状态 + def calc_status(node): + if "children" in node: + for c in node["children"]: + calc_status(c) + statuses = [c["status"] for c in node["children"]] + if "fail" in statuses: node["status"] = "fail" + elif "warn" in statuses: node["status"] = "warn" + else: node["status"] = "ok" + calc_status(feature_tree) + + # ── Tab 2: 数据实体表 ── + entities = [] + for tname, cnt in sorted(db_stats.items()): + readers = flows["db_read"].get(tname, []) + writers = flows["db_write"].get(tname, []) + # 扫描器漏检的手动补录写入方 + _manual_writers = { + "candidates": ["mofin_db", "market_screener"], + "candidate_score_history": ["mofin_db"], + "strategy_feedback": ["mofin_db", "server"], + "stock_daily": ["mofin_db"], + "stock_weekly": ["mofin_db"], + "stock_monthly": ["mofin_db"], + } + _manual_readers = { + "stock_weekly": ["multi_timeframe"], + "stock_monthly": ["multi_timeframe"], + "watchlist_log": ["watchlist_auto_exit", "mofin_db"], + } + if not writers and tname in _manual_writers: + writers = _manual_writers[tname] + if not readers and tname in _manual_readers: + readers = _manual_readers[tname] + + # 数据流详细描述 + flow_detail = FLOW_DETAIL.get(tname, {}) + + has_input = len(writers) > 0 + has_output = len(readers) > 0 + # 排除系统表 + is_system = tname.startswith("sqlite_") or tname.startswith("_") + if is_system: + continue + # 数据流状态:healthy / write_only / read_only / orphan + if has_input and has_output: + flow_status = "healthy" + elif has_input and not has_output: + flow_status = "write_only" + elif not has_input and has_output: + flow_status = "read_only" + else: + flow_status = "orphan" + entities.append({ + "name": tname, + "desc": TABLES_DESC.get(tname, ""), + "rows": cnt, + "readers": readers[:10], + "writers": writers[:10], + "has_input": has_input, + "has_output": has_output, + "orphan": flow_status in ("orphan", "read_only", "write_only"), + "flow_status": flow_status, + "warn": flow_status != "healthy", + "flow_detail": flow_detail, + }) + + # JSON文件 + json_entities = [] + for jf in sorted(WEB_DATA.glob("*.json")): + if jf.name == "stocks": continue + if jf.stem.startswith("temp_"): continue + readers = flows["json_read"].get(jf.name, []) + size = jf.stat().st_size / 1024 + json_entities.append({ + "name": jf.name, + "desc": JSON_DESC.get(jf.name, ""), + "size_kb": round(size, 1), + "readers": readers[:10], + "writers": [], # 难以精确追踪 + "last_modified": datetime.fromtimestamp(jf.stat().st_mtime).strftime("%m-%d %H:%M"), + "warn": len(readers) == 0 and jf.name not in ("portfolio.json", "market.json"), + }) + + # ── Tab 3: 流程/cron映射 ── + pipelines = [] + for j in sorted(cron_jobs, key=lambda x: x.get("name","")): + if not j.get("enabled", True): + continue + name = j.get("name", "?") + script = j.get("script", "") + status = j.get("last_status", "unknown") + last_run = str(j.get("last_run_at", ""))[:19] + schedule = j.get("schedule", {}).get("display", str(j.get("schedule",""))) + no_agent = j.get("no_agent", False) + pipelines.append({ + "name": name, + "type": "no_agent" if no_agent else "LLM", + "script": script, + "schedule": schedule, + "status": status, + "last_run": last_run, + "profile": j.get("profile", "?"), + }) + + # ── 写JSON ── + report = { + "generated_at": now.strftime("%Y-%m-%d %H:%M:%S"), + "feature_tree": feature_tree, + "entities": entities, + "json_files": json_entities, + "pipelines": pipelines, + } + out_path = WEB_DATA / "mofin_health.json" + with open(out_path, "w") as f: + json.dump(report, f, ensure_ascii=False, indent=2) + # 也写到static目录供dashboard直接serve + with open(STATIC_DIR / "mofin_health.json", "w") as f: + json.dump(report, f, ensure_ascii=False, indent=2) + print(f"[SILENT] mofin_health.json written ({len(entities)} entities, {len(pipelines)} pipelines)") + +if __name__ == "__main__": + build_report() diff --git a/deploy/profile-scripts/mofin_news.py b/deploy/profile-scripts/mofin_news.py new file mode 100644 index 00000000..38a32cec --- /dev/null +++ b/deploy/profile-scripts/mofin_news.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""mofin_news.py — 新闻采集(no_agent,无需LLM) + +读未处理的 sector_signals,用 akshare 搜相关新闻, +去重后写入 signal_news 供知微分析。 +""" + +import json +import os +import re +from pathlib import Path + +try: + import akshare as ak + HAS_AKSHARE = True +except ImportError: + HAS_AKSHARE = False + +DATA_DIR = Path(__file__).parent.parent / "data" +DB_PATH = DATA_DIR / "mofin.db" +MAX_ARTICLES = 5 + + +def clean_proxy(): + for k in ['http_proxy', 'https_proxy', 'HTTP_PROXY', 'HTTPS_PROXY']: + os.environ.pop(k, None) + + +def get_conn(): + import sqlite3 + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + return conn + + +def search_akshare_news(code, max_results=3): + """用 akshare 搜个股新闻(含全文)""" + articles = [] + if not HAS_AKSHARE: + return articles + try: + clean_proxy() + df = ak.stock_news_em(symbol=code) + for _, r in df.head(max_results).iterrows(): + title = r.get('新闻标题', '') + content = r.get('新闻内容', '') + if title and len(title) > 5: + articles.append({ + "title": title, + "content": content, + "url": r.get('新闻链接', ''), + }) + except: + pass + return articles + + +def main(): + conn = get_conn() + signals = conn.execute( + "SELECT * FROM sector_signals WHERE processed = 0 ORDER BY severity DESC, id ASC LIMIT 1" + ).fetchall() + + if not signals: + print("无未处理的信号", flush=True) + conn.close() + return + + signal = dict(signals[0]) + sector = signal["sector"] + related = json.loads(signal["related_stocks"] or "[]") + holdings = json.loads(signal["holdings_in_sector"] or "[]") + watchlist = json.loads(signal["watchlist_in_sector"] or "[]") + + print(f"处理信号: [{signal['severity']}] {signal['signal_type']} {sector}", flush=True) + + codes = {} + for item in related + holdings + watchlist: + if item.get("code"): + codes[item["code"]] = item.get("name", "") + + members = conn.execute( + "SELECT s.code, s.name FROM stocks s JOIN stock_sectors ss ON s.code=ss.code WHERE ss.sector_name=? LIMIT 5", + (sector,) + ).fetchall() + for m in members: + if m["code"] not in codes: + codes[m["code"]] = m["name"] + + all_articles = [] + for code, name in codes.items(): + arts = search_akshare_news(code, 3) + for a in arts: + if a["title"] not in [x["title"] for x in all_articles]: + all_articles.append(a) + print(f" 搜 {name}({code}): {len(arts)} 篇", flush=True) + + if not all_articles: + print(" 未搜到新闻", flush=True) + conn.execute("UPDATE sector_signals SET processed=1 WHERE id=?", (signal["id"],)) + conn.commit() + conn.close() + return + + # 过滤脏数据,取前5篇 + filtered = [] + for a in all_articles: + c = a.get('content', '') or '' + if any(kw in c for kw in ['主力资金', '资金净流入', '代码', '简称']): + continue + filtered.append(a) + if len(filtered) >= MAX_ARTICLES: + break + batch = filtered[:MAX_ARTICLES] + print(f" 共{len(all_articles)}篇,采集{len(batch)}篇,交由知微分析", flush=True) + + searched_names = list(set(codes.values())) + conn.execute( + "INSERT INTO signal_news (signal_id, sector, overall_sentiment, summary, key_articles, searched_stocks) VALUES (?, ?, ?, ?, ?, ?)", + (signal["id"], sector, "待知微判断", "", json.dumps(batch, ensure_ascii=False), json.dumps(searched_names, ensure_ascii=False)) + ) + conn.execute("UPDATE sector_signals SET processed=1 WHERE id=?", (signal["id"],)) + conn.commit() + print(f" 完成: {len(batch)} 篇新闻已入库,等知微分析", flush=True) + conn.close() + + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/mofin_query.py b/deploy/profile-scripts/mofin_query.py new file mode 100644 index 00000000..560664cf --- /dev/null +++ b/deploy/profile-scripts/mofin_query.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""mofin_query.py — MoFin 数据库通用查询工具 + +用法: + python3 mofin_query.py "半导体最近5次采集的涨跌幅" + python3 mofin_query.py "今天资金净流入最多的5个板块" + python3 mofin_query.py "最近3天连续净流入的板块" + python3 mofin_query.py "市场情绪趋势(最近10次)" + python3 mofin_query.py "数据库概览" +""" + +import sys +import re +from mofin_db import (get_conn, query_sector_trend, query_top_inflow, + query_consecutive_inflow, query_market_mood, query_db_stats) + + +def _print_sector_trend(name: str, limit: int = 5): + conn = get_conn() + rows = query_sector_trend(conn, name, limit) + conn.close() + if not rows: + print(f"未找到板块「{name}」的数据") + return + print(f"\n{'='*60}") + print(f" {name} 板块 — 最近 {len(rows)} 次采集") + print(f"{'='*60}") + print(f"{'时间':<20} {'涨跌幅%':>8} {'净流入(亿)':>10} {'上涨':>6} {'下跌':>6} {'领涨股':>10}") + print(f"{'-'*20} {'-'*8} {'-'*10} {'-'*6} {'-'*6} {'-'*10}") + for r in reversed(rows): + print(f"{r['timestamp']:<20} {r['change_pct']:>8.2f} {r['net_inflow']:>10.2f} " + f"{r['up_count'] or '-':>6} {r['down_count'] or '-':>6} {r['lead_stock'] or '-':>10}") + + +def _print_top_inflow(limit: int = 5): + conn = get_conn() + rows = query_top_inflow(conn, limit) + conn.close() + if not rows: + print("暂无数据") + return + print(f"\n{'='*60}") + print(f" 资金净流入 Top {len(rows)}({rows[0]['timestamp']})") + print(f"{'='*60}") + print(f"{'板块':<12} {'涨跌幅%':>8} {'净流入(亿)':>10} {'领涨股':>10}") + print(f"{'-'*12} {'-'*8} {'-'*10} {'-'*10}") + for r in rows: + print(f"{r['name']:<12} {r['change_pct']:>8.2f} {r['net_inflow']:>10.2f} {r['lead_stock'] or '-':>10}") + + +def _print_consecutive_inflow(days: int = 3): + conn = get_conn() + rows = query_consecutive_inflow(conn, days) + conn.close() + if not rows: + print(f"没有板块连续 {days} 次净流入") + return + print(f"\n{'='*60}") + print(f" 连续 {days} 次净流入的板块") + print(f"{'='*60}") + print(f"{'板块':<12} {'次数':>4} {'均净流入(亿)':>12} {'均涨跌幅%':>10}") + print(f"{'-'*12} {'-'*4} {'-'*12} {'-'*10}") + for r in rows: + print(f"{r['name']:<12} {r['times']:>4} {r['avg_inflow']:>12.2f} {r['avg_change']:>10.2f}") + + +def _print_market_mood(limit: int = 10): + conn = get_conn() + rows = query_market_mood(conn, limit) + conn.close() + if not rows: + print("暂无数据") + return + print(f"\n{'='*60}") + print(f" 市场情绪趋势 — 最近 {len(rows)} 次") + print(f"{'='*60}") + print(f"{'时间':<20} {'来源':>10} {'上涨占比%':>10} {'情绪':>10}") + print(f"{'-'*20} {'-'*10} {'-'*10} {'-'*10}") + for r in reversed(rows): + mood_emoji = {"bullish": "🟢", "neutral": "🟡", "bearish": "🔴"}.get(r['mood'], "⚪") + print(f"{r['timestamp']:<20} {r['source']:>10} {r['up_ratio']:>10.1f} {mood_emoji} {r['mood']:>8}") + + +def _print_stats(): + conn = get_conn() + stats = query_db_stats(conn) + conn.close() + print(f"\n{'='*40}") + print(f" MoFin 数据库概览") + print(f"{'='*40}") + print(f" 采集次数: {stats['snapshots']}") + print(f" 板块快照: {stats['sector_rows']}") + print(f" 个股数量: {stats['stocks']}") + print(f" 日K线数: {stats['daily_klines']}") + print(f" 价格事件: {stats['price_events']}") + ls = stats.get('latest_snapshot') + if ls: + print(f" 最新采集: {ls['timestamp']} ({ls['source']})") + else: + print(f" 最新采集: 暂无") + + +def route(query: str): + q = query.strip() + if "最近" in q and "次" in q and ("涨跌" in q or "趋势" in q or "采集" in q): + names = re.findall(r'["「]([^"」]+)["」]', q) + if not names: + for word in ["半导体", "银行", "医药", "新能源", "白酒", "军工", "芯片", "房地产", "汽车"]: + if word in q: + names = [word]; break + if names: + limit = 5 + m = re.search(r'(\d+)\s*次', q) + if m: limit = int(m.group(1)) + _print_sector_trend(names[0], limit) + return + if "净流入" in q and ("最多" in q or "排行" in q or "top" in q.lower()): + limit = 5 + m = re.search(r'(\d+)', q) + if m: limit = int(m.group(1)) + _print_top_inflow(limit) + return + if "连续" in q and "净流入" in q: + days = 3 + m = re.search(r'(\d+)\s*天', q) + if m: days = int(m.group(1)) + _print_consecutive_inflow(days) + return + if "情绪" in q or "mood" in q.lower(): + limit = 10 + m = re.search(r'(\d+)\s*次', q) + if m: limit = int(m.group(1)) + _print_market_mood(limit) + return + if "概览" in q or "统计" in q or "stats" in q.lower(): + _print_stats() + return + if q.upper().strip().startswith("SELECT"): + conn = get_conn() + try: + rows = conn.execute(q).fetchall() + if rows: + cols = [d[0] for d in conn.execute(q + " LIMIT 0").description] + print("\t".join(cols)) + for r in rows: + print("\t".join(str(c) for c in r)) + else: + print("(empty)") + except Exception as e: + print(f"SQL 错误: {e}") + finally: + conn.close() + return + print(f"未识别的查询: {q}\n") + print("支持的查询模式:") + print(" 「半导体」最近5次采集的涨跌幅") + print(" 今天资金净流入最多的5个板块") + print(" 最近3天连续净流入的板块") + print(" 市场情绪趋势(最近10次)") + print(" 数据库概览") + print(" SELECT * FROM market_snapshots ORDER BY id DESC LIMIT 5") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("用法: python3 mofin_query.py \"查询语句\"") + print("示例: python3 mofin_query.py \"半导体最近5次采集的涨跌幅\"") + sys.exit(1) + route(sys.argv[1]) diff --git a/deploy/profile-scripts/monitor_300308.py b/deploy/profile-scripts/monitor_300308.py new file mode 100644 index 00000000..61aef242 --- /dev/null +++ b/deploy/profile-scripts/monitor_300308.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""monitor_300308.py — 紧盯中际旭创(300308)站稳1330条件 + +每2分钟检查一次,条件满足时发XMPP信号,然后停用自身。 + +站稳1330条件(三条件同时满足): +1. 现价 >= 1332(1330留2元余量) +2. 连续2次检查都 >= 1332(排除毛刺) +3. 13:00之后(下午开盘后) + +发一次信号后就停,不再重复。 +""" + +import json, os, subprocess, sys +from datetime import datetime +from mo_data import get_price + +CODE = "300308" +NAME = "中际旭创" +PRICE_THRESHOLD = 1332.0 +BUY_QTY = 100 # 1手 +STOP_LOSS = 1293.88 +TAKE_PROFIT = 1456.53 + +STATE_FILE = "/home/hmo/.hermes/monitor_300308_state.json" +HERMES_SEND = ["hermes", "send", "--to", "xmpp:hmo@yoin.fun"] + + +def load_state(): + try: + with open(STATE_FILE) as f: + return json.load(f) + except: + return {"alerted": False, "consecutive_ok": 0, "last_price": 0} + + +def save_state(s): + os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True) + with open(STATE_FILE, "w") as f: + json.dump(s, f, indent=2) + + +def fetch_price(): + """从统一入口获取实时价""" + price, chg = get_price(CODE) + return price or 0, chg or 0 + + +def send_alert(price, change_pct): + msg = ( + f"【信号】{NAME}({CODE}) 站稳1330,可买入!\n" + f"现价{price} ({change_pct}%)\n" + f"信号:下午三条件满足(现价>=1332+连续确认+午后时段)\n" + f"操作:买入1手(100股)约{price*100:.0f}元\n" + f"止损{STOP_LOSS}({(price-STOP_LOSS)/price*100:.1f}%) 止盈{TAKE_PROFIT}({(TAKE_PROFIT-price)/price*100:.1f}%)\n" + f"RR={(TAKE_PROFIT-price)/(price-STOP_LOSS):.1f}\n" + f"现金150,625元,1手占{price*100/150625*100:.1f}%" + ) + try: + subprocess.run(HERMES_SEND + [msg], capture_output=True, timeout=15) + return True + except: + return False + + +def main(): + now = datetime.now() + state = load_state() + + # 已发过信号 → 静默 + if state.get("alerted"): + print("[SILENT] 已发过买入信号") + return + + # 非交易时段 → 静默(但允许14:30前) + if now.weekday() >= 5 or now.hour < 13 or now.hour >= 14: + print(f"[SILENT] 非监控时段(13:00-14:00)") + return + + # 取价 + price, change_pct = fetch_price() + if price == 0: + print(f"[SILENT] 取价失败") + return + + # 条件1:现价 >= 1332 + if price >= PRICE_THRESHOLD: + state["consecutive_ok"] = state.get("consecutive_ok", 0) + 1 + else: + state["consecutive_ok"] = 0 + print(f"[SILENT] 现价{price}低于阈值{PRICE_THRESHOLD}") + save_state(state) + return + + # 条件2:连续2次检查都满足(约4分钟确认) + if state["consecutive_ok"] < 2: + print(f"[SILENT] 连续确认中 {state['consecutive_ok']}/2") + save_state(state) + return + + # 条件3:13:00之后(已隐含在上面的时段检查中) + + # 全部满足 → 发信号 + success = send_alert(price, change_pct) + if success: + state["alerted"] = True + state["alerted_at"] = now.isoformat() + state["alert_price"] = price + save_state(state) + print(f"✅ 信号已推送: {NAME} {price} 可买入") + else: + print(f"⚠️ 信号生成失败,下次重试") + + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/morning_health_check.py b/deploy/profile-scripts/morning_health_check.py new file mode 100644 index 00000000..89ca01f7 --- /dev/null +++ b/deploy/profile-scripts/morning_health_check.py @@ -0,0 +1,900 @@ +#!/usr/bin/env python3 +"""morning_health_check.py — MoFin 系统常规体检 + +每日开盘前(8:00)运行,全面扫描MoFin所有组件健康状况。 +当前8类48项(清单自动扩展)。 +输出格式化的体检报告,有问题才出声,没问题静默。 + +核心设计: + 从 health_checklist.json 读检查清单 + 逐项检查,记录状态 + 报告异常项(只推异常,不推正常) + 自动发现新增cron/脚本(通过 self_discovery 函数) + 维护检查历史 (health_check_log 表) + 自动修复可修问题,不可修写TODO + + 新增组件自动发现机制: + - 对比当前cron list与checklist中记录的cron id + - 发现新cron → 自动追加到checklist + - 脚本修改 → 标记"需复核" + +用法: + python3 scripts/morning_health_check.py [--report] [--update-checklist] + --report: 强制输出完整报告(默认只输出异常) + --update-checklist: 运行自动发现并更新checklist + +no_agent模式:只输出异常项,无异常完全静默 +""" + +import json, os, sqlite3, subprocess, sys, time, urllib.request +from pathlib import Path +from datetime import datetime, timedelta + +# ── 路径 ── +BASE = Path("/home/hmo/MoFin") +DATA = BASE / "data" +SCRIPTS_DIR = BASE / "scripts" +PROFILE_SCRIPTS = Path("/home/hmo/.hermes/profiles/position-analyst/scripts") +CHECKLIST_PATH = DATA / "health_checklist.json" +DB_PATH = DATA / "mofin.db" +HERMES_CRON_DIR = Path("/home/hmo/.hermes/profiles/position-analyst/cron") + + +def derive_fix_action(detail, msg): + """根据issue信息推导可执行的修复命令""" + # 小果扫描 error → 验证脚本是否存在 + if "xiaoguo_scanner" in msg or "小果扫描" in msg: + return f"ls -la /home/hmo/.hermes/profiles/position-analyst/scripts/xiaoguo_scanner.py 2>&1 && echo 'ok'" + # system-audit error → 验证拷贝 + if "system_audit" in msg or "系统审计" in msg: + return f"ls -la /home/hmo/.hermes/profiles/position-analyst/scripts/system_audit.py 2>&1" + # cron errors(last_status=error)→ 验证文件存在,等下次cron运行自动恢复 + if "cron" in msg.lower() and "error" in msg.lower() and ("小果" in msg or "系统审计" in msg): + return f"ls -la /home/hmo/.hermes/profiles/position-analyst/scripts/xiaoguo_scanner.py /home/hmo/.hermes/profiles/position-analyst/scripts/system_audit.py 2>&1" + # 港股汇率 → 刷新 + if "港股汇率" in msg: + return f"cd {BASE} && python3 hk_rate.py 2>&1" + # 价格监控无事件 → 检查进程 + if "价格监控" in msg and "0 rows" in msg: + return "ps aux | grep price_monitor | grep -v grep | head -3" + # delivery目标缺失 → 改为local + if "deliver" in msg.lower() or "delivery" in msg.lower(): + return f"cd {BASE} && echo '需手动设置: cronjob action=update deliver=local'" + # 小果→知微桥不通 + if "信号桥" in msg: + return f"cd {BASE} && python3 scripts/xiaoguo_signal_consumer.py 2>&1" + return None + + +def auto_fix_issue(issue): + """对明确可自动修复的问题执行修复,返回 (fixed, fix_msg)""" + item_id = issue.get("detail", "") + msg = issue.get("msg", "") + + # 港股汇率缓存缺失 → 生成 + if "港股汇率缓存" in msg and "missing" in msg: + try: + # hk_rate.py 写入 ~/.cache/hk_exchange_rate.json,profile环境下解析到 profile/home/.cache/ + r = subprocess.run( + ["python3", str(BASE / "hk_rate.py")], + capture_output=True, text=True, timeout=15 + ) + if r.returncode == 0: + return True, f"已自动刷新港股汇率缓存: {r.stdout.strip()}" + else: + return False, f"汇率刷新失败: {r.stderr[:100]}" + except Exception as e: + return False, f"汇率刷新异常: {e}" + + # 价格监控今天无事件(交易日盘中)→ 检查进程 + if "价格监控" in msg and "0 rows" in msg: + now = ctx["started_at"] + if now.weekday() < 5 and 9 <= now.hour <= 15: + # 交易时段,应该有事 + ok, detail = check_process("price_monitor") + if not ok: + return True, "已检测:price_monitor进程不存在(需人工介入)" + return True, "已确认:price_monitor进程运行中,但今日无事件(可能无价格触发)" + # 非交易时段→正常 + return True, "非交易时段无价格事件属正常" + + # 其他问题→不可自动修复 + return False, "需人工处理" + + +def write_todos_for_issues(): + """将体检发现的异常写入 TODO 系统(去重、升级),先尝试自动修复""" + try: + if not ctx["report"]: + return + + # 只有 error/critical/warn 才处理 + issues = [e for e in ctx["report"] if e["level"] in ("critical", "error", "warn")] + if not issues: + return + + # 先尝试自动修复 + fixed_issues = [] + remaining = [] + for issue in issues: + fixed, fix_msg = auto_fix_issue(issue) + if fixed: + fixed_issues.append((issue, fix_msg)) + log("ok", issue["category"], f"已自动修复: {fix_msg}", issue.get("detail","")) + else: + remaining.append(issue) + + # 输出修复摘要 + if fixed_issues: + print() + print("🛠️ 自动修复:") + for issue, fix_msg in fixed_issues: + print(f" ✅ {issue['category']}: {fix_msg}") + + # 剩余的无法自动修复的→写TODO到数据库 + if not remaining: + return + + try: + conn = sqlite3.connect(str(DB_PATH)) + todo_priority = {"critical": "high", "error": "medium", "warn": "low"} + new_count = 0 + + for issue in remaining: + title = f"[体检发现] {issue['msg']}" + level = issue["level"] + pri = todo_priority.get(level, "medium") + + # 去重:检查是否已存在(含completed的也要查,避免重复加) + r_exist = conn.execute( + "SELECT id, status FROM todos WHERE title=?", + (title,) + ).fetchone() + + if r_exist: + if r_exist[1] == "blocked": + # 已阻塞的重新打开 + conn.execute( + "UPDATE todos SET status='pending', priority=?, note='已重新打开', updated_at=CURRENT_TIMESTAMP WHERE id=?", + (pri, r_exist[0]) + ) + else: + # 生成fix_action(必须非空) + fix_action = derive_fix_action(issue.get("detail", ""), issue.get("msg", "")) + if not fix_action: + # 没有fix_action就不创建TODO,直接输出到报告里 + print(f" ⚠️ 无法自动修复: [{pri}] {title[:60]}") + print(f" 原因: 未知修复方案,需人工分析") + continue + conn.execute( + "INSERT INTO todos (title, description, priority, source, status, fix_action) " + "VALUES (?, ?, ?, 'health_check', 'pending', ?)", + (title, + f"体检发现于 {ctx['started_at'].strftime('%Y-%m-%d %H:%M')}\n分类: {issue['category']}\n详情: {issue.get('detail', '')}\n无法当场修复原因: 需验证/需等待", + pri, fix_action) + ) + new_count += 1 + + conn.commit() + + if new_count > 0: + print() + print(f"📋 已加入TODO({new_count}条):") + for r2 in conn.execute( + "SELECT title, priority FROM todos WHERE status='pending' AND source='health_check' " + "ORDER BY created_at DESC LIMIT ?", (new_count,) + ).fetchall(): + print(f" [{r2[1]}] {r2[0][:70]}") + conn.close() + except Exception as e: + print(f" TODO写入异常: {e}") + except Exception as e: + pass # TODO 写入失败不阻碍体检主流程 + +# ── 上下文 ── +ctx = { + "report": [], + "issues": [], + "ok_count": 0, + "warn_count": 0, + "error_count": 0, + "critical_count": 0, + "started_at": datetime.now(), +} + +def log(level, category, msg, detail=None): + """记录检查结果""" + ctx["report"].append({ + "level": level, "category": category, "msg": msg, "detail": detail, + "timestamp": datetime.now().isoformat() + }) + if level == "critical": + ctx["critical_count"] += 1 + elif level == "error": + ctx["error_count"] += 1 + elif level == "warn": + ctx["warn_count"] += 1 + else: + ctx["ok_count"] += 1 + +def emit(msg, level="ok"): + """输出一行""" + prefix = {"critical": "🔴", "error": "❌", "warn": "⚠️", "ok": "✅", "info": "📎"}.get(level, "•") + return f"{prefix} {msg}" + +# ── 检查器集合 ── + +def check_systemctl(service_name): + """检查systemd服务状态""" + try: + r = subprocess.run(["systemctl", "is-active", service_name], + capture_output=True, text=True, timeout=5) + status = r.stdout.strip() + return status == "active", f"{status}" + except Exception as e: + return False, f"error:{e}" + +def check_port(port): + """检查端口是否在监听""" + try: + r = subprocess.run(["ss", "-tlnp"], capture_output=True, text=True, timeout=5) + return f":{port}" in r.stdout, "listening" if f":{port}" in r.stdout else "not_found" + except Exception as e: + return False, f"error:{e}" + +def check_process(pattern): + """检查进程是否存在""" + try: + r = subprocess.run(["pgrep", "-f", pattern], capture_output=True, timeout=5) + return r.returncode == 0, "running" if r.returncode == 0 else "not_found" + except: + return False, "check_error" + +def check_http(url, timeout=15): + """检查HTTP端点是否可达 (清理代理环境变量)""" + try: + # 清理所有代理环境变量 + old_env = {} + for k in list(os.environ.keys()): + if 'proxy' in k.lower(): + old_env[k] = os.environ.pop(k) + req = urllib.request.Request(url, method="GET") + resp = urllib.request.urlopen(req, timeout=timeout) + # 恢复 + for k, v in old_env.items(): + os.environ[k] = v + return True, str(resp.status) + except Exception as e: + return False, str(e)[:60] + +def check_disk(mount): + """检查磁盘空间""" + try: + r = subprocess.run(["df", "-h", mount], capture_output=True, text=True, timeout=5) + lines = r.stdout.strip().split("\n") + if len(lines) >= 2: + parts = lines[1].split() + if len(parts) >= 5: + pct = parts[4].replace("%", "") + return int(pct) < 90, f"{pct}% used" + return False, "parse_error" + except: + return False, "check_error" + +def check_file_exists(path): + """检查文件存在""" + p = Path(path) + exists = p.exists() + return exists, f"{p.stat().st_size}B" if exists else "missing" + +def check_file_freshness(path, max_hours): + """检查文件新鲜度""" + p = Path(path) + if not p.exists(): + return False, "missing" + mtime = datetime.fromtimestamp(p.stat().st_mtime) + hours_ago = (ctx["started_at"] - mtime).total_seconds() / 3600 + return hours_ago < max_hours, f"{hours_ago:.0f}h ago (threshold {max_hours}h)" + +def check_db_table_count(table, field, value, op="today", threshold=0): + """检查数据库中的记录数""" + try: + conn = sqlite3.connect(str(DB_PATH)) + cur = conn.cursor() + if op == "today": + today = ctx["started_at"].strftime("%Y-%m-%d") + # 先检查表有哪些列 + cols = [r[1] for r in cur.execute(f"PRAGMA table_info({table})").fetchall()] + date_col = None + for candidate in ["created_at", "date", "timestamp", "last_scanned_at", "signal_date"]: + if candidate in cols: + date_col = candidate + break + if not date_col: + conn.close() + return True, f"no_date_col_in_{table}" + sql = f"SELECT COUNT(*) FROM {table} WHERE date({date_col}) = ?" + cur.execute(sql, (today,)) + elif op == "unprocessed": + cols = [r[1] for r in cur.execute(f"PRAGMA table_info({table})").fetchall()] + if "processed" in cols: + sql = f"SELECT COUNT(*) FROM {table} WHERE (processed = 0 OR processed IS NULL)" + elif "source" in cols: + sql = f"SELECT COUNT(*) FROM {table} WHERE source LIKE '%xiaoguo%'" + else: + sql = f"SELECT COUNT(*) FROM {table}" + cur.execute(sql) + elif op == "count": + if field: + sql = f"SELECT COUNT(*) FROM {table} WHERE {field} = ?" + cur.execute(sql, (value,)) + else: + sql = f"SELECT COUNT(*) FROM {table}" + cur.execute(sql) + else: + sql = f"SELECT COUNT(*) FROM {table}" + cur.execute(sql) + count = cur.fetchone()[0] + conn.close() + if op == "unprocessed": + return count < threshold, f"{count} unprocessed" + return count >= threshold, f"{count} rows" + except Exception as e: + return True, f"skip({str(e)[:60]})" + +def check_cron(job_id): + """检查cron任务状态(通过jobs.json)""" + try: + cron_jobs_path = HERMES_CRON_DIR / "jobs.json" + if cron_jobs_path.exists(): + data = json.loads(cron_jobs_path.read_text()) + for job in data.get("jobs", []): + if job.get("id") == job_id or (not job.get("id") and job.get("name") and job_id in str(job)): + enabled = job.get("enabled", True) + if not enabled: + return False, "disabled" + last_status = job.get("last_status") + if last_status and last_status != "ok": + return False, f"status={last_status}" + last_run = job.get("last_run_at", "") + if last_run: + try: + last_dt = datetime.fromisoformat(last_run) + hours_ago = (ctx["started_at"] - last_dt).total_seconds() / 3600 + if hours_ago > 48: + return True, f"ok(stale:{hours_ago:.0f}h)" + except: + pass + return True, "ok" + # 没找到该job_id - 可能是不需要检查的cron + return True, "not_in_jobs_json" + return False, "no_jobs_json" + except Exception as e: + return False, f"check_error:{str(e)[:60]}" + +def check_cron_errors_last24h(): + """检查最近24h内cron是否有error状态""" + try: + cron_jobs_path = HERMES_CRON_DIR / "jobs.json" + if not cron_jobs_path.exists(): + return True, "no_jobs_json" + data = json.loads(cron_jobs_path.read_text()) + check_time = (ctx["started_at"] - timedelta(hours=24)).isoformat() + errors = [] + for job in data.get("jobs", []): + last_status = job.get("last_status") + last_run = job.get("last_run_at", "") + if last_status and last_status != "ok" and last_run: + try: + if last_run >= check_time[:19]: + errors.append(f"{job.get('name','?')}({last_status})") + except: + pass + if errors: + return False, f"{len(errors)} errors: {'; '.join(errors[:5])}" + return True, "0 errors" + except Exception as e: + return True, f"skip({str(e)[:60]})" + +def check_cron_paused(): + """检查不应暂停的cron是否被误暂停""" + should_run = [ + ("3a9fb3300a6a", "价格监控"), + ("0851c7838ca3", "小果扫描"), + ("e13323928f3a", "自选提醒"), + ("b809fcabfa5b", "分支评估"), + ] + try: + cron_jobs_path = HERMES_CRON_DIR / "jobs.json" + if not cron_jobs_path.exists(): + return True, "no_jobs_json" + data = json.loads(cron_jobs_path.read_text()) + job_map = {job.get("id"): job for job in data.get("jobs", [])} + paused = [] + for jid, name in should_run: + job = job_map.get(jid) + if job and not job.get("enabled", True): + paused.append(name) + if paused: + return False, f"paused: {', '.join(paused)}" + return True, "all_expected_running" + except Exception as e: + return True, f"skip({str(e)[:60]})" + +def check_delivery_targets(): + """检查deliver=origin的cron是否有目标""" + try: + cron_jobs_path = HERMES_CRON_DIR / "jobs.json" + if not cron_jobs_path.exists(): + return True, "no_jobs_json" + data = json.loads(cron_jobs_path.read_text()) + issues = [] + for job in data.get("jobs", []): + last_delivery_err = job.get("last_delivery_error", "") + if last_delivery_err and "delivery" in last_delivery_err.lower(): + issues.append(f"{job.get('name','?')}") + if issues: + return False, f"{len(issues)} issues: {', '.join(issues[:3])}" + return True, "all_ok" + except Exception as e: + return True, f"skip({str(e)[:60]})" + + +def check_cron_audit(): + """审计全部cron:最近24h内是否运行过""" + try: + cron_jobs_path = HERMES_CRON_DIR / "jobs.json" + if not cron_jobs_path.exists(): + return True, "no_jobs_json" + data = json.loads(cron_jobs_path.read_text()) + check_time = (ctx["started_at"] - timedelta(hours=24)).isoformat()[:19] + stale = [] + for job in data.get("jobs", []): + name = job.get("name", "?") + enabled = job.get("enabled", True) + script = job.get("script", "") + last_run = job.get("last_run_at", "") + last_status = job.get("last_status") + if not enabled or not script: + continue + if not last_run: + stale.append(f"{name}(从未运行)") + continue + if last_run[:19] < check_time: + if last_status and last_status == "ok": + stale.append(f"{name}(>24h未运行)") + else: + stale.append(f"{name}(>24h+状态异常)") + if stale: + return False, f"{len(stale)}个cron异常: {'; '.join(stale[:5])}" + total = sum(1 for j in data.get("jobs",[]) if j.get("enabled") and j.get("script")) + return True, f"全部{total}个cron正常" + except Exception as e: + return True, f"skip({str(e)[:60]})" + + +def check_meta_health_check_yesterday(): + """元检:昨天体检是否正常完成""" + try: + conn = sqlite3.connect(str(DB_PATH)) + yesterday = (ctx["started_at"] - timedelta(days=1)).strftime("%Y-%m-%d") + row = conn.execute( + "SELECT ok_count, error_count, critical_count FROM health_check_log " + "WHERE date(created_at) = ? ORDER BY created_at DESC LIMIT 1", + (yesterday,) + ).fetchone() + conn.close() + if row: + if row[1] == 0 and row[2] == 0: + return True, f"昨日体检通过({row[0]}项正常)" + return True, f"昨日体检有{row[1]}错误+{row[2]}严重(已记录)" + return True, "无昨日记录(首次运行)" + except: + return True, "skip" + + +def check_meta_checklist_completeness(): + """元检:检查清单是否覆盖了所有已知组件""" + try: + added = ctx.get("auto_discovered_items", []) + if added: + return True, f"自动发现并追加了{len(added)}个新组件到清单" + return True, "清单覆盖完整" + except: + return True, "skip" + + +# ── 自动发现 ── +def self_discovery(): + """自动发现新增组件并更新checklist""" + discovered = [] + + # 1. 发现新增cron任务 + try: + cron_jobs_path = HERMES_CRON_DIR / "jobs.json" + if cron_jobs_path.exists(): + data = json.loads(cron_jobs_path.read_text()) + all_crons = [(j.get("id"), j.get("name"), j.get("schedule"), j.get("script")) + for j in data.get("jobs", [])] + + # 读当前checklist中已有的cron ID + checklist = json.loads(CHECKLIST_PATH.read_text()) + known_cron_ids = set() + for cat in checklist["categories"]: + for item in cat["items"]: + if item["check"].startswith("cron:"): + known_cron_ids.add(item["check"].split(":")[1]) + + for jid, name, schedule, script in all_crons: + if jid and jid not in known_cron_ids and script: + # 新cron任务,追加到pipeline类 + discovered.append(f"新cron: {name}({jid})") + for cat in checklist["categories"]: + if cat["id"] == "pipeline": + cat["items"].append({ + "id": f"cron-auto-{jid[:8]}", + "description": f"{name} cron 已调度", + "check": f"cron:{jid}", + "expected": "enabled+ok", + "severity": "medium", + "auto_discovered": True + }) + break + + if discovered: + CHECKLIST_PATH.write_text(json.dumps(checklist, ensure_ascii=False, indent=2)) + except Exception as e: + discovered.append(f"cron_discovery_error: {e}") + + return discovered + +# ── 主流程 ── + +def run_check(item): + """运行单个检查项""" + check_spec = item["check"] + expected = item["expected"] + + if check_spec.startswith("systemctl:"): + service = check_spec.split(":", 1)[1] + ok, detail = check_systemctl(service) + elif check_spec.startswith("port:"): + port = check_spec.split(":", 1)[1] + ok, detail = check_port(port) + elif check_spec.startswith("proc:"): + pattern = check_spec.split(":", 1)[1] + ok, detail = check_process(pattern) + elif check_spec.startswith("http:"): + url = check_spec.split(":", 1)[1] + ok, detail = check_http(url) + elif check_spec.startswith("disk:"): + mount = check_spec.split(":", 1)[1] + ok, detail = check_disk(mount) + elif check_spec.startswith("fileexists:"): + path = check_spec.split(":", 1)[1] + ok, detail = check_file_exists(path) + elif check_spec.startswith("filefresh:"): + # filefresh:path:max_hours + parts = check_spec.split(":", 2) + path = parts[1] + max_hours = float(parts[2].replace("h", "")) + ok, detail = check_file_freshness(path, max_hours) + elif check_spec.startswith("db:"): + # db:table:field:value:op:threshold + parts = check_spec.split(":", 5) + table = parts[1] + field = parts[2] if len(parts) > 2 else None + value = parts[3] if len(parts) > 3 else None + op = parts[4] if len(parts) > 4 else "today" + threshold = int(parts[5]) if len(parts) > 5 else 0 + ok, detail = check_db_table_count(table, field, value, op, threshold) + elif check_spec.startswith("cron:"): + job_id = check_spec.split(":", 1)[1] + ok, detail = check_cron(job_id) + elif check_spec == "cron_errors:last24h": + ok, detail = check_cron_errors_last24h() + elif check_spec == "cron_paused:check": + ok, detail = check_cron_paused() + elif check_spec == "delivery:origin_targets": + ok, detail = check_delivery_targets() + elif check_spec == "cron_audit:all": + ok, detail = check_cron_audit() + elif check_spec == "meta:health_check_yesterday": + ok, detail = check_meta_health_check_yesterday() + elif check_spec == "meta:checklist_completeness": + ok, detail = check_meta_checklist_completeness() + elif check_spec == "pipeline:xiaoguo_signal_flow": + today_xiaoguo, d1 = check_db_table_count("signal_news", "created_at", None, "today", 0) + unproc, d2 = check_db_table_count("signal_news", None, None, "unprocessed", 30) + ok = today_xiaoguo or unproc + detail = f"today_xiaoguo={d1}, unprocessed={d2}" + elif check_spec == "pipeline:registry_audit": + ok = True + gaps = [] + unregistered = [] + try: + import json as j2 + reg = j2.loads(open(str(DATA / "pipeline_registry.json")).read()) + for p in reg.get("pipelines", []): + if not p.get("verified"): + gaps.append(p["name"]) + + # 自动发现:从cron jobs.json读所有脚本,交叉比对注册表 + known_sources = set() + for p in reg.get("pipelines", []): + src = p.get("source", "") + # 从source描述中提取脚本名 + import re as rr + for m in rr.findall(r'[\w_-]+\.py', src): + known_sources.add(m.replace('.py', '')) + + # 从jobs.json动态读取所有启用脚本 + unregistered = [] + try: + jobs_data = j2.loads(open(str(HERMES_CRON_DIR / "jobs.json")).read()) + active_scripts = set() + for job in jobs_data.get("jobs", []): + script = job.get("script", "") or "" + if script and job.get("enabled", True): + name = script.replace('.py', '') + active_scripts.add(name) + for s in sorted(active_scripts): + if s not in known_sources: + unregistered.append(s) + except Exception: + pass + + if unregistered: + ok = False + detail = f"{len(gaps)}条待验证 + {len(unregistered)}个新组件未注册" + # 自动修复:读脚本docstring,推算数据流 + try: + for s in unregistered: + script_path = HERMES_CRON_DIR.parent / "scripts" / f"{s}.py" + desc = "未知" + source_info = f"{s}.py" + consumer_info = "未知" + if script_path.exists(): + content = script_path.read_text() + # 提取docstring + import re as rr2 + doc_match = rr2.search(r'"""(.*?)"""', content, rr2.DOTALL) + if doc_match: + doc_text = doc_match.group(1).strip() + desc = doc_text.split('\\n')[0][:80] + # 尝试从docstring中提取管道信息 + pipe_match = rr2.search(r'管道[::].*?(?=\\n|$)', doc_text) + if pipe_match: + consumer_info = pipe_match.group(0).replace('管道','').strip(':: ') + # 检测写入模式 + if 'signal_news' in content: + consumer_info = 'signal_news表' + if 'macro_risk_state' in content: + consumer_info = 'macro_risk_state.json' + if 'watchlist' in content.lower(): + consumer_info = 'watchlist_stocks表 / holding_strategies表' + if 'INSERT INTO' in content: + for tbl in ['todos', 'price_events', 'macro_context_log', 'accuracy_stats']: + if tbl in content: + consumer_info = f'{tbl}表' + if '.write_text' in content or 'json.dump' in content: + for path in ['macro_risk_state', 'macro_context', 'market', 'portfolio', 'decisions']: + if path in content: + consumer_info = f'{path}.json' + reg["pipelines"].append({ + "id": f"auto-{s}", + "name": desc[:60], + "source": source_info, + "consumer": consumer_info, + "end_user": "待确认", + "verified": False, + "gap": f"自动发现({desc[:60]})", + "fix": "手动编辑pipeline_registry.json完善此项" + }) + open(str(DATA / "pipeline_registry.json"), 'w').write( + j2.dumps(reg, ensure_ascii=False, indent=2)) + detail += f" → 已自动注册{len(unregistered)}个(含推断)" + except Exception as e: + detail += f" (自动注册异常:{str(e)[:30]})" + elif gaps: + ok = False + detail = f"{len(gaps)}条管道未验证: {', '.join(gaps[:5])}" + else: + detail = f"全部{len(reg['pipelines'])}条管道正常" + except Exception as e: + ok = True + detail = f"注册表不可读({str(e)[:60]})" + else: + ok = False + detail = f"unknown_check:{check_spec}" + + level = "ok" if ok else item["severity"] + # 将critical/error/high都映射到error级别 + if not ok: + if item["severity"] == "critical": + level = "critical" + elif item["severity"] in ("high", "error"): + level = "error" + else: + level = "warn" + + return ok, level, detail + +def main(): + show_full = "--report" in sys.argv + update = "--update-checklist" in sys.argv + + start_time = time.time() + + # 加载checklist + if not CHECKLIST_PATH.exists(): + print("[SILENT] health_checklist.json 不存在") + return + + checklist = json.loads(CHECKLIST_PATH.read_text()) + + # 自动发现(每小时仅运行一次) + if update: + discovered = self_discovery() + else: + # 定期自动发现(检查上次扫描时间) + meta = checklist.get("meta", {}) + last_scan = meta.get("last_full_scan") + if not last_scan or (ctx["started_at"] - datetime.fromisoformat(last_scan)).total_seconds() > 3600: + discovered = self_discovery() + checklist["meta"]["last_full_scan"] = ctx["started_at"].isoformat() + CHECKLIST_PATH.write_text(json.dumps(checklist, ensure_ascii=False, indent=2)) + else: + discovered = [] + + # 按分类逐项检查 + dayname = ["一","二","三","四","五","六","日"][ctx["started_at"].weekday()] + lines = [f"MoFin 系统体检 | {ctx['started_at'].strftime('%Y-%m-%d')} 周{dayname} | {ctx['started_at'].strftime('%H:%M')}"] + lines.append("─" * 50) + + for cat in checklist["categories"]: + cat_issues = 0 + cat_lines = [] + for item in cat["items"]: + ok, level, detail = run_check(item) + msg = f"{item['description']}: {detail}" + log(level, cat["name"], msg, item["id"]) + cat_lines.append(emit(msg, level)) + if not ok: + cat_issues += 1 + + # 只在该分类有问题或--report时才输出 + if cat_issues > 0 or show_full: + lines.append(f"\n【{cat['name']}】") + lines.extend(cat_lines) + + # 自动发现结果 + if discovered: + lines.append(f"\n📎 自动发现:") + for d in discovered: + lines.append(f" {d}") + + # 汇总 + total = ctx["ok_count"] + ctx["warn_count"] + ctx["error_count"] + ctx["critical_count"] + if total == 0: + total = 1 # 避免除以0 + + lines.append(f"\n{'─' * 50}") + + critical = ctx["critical_count"] + errors = ctx["error_count"] + warns = ctx["warn_count"] + ok_count = ctx["ok_count"] + + # 构建严重级别输出 + severity_parts = [] + if critical > 0: + severity_parts.append(f"🔴{critical}严重") + if errors > 0: + severity_parts.append(f"❌{errors}错误") + if warns > 0: + severity_parts.append(f"⚠️{warns}警告") + if ok_count > 0: + severity_parts.append(f"✅{ok_count}正常") + + lines.append(f"总计: {' | '.join(severity_parts)} ({(time.time()-start_time):.0f}s)") + + report = "\n".join(lines) + + # 保存历史到DB + try: + conn_hist = sqlite3.connect(str(DB_PATH)) + details = json.dumps([e for e in ctx["report"] if e["level"] in ("critical", "error")]) + conn_hist.execute( + "INSERT INTO health_check_log (ok_count, warn_count, error_count, critical_count, duration_s, details) " + "VALUES (?, ?, ?, ?, ?, ?)", + (ok_count, warns, errors, critical, round(time.time()-start_time, 1), details)) + conn_hist.commit() + conn_hist.close() + except: + pass + + # 输出 + # no_agent模式:有问题才出声;--report则强制输出 + has_issues = critical > 0 or errors > 0 or warns > 0 + + if has_issues or show_full: + print(report) + else: + print(f"[SILENT] MoFin 体检通过 | {ok_count}/{total} 检查正常 ({(time.time()-start_time):.0f}s)") + + # 如果有严重问题,额外输出可读摘要 + if critical > 0 or errors > 0: + print() + print("🔴 需立即处理的问题:") + for entry in ctx["report"]: + if entry["level"] in ("critical", "error"): + print(f" [{entry['level'].upper()}] {entry['category']}: {entry['msg']}") + + # 检查是否有执行器升级来的TODO(通知失败挂起的) + try: + conn2 = sqlite3.connect(str(DB_PATH)) + needs_llm = conn2.execute( + "SELECT id, title, priority, created_at, note FROM todos " + "WHERE status='needs_llm' " + "ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, created_at ASC LIMIT 10" + ).fetchall() + if needs_llm: + print() + print("🔶 需知微介入(执行器无法自动修复):") + for n in needs_llm: + note = (n[4] or "")[:60] + print(f" [{n[2]}] #{n[0]} {n[1][:60]} → {note}") + conn2.close() + except: + pass + + # 将异常写入 TODO 系统 + write_todos_for_issues() + + +if __name__ == "__main__": + # --verify-new-cron 模式:创建cron后立即验证完整性 + if "--verify-new-cron" in sys.argv: + idx = sys.argv.index("--verify-new-cron") + if idx + 1 < len(sys.argv): + script_name = sys.argv[idx + 1] + import json as j2 + from pathlib import Path as P2 + DATA = P2("/home/hmo/MoFin/data") + # 检查cron-catalog.md + catalog = DATA.parent / "docs" / "cron-catalog.md" + if catalog.exists(): + content = catalog.read_text() + if script_name in content: + print(f" ✅ cron-catalog.md: 已登记") + else: + print(f" ⚠️ cron-catalog.md: 未登记(可从docstring自动生成)") + # 检查pipeline_registry.json + reg_path = DATA / "pipeline_registry.json" + if reg_path.exists(): + reg = j2.loads(reg_path.read_text()) + registered = any(script_name in p.get("source","") for p in reg["pipelines"]) + if registered: + print(f" ✅ pipeline_registry.json: 已注册") + else: + print(f" ⚠️ pipeline_registry.json: 未注册(自动添加占位)") + # 读docstring自动注册 + script_path = P2("/home/hmo/.hermes/profiles/position-analyst/scripts") / f"{script_name}.py" + desc = script_name + if script_path.exists(): + import re + m = re.search(r'"""(.*?)"""', script_path.read_text(), re.DOTALL) + if m: + desc = m.group(1).strip().split('\n')[0][:80] + reg["pipelines"].append({ + "id": f"auto-{script_name}", "name": desc[:60], + "source": f"{script_name}.py", "consumer": "待确认", + "end_user": "待确认", "verified": False, + "gap": f"新建后自动注册({desc[:60]})", + "fix": "手动完善pipeline_registry.json" + }) + reg_path.write_text(j2.dumps(reg, ensure_ascii=False, indent=2)) + print(f" 已自动添加占位记录(desc={desc[:50]})") + sys.exit(0) + main() diff --git a/deploy/profile-scripts/multi_timeframe.py b/deploy/profile-scripts/multi_timeframe.py new file mode 100644 index 00000000..9064475b --- /dev/null +++ b/deploy/profile-scripts/multi_timeframe.py @@ -0,0 +1,660 @@ +#!/usr/bin/env python3 +"""multi_timeframe.py — 多周期技术分析模块 + +从腾讯API获取日/周/月K线数据,计算: +- 多周期支撑压力位(日线/周线/月线) +- 移动均线(MA5/10/20/60) +- 趋势方向判断(上升/下降/震荡) +- 综合策略调整建议 + +集成到 strategy_lifecycle.py 中使用。 +""" + +import json +import os +import urllib.request +import urllib.error +from datetime import datetime, date, timedelta +from typing import Optional + +from mofin_db import get_conn +from mo_data import get_price + +DATA_DIR = "/home/hmo/web-dashboard/data" +HISTORY_PATH = os.path.join(DATA_DIR, "price_history.json") +# multi_tf_cache.json 已迁移到 DB (mtf_cache 表) + +# 腾讯API K线端点 +KLINE_URL = "http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param={market}{code},{period},,,{count},qfq" + + +def _write_klines_to_db(code: str, daily: list, weekly: list, monthly: list, fundamentals: dict = None): + """K线数据双写 SQLite(失败不影响缓存写入)""" + try: + from mofin_db import get_conn, init_all_tables, write_klines + conn = get_conn() + init_all_tables(conn) + # 从 stock_profiles.json 获取名称 + name = code + try: + import json + profiles_path = os.path.join(DATA_DIR, "stock_profiles.json") + if os.path.exists(profiles_path): + with open(profiles_path, encoding="utf-8") as f: + profiles = json.load(f) + for p in profiles.get("profiles", []): + if p.get("code") == code: + name = p.get("name", code) + break + except Exception: + pass + write_klines(conn, code, name, daily, weekly, monthly, fundamentals) + conn.close() + except Exception: + pass # SQLite 写入失败不影响主流程 + + +def _market_prefix(code: str) -> str: + """根据代码确定市场前缀""" + raw = str(code).split("_")[0] + # 指数代码:sh/sz/hk开头 + if raw.startswith("sh"): + return "sh" + if raw.startswith("sz"): + return "sz" + if raw.startswith("hk"): + return "hk" + if len(raw) == 5 and raw.isdigit(): + return "hk" + if raw.startswith("6") or raw.startswith("5"): + return "sh" + return "sz" + + +def _user_agent() -> dict: + return { + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" + } + + +# 多周期缓存TTL(秒):日K线1小时,周/月K线1天 +_KLINE_CACHE_TTL = {"day": 3600, "week": 86400, "month": 86400} + +# 模块级缓存:避免每次 fetch_kline 都重新读 DB +_MTF_CACHE_DATA = None +_MTF_CACHE_DIRTY = False + + +def _load_mtf_cache(): + """从 DB 加载多周期缓存""" + global _MTF_CACHE_DATA + if _MTF_CACHE_DATA is not None: + return _MTF_CACHE_DATA + try: + db = get_conn() + rows = db.execute("SELECT code, cache_json FROM mtf_cache").fetchall() + _MTF_CACHE_DATA = {} + for code, json_str in rows: + try: + _MTF_CACHE_DATA[code] = json.loads(json_str) + except: + pass + db.close() + except Exception: + _MTF_CACHE_DATA = {} + return _MTF_CACHE_DATA + + +def _save_mtf_cache(): + """将模块级缓存写回 DB""" + global _MTF_CACHE_DATA + if _MTF_CACHE_DATA is None: + return + try: + db = get_conn() + for code, data in _MTF_CACHE_DATA.items(): + db.execute( + "INSERT OR REPLACE INTO mtf_cache (code, cache_json, updated_at) VALUES (?,?,datetime('now','localtime'))", + (code, json.dumps(data, ensure_ascii=False)) + ) + db.commit() + db.close() + except Exception: + pass + + +def fetch_kline(code: str, period: str = "day", count: int = 120) -> list: + """从腾讯API获取K线数据,优先使用本地缓存 + + Args: + code: 股票代码 (如 "300548") + period: "day" / "week" / "month" + count: 需要多少条 + + Returns: + list of dict: [{"date":str, "open":float, "close":float, + "high":float, "low":float, "volume":float}, ...] + """ + import time + now = time.time() + + # 优先检查本地缓存(模块级,避免重复读盘) + # 注意:缓存中存储的key是'daily'/'weekly'/'monthly',参数period是'day'/'week'/'month' + _PERIOD_MAP = {"day": "daily", "week": "weekly", "month": "monthly"} + cache_data = _load_mtf_cache() + cached = cache_data.get(code, {}) + cache_key = _PERIOD_MAP.get(period, period) + cached_klines = cached.get(cache_key, cached.get(period, [])) + updated_at = cached.get("updated_at", 0) + if cached_klines and updated_at and (now - updated_at) < _KLINE_CACHE_TTL.get(period, 3600): + return cached_klines + + market = _market_prefix(code) + is_index = any(code.startswith(p) for p in ["sh", "sz", "hk"]) + + # 指数代码已经自带前缀,API直接用code;普通股票需要加market前缀 + api_code = code if is_index else f"{market}{code}" + url = f"http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param={api_code},{period},,,{count},qfq" + + try: + req = urllib.request.Request(url, headers=_user_agent()) + with urllib.request.urlopen(req, timeout=10) as resp: + raw = json.loads(resp.read().decode("utf-8")) + except Exception as e: + return {"error": str(e), "code": code, "period": period} + + if not isinstance(raw, dict): + return {"error": f"API returned {type(raw).__name__}", "raw": str(raw)[:200]} + + api_data = raw.get("data", {}) + if not isinstance(api_data, dict): + return {"error": f"data field is {type(api_data).__name__}", "raw": str(api_data)[:200]} + + # 指数代码已经自带前缀(sh000001/sz399001),直接用 + # 普通股票代码需要加market前缀(sh600036/sz300750) + is_index = any(code.startswith(p) for p in ["sh", "sz", "hk"]) + stock_key = code if is_index else f"{market}{code}" + stock_data = api_data.get(stock_key, {}) + + # 腾讯API的K线字段名: qfqday, qfqweek, qfqmonth + period_key = f"qfq{period}" + klines = stock_data.get(period_key, []) + + if not klines: + # 尝试其他字段名 + for k in stock_data: + if isinstance(stock_data[k], list) and len(stock_data[k]) > 0: + if isinstance(stock_data[k][0], list) and len(stock_data[k][0]) >= 6: + klines = stock_data[k] + break + + result = [] + for k in klines: + if len(k) >= 6: + try: + result.append({ + "date": str(k[0]), + "open": float(k[1]), + "close": float(k[2]), + "high": float(k[3]), + "low": float(k[4]), + "volume": float(k[5]), + }) + except (ValueError, IndexError): + continue + + return result + + +def calc_moving_averages(klines: list, windows: list = [5, 10, 20, 60]) -> dict: + """计算移动均线 + + Args: + klines: K线数据(按时间正序或倒序均可,自动处理) + windows: 均线周期列表 + + Returns: + dict: {ma5: float|None, ma10: float|None, ...} + """ + if not klines: + return {f"ma{w}": None for w in windows} + + # 确保按时间正序(旧的在前) + closes = [k["close"] for k in klines] + # 使用日期判断顺序(不能用价格:下跌趋势下closes[0]>closes[-1]也会触发反转) + is_reversed = False + if len(klines) >= 2: + d0 = klines[0].get("date", "") + d1 = klines[-1].get("date", "") + if d0 and d1: + from datetime import datetime + try: + is_reversed = datetime.strptime(d0, "%Y-%m-%d") > datetime.strptime(d1, "%Y-%m-%d") + except: + is_reversed = (closes[0] > closes[-1] * 1.5) if len(closes) >= 2 else False + if is_reversed: + closes = list(reversed(closes)) + + result = {} + for w in windows: + if len(closes) >= w: + result[f"ma{w}"] = round(sum(closes[-w:]) / w, 2) + else: + result[f"ma{w}"] = None + return result + + +def calc_multi_tf_support_resistance(klines: list, lookback: int = 0) -> dict: + """基于K线数据计算多周期支撑压力位 + + 使用近期高点和低点作为关键位: + - 强阻力 = 近期最高(或倒数第二高) + - 弱阻力 = 近期中枢上沿 + - 弱支撑 = 近期中枢下沿 + - 强支撑 = 近期最低(或倒数第二低) + + Args: + klines: K线数据 + lookback: 取最近多少条(0=全部) + + Returns: + dict: {strong_resist, weak_resist, weak_support, strong_support, + high_52w, low_52w, range_pct} + """ + if not klines or len(klines) < 3: + return {} + + # 取最近N条(日线看近期,周线/月线看全部) + if lookback <= 0: + lookback = min(len(klines), 20) # 日线默认20天 + n = min(len(klines), lookback) + recent = klines[-n:] + + # 全量数据(用于52周高低) + all_highs = [k["high"] for k in klines] + all_lows = [k["low"] for k in klines] + + highs = [k["high"] for k in recent] + lows = [k["low"] for k in recent] + + max_h = max(highs) + min_l = min(lows) + mid = (max_h + min_l) / 2 + + # 找第二高和第二低作为更稳健的边界 + sorted_h = sorted(set(highs), reverse=True) + sorted_l = sorted(set(lows)) + + strong_resist = sorted_h[0] if sorted_h else max_h + strong_support = sorted_l[0] if sorted_l else min_l + + weak_resist = sorted_h[1] if len(sorted_h) > 1 else (max_h + mid) / 2 + weak_support = sorted_l[1] if len(sorted_l) > 1 else (min_l + mid) / 2 + + # 最近20日的振幅比例(判断波动率) + if len(closes := [k["close"] for k in recent]) >= 2: + recent_range = (max_h - min_l) / min_l * 100 if min_l > 0 else 0 + else: + recent_range = 0 + + return { + "strong_resist": round(strong_resist, 2), + "weak_resist": round(weak_resist, 2), + "weak_support": round(weak_support, 2), + "strong_support": round(strong_support, 2), + "high_52w": round(max(all_highs), 2), + "low_52w": round(min(all_lows), 2), + "range_pct": round(recent_range, 1), + } + + +def assess_trend(klines: list) -> dict: + """判断趋势方向 + + Args: + klines: K线数据 + + Returns: + dict: {trend (up/down/sideways), strength (0~1), + description, ma_trend} + """ + if not klines or len(klines) < 10: + return {"trend": "unknown", "strength": 0, "description": "数据不足"} + + closes = [k["close"] for k in klines] + # 确保正序(使用日期不用价格,避免下跌趋势中错误反转) + is_reversed = False + if len(klines) >= 2: + d0 = klines[0].get("date", "") + d1 = klines[-1].get("date", "") + if d0 and d1: + from datetime import datetime + try: + is_reversed = datetime.strptime(d0, "%Y-%m-%d") > datetime.strptime(d1, "%Y-%m-%d") + except: + is_reversed = (closes[0] > closes[-1] * 1.5) if len(closes) >= 2 else False + if is_reversed: + closes = list(reversed(closes)) + + n = len(closes) + ma20 = sum(closes[-20:]) / 20 if n >= 20 else sum(closes) / n + ma60 = sum(closes[-60:]) / 60 if n >= 60 else None + current = closes[-1] + + # 均线多头/空头排列判断 + ma5 = sum(closes[-5:]) / 5 if n >= 5 else None + ma10 = sum(closes[-10:]) / 10 if n >= 10 else None + + # 趋势判断 + up_count = sum(1 for i in range(1, len(closes)) if closes[i] > closes[i-1]) + up_ratio = up_count / (len(closes) - 1) + + # 价格相对均线位置 + above_ma20 = current > ma20 if ma20 else True + + if up_ratio > 0.6 and above_ma20: + if ma60 and current > ma60 * 1.2: + trend = "strong_up" + strength = min(1.0, up_ratio + 0.2) + desc = "强势上升" + else: + trend = "up" + strength = up_ratio + desc = "震荡上升" + elif up_ratio < 0.4 and not above_ma20: + if ma60 and current < ma60 * 0.8: + trend = "strong_down" + strength = min(1.0, (1 - up_ratio) + 0.2) + desc = "强势下跌" + else: + trend = "down" + strength = 1 - up_ratio + desc = "震荡下跌" + else: + trend = "sideways" + strength = 0.3 + desc = "横盘震荡" + + # 均线排列 + ma_trend = "unknown" + if ma5 and ma10 and ma20: + if ma5 > ma10 > ma20: + ma_trend = "多头排列" + elif ma5 < ma10 < ma20: + ma_trend = "空头排列" + else: + ma_trend = "粘合/交叉" + + return { + "trend": trend, + "strength": round(strength, 2), + "description": desc, + "ma_trend": ma_trend, + "ma5": round(ma5, 2) if ma5 else None, + "ma10": round(ma10, 2) if ma10 else None, + "ma20": round(ma20, 2), + "ma60": round(ma60, 2) if ma60 else None, + "current_above_ma20": current > ma20 if ma20 else None, + } + + +def full_multi_tf_analysis(code: str) -> dict: + """完整多周期分析入口 + + 同时获取日/周/月K线,计算: + - 各周期支撑压力位 + - 均线系统 + - 趋势方向 + - 综合策略建议 + + Args: + code: 股票代码 (如 "300548") + + Returns: + dict: 完整分析结果 + """ + # 获取三个周期的数据 + daily = fetch_kline(code, "day", 120) + weekly = fetch_kline(code, "week", 24) + monthly = fetch_kline(code, "month", 12) + + # 如果API失败,检查是否有本地缓存 + if isinstance(daily, dict) and "error" in daily: + daily = _load_local_history(code, "daily") + if isinstance(weekly, dict) and "error" in weekly: + weekly = _load_local_history(code, "weekly") + if isinstance(monthly, dict) and "error" in monthly: + monthly = _load_local_history(code, "monthly") + + result = { + "code": code, + "analyzed_at": datetime.now().strftime("%Y-%m-%d %H:%M"), + } + + # 日线分析 + if daily and not (isinstance(daily, dict) and "error" in daily): + result["daily"] = { + "count": len(daily), + "latest": daily[-1] if daily else None, + "support_resistance": calc_multi_tf_support_resistance(daily, lookback=20), + "mas": calc_moving_averages(daily, [5, 10, 20, 60]), + "trend": assess_trend(daily), + } + + # 周线分析 + if weekly and not (isinstance(weekly, dict) and "error" in weekly): + result["weekly"] = { + "count": len(weekly), + "latest": weekly[-1] if weekly else None, + "support_resistance": calc_multi_tf_support_resistance(weekly, lookback=12), + "mas": calc_moving_averages(weekly, [5, 10]), + "trend": assess_trend(weekly), + } + + # 月线分析 + if monthly and not (isinstance(monthly, dict) and "error" in monthly): + result["monthly"] = { + "count": len(monthly), + "latest": monthly[-1] if monthly else None, + "support_resistance": calc_multi_tf_support_resistance(monthly, lookback=6), + "mas": calc_moving_averages(monthly, [5]), + "trend": assess_trend(monthly), + } + + # 综合策略建议 + result["strategy_adjustment"] = _generate_strategy_adjustment(result) + + # 写入本地缓存(供离线使用) + _save_local_history(code, daily, weekly, monthly) + + return result + + +def flush_mtf_cache(): + """将模块级缓存显式刷回磁盘(供批量处理后调用)""" + _save_mtf_cache() + + +def _generate_strategy_adjustment(analysis: dict) -> dict: + """基于多周期分析生成策略调整建议""" + adj = { + "stop_loss_reference": None, + "take_profit_reference": None, + "trend_alignment": "unknown", + "multi_tf_summary": {}, + "cautions": [], + } + + daily_trend = analysis.get("daily", {}).get("trend", {}) + weekly_trend = analysis.get("weekly", {}).get("trend", {}) + monthly_trend = analysis.get("monthly", {}).get("trend", {}) + + # 均线数据 + daily_mas = analysis.get("daily", {}).get("mas", {}) + daily_sr = analysis.get("daily", {}).get("support_resistance", {}) + weekly_sr = analysis.get("weekly", {}).get("support_resistance", {}) + monthly_sr = analysis.get("monthly", {}).get("support_resistance", {}) + + current = analysis.get("daily", {}).get("latest", {}).get("close", 0) + + # 多周期趋势一致性 + up_tfs, down_tfs = 0, 0 + tf_details = [] + for tf_name, tf_data in [("daily", daily_trend), ("weekly", weekly_trend), + ("monthly", monthly_trend)]: + t = tf_data.get("trend", "unknown") + desc = tf_data.get("description", "") + ma_t = tf_data.get("ma_trend", "") + tf_details.append(f"{tf_name}:{desc}({ma_t})") + if "up" in t or "strong_up" in t: + up_tfs += 1 + elif "down" in t or "strong_down" in t: + down_tfs += 1 + + adj["multi_tf_summary"] = { + "daily_trend": daily_trend.get("description", "未知"), + "weekly_trend": weekly_trend.get("description", "未知"), + "monthly_trend": monthly_trend.get("description", "未知"), + "daily_ma_trend": daily_trend.get("ma_trend", "未知"), + } + + if up_tfs >= 2: + adj["trend_alignment"] = "多周期看多" + elif down_tfs >= 2: + adj["trend_alignment"] = "多周期看空" + elif up_tfs >= 1 and down_tfs >= 1: + adj["trend_alignment"] = "多周期分化" + else: + adj["trend_alignment"] = "震荡/无明显方向" + + if not current: + return adj + + # ===== 参考止损位(三级递进)===== + # 第一级:MA20(短线交易的生命线) + ma20 = daily_mas.get("ma20") + # 第二级:日线弱支撑(近20天次低点) + daily_ws = daily_sr.get("weak_support") + # 第三级:日线强支撑 / MA60 + ma60 = daily_mas.get("ma60") + daily_ss = daily_sr.get("strong_support") + + stop_candidates = [] + if ma20: + stop_candidates.append(("MA20", ma20, abs(current - ma20) / current * 100)) + if daily_ws: + stop_candidates.append(("日弱支撑", daily_ws, abs(current - daily_ws) / current * 100)) + if ma60: + stop_candidates.append(("MA60", ma60, abs(current - ma60) / current * 100)) + if daily_ss: + stop_candidates.append(("日强支撑", daily_ss, abs(current - daily_ss) / current * 100)) + + if stop_candidates: + # 选一个合理的止损参考:MA20优先(如果距现价不太近),否则选日弱支撑 + best_stop = None + for name, level, dist in stop_candidates: + if level < current: # 止损必须在现价下方 + if 2 <= dist <= 15: # 距现价2~15%之间比较合理 + best_stop = {"source": name, "level": level, + "distance_pct": round(dist, 2)} + break + if not best_stop: + # 没有2~15%内的,选最近的一个 + below = [(n, l, d) for n, l, d in stop_candidates if l < current] + if below: + nearest = min(below, key=lambda x: x[2]) + best_stop = {"source": nearest[0], "level": nearest[1], + "distance_pct": round(nearest[2], 2)} + if best_stop: + adj["stop_loss_reference"] = best_stop + + # ===== 参考止盈位 ===== + take_candidates = [] + # 日线阻力 + for name, level in [("日弱阻", daily_sr.get("weak_resist")), + ("日强阻", daily_sr.get("strong_resist")), + ("周强阻", weekly_sr.get("strong_resist")), + ("月强阻", monthly_sr.get("strong_resist"))]: + if level and level > current: + dist = (level - current) / current * 100 + take_candidates.append((name, level, dist)) + + if take_candidates: + # 选距现价5~30%内的最高阻力位 + best_take = None + for name, level, dist in sorted(take_candidates, key=lambda x: x[1], reverse=True): + if 3 <= dist <= 40: + best_take = {"source": name, "level": level, + "distance_pct": round(dist, 2)} + break + if not best_take: + farthest = max(take_candidates, key=lambda x: x[2]) + best_take = {"source": farthest[0], "level": farthest[1], + "distance_pct": round(farthest[2], 2)} + if best_take: + adj["take_profit_reference"] = best_take + + # ===== 风险提示 ===== + if ma20 and current < ma20: + adj["cautions"].append(f"价格{current} list: + """从 DB 多周期缓存读取历史数据""" + data = _load_mtf_cache() + stock = data.get(code, {}) + return stock.get(period, []) + + +def _save_local_history(code: str, daily: list, weekly: list, monthly: list): + """将多周期数据写入模块级缓存(含时间戳),不直接写磁盘""" + import time + global _MTF_CACHE_DATA + cache_data = _load_mtf_cache() + stock = cache_data.get(code, {}) + + if daily and not (isinstance(daily, dict) and "error" in daily): + stock["daily"] = daily + if weekly and not (isinstance(weekly, dict) and "error" in weekly): + stock["weekly"] = weekly + if monthly and not (isinstance(monthly, dict) and "error" in monthly): + stock["monthly"] = monthly + stock["updated_at"] = time.time() # 缓存时间戳 + + cache_data[code] = stock + _MTF_CACHE_DATA = cache_data # 更新模块级缓存 + + # ── SQLite 双写 ── + _write_klines_to_db(code, daily, weekly, monthly, stock.get("fundamentals")) + + +def batch_update_all(codes: list): + """批量更新多只股票的多周期数据""" + results = {} + for code in codes: + try: + r = full_multi_tf_analysis(code) + results[code] = { + "status": "ok", + "periods": [k for k in ["daily", "weekly", "monthly"] + if k in r] + } + except Exception as e: + results[code] = {"status": "error", "error": str(e)} + return results + + +if __name__ == "__main__": + import sys + codes = sys.argv[1:] or ["300548", "600110"] + for code in codes: + r = full_multi_tf_analysis(code) + print(json.dumps(r, ensure_ascii=False, indent=2)) + print("-" * 60) diff --git a/deploy/profile-scripts/ocr_client.py b/deploy/profile-scripts/ocr_client.py new file mode 100644 index 00000000..90485a2c --- /dev/null +++ b/deploy/profile-scripts/ocr_client.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +""" +ocr_client.py — 通过小果网关(node122:18003)的 GLM-OCR-8bit 做截图OCR + +用法: + python3 ocr_client.py + python3 ocr_client.py "自定义提问" + +返回 dict {"success": bool, "text": "...", "raw": "..."} +""" +import sys +import json +import base64 +import urllib.request + +OCR_URL = "http://node122:18003/v1/chat/completions" +OCR_MODEL = "GLM-OCR-8bit" + +def ocr_image(image_path, prompt="请详细识别这张图片中的所有文字,包括数字、股票名称、金额等。用中文回复。"): + """发送截图到小果的GLM-OCR-8bit识别文字""" + try: + with open(image_path, 'rb') as f: + img_b64 = base64.b64encode(f.read()).decode() + except FileNotFoundError: + return {"success": False, "text": f"文件不存在: {image_path}"} + except Exception as e: + return {"success": False, "text": f"读取文件失败: {e}"} + + payload = json.dumps({ + "model": OCR_MODEL, + "messages": [{ + "role": "user", + "content": [ + {"type": "text", "text": prompt}, + {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{img_b64}"}} + ] + }], + "max_tokens": 1500, + "temperature": 0.1, + }).encode() + + req = urllib.request.Request(OCR_URL, data=payload, headers={"Content-Type": "application/json"}) + try: + resp = urllib.request.urlopen(req, timeout=60) + data = json.loads(resp.read().decode()) + text = data.get("choices", [{}])[0].get("message", {}).get("content", "") + return {"success": True, "text": text, "raw": data} + except urllib.error.URLError as e: + return {"success": False, "text": f"请求失败: {e}"} + except Exception as e: + return {"success": False, "text": f"解析失败: {e}"} + + +if __name__ == "__main__": + args = sys.argv[1:] + if not args: + print("用法: python3 ocr_client.py <图片路径> [自定义提示]") + sys.exit(1) + + img_path = args[0] + prompt = args[1] if len(args) > 1 else "请详细识别这张图片中的所有文字,包括数字、股票名称、金额等。用中文回复。" + + result = ocr_image(img_path, prompt) + if result["success"]: + print(result["text"]) + else: + print(f"OCR失败: {result['text']}", file=sys.stderr) + sys.exit(1) diff --git a/deploy/profile-scripts/opening_brief.py b/deploy/profile-scripts/opening_brief.py new file mode 100644 index 00000000..c58dd7ca --- /dev/null +++ b/deploy/profile-scripts/opening_brief.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Wrapper script: generates opening brief report. +Hermes cron runner does not support script arguments, so this wrapper +calls generate_report.py with the 'opening_brief' argument. +""" +import sys +import subprocess +from pathlib import Path + +script_dir = Path(__file__).parent.resolve() +target = script_dir / "generate_report.py" + +if not target.exists(): + print(f"ERROR: generate_report.py not found at {target}", file=sys.stderr) + sys.exit(1) + +result = subprocess.run( + [sys.executable, str(target), "opening_brief"], + capture_output=True, text=True, timeout=120, +) + +if result.stdout: + print(result.stdout.strip()) +if result.returncode != 0: + error_msg = f"Script exited with code {result.returncode}" + if result.stderr: + error_msg += f"\nstderr:\n{result.stderr}" + print(error_msg, file=sys.stderr) + sys.exit(result.returncode) +if result.stderr: + print(f"stderr:\n{result.stderr}", file=sys.stderr) diff --git a/deploy/profile-scripts/per_stock_reassess.py b/deploy/profile-scripts/per_stock_reassess.py new file mode 100644 index 00000000..c5c1d172 --- /dev/null +++ b/deploy/profile-scripts/per_stock_reassess.py @@ -0,0 +1,741 @@ +#!/usr/bin/env python3 +""" +per_stock_reassess.py — 按个股触发重评 + +对每只传进来的 code 执行 reassess_with_context(),然后写入 +DB holding_strategies 表(纯DB模式,已移除JSON依赖)。 +""" +import sys, json, os, re +from datetime import datetime + +COOLDOWN_HOURS_TRADING = 1 # 交易时段冷却(1小时) +COOLDOWN_HOURS_NONTRADING = 24 # 非交易时段冷却 + +def _in_cooldown(code): + """检查个股是否在重评冷却期内""" + try: + import sqlite3 + conn = sqlite3.connect("/home/hmo/MoFin/data/mofin.db") + r = conn.execute("SELECT reassessed_at FROM holding_strategies WHERE code=? AND status='active' ORDER BY id DESC LIMIT 1", (code,)).fetchone() + conn.close() + if not r or not r[0]: + return False # 从未重评,立即执行 + last = datetime.fromisoformat(r[0]) + now = datetime.now() + # 交易时段 vs 非交易时段 + if 9 <= now.hour < 15: + hours = COOLDOWN_HOURS_TRADING + else: + hours = COOLDOWN_HOURS_NONTRADING + diff = (now - last).total_seconds() / 3600 + return diff < hours + except: + return False + +sys.path.insert(0, "/home/hmo/web-dashboard") +sys.path.insert(0, "/home/hmo/MoFin") +from strategy_lifecycle import reassess_with_context as reassess_strategy +from mo_data import read_decisions, read_portfolio + + +def _build_full_analysis(code, entry, result): + """从重评结果构建完整九维分析文本""" + if not result: + return "" + lines = [] + name = entry.get("name", code) + price = result.get("price") or entry.get("price", 0) + + tech = result.get("tech_snapshot") or entry.get("tech_snapshot", "") + sector = result.get("sector_context") or entry.get("sector_context", "") + signal = result.get("timing_signal") or entry.get("timing_signal", "") + category = result.get("stock_category") or entry.get("stock_category", "") + + el = result.get("entry_low") or entry.get("entry_low", 0) + eh = result.get("entry_high") or entry.get("entry_high", 0) + sl = result.get("stop_loss") or entry.get("stop_loss", 0) + tp = result.get("take_profit") or entry.get("take_profit", 0) + rr = result.get("rr_ratio") or entry.get("rr_ratio", 0) + act = result.get("action", "") + + # ── 从DB拉取大盘、基本面、资金流 ── + macro_desc = "" + pe_val = pb_val = "" + try: + import sqlite3 as _sq, json as _j + _db = _sq.connect("/home/hmo/MoFin/data/mofin.db") + # 大盘(从structure列读取) + _m = _db.execute("SELECT structure, sector_mood FROM macro_context_log ORDER BY id DESC LIMIT 1").fetchone() + if _m and _m[0]: + _st = _j.loads(_m[0]) + _ix = _st.get("indices", {}) + _desc = _st.get("description", "") + if _ix: + _parts = [] + for _name in ["上证指数", "深证成指", "创业板指", "科创50", "恒生指数"]: + if _name in _ix: + _d = _ix[_name] + if isinstance(_d, dict): + _p = _d.get("price", 0) + _c = _d.get("change_pct", 0) + _parts.append(f"{_name}({_p:.0f},{_c:+.1f}%)") + elif isinstance(_d, (int, float)): + _parts.append(f"{_name}({_d})") + macro_desc = " ".join(_parts) + elif _desc: + macro_desc = _desc + _mood = str(_m[1] or "") + if _mood and not macro_desc: + macro_desc = f"情绪={_mood}" + elif _mood: + macro_desc += f" 情绪={_mood}" + if not macro_desc: + # fallback: 直接用腾讯API拉大盘 + try: + _r2 = __import__('subprocess').run(["curl", "-s", "http://qt.gtimg.cn/q=sh000001,sz399001,sz399006,sh000688"], + capture_output=True, timeout=10) + _txt = _r2.stdout.decode("gbk", errors="ignore") + _parts = [] + for _line in _txt.strip().split("\n"): + if "~" not in _line: continue + _p = _line.split("~") + if len(_p) < 4: continue + _name2 = _p[1] + _price2 = _p[3] + _chg2 = _p[32] if len(_p) > 32 else "0" + _parts.append(f"{_name2}({_price2},{_chg2}%)") + if _parts: + macro_desc = "腾讯实时 " + " ".join(_parts[:3]) + except: + pass + # 基本面+实时价:直接从腾讯API拉(盘后也有收盘价) + try: + _pfx = "sh" if str(code).startswith(("6", "9")) else "sz" + _r3 = __import__('subprocess').run(["curl", "-s", f"http://qt.gtimg.cn/q={_pfx}{code}"], + capture_output=True, timeout=10) + _txt3 = _r3.stdout.decode("gbk", errors="ignore") + _p3 = _txt3.split("~") + if len(_p3) > 45: + _pe = _p3[39] if _p3[39] else "" + _pb = _p3[40] if len(_p3) > 40 and _p3[40] else "" + _mcap = _p3[44] if len(_p3) > 44 and _p3[44] else "" + _price_now = float(_p3[3]) if _p3[3] else 0 + _chg_now = float(_p3[32]) if len(_p3) > 32 and _p3[32] else 0 + if _price_now > 0: + price = _price_now # 覆盖策略中的price=0 + if _pe: pe_val = f"PE={_pe}" + if _pb: pb_val = f"PB={_pb}" + if _mcap: + mcap_val = f"市值{float(_mcap)/10000:.1f}亿" if float(_mcap) > 10000 else f"市值{_mcap}万" + pe_val += f" {mcap_val}" if pe_val else mcap_val + except: + pass + _db.close() + except Exception as _e: + pass + + # ── 从tech_snapshot提取MA和支撑阻力 ── + import re + ma5 = ma10 = ma20 = ma60 = "?" + ma_match = re.search(r'MA5=([\d.]+).*?MA10=([\d.]+).*?MA20=([\d.]+).*?MA60=([\d.]+)', tech) + if ma_match: + ma5, ma10, ma20, ma60 = ma_match.groups() + + lines.append(f"【{name}({code} 九维全析)】") + lines.append("") + if macro_desc: + lines.append(f"① 大盘环境(当日实时):{macro_desc}") + else: + lines.append(f"① 大盘环境(当日实时):数据待刷新") + if pe_val or pb_val: + lines.append(f"② 个股基本面(最新财报):{pe_val} {pb_val}") + else: + lines.append(f"② 个股基本面(最新财报):数据待补充") + lines.append(f"③ 技术面(MA5/10/20/60日 支撑阻力近20日):MA5={ma5} MA10={ma10} MA20={ma20} MA60={ma60}") + if el and eh and price > 0: + pos = "在买入区内" if el <= price <= eh else (f"低于买入区{(1-price/el)*100:.0f}%" if price < el else f"高于买入区{(price/eh-1)*100:.0f}%") + lines.append(f"④ 价格位置:{price} {pos} 区间{el}~{eh}") + else: + lines.append(f"④ 价格位置:数据待刷新") + if sl and tp and rr: + lines.append(f"⑤ 风报比:止损{sl} 止盈{tp} RR={rr:.1f}") + # 支撑阻力 + sr_m = re.search(r'强撑:([\d.]+).*?弱撑:([\d.]+).*?弱压:([\d.]+).*?强压:([\d.]+)', tech) + if sr_m: + lines.append(f"⑥ 支撑阻力:强撑{sr_m.group(1)}→弱撑{sr_m.group(2)}→弱压{sr_m.group(3)}→强压{sr_m.group(4)}") + if sector: + lines.append(f"⑦ 行业背景:{sector}") + else: + # 从stock_sectors表补行业 + try: + _s2 = __import__('sqlite3').connect("/home/hmo/MoFin/data/mofin.db") + _sr = _s2.execute("SELECT sector_name FROM stock_sectors WHERE code=? LIMIT 1", (code,)).fetchone() + if _sr and _sr[0]: + lines.append(f"⑦ 行业背景:{_sr[0]}") + _s2.close() + except: + pass + + # 消息面:从signal_news读最新信号 + news_lines = [] + try: + _n_db = __import__('sqlite3').connect("/home/hmo/MoFin/data/mofin.db") + _nr = _n_db.execute( + "SELECT summary, overall_sentiment, created_at FROM signal_news " + "WHERE (sector LIKE ? OR sector LIKE ?) AND overall_sentiment IN ('利好','利空') " + "ORDER BY id DESC LIMIT 2", + (f'%{code}%', f'%{name[:4]}%') + ).fetchall() + for _ns in _nr: + _sent = _ns[1] + _icon = '📈' if '利好' in str(_sent) else '📉' + news_lines.append(f"{_icon} {_ns[0][:60]} ({str(_ns[2])[:10]})") + _n_db.close() + except: + pass + if category: + lines.append(f"⑧ 分类评级:{category}") + lines.append(f"⑨ 策略信号:{signal}") + if news_lines: + lines.append("") + lines.extend(news_lines) + if act: + lines.append(f"\n策略详情:{act[:200]}") + + return "\n".join(lines) + + +def main(): + codes = [a for a in sys.argv[1:] if not a.startswith("-")] + if not codes: + print("[FULL] 无指定编码,跑全量 regenerate_all()") + from strategy_lifecycle import regenerate_all + regenerate_all(stdout=False) + print("[FULL] 全量重评完成") + return + + # 读现有 decisions + raw = read_decisions() + decisions_map = {d["code"]: d for d in raw.get("decisions", []) if d.get("code")} + + ok = 0 + errors = 0 + skipped = 0 + for code in codes: + # 冷却期检查 + if _in_cooldown(code): + print(f" ⏭ {code}: 冷却期内跳过") + skipped += 1 + continue + entry = decisions_map.get(code) + if not entry: + # 不在 decisions 中的自选股 → 从 holding_strategies 构建entry + import sqlite3 + _db = sqlite3.connect('/home/hmo/MoFin/data/mofin.db') + _db.row_factory = sqlite3.Row + _wl = _db.execute("SELECT * FROM holding_strategies WHERE code=? AND status='active' AND decision_type='自选策略'", (code,)).fetchone() + _db.close() + if _wl: + entry = { + "code": code, + "name": _wl["name"], + "price": _wl["price"] or 0, + "cost": 0, + "shares": 0, + "entry_low": _wl["entry_low"] or 0, + "entry_high": _wl["entry_high"] or 0, + "stop_loss": _wl["stop_loss"] or 0, + "take_profit": 0, + "action": "", + "type": "自选策略", + "is_watchlist": True, + "analysis": json.loads(_wl["analysis_json"]) if _wl["analysis_json"] else {} + } + print(f"[WL] {code} {_wl['name']}: 从自选表构建entry") + if not entry: + print(f"[SKIP] {code}: 不在 decisions 或 watchlist_stocks 中") + errors += 1 + continue + + try: + # Always fetch live price for accurate reassessment + price = 0 + try: + # 价格从 DB 读取(price_monitor 每2分钟更新,唯一价格入口) + code_raw = entry.get("code", "") + price = 0 + import sqlite3 + db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') + db.row_factory = sqlite3.Row + row = db.execute("SELECT price FROM holdings WHERE code=? AND is_active=1", (code_raw,)).fetchone() + if not row: + row = db.execute("SELECT price FROM watchlist_stocks WHERE code=? AND is_active=1", (code_raw,)).fetchone() + if not row: + row = db.execute("SELECT price FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (code_raw,)).fetchone() + if row: + price = row['price'] or 0 + db.close() + if price > 0: + print(f" 实时价: {price} (来自DB)") + else: + # fallback to DB portfolio data + _pf_data = read_portfolio() + for _h in _pf_data.get("holdings", []): + if _h["code"] == code_raw: + price = float(_h.get("price", 0)) + break + if price <= 0: + price = entry.get("current_price") or entry.get("price") or 0 + except Exception as e: + print(f" 价格获取失败: {e}", file=sys.stderr) + price = entry.get("current_price") or entry.get("price") or 0 + + # Price diff debounce: skip reassessment if price changed < 1% since last update + last_price = entry.get("last_reassessed_price") or 0 + if last_price > 0 and price > 0: + diff_pct = abs(price - last_price) / last_price * 100 + if diff_pct < 1.0: + print(f" 价差仅{diff_pct:.2f}% (<1%),跳过重评(上次价={last_price},现价={price})") + skipped += 1 + continue + # 打印参数调试 + if entry is None: + print(f" DEBUG: code={code} ENTRY=NONE 跳过") + print(f" [SKIP] {code} 策略数据不存在") + skipped += 1 + continue + entry_action = str(entry.get('action') or '') + print(f" DEBUG: code={code} name={entry.get('name','')} price={price} cost={entry.get('cost')} shares={entry.get('shares')} action={entry_action[:30]} is_wl={entry.get('type','') in ('自选策略','watchlist')}", flush=True) + result = reassess_strategy( + code=code, + name=entry.get("name", ""), + price=price or 0, + cost=entry.get("cost") or 0, + shares=entry.get("shares") or 0, + current_action=entry.get("action", ""), + is_watchlist=entry.get("type", "") in ("自选策略", "watchlist"), + ) + if result and result.get("action"): + # 持仓股止损不下移(移动止损规则):已有仓位的止损只上不下 + is_held = (entry.get("cost") or 0) > 0 and (entry.get("shares") or 0) > 0 and \ + entry.get("type", "") not in ("自选策略", "watchlist") + old_stop = entry.get("stop_loss") or 0 + new_stop = result.get("stop_loss") or 0 + if is_held and old_stop > 0 and new_stop > 0 and new_stop < old_stop: + print(f" 移动止损保护: {new_stop}→保持{old_stop} (持仓止损不下移)") + result["stop_loss"] = old_stop + # 同时更新 action 字符串中的止损值 + act = result.get("action", "") + if act: + act = re.sub(r'止损[\d.]+', f'止损{old_stop}', act) + result["action"] = act + + # ── 写入 DB holding_strategies 表(替代 decisions.json)── + try: + from mofin_db import get_conn, write_holding_strategy + _conn = get_conn() + _db_entry = { + "code": code, + "name": entry.get("name", ""), + "price": price, + "cost": entry.get("cost", 0), + "shares": entry.get("shares", 0), + "stop_loss": result.get("stop_loss", entry.get("stop_loss")), + "take_profit": result.get("take_profit", entry.get("take_profit")), + "entry_low": result.get("entry_low", entry.get("entry_low")), + "entry_high": result.get("entry_high", entry.get("entry_high")), + "currency": "HKD" if (len(str(code)) == 5 and str(code)[0] in '01') else "CNY", + "strategy_type": "自选策略" if entry.get("type", "") in ("自选策略", "watchlist") else "持仓策略", + "action": result.get("action", ""), + "timing_signal": result.get("timing_signal", entry.get("timing_signal", "")), + "rr_ratio": result.get("rr_ratio", entry.get("rr_ratio", 0)), + "tech_snapshot": result.get("tech_snapshot", entry.get("tech_snapshot", "")), + "stock_category": result.get("stock_category", entry.get("stock_category", "")), + "sector_context": result.get("sector_context", entry.get("sector_context", "")), + "status": result.get("status", "active"), + "source": entry.get("source", "auto"), + "reason": result.get("action_note", ""), + "version": entry.get("version", 1), + "full_analysis": _build_full_analysis(code, entry, result) if result else "", + } + write_holding_strategy(_conn, code, entry.get("name", ""), _db_entry) + _conn.commit() + _conn.close() + # 验证写入 + _fa_check = _db_entry.get("full_analysis", "") + print(f" DEBUG: full_analysis长度={len(_fa_check)} 内容=[{_fa_check[:100]}]") + # 直接用SQL写入full_analysis + try: + _fa_conn = __import__('sqlite3').connect("/home/hmo/MoFin/data/mofin.db") + _fa_conn.execute("UPDATE holding_strategies SET full_analysis=? WHERE code=? AND status='active'", (_fa_check, code)) + _fa_conn.commit() + _fa_conn.close() + print(f" ✅ full_analysis直接SQL写入成功") + except Exception as _fa_e: + print(f" ⚠️ 直接SQL写入失败: {_fa_e}") + _v = __import__('sqlite3').connect(str(__import__('pathlib').Path("/home/hmo/MoFin/data/mofin.db"))) + _fa = _v.execute("SELECT full_analysis FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + if _fa and _fa[0]: print(f" ✅ full_analysis已写入({len(_fa[0])}字)") + else: print(f" ⚠️ full_analysis为空") + _v.close() + # LLM生成完整九维分析 + _macro_desc = "" + _pe_val = "" + _pb_val = "" + try: + _mdb = __import__('sqlite3').connect("/home/hmo/MoFin/data/mofin.db") + _mr = _mdb.execute("SELECT structure FROM macro_context_log ORDER BY id DESC LIMIT 1").fetchone() + if _mr and _mr[0]: + _st = __import__('json').loads(_mr[0]) + _macro_desc = _st.get("description", "") + _mood = _mr[1] if len(_mr) > 1 else "" + if _mood: _macro_desc += f" 情绪={_mood}" + # 基本面从腾讯API + _p = "sh" if str(code).startswith(("6","9")) else "sz" + _cr = __import__('subprocess').run(["curl","-s",f"http://qt.gtimg.cn/q={_p}{code}"], capture_output=True, timeout=10) + _ct = _cr.stdout.decode("gbk", errors="ignore").split("~") + if len(_ct) > 39 and _ct[39]: _pe_val = f"PE={_ct[39]}" + if len(_ct) > 44 and _ct[44]: _pb_val = f"PB≈{float(_ct[44])/10000:.1f}亿" + _mdb.close() + except: + pass + + # 拉取资金流数据 + _flow_note = "暂无资金流数据" + try: + _fdb = __import__('sqlite3').connect("/home/hmo/MoFin/data/mofin.db") + _fr = _fdb.execute("SELECT cache_json FROM capital_flow_cache ORDER BY id DESC LIMIT 1").fetchone() + if _fr and _fr[0]: + _fc = __import__('json').loads(_fr[0]) + _s = _fc.get("stocks", {}).get(code, {}) + if _s and _s.get("analysis"): + _a = _s["analysis"] + _flow_note = f"净流入{_a.get('net_flow',0):.0f}万 主力{_a.get('main_force',0):.0f}万 趋势{_a.get('trend','中性')}" + _fdb.close() + except: + pass + + # 拉取近期消息面 + _news_note = "暂无近期消息" + try: + _ndb = __import__('sqlite3').connect("/home/hmo/MoFin/data/mofin.db") + _nr2 = _ndb.execute( + "SELECT summary, overall_sentiment, created_at FROM signal_news " + "WHERE (code=? OR sector LIKE ?) AND overall_sentiment IN ('利好','利空') " + "ORDER BY id DESC LIMIT 3", + (code, f'%{entry.get("name","")[:4]}%') + ).fetchall() + if _nr2: + _news_note = " | ".join([f"{r[2][:10]} {r[1]} {r[0][:40]}" for r in _nr2]) + _ndb.close() + except: + pass + + _prompt = f"""你是一个资深股票分析师。请对股票{code}做一个完整的12维矩阵分析(3横×4纵:大盘/行业/个股 × 基本面/消息面/技术面/资金面)。 + +⚠️ 重要:12个维度必须交叉对比,找出矛盾/共振点,给出综合判断。 + +当前数据(实时API,每条标注时间窗口,禁止使用模型训练数据): +大盘={_macro_desc or "震荡"}(当日实时) | PE/市值={_pe_val} {_pb_val}(最新财报) | 价格={price} 区间={entry.get("entry_low",0)}~{entry.get("entry_high",0)} 止损={entry.get("stop_loss",0)} 止盈={entry.get("take_profit",0)} RR={result.get("rr_ratio",entry.get("rr_ratio",0))} | 信号={result.get("timing_signal") or entry.get("timing_signal","")} | 行业={(result.get("sector_context") or entry.get("sector_context",""))[:50]}(当日实时) +策略={(result.get("action") or entry.get("action",""))[:200]} +技术={(result.get("tech_snapshot") or entry.get("tech_snapshot",""))[:200]}(MA=5/10/20/60日 支撑阻力=近20日 量价=当日+近5日趋势) +资金流={_flow_note}(近5日累计) +消息面={_news_note}(最近3条,自动标注抓取时间) + +格式: +【交叉分析】哪些维度矛盾/共振,关键信号 +① 大盘×基本面 ② 大盘×消息面 ③ 大盘×技术面 ④ 大盘×资金面 +⑤ 行业×基本面 ⑥ 行业×消息面 ⑦ 行业×技术面 ⑧ 行业×资金面 +⑨ 个股×基本面 ⑩ 个股×消息面 ⑪ 个股×技术面 ⑫ 个股×资金面 + +最后必须输出: +【综合结论】(买入/关注/观望/卖出) +【操作建议】 +【建议止损】 +【建议止盈】""" + try: + _ur = __import__('urllib.request', fromlist=['Request']) + _req = _ur.Request("http://127.0.0.1:8643/v1/chat/completions", + data=__import__('json').dumps({"model":"deepseek-v4-flash","messages":[{"role":"user","content":_prompt}],"max_tokens":1024}).encode(), + headers={"Content-Type":"application/json","Authorization":"Bearer hermes123"}) + _resp = _ur.build_opener(_ur.ProxyHandler({})).open(_req, timeout=300) + _llm_out = __import__('json').loads(_resp.read().decode())["choices"][0]["message"]["content"] + _full_analysis_text = _llm_out + print(f" ✅ LLM12维分析完成({len(_full_analysis_text)}字)", flush=True) + except Exception as _e: + print(f" ❌ LLM12维分析失败: {_e}", file=__import__('sys').stderr) + _full_analysis_text = None + + # 保存到DB + _fa_conn = __import__('sqlite3').connect("/home/hmo/MoFin/data/mofin.db") + _fa_conn.execute("UPDATE holding_strategies SET full_analysis=?, reassessed_at=? WHERE code=? AND status='active'", (_full_analysis_text, __import__('datetime').datetime.now().isoformat(), code)) + _fa_conn.commit() + _fa_conn.close() + print(f" ✅ 完整12维分析已保存({len(_full_analysis_text)}字)" if _full_analysis_text else f" ⚠️ 12维分析未完成,跳过保存") + print(f" [DB] holding_strategies 已更新: {code}") + # 从LLM输出提取信号 + if _full_analysis_text and '【综合结论】' in _full_analysis_text: + try: + _sig_line = [l for l in _full_analysis_text.split('\n') if '综合结论' in l] + if _sig_line: + _sig = '买入' if '买入' in _sig_line[0] else '关注' if '关注' in _sig_line[0] else '观望' if '观望' in _sig_line[0] else '卖出' if '卖出' in _sig_line[0] else '' + if _sig: + __import__('sqlite3').connect('/home/hmo/MoFin/data/mofin.db').execute( + "UPDATE holding_strategies SET timing_signal=? WHERE code=? AND status='active'", (_sig, code)).connection.commit() + print(f" ✅ LLM信号={_sig} 已写入") + # 买入信号→推XMPP + if _sig == "买入": + try: + _nr2 = __import__('sqlite3').connect('/home/hmo/MoFin/data/mofin.db').execute( + "SELECT name, price, entry_low, entry_high, stop_loss, take_profit, position_advice FROM holding_strategies WHERE code=? AND status='active'", (code,)).fetchone() + if _nr2: + _xm = f"📈 {_nr2[0] or code}({code}) 价{_nr2[1]}→12维买入信号!区间{_nr2[2]}~{_nr2[3]} 损{_nr2[4]} 盈{_nr2[5]} 仓位{_nr2[6] or '-'}" + _xr = __import__('urllib.request').Request("http://127.0.0.1:5805/", + data=__import__('json').dumps({"body": _xm, "to": "hmo@yoin.fun", "type": "chat"}).encode(), + headers={"Content-Type": "application/json"}) + __import__('urllib.request').urlopen(_xr, timeout=5) + print(f" 📨 XMPP推送买入信号") + except: pass + except: pass + # 冷却期已更新(reassessed_at写入) + except Exception as _dbe: + print(f" [DB FAIL] holding_strategies 写入失败: {_dbe}", file=sys.stderr) + + # 更新 decisions_map 中对应的条目 + updated = entry.copy() + # 币种标记:HK股保留HKD原始值,A股为CNY + is_hk = len(str(code)) == 5 and str(code)[0] in '01' + updated.update({ + "action": result["action"], + "stop_loss": result.get("stop_loss", entry.get("stop_loss")), + "entry_low": result.get("entry_low", entry.get("entry_low")), + "entry_high": result.get("entry_high", entry.get("entry_high")), + "take_profit": result.get("take_profit"), + "tech_snapshot": result.get("tech_snapshot", entry.get("tech_snapshot")), + "timing_signal": result.get("timing_signal", entry.get("timing_signal")), + "rr_ratio": result.get("rr_ratio", entry.get("rr_ratio", 0)), + "status": result.get("status", "updated"), + "price": price, + "currency": "HKD" if is_hk else "CNY", + }) + # Save last reassessed price for debounce tracking + updated["last_reassessed_price"] = price + decisions_map[code] = updated + # ——— 初始化多分支策略树 ——— + try: + sys.path.insert(0, '/home/hmo/MoFin') + from strategy_tree import init_default_branches + branches = init_default_branches( + code, + entry.get('name', ''), + result.get('entry_low', 0), + result.get('entry_high', 0), + result.get('stop_loss', 0), + result.get('take_profit', 0), + ) + st = updated.setdefault('strategy_tree', {}) + st['branches'] = branches + except Exception: + pass + print(f"[OK] {code} {entry.get('name','')}: {result['action'][:80]}") + ok += 1 + else: + print(f"[SYNCED] {code}: 无变更") + ok += 1 + except Exception as e: + print(f"[ERROR] {code}: {e}", file=sys.stderr) + import traceback + traceback.print_exc(file=sys.stderr) + errors += 1 + + # 同步自选股更新回 watchlist_stocks 表(持仓策略已通过 write_holding_strategy 写入 DB) + try: + from datetime import datetime as _dt + import sqlite3 + _db2 = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') + for _code in codes: + _entry = decisions_map.get(_code) + if _entry and _entry.get("is_watchlist"): + _db2.execute(""" + UPDATE watchlist_stocks + SET entry_low=?, entry_high=?, stop_loss=?, price=?, + analysis_json=json(?) + WHERE code=? AND is_active=1 + """, ( + _entry.get("entry_low", 0), + _entry.get("entry_high", 0), + _entry.get("stop_loss", 0), + _entry.get("price", 0), + json.dumps({ + "action": _entry.get("action",""), + "take_profit": _entry.get("take_profit", 0), + "stop_loss": _entry.get("stop_loss", 0), + "tech_snapshot": _entry.get("tech_snapshot", ""), + "rr": _entry.get("rr_ratio", 0), + "reassessed_at": _dt.now().strftime("%Y-%m-%d") + }, ensure_ascii=False), + _code + )) + _db2.commit() + _db2.close() + if any(e.get("is_watchlist") for e in [decisions_map.get(c) for c in codes] if e): + print("[SYNC] 自选股策略已同步回 watchlist_stocks 表") + except Exception as e: + print(f"[SYNC FAIL] watchlist_stocks 同步失败: {e}", file=sys.stderr) + + print(f"[DONE] {ok}成功 {skipped}跳过 {errors}失败") + + # ── 第二步:扫描自选股(watchlist),价格偏离买入区>20%触发重评 ── + scan_watchlist_stocks() + + +# ════════════════════════════════════════════════════════════════════ +# 自选股扫描 +# ════════════════════════════════════════════════════════════════════ + +def scan_watchlist_stocks(): + """扫描自选股表 (watchlist_stocks),对价格偏离买入区 >20% 的股票自动重评。 + + 偏离公式: max(|price - entry_low|, |price - entry_high|) / entry_low * 100 > 20 + + 通过 technical_analysis.full_analysis() 获取最新支撑/阻力位, + 更新 entry_low / entry_high / stop_loss / price / analysis_json。 + 每轮最多处理 3 只,超过时标记剩余数量待下次扫描。 + """ + import sqlite3, json + from datetime import datetime + from technical_analysis import full_analysis + from mo_models import is_hk_stock + + DB = '/home/hmo/web-dashboard/data/mofin.db' + db = sqlite3.connect(DB) + db.row_factory = sqlite3.Row + + rows = db.execute( + "SELECT * FROM watchlist_stocks WHERE is_active=1" + ).fetchall() + + if not rows: + print("[WL-SCAN] 自选股表为空,跳过") + db.close() + return + + # ── 筛选偏离 >20% 的股票 ── + candidates = [] # (code, name, price, entry_low, entry_high, stop_loss, deviation, analysis_json) + for r in rows: + code = r["code"] + name = r["name"] + price = r["price"] or 0 + entry_low = r["entry_low"] or 0 + entry_high = r["entry_high"] or 0 + stop_loss = r["stop_loss"] or 0 + analysis_json = r["analysis_json"] + + if entry_low <= 0 or price <= 0: + continue + + dev_low = abs(price - entry_low) + dev_high = abs(price - entry_high) + deviation = max(dev_low, dev_high) / entry_low * 100 + + if deviation > 20: + candidates.append((code, name, price, entry_low, entry_high, stop_loss, deviation, analysis_json)) + + total_needed = len(candidates) + print(f"[WL-SCAN] 自选股共{len(rows)}只,偏离>20%需重评: {total_needed}只") + + MAX_PER_RUN = 3 + to_process = candidates[:MAX_PER_RUN] + remaining = max(0, total_needed - MAX_PER_RUN) + + if remaining > 0: + print(f"[WL-SCAN] 本轮限{MAX_PER_RUN}只,剩余{remaining}只待下次扫描") + + if not to_process: + print("[WL-SCAN] 无需重评") + db.close() + return + + ok = 0 + errors = 0 + for code, name, price, old_low, old_high, old_stop, deviation, old_analysis_json in to_process: + print(f"[WL-REASSESS] {code} {name}: 偏离{deviation:.1f}%,触发重评") + try: + ta = full_analysis(code) + if not ta or "error" in ta: + print(f" [WARN] TA失败: {ta}") + errors += 1 + continue + + sr = ta.get("support_resistance", {}) + if "error" in sr: + print(f" [WARN] 支撑/阻力计算失败: {sr}") + errors += 1 + continue + + new_price = ta.get("quote", {}).get("price", price) + new_entry_low = round(sr.get("weak_support", old_low), 2) + new_entry_high = round(sr.get("weak_resist", old_high), 2) + new_stop_loss = round(sr.get("strong_support", old_stop), 2) + new_take_profit = round(sr.get("strong_resist", 0), 2) + + # ── 更新 analysis_json + changelog ── + old_analysis = json.loads(old_analysis_json) if old_analysis_json else {} + changelog = old_analysis.get("changelog", []) + changelog.append({ + "action": "auto_reassess_watchlist", + "reason": f"价格偏离买入区{deviation:.1f}%", + "old_entry_low": old_low, + "old_entry_high": old_high, + "new_entry_low": new_entry_low, + "new_entry_high": new_entry_high, + "old_stop_loss": old_stop, + "new_stop_loss": new_stop_loss, + "take_profit": new_take_profit, + "price": new_price, + "deviation_pct": round(deviation, 1), + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"), + }) + + new_analysis = { + **old_analysis, + "take_profit": new_take_profit, + "tech_snapshot": { + "support_resistance": sr, + "candlestick": ta.get("candlestick", {}), + "volume": ta.get("volume", {}), + "analyzed_at": ta.get("analyzed_at", ""), + }, + "reassessed_at": datetime.now().strftime("%Y-%m-%d"), + "changelog": changelog, + } + + currency = "HKD" if is_hk_stock(code) else "CNY" + + db.execute(""" + UPDATE watchlist_stocks + SET entry_low=?, entry_high=?, stop_loss=?, price=?, + currency=?, analysis_json=? + WHERE code=? AND is_active=1 + """, ( + new_entry_low, new_entry_high, new_stop_loss, + new_price, currency, json.dumps(new_analysis, ensure_ascii=False), + code, + )) + db.commit() + print(f" [OK] {code} {name}: 买入区{old_low}-{old_high} -> {new_entry_low}-{new_entry_high}, " + f"止损{new_stop_loss}, 止盈{new_take_profit}") + ok += 1 + except Exception as e: + import traceback + print(f" [ERROR] {code}: {e}", file=sys.stderr) + traceback.print_exc(file=sys.stderr) + errors += 1 + + db.close() + remaining_msg = f" (剩余{remaining}只)" if remaining else "" + print(f"[WL-SCAN] DONE: {ok}成功 {errors}失败{remaining_msg}") + + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/pre-flight-check.py b/deploy/profile-scripts/pre-flight-check.py new file mode 100644 index 00000000..e0750e0f --- /dev/null +++ b/deploy/profile-scripts/pre-flight-check.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +""" +pre-flight-check.py — 策略上线前检查清单的自动化部分 + +检查三项可自动验证的关卡,输出结构化结果供知微在报告中引用。 +只检查,不拦报告。不过的项目标记 @preflight:fail。 + +用法: + python3 pre-flight-check.py [code] + 不传 code → 校验版本一致性 + 数据时效 + 全量持仓成本检查 + 传 code → 只检查单只股票(止损技术位 + R/R + 成本) + +依赖: + - /home/hmo/projects/MoFin/data/prompts/registry.json(版本一致性) + - mofin.db: holding_strategies 表(策略+成本) + - mofin.db: holdings + holding_strategies 表(持仓+策略数据) + +输出格式: + ✅ 项目名 — 通过 + ❌ 项目名 — 失败原因 + ⚠️ 项目名 — 警告 +""" +import json +import sys +from pathlib import Path +from datetime import datetime, timezone + +from mo_data import read_decisions + +# === 路径 === +REGISTRY = Path("/home/hmo/projects/MoFin/data/prompts/registry.json") + +# === 检查 7:版本一致性 === +def check_version_consistency(): + """analysis-rules.depends_on.version == strategy-generation.current_version""" + try: + reg = json.loads(REGISTRY.read_text()) + except Exception as e: + return ("⚠️ 版本一致性", f"无法读取 registry.json: {e}") + + sg = None + ar = None + for p in reg["prompts"]: + if p["id"] == "strategy-generation": + sg = p + if p["id"] == "analysis-rules": + ar = p + + if not sg or not ar: + return ("⚠️ 版本一致性", "registry.json 中缺少 strategy-generation 或 analysis-rules") + + sg_ver = sg["current_version"] + ar_dep_ver = ar.get("depends_on", {}).get("version", "none") + + if sg_ver == ar_dep_ver: + return ("✅ 版本一致性", f"strategy-generation@{sg_ver} == analysis-rules.depends_on@{ar_dep_ver}") + else: + return ("❌ 版本一致性", + f"strategy-generation@{sg_ver} != analysis-rules.depends_on@{ar_dep_ver}。" + f"运行 check-prompt-deps.py 修复") + + +# === 检查 1:数据时效 === +def check_data_freshness(): + """检查 holding_strategies 表的最新更新时间""" + from datetime import datetime, timezone + try: + import sqlite3 + c = sqlite3.connect(str(WEB_DATA / "mofin.db")) + row = c.execute("SELECT MAX(updated_at) FROM holding_strategies WHERE status IN ('active','updated')").fetchone() + c.close() + if row and row[0]: + mtime_dt = datetime.strptime(row[0][:19], '%Y-%m-%d %H:%M:%S') + age_minutes = (datetime.now() - mtime_dt).total_seconds() / 60 + else: + return ("⚠️ 数据时效", "策略表无有效数据") + except Exception as e: + return ("⚠️ 数据时效", f"无法检查策略表: {e}") + + if age_minutes < 60: + return ("✅ 数据时效", + f"策略数据 更新于 {age_minutes:.0f} 分钟前 ({mtime_dt.strftime('%H:%M')})") + elif age_minutes < 240: + return ("⚠️ 数据时效", + f"策略数据 已 {age_minutes:.0f} 分钟未更新(可能已收盘),数据视为陈旧") + else: + return ("❌ 数据时效", + f"策略数据 已 {age_minutes:.0f} 分钟未更新,数据过期,请检查数据管线") + + +# === 检查 5:成本有效 === +def check_cost_validity(): + """检查所有持仓的成本是否有效""" + try: + dec = read_decisions() + except Exception as e: + return ("⚠️ 成本有效性", f"无法读取策略数据: {e}") + + stocks = dec.get("stocks", dec.get("holdings", dec.get("strategies", []))) + if not stocks: + stocks = dec.get("portfolio", []) + + issues = [] + ok = 0 + total = 0 + + for s in stocks: + # 支持 stocks[] 和 strategies[] 两种结构 + cost = s.get("cost") or s.get("actual", {}).get("cost", None) + code = s.get("code") or s.get("symbol", "?") + name = s.get("name", "") + + if cost is None or cost == 0: + issues.append(f"{code} {name}: cost={'null' if cost is None else cost}") + else: + ok += 1 + total += 1 + + if not issues: + return ("✅ 成本有效性", f"全部 {total} 条持仓成本有效") + else: + return ("⚠️ 成本有效性", + f"{len(issues)}/{total} 条持仓成本缺失:{','.join(issues[:5])}" + f"{'...' if len(issues) > 5 else ''}") + + +# === 检查 3(单股):止损技术依据 === +def check_stop_technical(code): + """检查单只股票的止损是否基于技术位(仅对单股模式生效)""" + try: + dec = read_decisions() + except Exception as e: + return ("⚠️ 止损技术位", f"无法读取策略数据: {e}") + + stocks = dec.get("stocks", dec.get("strategies", [])) + for s in stocks: + c = s.get("code", "") + if c.replace(".", "").replace("SZ", "").replace("SH", "").replace("HK", "") == code.replace(".", "").replace("SZ", "").replace("SH", "").replace("HK", ""): + stop = s.get("stop_loss", s.get("actual", {}).get("stop_loss")) + buy_zone = s.get("entry_low", s.get("buy_zone", [None, None])) + if isinstance(buy_zone, list): + entry_low = buy_zone[0] + else: + entry_low = buy_zone + + if stop and entry_low and stop < entry_low * 0.95: + return ("⚠️ 止损技术位", + f"{code} 止损{stop} < 买入区下沿{entry_low}×0.95," + f"检查是否用了固定百分比而非技术位") + return ("✅ 止损技术位", f"{code} 止损{stop},需要人工确认是否基于支撑位") + + return ("⚠️ 止损技术位", f"代码 {code} 未在策略数据中找到") + + +# === 检查 4(单股):R/R 达标 === +def check_rr(code, price=None): + """检查单只股票的 R/R 是否达标""" + try: + dec = read_decisions() + except Exception as e: + return ("⚠️ R/R 达标", f"无法读取策略数据: {e}") + + stocks = dec.get("stocks", dec.get("strategies", [])) + for s in stocks: + c = s.get("code", "") + if c.replace(".", "").replace("SZ", "").replace("SH", "").replace("HK", "") == code.replace(".", "").replace("SZ", "").replace("SH", "").replace("HK", ""): + stop = s.get("stop_loss", s.get("actual", {}).get("stop_loss")) + target = s.get("take_profit", s.get("actual", {}).get("take_profit")) + p = price or s.get("price", s.get("current_price")) + + if not all([stop, target, p]): + return ("⚠️ R/R 达标", f"{code} 缺少止损/止盈/现价之一") + + risk = p - stop + reward = target - p + if risk <= 0: + return ("❌ R/R 达标", f"{code} 现价{p}已低于止损{stop},无R/R可言") + + rr = reward / risk + cost = s.get("cost", s.get("actual", {}).get("cost")) + is_new = (cost is None or cost == 0) + + min_rr = 2.5 if is_new else 2.0 # 简化处理,弱阻距检测需额外输入 + if rr >= min_rr: + return ("✅ R/R 达标", f"{code} R/R={rr:.2f} >= {min_rr}") + else: + return ("❌ R/R 达标", f"{code} R/R={rr:.2f} < {min_rr}") + + return ("⚠️ R/R 达标", f"代码 {code} 未在策略数据中找到") + + +def main(): + print("「策略上线前检查」自动化部分") + print(f"检查时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}") + print() + + if len(sys.argv) > 1: + # 单股模式 + code = sys.argv[1] + price = float(sys.argv[2]) if len(sys.argv) > 2 else None + results = [ + check_stop_technical(code), + check_rr(code, price), + ] + else: + # 全量模式 + results = [ + check_version_consistency(), + check_data_freshness(), + check_cost_validity(), + ] + + for tag, msg in results: + print(f" {tag} — {msg}") + + # 汇总 + fails = sum(1 for tag, _ in results if tag.startswith("❌")) + warns = sum(1 for tag, _ in results if tag.startswith("⚠️")) + total = len(results) + + print() + if fails == 0 and warns == 0: + print(f" ✅ {total}/{total} 项通过") + elif fails == 0: + print(f" ⚠️ {warns} 项警告,{total - warns} 项通过") + else: + print(f" ❌ {fails} 项失败,{warns} 项警告,{total - fails - warns} 项通过") + + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/preflight_verify.py b/deploy/profile-scripts/preflight_verify.py new file mode 100644 index 00000000..496009b6 --- /dev/null +++ b/deploy/profile-scripts/preflight_verify.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""preflight_verify.py — 开盘前钉对钉验证 + +在 morning_health_check 之后运行(8:30),专测今天踩过的坑: + 1. MoFin ↔ cron 脚本同步 + 2. 关键脚本可正常导入(修 import 遗漏) + 3. 关键函数可正常调用(修 NameError / 参数不匹配) + 4. DB 现金/资产公式一致性 + 5. 关键 no_agent 脚本可执行(exit=0) + +输出格式:PASS/FAIL 逐项。任意 FAIL → 写 TODO + 推报警。 +""" +import sys, os, json, sqlite3, subprocess +from pathlib import Path +from datetime import datetime + +MOFIN = Path("/home/hmo/MoFin") +DATA = MOFIN / "data" +DB_PATH = DATA / "mofin.db" +PROFILE_SCRIPTS = Path("/home/hmo/.hermes/profiles/position-analyst/scripts") + +results = {"pass": 0, "fail": 0, "items": []} +def check(name, ok, detail=""): + results["items"].append({"name": name, "ok": bool(ok), "detail": detail}) + if ok: + results["pass"] += 1 + print(f" ✅ {name}") + else: + results["fail"] += 1 + print(f" ❌ {name}") + if detail: + print(f" {detail}") + +# ── 1. 同步检查 ── +def check_sync(): + r = subprocess.run(["bash", str(MOFIN/"scripts/deploy_sync.sh"), "--check"], + capture_output=True, text=True, timeout=15) + ok = "一致" in r.stdout + check("脚本同步: MoFin↔cron目录", ok, r.stdout.strip()) + +# ── 2. 关键脚本导入检查 ── +SCRIPTS = [ + "price_monitor", "strategy_review", "collect_evaluation_data", + "data_governance", "market_screener", "stock_quote", + "strategy_evaluator", "mo_data", "mofin_db", +] +def check_imports(): + for name in SCRIPTS: + path = PROFILE_SCRIPTS / f"{name}.py" + if not path.exists(): + check(f"导入检查: {name}", False, f"文件不存在: {path}") + continue + # 检查是否有明显的语法/依赖错误 + r = subprocess.run([sys.executable, "-c", f"import sys; sys.path.insert(0, '{PROFILE_SCRIPTS}'); import {name}; print('ok')"], + capture_output=True, text=True, timeout=10) + ok = r.returncode == 0 + err = r.stderr.strip()[:200] if not ok else "" + check(f"导入检查: {name}", ok, err) + +# ── 3. DB 完整性检查 ── +def check_db(): + if not DB_PATH.exists(): + check("DB 文件存在", False, f"找不到 {DB_PATH}") + return + try: + conn = sqlite3.connect(str(DB_PATH)) + pragma = conn.execute("PRAGMA integrity_check").fetchone()[0] + check("DB 完整性", pragma == "ok", pragma) + + # 检查核心表是否存在 + core_tables = ["portfolio_summary", "holdings", "holding_strategies", "cash_log", "live_prices"] + missing = [] + for t in core_tables: + r = conn.execute(f"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='{t}'").fetchone()[0] + if r == 0: missing.append(t) + check("核心表完整", len(missing)==0, f"缺失: {missing}" if missing else "") + + # 现金/资产公式检查 + try: + ps = conn.execute("SELECT id, cash, frozen_cash, total_mv, total_assets, updated_at FROM portfolio_summary ORDER BY id DESC LIMIT 1").fetchone() + if ps: + # total_assets = total_mv + cash + frozen_cash + expected = ps[3] + ps[1] + ps[2] if ps[3] else 0 + actual = ps[4] + if expected > 0 and actual: + diff = abs(actual - expected) + ok = diff < 1.0 # 最多差1元 + check(f"资产公式: total_mv({ps[3]:.0f})+cash({ps[1]:.0f})+frozen({ps[2]:.0f})={expected:.0f} vs total_assets({actual:.0f})", + ok, f"差异{diff:.2f}") + else: + check("资产公式验证", False, f"total_mv或total_assets为空: mv={ps[3]}, assets={ps[4]}") + else: + check("资产公式验证", False, "portfolio_summary无数据") + except Exception as e: + check("资产公式验证", False, str(e)) + + conn.close() + except Exception as e: + check("DB 检查异常", False, str(e)) + +# ── 4. 关键 no_agent 脚本快速执行检查 ── +SMOKE_SCRIPTS = [ + ("stock_quote.py", ["--code", "000001", "--once"]), + ("strategy_review.py", None), # 不加参数运行 +] +def check_smoke(): + for name, args in SMOKE_SCRIPTS: + cmd = [sys.executable, str(PROFILE_SCRIPTS / name)] + if args: cmd.extend(args) + r = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + ok = r.returncode == 0 + err = r.stderr.strip()[:200] if not ok else "" + out = r.stdout.strip()[:100] if ok else "" + check(f"冒烟测试: {name}", ok, err or out) + +# ── 5. 关键cron时间戳检查 ── +def check_cron_timestamps(): + cron_path = Path("/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json") + if not cron_path.exists(): + check("cron时间戳", False, "jobs.json不存在") + return + with open(cron_path) as f: + jobs = json.load(f).get("jobs", []) + now = datetime.now() + stale_count = 0 + for j in jobs: + if not j.get("enabled", True): + continue + name = j.get("name", "?") + last = j.get("last_run_at", "") + status = j.get("last_status", "") + if not last or not status: + continue + # 非周任务检查是否在48小时内跑过 + try: + last_dt = datetime.fromisoformat(last) + days_gone = (now - last_dt).total_seconds() / 86400 + # 周任务可接受7天,日任务可接受2天 + limit = 7 if any(k in name for k in ["每周", "周末", "周六"]) else 2 + if days_gone > limit and status != "ok": + stale_count += 1 + except: + pass + check("cron过期检查", stale_count == 0, f"{stale_count}个cron长时间未正常运行" if stale_count else "") + +# ── 7. 提示词一致性检查 ── +def check_prompt_sync(): + """检查注册的cron prompt版本与jobs.json实际运行的是否一致""" + try: + reg_path = "/home/hmo/projects/MoFin/data/prompts/registry.json" + cron_path = "/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json" + if not os.path.exists(reg_path) or not os.path.exists(cron_path): + check("提示词一致性", False, "registry或jobs.json不存在") + return + with open(reg_path) as f: + reg = json.load(f) + with open(cron_path) as f: + crons = json.load(f).get("jobs", []) + # 建立cron prompt索引 + cron_prompts = {} + for j in crons: + name = j.get("name", "") + pid = name.replace(" ","-").replace("(","-").replace(")","").replace("(","-").replace(")","") + if j.get("prompt"): + cron_prompts[pid] = {"name": name, "prompt": j["prompt"]} + # 逐个检查注册的版本文件是否与cron一致 + drift = 0 + for p in reg.get("prompts", []): + pid = p["id"] + if pid not in cron_prompts: + continue + cv = p.get("current_version", "v1") + # 找版本文件路径 + content_path = "" + for v in p.get("versions", []): + if v.get("version") == cv: + content_path = v.get("content_path", "") + break + if not content_path or not os.path.exists(content_path): + drift += 1 + continue + with open(content_path) as f: + registered = f.read() + actual = cron_prompts[pid]["prompt"] + if registered != actual: + drift += 1 + check("提示词一致性: 注册版≈运行版", drift == 0, + f"{drift}个prompt不一致" if drift else "") + except Exception as e: + check("提示词一致性", False, str(e)) +def check_db_locks(): + """检查最近24小时是否有 database is locked 错误""" + try: + import subprocess + r = subprocess.run( + ["sudo", "journalctl", "-u", "xmpp-zhiwei", "--since", "24 hours ago", "--no-pager"], + capture_output=True, text=True, timeout=30 + ) + locked_lines = [l for l in r.stdout.split("\n") if "database is locked" in l.lower()] + check("DB死锁: 24h内lock错误", len(locked_lines) == 0, + f"发现{len(locked_lines)}次: {locked_lines[0][:120]}" if locked_lines else "") + except Exception as e: + check("DB死锁检查", False, str(e)) + +if __name__ == "__main__": + print("🔍 MoFin 开盘前验证", datetime.now().strftime("%m-%d %H:%M")) + print("=" * 40) + check_sync() + check_imports() + check_db() + check_smoke() + check_cron_timestamps() + check_db_locks() + check_prompt_sync() + + # 汇总 + print() + total = results["pass"] + results["fail"] + status = "✅ 全部通过" if results["fail"] == 0 else f"❌ {results['fail']}/{total} 项失败" + summary = { + "timestamp": datetime.now().isoformat(), + "total": total, + "pass": results["pass"], + "fail": results["fail"], + "status": "pass" if results["fail"] == 0 else "fail", + "items": results["items"], + } + summary_path = DATA / "preflight_result.json" + with open(summary_path, "w") as f: + json.dump(summary, f, ensure_ascii=False, indent=2) + print(f"{status} | {summary_path}") + + # FAIL 项写 TODO + if results["fail"] > 0: + failures = [i["name"] for i in results["items"] if not i["ok"]] + todo_text = f"开盘前验证发现 {results['fail']} 项失败: {'; '.join(failures)}" + print(f"\n⚠️ 已记录TODO: {todo_text}") + # 尝试写mofin.db todos + try: + conn = sqlite3.connect(str(DB_PATH)) + conn.execute(""" + INSERT OR REPLACE INTO todos (id, content, status, source, created_at, fix_action) + VALUES (?, ?, 'pending', 'preflight', datetime('now'), 'check preflight_result.json and fix issues') + """, (f"preflight-{datetime.now().strftime('%Y%m%d')}", todo_text)) + conn.commit() + conn.close() + except: + pass + + sys.exit(0 if results["fail"] == 0 else 1) diff --git a/deploy/profile-scripts/premarket_full_review.py b/deploy/profile-scripts/premarket_full_review.py new file mode 100644 index 00000000..1cebcd1c --- /dev/null +++ b/deploy/profile-scripts/premarket_full_review.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""premarket_full_review.py — 盘前全量重评 + +执行顺序: +1. regenerate_all() 全量技术分析重评(持仓+自选) +2. watchlist_auto_exit() 自选退出检查 +3. 输出摘要 + +调度:交易日 08:10(A股09:30开盘) +""" +import sys, os, json +sys.path.insert(0, '/home/hmo/MoFin') + +# Step 1: 全量重评 +print("=" * 50) +print("📊 盘前全量重评开始") +print("=" * 50) +from strategy_lifecycle import regenerate_all +result = regenerate_all(stdout=True) +print(f"\n重评完成: {result.get('ok',0)}/{result.get('total',0)}成功") + +# Step 2: 自选退出 +print("\n" + "=" * 50) +print("🔍 自选退出检查") +print("=" * 50) +from scripts.watchlist_auto_exit import main as auto_exit +exited = auto_exit(dry_run=False) + +# Step 3: 写入摘要供开盘简报引用 +summary = { + "premarket_at": __import__('datetime').datetime.now().isoformat(), + "reassess": result, + "auto_exit": [{"code": c, "name": n, "reason": r} for c, n, s, r in exited], + "total_kept": result.get('total', 0) - len(exited), +} +os.makedirs("/tmp/mofin_premarket", exist_ok=True) +with open("/tmp/mofin_premarket/summary.json", "w") as f: + json.dump(summary, f, ensure_ascii=False, indent=2) + +print(f"\n✅ 盘前重评完毕") diff --git a/deploy/profile-scripts/prepare_recommendation.py b/deploy/profile-scripts/prepare_recommendation.py new file mode 100644 index 00000000..239912ae --- /dev/null +++ b/deploy/profile-scripts/prepare_recommendation.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +""" +prepare_recommendation.py - 标准化操作建议前置流程 +调用方式: python3 prepare_recommendation.py +输出: JSON,包含: + - strategy: 信号/止损/止盈/动作 + - trade_constraints: 市场/最小交易单位/可否拆半手 + - pnl: 成本/盈亏金额/盈亏比例 (仅持仓股) + - timing_signal: 系统策略信号 + - action_note: 策略建议说明 + +依赖: per_stock_reassess.py, holdings/portfolio数据 +""" + +import sys +import json +import os +import sqlite3 + +DB_PATH = "/home/hmo/MoFin/data/mofin.db" +SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__)) + +def get_stock_info(code): + """从数据库获取股票信息""" + db = sqlite3.connect(DB_PATH) + db.row_factory = sqlite3.Row + try: + # 检查持仓 + row = db.execute( + "SELECT * FROM holdings WHERE code=? AND is_active=1 AND shares>0", + (code,) + ).fetchone() + if row: + hold = dict(row) + hold['is_holding'] = True + else: + hold = {'is_holding': False} + + # 检查是否是港股 + hk_codes = ['0' + str(i) for i in range(1, 100)] # 港股5位码 + is_hk = code.isdigit() and not code.startswith(('6', '3', '0')) + if code.startswith(('01', '02', '09')): + is_hk = True + + hold['market'] = 'HK' if is_hk else 'CN' + + # 最小交易单位 + if is_hk: + hold['min_lot'] = 100 + hold['can_split'] = False + elif code.startswith('688'): + hold['min_lot'] = 200 + hold['can_split'] = False + else: + hold['min_lot'] = 100 + hold['can_split'] = True # A股可以拆散 + + return hold + finally: + db.close() + +def run_reassess(code, price): + """调用per_stock_reassess获取策略信号""" + reassess_script = os.path.join(SCRIPTS_DIR, "per_stock_reassess.py") + import subprocess + result = subprocess.run( + ["python3", reassess_script, code, str(price)], + capture_output=True, text=True, timeout=60 + ) + return result.stdout + +def parse_reassess_output(output): + """从per_stock_reassess输出中提取关键信号""" + result = { + 'timing_signal': 'unknown', + 'action_note': '', + 'stop_loss': None, + 'take_profit': None, + 'buy_zone_low': None, + 'buy_zone_high': None, + 'rr': None, + } + + for line in output.split('\n'): + line = line.strip() + if '[OK]' in line: + # 格式: [OK] 688981 中芯国际: 盈利持有 | ⚠️盈亏比不足1:1.5 | ... | 信号:买入 + parts = line.split(': ', 2) + if len(parts) >= 2: + action_part = parts[-1] + result['action_note'] = action_part + # 提取信号 + if '信号:' in action_part: + signal = action_part.split('信号:')[-1].split()[0] + result['timing_signal'] = signal + # 提取止损 + if '止损' in action_part: + import re + m = re.search(r'止损(\d+\.?\d*)', action_part) + if m: + result['stop_loss'] = float(m.group(1)) + # 提取目标/止盈 + if '目标' in action_part: + import re + m = re.search(r'目标(\d+\.?\d*)', action_part) + if m: + result['take_profit'] = float(m.group(1)) + # 提取买入区 + if '买入区' in action_part: + import re + m = re.search(r'买入区(\d+\.?\d*)~(\d+\.?\d*)', action_part) + if m: + result['buy_zone_low'] = float(m.group(1)) + result['buy_zone_high'] = float(m.group(2)) + # 提取RR + if 'RR' in action_part: + import re + m = re.search(r'RR(\d+\.?\d*)', action_part) + if m: + result['rr'] = float(m.group(1)) + + if 'factors=' in line: + import re + m = re.search(r"factors=\['(.*?)'\]", line) + if m: + factors = m.group(1).split("', '") + result['factors'] = factors + + return result + +def build_output(code, price, stock_info, strategy): + """构建标准化JSON输出""" + output = { + 'code': code, + 'price': price, + 'market': stock_info.get('market', 'CN'), + 'strategy': { + 'timing_signal': strategy.get('timing_signal', 'unknown'), + 'action_note': strategy.get('action_note', ''), + 'stop_loss': strategy.get('stop_loss'), + 'take_profit': strategy.get('take_profit'), + 'buy_zone': { + 'low': strategy.get('buy_zone_low'), + 'high': strategy.get('buy_zone_high') + }, + 'rr': strategy.get('rr'), + 'factors': strategy.get('factors', []), + }, + 'trade_constraints': { + 'market': '港股' if stock_info.get('market') == 'HK' else 'A股', + 'min_trading_unit': stock_info.get('min_lot', 100), + 'can_split_lot': stock_info.get('can_split', True), + 'note': ( + f"港股每手{stock_info.get('min_lot', 100)}股不能拆半手" + if stock_info.get('market') == 'HK' + else f"科创板每手{stock_info.get('min_lot', 200)}股不能拆半手" + if stock_info.get('min_lot') == 200 + else "A股可拆散交易" + ) + }, + 'pnl': None, + } + + if stock_info.get('is_holding'): + cost = stock_info.get('cost', 0) + shares = stock_info.get('shares', 0) + market_value = price * shares + cost_value = cost * shares + pnl_amount = market_value - cost_value + pnl_pct = ((price / cost) - 1) * 100 if cost else 0 + output['pnl'] = { + 'cost_price': cost, + 'shares': shares, + 'market_value': round(market_value, 2), + 'cost_value': round(cost_value, 2), + 'pnl_amount': round(pnl_amount, 2), + 'pnl_pct': round(pnl_pct, 2), + 'status': '浮盈' if pnl_amount >= 0 else '浮亏', + } + + return output + +def main(): + if len(sys.argv) < 3: + print(json.dumps({ + 'error': '用法: python3 prepare_recommendation.py ' + }, ensure_ascii=False)) + sys.exit(1) + + code = sys.argv[1] + try: + price = float(sys.argv[2]) + except ValueError: + print(json.dumps({'error': f'无效价格: {sys.argv[2]}'}, ensure_ascii=False)) + sys.exit(1) + + # 获取股票信息(市场/最小单位/持仓) + stock_info = get_stock_info(code) + + # 运行策略重评 + reassess_output = run_reassess(code, price) + strategy = parse_reassess_output(reassess_output) + + # 构建输出 + output = build_output(code, price, stock_info, strategy) + + # 输出JSON + print(json.dumps(output, ensure_ascii=False, indent=2)) + +if __name__ == '__main__': + main() diff --git a/deploy/profile-scripts/price_monitor.py b/deploy/profile-scripts/price_monitor.py new file mode 100644 index 00000000..5c1a3641 --- /dev/null +++ b/deploy/profile-scripts/price_monitor.py @@ -0,0 +1,742 @@ +#!/usr/bin/env python3 +"""price_monitor.py — 高频价格监控脚本(批量版) +规则:进入区间报一次,离开区间报一次,中间不重复。 +每次运行时一次性刷新所有持仓+自选股的实时价。 +""" +import urllib.request +import os, sys, time, json +import sqlite3 +from datetime import datetime + +from mo_data import read_decisions + +BREACH_PATH = "/home/hmo/.hermes/zone_breach.json" +STATE_PATH = "/home/hmo/.hermes/price_trigger_state.json" +EVENTS_PATH = "/home/hmo/web-dashboard/data/price_events.json" + +# DB 模块(同步实时价到 mofin.db) +sys.path.insert(0, "/home/hmo/MoFin") +try: + from mofin_db import get_conn, DB_PATH + from mo_models import calc_total_mv, calc_total_assets + HAS_DB = True +except ImportError: + HAS_DB = False + +# 策略重评依赖(技术面驱动,非机械百分比) +sys.path.insert(0, "/home/hmo/web-dashboard") +try: + from strategy_lifecycle import reassess_strategy, reassess_with_context + HAS_REASSESS = True +except ImportError: + HAS_REASSESS = False + +UA = "Mozilla/5.0" + +# ── XMPP推送 ────────────────────────────────────────────────────────── +XMPP_USER = "hmo@yoin.fun" +XMPP_BRIDGE = "http://127.0.0.1:5805/" + +def push_to_xmpp(text): + """通过知微 HTTP bridge 推送到Dad私信""" + if not text.strip(): + return + try: + payload = json.dumps({ + "to": XMPP_USER, + "body": text.strip(), + "type": "chat", + }).encode("utf-8") + req = urllib.request.Request(XMPP_BRIDGE, data=payload, headers={"Content-Type": "application/json"}) + urllib.request.urlopen(req, timeout=5) + except Exception as e: + print(f"[XMPP推送失败] {e}", file=sys.stderr) + +# ── 批量拉取价格 ────────────────────────────────────────────────────────── + +def fetch_all_prices(codes): + """腾讯批量行情API:一次请求拉取所有股票(A股+港股) + A股:sh600110 / sz000001 + 港股:hk00700 + 返回 {code: (price, change, change_pct)} + """ + 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 + change_pct = fields[32] if len(fields) > 32 and fields[32] else "0" + results[orig_code] = (price, change, change_pct) + except (ValueError, IndexError): + continue + + return results + + +def refresh_data_prices(): + """一次性刷新所有持仓+自选股的实时价(完全DB版,不写JSON)""" + all_codes = set() + + # 从DB读所有需要拉取价格的代码 + try: + conn = get_conn() + for r in conn.execute("SELECT code FROM holdings WHERE is_active=1"): + all_codes.add(r['code']) + for r in conn.execute("SELECT code FROM watchlist_stocks"): + all_codes.add(r['code']) + for r in conn.execute("SELECT code FROM holding_strategies WHERE status='active'"): + all_codes.add(r['code']) + conn.close() + except Exception as e: + print(f"⚠️ 从DB读代码失败: {e}", file=sys.stderr) + return 0 + + if not all_codes: + return 0 + + # 一次性批量拉取 + prices = fetch_all_prices(list(all_codes)) + updated = len(prices) + + # === 弹性同步实时价到 mofin.db === + # 防死锁策略(经2026-07-14 WAL死锁复盘改进): + # ① 启动时 checkpoint WAL(清理残留事务) + # ② 统一 BEGIN IMMEDIATE 包裹整个写操作 + # ③ 5次重试 + 指数退避: 1s → 2s → 4s → 8s → 16s(共~31s) + # ④ get_conn() 的 busy_timeout=30000 保证等待上限 + # ⑤ 每个写操作检查返回值,任一失败立即 rollback + 重试 + # ⑥ try/finally 确保连接始终释放 + if HAS_DB and prices: + # 先checkpoint一次,清理上次被kill残留的WAL + try: + c = get_conn() + c.execute("PRAGMA wal_checkpoint(TRUNCATE)") + c.close() + except Exception: + pass + + max_tries = 5 + conn = None + for db_attempt in range(max_tries): + try: + conn = get_conn() + # BEGIN IMMEDIATE 立即获取写锁——失败则等 busy_timeout(30s) + conn.execute("BEGIN IMMEDIATE") + + # ── 构建 holdings 更新数据 ── + db_holdings = [] + for r in conn.execute("SELECT * FROM holdings WHERE is_active=1"): + h = dict(r) + code = str(h.get('code', '')) + if code in prices: + price_val, _, change_pct = prices[code] + if price_val > 0: + h['price'] = round(price_val, 2) + h['change_pct'] = float(change_pct) if change_pct else 0 + db_holdings.append(h) + + # ── 写 holdings 表 ── + for h in db_holdings: + currency = str(h.get('currency', 'CNY')).upper() + if currency not in ('CNY', 'HKD'): + raise ValueError(f"非法币种: {currency}") + conn.execute(""" + INSERT INTO holdings (code, name, shares, cost, price, market_value, + change_pct, currency, position_pct, added_at, is_active) + VALUES (?,?,?,?,?,?,?,?,?,datetime('now','localtime'),1) + ON CONFLICT(code) DO UPDATE SET + name=excluded.name, shares=excluded.shares, cost=excluded.cost, + price=excluded.price, market_value=excluded.market_value, + change_pct=excluded.change_pct, currency=excluded.currency, + position_pct=excluded.position_pct + """, ( + h.get('code'), h.get('name'), h.get('shares', 0), + h.get('cost'), h.get('price'), + h.get('market_value'), h.get('change_pct'), + h.get('currency', 'CNY'), h.get('position_pct'), + )) + + # ── 写 portfolio_summary ── + mv = calc_total_mv(db_holdings) + existing = conn.execute( + 'SELECT cash, frozen_cash FROM portfolio_summary WHERE id=1' + ).fetchone() + db_cash = existing['cash'] if existing else 0.0 + db_frozen = existing['frozen_cash'] if existing else 0.0 + assets = calc_total_assets({'holdings': db_holdings, 'cash': db_cash, 'frozen_cash': db_frozen}) + position_pct = round(mv / assets * 100, 2) if assets > 0 else 0 + conn.execute(""" + INSERT INTO portfolio_summary (id, total_assets, total_mv, stock_value, + cash, frozen_cash, position_pct, total_pnl, currency, updated_at) + VALUES (1,?,?,?,?,?,?,?,?,datetime('now','localtime')) + ON CONFLICT(id) DO UPDATE SET + total_assets=excluded.total_assets, total_mv=excluded.total_mv, + stock_value=excluded.stock_value, cash=excluded.cash, + frozen_cash=excluded.frozen_cash, position_pct=excluded.position_pct, + total_pnl=excluded.total_pnl, currency=excluded.currency, + updated_at=datetime('now','localtime') + """, ( + assets, mv, mv, db_cash, db_frozen, + position_pct, 0, 'CNY', + )) + + # ── 写 live_prices ── + for h in db_holdings: + code = h.get('code', '') + if code: + p = h.get('price', 0) + cp = h.get('change_pct', 0) + conn.execute( + "INSERT OR REPLACE INTO live_prices (code, price, change_pct, updated_at) " + "VALUES (?,?,?,datetime('now','localtime'))", + (code, p, cp) + ) + # 补充策略股/自选股的价格(不在holdings中的) + for code, pdata in prices.items(): + if code not in {h.get('code') for h in db_holdings}: + price_val = pdata[0] if isinstance(pdata, (list, tuple)) else pdata.get('price', 0) + cp_val = pdata[1] if isinstance(pdata, (list, tuple)) else pdata.get('change_pct', 0) + conn.execute( + "INSERT OR REPLACE INTO live_prices (code, price, change_pct, updated_at) " + "VALUES (?,?,?,datetime('now','localtime'))", + (code, price_val, cp_val) + ) + + conn.commit() + conn.close() + conn = None + if db_attempt > 0: + print(f"DB同步成功(第{db_attempt+1}次重试)") + break # success + + except (sqlite3.OperationalError, sqlite3.DatabaseError) as e: + if conn: + try: conn.rollback() + except Exception: pass + try: conn.close() + except Exception: pass + conn = None + err_str = str(e) + if "locked" in err_str or "cannot commit" in err_str or "busy" in err_str: + if db_attempt < max_tries - 1: + wait = 2 ** db_attempt # 1, 2, 4, 8, 16 + print(f"⏳ DB锁(尝试{db_attempt+1}/{max_tries}): {e} → {wait}s后重试", file=sys.stderr) + time.sleep(wait) + else: + print(f"❌ DB锁(重试{max_tries}次耗尽): {e}", file=sys.stderr) + else: + print(f"❌ DB错误: {e}", file=sys.stderr) + break + except Exception as e: + if conn: + try: conn.rollback() + except Exception: pass + try: conn.close() + except Exception: pass + conn = None + print(f"⚠️ DB同步异常: {e}", file=sys.stderr) + break + else: + # for-else: loop exhausted without break + print("❌ DB同步失败(所有重试耗尽)", file=sys.stderr) + # 尝试紧急 WAL checkpoint(释放死锁) + try: + c = sqlite3.connect(str(DB_PATH), timeout=1) + c.execute("PRAGMA wal_checkpoint(TRUNCATE)") + c.close() + print(" ↪ 紧急WAL checkpoint完成", file=sys.stderr) + except Exception as we: + print(f" ↪ WAL checkpoint也失败: {we}", file=sys.stderr) + + 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 _cleanup_lock(): + """清理进程锁文件""" + try: + os.remove("/tmp/price_monitor.lock") + except Exception: + pass + +def _handle_sigterm(signum, frame): + """收到SIGTERM时清理锁文件后退出""" + _cleanup_lock() + sys.exit(0) + +def run_once(round_label=""): + """执行一轮完整的监控流程""" + import os, signal # 必须在开头import,否则os变量会被后面的局部import绑定覆盖 + signal.signal(signal.SIGTERM, _handle_sigterm) + os.nice(10) # 降低优先级,避免与DB其他写操作抢占 + # ── 进程锁:同一时间只跑一个实例 ── + _lk = "/tmp/price_monitor.lock" + _pid = None + try: + with open(_lk) as _f: + _pid = int(_f.read().strip()) + os.kill(_pid, 0) + print(f"[LOCK] 已有实例(PID {_pid})在运行,跳过本轮", file=sys.stderr, flush=True) + return + except (FileNotFoundError, ProcessLookupError, ValueError): + pass + with open(_lk, "w") as _f: + _f.write(str(os.getpid())) + + label = f" [{round_label}]" if round_label else "" + start = time.time() + TIME_BUDGET = 90 # 预留30s给输出和清理,90s内必须完成核心逻辑 + + # === 第一步:一次性刷新所有价格 === + refreshed = refresh_data_prices() + + # === 第二步:检查触发条件 === + try: + dec = read_decisions() + except: + print(f"❌{label} 无法读取decisions(DB)", file=sys.stderr) + return + + active = [d for d in dec.get("decisions", []) if d.get("status") == "active"] + state = load_state() + outputs = [] + state_updated = False + # 时间冷却:同股同区间30分钟内不重复推 + _push_cooldown = {} + _cooldown_file = "/home/hmo/.hermes/.price_push_cooldown.json" + try: + import os + if os.path.exists(_cooldown_file): + with open(_cooldown_file) as _f: + _push_cooldown = json.load(_f) + except Exception: + _push_cooldown = {} + + def _can_push(code, zone_key): + now = time.time() + key = f"{code}_{zone_key}" + last = _push_cooldown.get(key, 0) + if now - last < 1800: # 30分钟 + return False + _push_cooldown[key] = now + # 持久化写入 + try: + with open(_cooldown_file, "w") as _f: + json.dump(_push_cooldown, _f) + except Exception: + pass + return True + + # 收集所有需要检查的代码 + 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] = {} + + # 时间预算检查:如果超时,跳过重评只做状态记录 + _budget_low = (time.time() - start) > TIME_BUDGET + + 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)) + # 止损触发 → 立即重评并推送给Dad(时间不够则直接推原始告警) + if _budget_low: + outputs.append(f" 📨 止损触发(超时跳过重评)→已推送Dad") + if _can_push(code, "stop_loss"): + push_to_xmpp(f"⚠️ {name}({code}) {price} → 跌破止损{hi}!") + else: + try: + cost = d.get("cost", 0) or 0 + shares = d.get("shares", 0) or 0 + current_action = d.get("action", "") + result = reassess_with_context(code, name, price, cost, shares, current_action) + if result: + timing_signal = result.get("timing_signal", "") + action = result.get("action", "") + if "买入" in timing_signal or "加仓" in timing_signal or timing_signal in ("卖出","止盈"): + buy_lo = d.get("entry_low", 0) + buy_hi = d.get("entry_high", 0) + rr = result.get("rr_ratio", 0) + if _can_push(code, "stop_loss"): + msg = f"🔔 {name}({code}) 价{price}→触发操作区间{max(buy_lo,0):.2f}~{buy_hi:.2f},已触发重评|RR={rr}" + push_to_xmpp(msg) + outputs.append(f" 📨 止损重评→已推送Dad: {action}") + except Exception as e: + outputs.append(f" ⚠️ 止损重评失败: {e}") + 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) + # 进入区间 → 立即重评并推送给Dad(时间不够则跳过重评直接推原始告警) + if _budget_low: + if _can_push(code, key): + push_to_xmpp(f"⚡ {name}({code}) {price} → 进入{label}{lo}~{hi}") + outputs.append(f" 📨 区间触发(超时跳过重评)→已推送Dad") + else: + try: + cost = d.get("cost", 0) or 0 + shares = d.get("shares", 0) or 0 + current_action = d.get("action", "") + result = reassess_with_context(code, name, price, cost, shares, current_action) + if result: + timing_signal = result.get("timing_signal", "") + action = result.get("action", "") + # 格式化区间描述(止盈区lo=0时美化显示) + if key == "take_profit_zone" and lo == 0: + zone_desc = f"止盈监控(目标{hi:.0f})" + else: + zone_desc = f"操作区间{lo}~{hi}" + if "买入" in timing_signal or "加仓" in timing_signal or timing_signal in ("卖出","止盈"): + rr = result.get("rr_ratio", 0) + if _can_push(code, key): + msg = f"🔔 {name}({code}) 价{price}→触发{zone_desc},已触发重评|RR={rr}" + push_to_xmpp(msg) + outputs.append(f" 📨 区间触发重评→已推送Dad: {action}") + else: + reason = f"重评结果:{timing_signal},不构成操作建议" + outputs.append(f" 📋 本地日志(不推): {reason}") + except Exception as e: + outputs.append(f" ⚠️ 区间重评失败: {e}") + state[code][key] = True + state_updated = True + + elif not in_zone and prev_in_zone == True: + if key != "stop_loss": + outputs.append(f"📌 {name}({code}) {price} → 离开{label}{lo}~{hi}") + state[code][key] = False + state_updated = True + + # === 第三步:买入区偏离检测 + 自动重评 === + reassesed_codes = [] + # 先做急跌检测(仅持仓,自选股不推送暴跌告警) + holdings_codes = {d["code"] for d in active if d.get("shares", 0) > 0} + for d in active: + code = d["code"] + # 非持仓跳过 + if code not in holdings_codes: + continue + name = d.get("name", code) + price_info = prices.get(code) + if not price_info: + continue + price, _, change_pct = price_info + if price == 0: + continue + # 单日跌幅>7%告警(不依赖zone边界,盘中急跌即触发) + try: + cp = float(change_pct) if change_pct else 0 + except: + cp = 0 + if cp <= -7: + prev_alert = state.get(code, {}).get("__sharp_decline_triggered", False) + if not prev_alert: + stop_loss = d.get("stop_loss", 0) + sl_note = f" 止损{stop_loss}" if stop_loss else "" + msg = f"🔻 {name}({code}) {price} 暴跌{cp:.1f}%!{sl_note}" + push_to_xmpp(msg) + outputs.append(msg) + state.setdefault(code, {})["__sharp_decline_triggered"] = True + state_updated = True + # 立即持久化,防止后续超时导致状态丢失而重复推送 + save_state(state) + elif cp > -5: + # 反弹后清除告警标记,下次再跌还能报 + state.setdefault(code, {}).pop("__sharp_decline_triggered", None) + + for d in active: + code = d["code"] + name = d.get("name", code) + price_info = prices.get(code) + if not price_info: + continue + price, _, _ = price_info + if price == 0: + continue + + # 从 decisions (DB holding_strategies) 中读取 analysis 的买入区 + entry_low = d.get("entry_low", 0) + entry_high = d.get("entry_high", 0) + if not entry_low or not entry_high: + continue + + in_buy_zone = entry_low <= price <= entry_high + prev_in_buy_zone = state.get(code, {}).get("__buy_zone", None) + + # 状态变化时才触发 + if in_buy_zone and prev_in_buy_zone == False: + # 重新进入买入区 → 重评确认区间是否仍然有效 + outputs.append(f"🔄 {name}({code}) {price} → 重新进入买入区{entry_low}~{entry_high},触发技术面重评") + do_reassess = True + elif not in_buy_zone and prev_in_buy_zone == True: + # 离开买入区 → 立即重评,更新止损/止盈/区间 + outputs.append(f"🔄 {name}({code}) {price} → 离开买入区{entry_low}~{entry_high},立即技术面重评") + do_reassess = True + else: + do_reassess = False + + if do_reassess and HAS_REASSESS: + try: + cost = d.get("cost", 0) or 0 + shares = d.get("shares", 0) or 0 + profit_pct = (price - cost) / cost * 100 if cost else 0 + is_deep_loss = profit_pct < -20 + sentiment = "neutral" + if d.get("tech_snapshot"): + if "bearish" in d["tech_snapshot"]: + sentiment = "bearish" + elif "bullish" in d["tech_snapshot"]: + sentiment = "bullish" + + # 调用技术面驱动重评(非机械百分比) + result = reassess_strategy( + code, name, price, cost, shares, + current_action=d.get("action", ""), + volume_signal="中性", sentiment=sentiment, + ) + outputs.append(f" 📊 新策略: 损{result['stop_loss']} 盈{result['take_profit']} 区{result['entry_low']}~{result['entry_high']} RR={result['rr_ratio']}") + reassesed_codes.append(code) + except Exception as e: + outputs.append(f" ⚠️ 重评失败: {e}") + + # 更新买入区状态 + if "__buy_zone" not in state.get(code, {}): + if code not in state: + state[code] = {} + state[code]["__buy_zone"] = in_buy_zone + state_updated = True + + # 如果有重评过的股票,更新 DB holding_strategies(此前写入 decisions.json,已废弃) + if reassesed_codes and HAS_REASSESS: + # ── 5分钟冷却:regenerate_all 开销太大,不每2分钟跑一次 ── + _regen_marker = "/tmp/price_monitor_regen_at" + _skip_regen = False + try: + if os.path.exists(_regen_marker): + with open(_regen_marker) as _f: + _last_regen = float(_f.read().strip()) + if time.time() - _last_regen < 300: + _skip_regen = True + except: + pass + + if _skip_regen: + outputs.append(f" ⏭ 跳过全量重评(距上次<5min),下次再跑") + else: + try: + from strategy_lifecycle import regenerate_all + r = regenerate_all(stdout=False) + outputs.append(f" ✅ 策略已全量重评: {r.get('ok',0)}/{r.get('total',0)}成功") + outputs.append(f" 📌 触发股票: {', '.join(reassesed_codes)}") + try: + with open(_regen_marker, "w") as _f: + _f.write(str(time.time())) + except: + pass + except Exception as e: + outputs.append(f" ⚠️ 全量重评失败: {e}") + + # === 第四步:输出 === + 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) + + # 清理进程锁 + try: + os.remove("/tmp/price_monitor.lock") + except Exception: + pass + + +def main(): + """每cron触发跑一轮""" + run_once() + + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/promote_candidates.py b/deploy/profile-scripts/promote_candidates.py new file mode 100644 index 00000000..83718d80 --- /dev/null +++ b/deploy/profile-scripts/promote_candidates.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""promote_candidates.py — 自动提拔候选股入自选 + +从 candidates 表读未提拔的候选,评估后自动加入 holding_strategies。 +""" +import sys, json, sqlite3 +from pathlib import Path +from datetime import datetime + +DB_PATH = Path("/home/hmo/MoFin/data/mofin.db") + +def main(): + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + + # 读未提拔候选(按评分降序) + rows = conn.execute(""" + SELECT c.code, c.name, c.score_final, c.entry_range, c.stop_loss, c.target + FROM candidates c + WHERE (c.promoted IS NULL OR c.promoted = 0) + AND (c.dropped IS NULL OR c.dropped = 0) + AND c.score_final >= 4 + ORDER BY c.score_final DESC + """).fetchall() + + if not rows: + print("[PROMOTE] 无待提拔候选") + conn.close() + return + + promoted = 0 + for r in rows: + code = str(r[0]) + name = r[1] or code + score = r[2] or 0 + entry_range = r[3] or "" + sl = r[4] or 0 + tp = r[5] or 0 + + # 解析 entry_range + el, eh = 0, 0 + if "~" in entry_range: + parts = entry_range.split("~") + try: + el = float(parts[0]) + eh = float(parts[1]) + except: pass + + # 查是否已在 holding_strategies + exists = conn.execute( + "SELECT id FROM holding_strategies WHERE code=? AND status='active'", + (code,) + ).fetchone() + if exists: + conn.execute("UPDATE candidates SET promoted=1 WHERE code=?", (code,)) + print(f" ⏭ {code} {name} 已在自选中,标记promoted") + continue + + # 验证实时价格:无有效价格的候选股不入自选(防假数据污染) + try: + import subprocess, json as _jj + _r = subprocess.run(["python3", "/home/hmo/.hermes/profiles/position-analyst/scripts/stock_quote.py", code], + capture_output=True, text=True, timeout=10) + _q = _jj.loads(_r.stdout) + if float(_q.get("price", 0)) <= 0: + print(f" ⏭ {code} {name} 无实时价格,跳过") + continue + except Exception as _e: + print(f" ⏭ {code} {name} 价格获取失败({_e}),跳过") + continue + + # 构建策略 + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + timing_signal = "买入" if score >= 7 else "关注" + price_est = (el + eh) / 2 if el > 0 and eh > 0 else 0 + reason_text = [] + if el > 0: reason_text.append(f"买{el}~{eh}") + if sl > 0: reason_text.append(f"损{sl}") + if tp > 0: reason_text.append(f"盈{tp}") + if sl > 0 and tp > 0 and price_est > 0: + rr = (tp - price_est) / (price_est - sl) if (price_est - sl) > 0 else 0 + reason_text.append(f"RR{rr:.1f}") + reason_text.append(f"评分{score}") + action = " | ".join(reason_text) if reason_text else f"市场扫描发现(评分{score})" + + conn.execute(""" + INSERT INTO holding_strategies + (code, name, price, entry_low, entry_high, stop_loss, take_profit, + timing_signal, action, decision_type, strategy_type, status, + rr_ratio, stock_category, created_at, updated_at, + sector_context, quality_check) + VALUES (?,?,?,?,?,?,?,?,?,'自选策略','scan', + 'active',0,'关注',?,?,'', 'pending') + """, (code, name, 0, el, eh, sl, tp, timing_signal, action, now, now)) + + conn.execute("UPDATE candidates SET promoted=1 WHERE code=?", (code,)) + promoted += 1 + print(f" ✅ {code} {name} 评分{score} → 已加入自选({timing_signal})", flush=True) + + # 触发全量重评(生成完整9维策略) + try: + import subprocess as _sp + r = _sp.run(["python3", "/home/hmo/MoFin/scripts/per_stock_reassess.py", code], + capture_output=True, text=True, timeout=60) + if r.returncode == 0: + print(f" 重评完成", flush=True) + else: + print(f" 重评失败: {r.stderr.strip()[:100]}", flush=True) + except Exception as e: + print(f" 重评异常: {e}", flush=True) + + conn.commit() + print(f"\n[PROMOTE] 本次提拔{promoted}只", flush=True) + + # 推XMPP + if promoted > 0: + try: + import urllib.request + msg = f"📈 自动提拔{promoted}只候选入自选" + payload = json.dumps({"to": "hmo@yoin.fun", "body": msg, "type": "chat"}).encode() + req = urllib.request.Request("http://127.0.0.1:5805/", data=payload, + headers={"Content-Type": "application/json"}) + urllib.request.urlopen(req, timeout=5) + except Exception: + pass + + conn.close() + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/prune_branches.py b/deploy/profile-scripts/prune_branches.py new file mode 100644 index 00000000..97f3cd12 --- /dev/null +++ b/deploy/profile-scripts/prune_branches.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +""" +prune_branches.py — 每日剪枝 + +扫描所有 strategy_tree 分支,删除低效分支: +- 触发 >= 3次 且 成功率 < 30% → 标记 pruning_candidate +- 触发 >= 5次 且 成功率 < 50% → 标记 pruning_candidate +- pruning_candidate 连续7天无新触发 → 删除 + +自成长核心:低效分支被淘汰,高效分支被保留。 +数据写入 decisions.json 的 strategy_tree.branches[]。 +""" + +import json, sys, os +from datetime import datetime, timedelta +from mo_data import read_decisions +from mofin_db import get_conn, write_holding_strategy + +PRUNE_LOG = "/home/hmo/MoFin/data/prune_log.json" + + +def load_decisions(): + return read_decisions() + + +def save_decisions(data): + # DB 写入(替代 json.dump) + try: + conn = get_conn() + for d in data.get("decisions", []): + write_holding_strategy(conn, d.get("code", ""), d.get("name", ""), d) + conn.close() + except Exception: + pass + +def main(): + data = load_decisions() + decisions = data.get("decisions", []) + today = datetime.now().strftime("%Y-%m-%d") + pruned = [] + warnings = [] + + for entry in decisions: + code = entry.get("code", "") + tree = entry.get("strategy_tree", {}) + branches = tree.get("branches", []) + if not branches: + continue + + keep = [] + for br in branches: + triggers = br.get("trigger_count", 0) + success = br.get("success_rate") + last = br.get("last_triggered", "") + priority = br.get("priority", 99) + + # 跳过默认持有分支 + if priority == 99: + keep.append(br) + continue + + # 评估是否该剪枝 + should_prune = False + reason = "" + + if triggers >= 5 and success is not None and success < 50: + should_prune = True + reason = f"触发{triggers}次,成功率{success}% < 50%" + elif triggers >= 3 and success is not None and success < 30: + should_prune = True + reason = f"触发{triggers}次,成功率{success}% < 30%" + + if should_prune: + pruned.append({ + "code": code, + "branch_id": br.get("id", ""), + "action": br.get("action", {}).get("type", ""), + "rationale": br.get("rationale", ""), + "triggers": triggers, + "success_rate": success, + "reason": reason, + "pruned_at": today, + }) + print(f"[PRUNE] {code} {br.get('id','?')}: {reason}") + else: + keep.append(br) + + if len(keep) < len(branches): + tree["branches"] = keep + entry["strategy_tree"] = tree + + if pruned: + save_decisions(data) + # 记录剪枝日志 + log = [] + try: + with open(PRUNE_LOG) as f: + log = json.load(f) + except Exception: + pass + log.append({ + "date": today, + "pruned": pruned, + "total_before": sum(len(e.get("strategy_tree", {}).get("branches", [])) for e in decisions), + }) + os.makedirs(os.path.dirname(PRUNE_LOG), exist_ok=True) + with open(PRUNE_LOG, "w") as f: + json.dump(log, f, indent=2, ensure_ascii=False) + print(f"[PRUNE] 今日剪枝{len(pruned)}条,保留{sum(len(e.get('strategy_tree',{}).get('branches',[])) for e in decisions)}条") + else: + print("[PRUNE] 无需要剪枝的分支") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/deploy/profile-scripts/refresh_macro_context.py b/deploy/profile-scripts/refresh_macro_context.py new file mode 100644 index 00000000..f9facc1a --- /dev/null +++ b/deploy/profile-scripts/refresh_macro_context.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""refresh_macro_context.py — 刷新macro_context_log + +读取最新market_snapshots + 腾讯实时指数, 写入macro_context_log +让load_macro_context()拿到最新市场偏向, 而不是12天前的数据 + +每30分钟跑一次(交易日) +""" +import json, sqlite3, sys +from pathlib import Path +from datetime import datetime +from mo_data import get_price + +DB = Path("/home/hmo/MoFin/data/mofin.db") + +def fetch_index(code, name): + """从统一入口获取指数行情""" + try: + price, change_pct = get_price(code) + if price is not None: + return { + "price": price, + "change_pct": round(change_pct or 0, 2), + "high": price, + "low": price, + } + return None + except: + return None + +def main(): + # 采集各指数 + indices = { + "上证指数": fetch_index("sh000001", "上证指数"), + "深证成指": fetch_index("sz399001", "深证成指"), + "创业板指": fetch_index("sz399006", "创业板指"), + "恒生指数": fetch_index("szHSI", "恒生指数"), + "国企指数": fetch_index("szHSCEI", "国企指数"), + } + indices = {k: v for k, v in indices.items() if v} + + # 计算偏向 + sh = indices.get("上证指数", {}) + sh_change = sh.get("change_pct", 0) if sh else 0 + if sh_change < -1.5: + overall = "bearish" + desc = "大盘偏弱" + elif sh_change > 1.0: + overall = "bullish" + desc = "大盘偏强" + else: + overall = "neutral" + desc = "大盘震荡" + + structure = json.dumps({"overall": overall, "description": desc}, ensure_ascii=False) + indices_json = json.dumps(indices, ensure_ascii=False) + + # 写入macro_context_log + now = datetime.now() + session = "midday" if now.hour >= 12 else "morning" + ts = now.strftime("%Y-%m-%d %H:%M:%S") + try: + conn = sqlite3.connect(str(DB)) + conn.execute(""" + INSERT INTO macro_context_log + (data_timestamp, session, has_valid_data, indices, structure, created_at) + VALUES (?, ?, 1, ?, ?, ?) + """, (ts, session, indices_json, structure, ts)) + conn.commit() + conn.close() + print(f"[SILENT] macro_context updated: {overall} {desc} {len(indices)} indices") + except Exception as e: + print(f"[SILENT] macro_context write failed: {e}") + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/refresh_mtf_cache.py b/deploy/profile-scripts/refresh_mtf_cache.py new file mode 100644 index 00000000..f88cc5bd --- /dev/null +++ b/deploy/profile-scripts/refresh_mtf_cache.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""多周期缓存刷新脚本 — 在开盘前预填充K线数据 + +为所有持仓+自选股预先拉取日/周/月K线,写入 multi_tf_cache.json, +这样收盘后全量重评(regenerate_all)运行时K线数据已有缓存,无需逐个拉取。 + +运行时间:每天9:00(开盘前),no_agent模式。 +无输出 = 成功(避免每天收到无意义消息)。 +""" + +import sys +import os +import json +from datetime import datetime + +# 确保能找到 web-dashboard 模块 +sys.path.insert(0, "/home/hmo/web-dashboard") + +# 控制台UTC日志 +def log(msg): + ts = datetime.utcnow().strftime("%H:%M:%S") + print(f"[{ts}] {msg}", file=sys.stderr) + +def main(): + from mofin_db import get_conn + + # 收集所有股票代码 + codes = [] + seen = set() + + conn = get_conn() + try: + rows = conn.execute("SELECT code FROM holdings WHERE is_active=1").fetchall() + for r in rows: + code = r["code"] + if code: + codes.append(("portfolio", code)) + seen.add(code) + + rows = conn.execute("SELECT code FROM watchlist_stocks WHERE is_active=1").fetchall() + for r in rows: + code = r["code"] + if code and code not in seen: + codes.append(("watchlist", code)) + seen.add(code) + finally: + conn.close() + + # 加入指数代码(用于多周期趋势研判) + INDEXES = { + "sh000001": "上证指数", "sz399001": "深证成指", + "sz399006": "创业板指", "sh000688": "科创50", + "hkHSI": "恒生指数", "hkHSTECH": "恒生科技", + } + for idx_code in INDEXES: + if idx_code not in seen: + codes.append(("index", idx_code)) + seen.add(idx_code) + + log(f"Pre-populating multi-timeframe cache for {len(codes)} stocks...") + + # 从 DB 读取现有缓存(替代 multi_tf_cache.json) + from multi_timeframe import _load_mtf_cache, _save_mtf_cache + existing = _load_mtf_cache() + + import time + from multi_timeframe import full_multi_tf_analysis + + cached = 0 + fetched = 0 + errors = 0 + + for source, code in codes: + cached_entry = existing.get(code, {}) + updated_at = cached_entry.get("updated_at", 0) + now = time.time() + + # 检查缓存是否新鲜:日K 1小时内,周/月K 1天内 + has_daily = bool(cached_entry.get("daily")) + has_weekly = bool(cached_entry.get("weekly")) + has_monthly = bool(cached_entry.get("monthly")) + cache_fresh = (updated_at > 0 and (now - updated_at) < 3600) + + if has_daily and has_weekly and has_monthly and cache_fresh: + cached += 1 + continue + + try: + r = full_multi_tf_analysis(code) + if any(k in r for k in ["daily", "weekly", "monthly"]): + fetched += 1 + log(f" OK {code} ({source})") + else: + errors += 1 + log(f" EMPTY {code} ({source})") + except Exception as e: + errors += 1 + log(f" FAIL {code} ({source}): {e}") + + log(f"Done: {cached} cached, {fetched} fetched, {errors} errors") + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/review_needed_watchdog.py b/deploy/profile-scripts/review_needed_watchdog.py new file mode 100644 index 00000000..82286fdb --- /dev/null +++ b/deploy/profile-scripts/review_needed_watchdog.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +""" +review_needed_watchdog.py — review_needed 策略自动跟进 + +每30分钟扫描DB中 status=review_needed 的策略: +1. 对每只策略调用 per_stock_reassess 重评 +2. 重评后 status 变 active → 通过,写入 changelog +3. 还是 review_needed → retry_count+=1 +4. retry_count>=3 → 推 Dad 人工介入 +""" + +import sys, json, os, datetime +from mo_data import read_decisions + +sys.path.insert(0, "/home/hmo/web-dashboard") +os.chdir("/home/hmo/MoFin") + +DEC_PATH = "/home/hmo/web-dashboard/data/decisions.json" +RETRY_FILE = "/home/hmo/web-dashboard/data/review_needed_retry.json" +XMPP_USER = "hmo@yoin.fun" +XMPP_BRIDGE = "http://192.168.1.246:5805/xmpp/send" + +def load_retry(): + try: + return json.load(open(RETRY_FILE)) + except: + return {} + +def save_retry(data): + json.dump(data, open(RETRY_FILE, "w"), indent=2) + +def push_xmpp(text): + try: + from urllib.request import Request, urlopen + payload = json.dumps({"to": XMPP_USER, "body": text.strip()}).encode() + req = Request(XMPP_BRIDGE, data=payload, headers={"Content-Type": "application/json"}) + urlopen(req, timeout=5) + print(f" [XMPP] 已推送") + except Exception as e: + print(f" [XMPP推送失败] {e}") + +def main(): + dec = read_decisions() + review_list = [d for d in dec.get("decisions", []) if d.get("status") == "review_needed"] + retry_data = load_retry() + today = datetime.date.today().isoformat() + + if not review_list: + print("[SILENT] 无待处理策略") + return + + print(f"发现 {len(review_list)} 只 review_needed 策略") + changes = False + for d in review_list: + code = d["code"] + name = d.get("name", code) + retries = retry_data.get(code, {}).get("count", 0) + 1 + retry_data[code] = {"count": retries, "last_attempt": today} + + if retries >= 3: + print(f" ⛔ {name}({code}) 已重试{retries}次,跳过") + continue + + print(f" 🔄 {name}({code}) 第{retries}次重试...") + import subprocess + r = subprocess.run( + [sys.executable, "/home/hmo/MoFin/scripts/per_stock_reassess.py", code], + capture_output=True, text=True, timeout=30 + ) + out = (r.stdout or "") + (r.stderr or "") + print(f" {out[:200]}") + + # 重读决策 + dec2 = read_decisions() + for d2 in dec2.get("decisions", []): + if d2["code"] == code: + if d2.get("status") == "active": + print(f" ✅ {name}({code}) 重评通过!") + retry_data[code] = {"count": 0, "last_attempt": today} + changes = True + elif d2.get("status") == "review_needed": + issues = d2.get("quality_issues", {}).get("critical", []) + print(f" ❌ {name}({code}) 仍 review_needed ({issues})") + break + + # 3次以上失败 → 推 Dad + dead = [code for code, v in retry_data.items() if v.get("count", 0) >= 3] + if dead: + names = [] + for code in dead: + for d in dec.get("decisions", []): + if d["code"] == code: + names.append(f"{d.get('name', code)}({code})") + break + msg = f"【知微】策略质量审核 {today}\n以下策略3次自动重评均失败,需人工介入:\n" + for n in names: + msg += f" - {n}\n" + msg += "\n原因可能是:缺少技术面数据 / 行业信息不完整 / 利润保护目标无法确定。" + push_xmpp(msg) + + save_retry(retry_data) + if not changes: + print("[SILENT] 状态无变化") + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/run_all_tests.py b/deploy/profile-scripts/run_all_tests.py new file mode 100644 index 00000000..1f5a2c63 --- /dev/null +++ b/deploy/profile-scripts/run_all_tests.py @@ -0,0 +1,206 @@ +"""MoFin 全面测试 —— 按 TEST_PLAN.md 执行""" +import sys, os, subprocess +sys.path.insert(0, '/home/hmo/MoFin') + +passed = 0 +failed = 0 +results = [] + +def test(name, cond, detail=""): + global passed, failed + if cond: + passed += 1 + results.append(f" ✅ {name}") + else: + failed += 1 + results.append(f" ❌ {name}: {detail}") + +print("=" * 50) +print("MoFin 全面测试") +print("=" * 50) + +# ── 1. 导入测试 ── +print("\n--- 1. 导入 ---") +try: + from mo_data import read_portfolio, read_decisions, read_watchlist + from mo_models import is_hk_stock, get_hk_rate, to_cny, calc_total_assets, calc_total_mv + from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_holding_strategy + test("mo_data 导入", True) + test("mo_models 导入", True) + test("mofin_db 导入", True) +except Exception as e: + test("核心模块导入", False, str(e)) + +# ── 2. 数据读取 ── +print("\n--- 2. 数据读取 ---") +pf = read_portfolio() +dec = read_decisions() +wl = read_watchlist() +test("read_portfolio 有数据", len(pf.get('holdings', [])) > 0, f"got {len(pf.get('holdings',[]))}") +test("read_decisions 有数据", len(dec.get('decisions', [])) > 0, f"got {len(dec.get('decisions',[]))}") +test("read_watchlist 有数据", len(wl.get('stocks', [])) > 0, f"got {len(wl.get('stocks',[]))}") + +# ── 3. 币种存储 ── +print("\n--- 3. 币种存储 ---") +hk_ok = 0; hk_fail = 0; a_ok = 0; a_fail = 0 +for h in pf.get('holdings', []): + code = str(h.get('code', '')) + curr = h.get('currency', h.get('_currency', '')) + if is_hk_stock(code): + if curr == 'HKD': hk_ok += 1 + else: hk_fail += 1 + else: + if curr == 'CNY': a_ok += 1 + else: a_fail += 1 +test(f"港股 currency=HKD", hk_fail == 0, f"{hk_ok} OK, {hk_fail} wrong") +test(f"A股 currency=CNY", a_fail == 0, f"{a_ok} OK, {a_fail} wrong") + +# 决策币种 +dec_hk_ok = 0; dec_hk_fail = 0 +for d in dec.get('decisions', []): + code = str(d.get('code', '')) + curr = d.get('currency', '') + if is_hk_stock(code) and curr != 'HKD': + dec_hk_fail += 1 + elif is_hk_stock(code): + dec_hk_ok += 1 +test(f"决策 港股 currency=HKD", dec_hk_fail == 0, f"{dec_hk_ok} OK, {dec_hk_fail} wrong") + +# ── 4. 币种转换 ── +print("\n--- 4. 币种转换 ---") +rate = get_hk_rate() +test("is_hk_stock('01888')", is_hk_stock('01888') == True) +test("is_hk_stock('000657')", is_hk_stock('000657') == False) +test("is_hk_stock('AAPL')", is_hk_stock('AAPL') == False) +test("get_hk_rate 有效", 0.85 < rate < 0.95, f"rate={rate}") +test("to_cny HK convert", abs(to_cny(100, '00700') - 100 * rate) < 0.01) +test("to_cny A股 no convert", to_cny(100, '000657') == 100) + +# ── 5. 总资产 ── +print("\n--- 5. 总资产 ---") +stored_ta = pf.get('total_assets', 0) +stored_mv = pf.get('total_mv', 0) +calc_ta = calc_total_assets(pf) +calc_mv = calc_total_mv(pf.get('holdings', [])) +test("total_assets stored ≈ calculated", abs(stored_ta - calc_ta) < 500, f"stored={stored_ta:.2f} calc={calc_ta:.2f} diff={abs(stored_ta-calc_ta):.1f}") +test("total_mv stored ≈ calculated", abs(stored_mv - calc_mv) < 500, f"stored={stored_mv:.2f} calc={calc_mv:.2f}") +test("total_assets > 0", stored_ta > 0) +test("total_mv > 0", stored_mv > 0) +test("frozen_cash 已清零", pf.get('frozen_cash', 0) == 0) + +# ── 6. P&L ── +print("\n--- 6. P&L ---") +import sqlite3 +db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') +rows = db.execute("SELECT code, name, cost, price, shares, currency FROM holdings WHERE is_active=1 AND shares>0").fetchall() +pnl_issues = [] +for r in rows: + code, name, cost, price, shares, curr = r + if not cost or cost <= 0: continue + if not price or price <= 0: continue + pnl_pct = (price - cost) / cost * 100 + # 港股 P&L 应在 HKD 范围内合理(-99% ~ +1000%) + if is_hk_stock(str(code)): + if pnl_pct < -95 or pnl_pct > 500: + pnl_issues.append(f"{code} {name}: P&L={pnl_pct:.1f}% (HKD)") +test("港股P&L 合理性", len(pnl_issues) == 0, "; ".join(pnl_issues)) +db.close() + +# ── 7. DB 完整性 ── +print("\n--- 7. DB 完整性 ---") +db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') +n_holds = db.execute("SELECT COUNT(*) FROM holdings WHERE is_active=1").fetchone()[0] +n_strat = db.execute("SELECT COUNT(*) FROM holding_strategies WHERE status IN ('active','updated')").fetchone()[0] +n_wl = db.execute("SELECT COUNT(*) FROM watchlist_stocks WHERE is_active=1").fetchone()[0] +test("holdings 记录数", n_holds > 0, str(n_holds)) +test("holding_strategies 记录数", n_strat > 0, str(n_strat)) +# 检查 cost=0 且 shares>0 的 bug +zero_cost = db.execute("SELECT code, name FROM holdings WHERE is_active=1 AND shares>0 AND (cost IS NULL OR cost=0)").fetchall() +if zero_cost: + names = [f"{r[0]} {r[1]}" for r in zero_cost] + results.append(f" ⚠️ cost=0 持仓: {'; '.join(names)} (需从 holding.xls 重新导入)") +else: + test("无 cost=0 持仓", True) +db.close() + +# ── 8. JSON 残留 ── +print("\n--- 8. JSON 残留 ---") +import glob, re +json_refs = [] +exclude_patterns = ['mo_config.py', '__pycache__', 'test_', 'inspect_', 'check_', 'verify_', 'diagnose_', 'audit_', 'deep_', 'rollback_', 'close_', 'run_all', 'migrate_all'] +for pattern in ['*.py', 'scripts/*.py']: + for f in glob.glob(f'/home/hmo/MoFin/{pattern}'): + if any(x in f for x in exclude_patterns): + continue + try: + with open(f) as fh: + content = fh.read() + # Only catch actual I/O: json.load(open( + json filename + for kw in ['portfolio.json', 'decisions.json', 'watchlist.json']: + # Pattern: json.load and json.dump with open + if re.search(rf'json\.(load|dump).*{kw}', content): + for i, line in enumerate(content.split('\n')): + if kw in line and ('json.load' in line or 'json.dump' in line) and not line.strip().startswith('#'): + json_refs.append(f"{f}:{i+1}: {line.strip()[:60]}") + except: pass +test("无活跃 JSON I/O", len(json_refs) == 0, f"{len(json_refs)} refs" + (f" e.g. {json_refs[0]}" if json_refs else "")) + +# ── 9. LLM Prompt ── +print("\n--- 9. LLM Prompt ---") +prompt_json_issues = [] +try: + for fpath in glob.glob('/home/hmo/MoFin/prompt_manager/*.py'): + with open(fpath) as fh: + content = fh.read() + if '.json' not in content: + continue + for i, line in enumerate(content.split('\n')): + s = line.strip() + if s.startswith('#') or not s: + continue + if '.json' in s and any(x in s for x in ['evaluation.json', 'accuracy_stats.json', 'decisions.json', 'portfolio.json']): + prompt_json_issues.append(f"{fpath}:{i+1}: {s[:60]}") + test("prompt_manager 无 JSON 引用", len(prompt_json_issues) == 0, f"{len(prompt_json_issues)} issues: {prompt_json_issues[0] if prompt_json_issues else ''}") +except Exception as e: + test("LLM prompt 检查", False, str(e)[:80]) + +# ── 10. API ── +print("\n--- 10. API ---") +import urllib.request, json +try: + r = urllib.request.urlopen("http://localhost:8899/api/portfolio", timeout=5) + data = json.loads(r.read()) + test("GET /api/portfolio", True, f"total_assets={data.get('total_assets')}") +except Exception as e: + test("GET /api/portfolio", False, str(e)[:60]) + +try: + r = urllib.request.urlopen("http://localhost:8899/api/decisions", timeout=5) + data = json.loads(r.read()) + test("GET /api/decisions", True, f"total={data.get('total')}") +except Exception as e: + test("GET /api/decisions", False, str(e)[:60]) + +# ── 11. Cron 脚本可导入 ── +print("\n--- 11. Cron 可导入 ---") +cron_scripts = ['price_monitor', 'market_watch', 'market_screener', 'system_audit', 'data_freshness'] +for s in cron_scripts: + try: + __import__(s) + test(f"{s} 可导入", True) + except Exception as e: + test(f"{s} 可导入", False, str(e)[:50]) + +# ── 12. price_monitor dry run ── +print("\n--- 12. price_monitor 函数测试 ---") +try: + from price_monitor import refresh_data_prices, is_hk_stock as pm_is_hk + test("price_monitor.is_hk_stock 一致", pm_is_hk('01888') == is_hk_stock('01888')) +except Exception as e: + test("price_monitor 函数", False, str(e)[:80]) + +# ── 结果 ── +print(f"\n{'='*50}") +print(f"结果: {passed} passed, {failed} failed ({passed+failed} total)") +for r in results: + print(r) diff --git a/deploy/profile-scripts/self_todo_executor.py b/deploy/profile-scripts/self_todo_executor.py new file mode 100644 index 00000000..83d70fac --- /dev/null +++ b/deploy/profile-scripts/self_todo_executor.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""self_todo_executor.py — TODO自动执行器 (no_agent模式) + +每10分钟轮询pending TODOs,执行fix_action。 +成功→completed。失败→调gateway API,带完整上下文让知微处理。 +""" + +import json, subprocess, time, urllib.request +from pathlib import Path +from datetime import datetime +from mofin_db import get_conn + +BASE = Path("/home/hmo/MoFin") +DB_PATH = BASE / "data" / "mofin.db" +GATEWAY_URL = "http://localhost:8643/v1/chat/completions" +GATEWAY_KEY = "hermes123" + + +def send_xmpp(msg): + """通过zhiwei发XMPP消息给Dad""" + try: + subprocess.run( + ["hermes", "send", "--to", "xmpp:hmo@yoin.fun", msg], + capture_output=True, text=True, timeout=15 + ) + except: + pass + + +def main(): + start = time.time() + conn = get_conn() + + rows = conn.execute( + "SELECT id, title, description, fix_action FROM todos WHERE status='pending' " + "ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, " + "created_at ASC LIMIT 3" + ).fetchall() + + if not rows: + conn.close() + print("[SILENT] 无待处理TODO") + return + + for row in rows: + tid = row["id"] + title = row["title"] + desc = row["description"] or "" + fix = row["fix_action"] + + conn.execute("UPDATE todos SET status='in_progress' WHERE id=?", (tid,)) + conn.commit() + + if not fix: + # 无修复方案 → 带完整描述调gateway + context = f"[自愈执行器] 系统体检发现以下问题,无自动修复方案,需分析处理。\n\n问题: {title}\n\n详情:\n{desc}".strip() + send_xmpp(f"📋 TODO已创建(无自动修复): {title[:80]}") + else: + # 执行修复命令 + try: + r = subprocess.run(fix, shell=True, capture_output=True, text=True, timeout=30) + if r.returncode == 0: + conn.execute("UPDATE todos SET status='completed', note=? WHERE id=?", + (f"已修复: {r.stdout.strip()[:200]}", tid)) + conn.commit() + send_xmpp(f"✅ TODO修复成功: {title[:80]}") + print(f" ✅ {title}: 已修复") + continue + output = r.stderr.strip()[:500] or r.stdout.strip()[:500] + except subprocess.TimeoutExpired: + output = "执行超时(30s)" + except Exception as e: + output = str(e)[:200] + + context = ( + f"[自愈执行器] 尝试自动修复失败,需知微分析处理。\n\n" + f"问题: {title}\n\n" + f"详情:\n{desc}\n\n" + f"尝试的修复命令: {fix}\n" + f"失败输出: {output}\n\n" + f"请分析失败原因并完成修复,完成后标记TODO #{tid} 为 completed。" + ) + + # 调gateway让知微处理(带完整上下文) + payload = json.dumps({ + "model": "default", + "messages": [{"role": "user", "content": context}], + "max_tokens": 1000, + }).encode() + req = urllib.request.Request(GATEWAY_URL, data=payload, + headers={"Content-Type": "application/json", + "Authorization": f"Bearer {GATEWAY_KEY}"}) + try: + resp = urllib.request.urlopen(req, timeout=120) + reply = json.loads(resp.read()) + result = reply["choices"][0]["message"]["content"][:500] + conn.execute("UPDATE todos SET status='completed', note=? WHERE id=?", + (f"知微已处理: {result[:200]}", tid)) + send_xmpp(f"🔶 TODO需知微处理: {title[:60]}\n{result[:200]}") + print(f" 🔶 {title}") + print(f" {result[:300]}") + except Exception as e: + conn.execute("UPDATE todos SET status='pending', note=? WHERE id=?", + (f"调用知微失败: {str(e)[:100]},下次再试", tid)) + send_xmpp(f"⚠️ TODO处理失败(将重试): {title[:60]}\n{str(e)[:100]}") + print(f" ⚠️ {title}: gateway API调用失败,下次再试") + + conn.commit() + + conn.close() + + if rows: + print(f"自愈执行器 | {datetime.now().strftime('%H:%M')} | {len(rows)}条 ({time.time()-start:.0f}s)") + else: + print("[SILENT] 无待处理TODO") + + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/server.py b/deploy/profile-scripts/server.py new file mode 100644 index 00000000..8eb6f143 --- /dev/null +++ b/deploy/profile-scripts/server.py @@ -0,0 +1,1071 @@ +#!/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 + +# 提示词管理模块 +from prompt_manager.dashboard_views import register_routes + +# MoFin 数据层(纯 DB,不再读 JSON) +from mo_data import read_portfolio, read_decisions, read_watchlist +from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_watchlist_stock, write_holding_strategy + +app = Flask(__name__, static_folder="static", static_url_path="") + +DATA_DIR = Path(__file__).parent.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): + """仅用于非核心文件(reports, stocks, market 等)。portfolio/decisions/watchlist 已迁移到 DB。""" + 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): + """仅用于非核心文件(reports, stocks, market 等)。portfolio/decisions/watchlist 已迁移到 DB。""" + 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) + + +def _save_portfolio(data): + """写入持仓数据到 DB。data 必须包含 holdings[] 和顶层 summary 字段。""" + conn = get_conn() + try: + write_holdings_batch(conn, data.get('holdings', [])) + write_portfolio_summary(conn, data) + finally: + conn.close() + + +def _save_decision(code, name, data): + """写入单条决策到 DB。""" + conn = get_conn() + try: + write_holding_strategy(conn, code, name, data) + finally: + conn.close() + + +def _save_watchlist(data): + """写入自选股列表到 DB。""" + conn = get_conn() + for s in data.get('stocks', []): + s.setdefault('currency', 'CNY') + write_watchlist_stock(conn, s) + conn.close() + + +# ── API 路由 ────────────────────────────────────────── + +@app.route("/") +def index(): + return send_from_directory(app.static_folder, "index.html") + + +@app.route("/api/portfolio") +def api_portfolio(): + """持仓列表""" + try: + from mofin_db import get_conn, query_holdings, query_portfolio_summary + conn = get_conn() + holdings = query_holdings(conn) + summary = query_portfolio_summary(conn) + conn.close() + if holdings: + data = dict(summary) + data["holdings"] = holdings + return jsonify(data) + except Exception: + pass + return jsonify({"error": "数据库查询失败"}), 500 + + +@app.route("/api/watchlist") +def api_watchlist(): + """自选列表""" + try: + from mofin_db import get_conn, query_watchlist + conn = get_conn() + stocks = query_watchlist(conn) + conn.close() + if stocks: + return jsonify({"stocks": stocks}) + except Exception: + pass + return jsonify({"error": "数据库查询失败"}), 500 + + +@app.route("/api/overview") +def api_overview(): + """概览数据""" + try: + from mofin_db import get_conn, query_holdings, query_portfolio_summary, query_latest_market + conn = get_conn() + holdings = query_holdings(conn) + summary = query_portfolio_summary(conn) + market = query_latest_market(conn) + conn.close() + if holdings: + total_assets = summary.get("total_assets", 0) or 0 + stock_value = summary.get("stock_value", 0) or 0 + cash = summary.get("cash", 0) or 0 + position_pct = summary.get("position_pct", 0) or 0 + total_pnl = summary.get("total_pnl", 0) or 0 + top_movers = sorted( + [h for h in holdings if abs(h.get("change_pct", 0) or 0) >= 3], + key=lambda x: abs(x.get("change_pct", 0) or 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": _load_json(DATA_DIR / "alerts.json", [])[:10], + "updated_at": summary.get("updated_at", ""), + }) + except Exception: + return jsonify({"error": "数据库查询失败"}), 500 + + +@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(): + """市场观察""" + try: + from mofin_db import get_conn, query_latest_market + conn = get_conn() + data = query_latest_market(conn) + conn.close() + if data and data.get("sectors"): + return jsonify(data) + except Exception: + pass + return jsonify(_load_json(DATA_DIR / "market.json", {})) + + +# ── 信号API(新增) ───────────────────────────────────── + + +@app.route("/api/signals") +def api_signals(): + """最近信号 + 小果分析""" + try: + from mofin_db import get_conn + conn = get_conn() + signals = conn.execute(""" + SELECT sn.id, sn.sector, sn.overall_sentiment, + sn.summary, sn.source, sn.created_at, + ss.signal_type, ss.severity + FROM signal_news sn + LEFT JOIN sector_signals ss ON sn.signal_id = ss.id + ORDER BY sn.id DESC LIMIT 20 + """).fetchall() + conn.close() + return jsonify([dict(r) for r in signals]) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/xiaoguo-scan") +def api_xiaoguo_scan(): + """小果扫描统计""" + try: + from mofin_db import get_conn + conn = get_conn() + total = conn.execute("SELECT COUNT(*) FROM xiaoguo_scan_tracker").fetchone()[0] + found = conn.execute("SELECT COUNT(*) FROM xiaoguo_scan_tracker WHERE found_count>0").fetchone()[0] + recent = conn.execute(""" + SELECT code, name, last_scanned_at, found_count + FROM xiaoguo_scan_tracker + ORDER BY last_scanned_at DESC LIMIT 20 + """).fetchall() + source_count = conn.execute(""" + SELECT source, COUNT(*) as cnt FROM signal_news + WHERE datetime(created_at) > datetime('now', '-1 day') + GROUP BY source + """).fetchall() + conn.close() + return jsonify({ + "total_scanned": total, + "found_signals": found, + "recent": [dict(r) for r in recent], + "source_today": {r["source"]: r["cnt"] for r in source_count} + }) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +# ── 数据写入API ── + +@app.route("/api/update/portfolio", methods=["POST"]) +def update_portfolio(): + data = request.get_json(force=True) + _save_portfolio(data) + return jsonify({"status": "ok"}) + + +@app.route("/api/update/watchlist", methods=["POST"]) +def update_watchlist(): + data = request.get_json(force=True) + _save_watchlist(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 = read_portfolio() + 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_portfolio(pf) + + # 更新自选 + if "watchlist" in data: + wl = read_watchlist() + 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_watchlist(wl) + + return jsonify({"status": "ok", "updated_at": datetime.now().isoformat()}) + + +# ── 操作决策库API ── +@app.route("/api/decisions", methods=["GET"]) +def get_decisions(): + """返回决策库数据,统一新旧格式""" + raw = read_decisions() + decisions = raw.get("decisions", []) + if not decisions and isinstance(raw, list): + decisions = raw + + # portfolio 用来判断是持仓还是自选 + portfolio = read_portfolio() + watchlist = read_watchlist() + 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 = read_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_decision(code, entry.get('name',''), entry) + 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 = read_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_decision(code, e.get('name',''), e) + return jsonify({"status": "ok", "code": code, "tag": tag}) + + +@app.route("/api/decisions/pending") +def get_pending_decisions(): + """返回所有有未确认建议的条目""" + d = read_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(): + """记录一条分析建议,自动去重(相同code+同天+同方向=跳过)""" + data = request.get_json(force=True) or {} + code = data.get("code", "") + if not code: + return jsonify({"status": "error", "message": "code required"}), 400 + + direction = data.get("direction", "持有") + today = datetime.now().strftime("%Y-%m-%d") + + d = read_decisions() + + entry = None + for e in d["decisions"]: + if e["code"] == code and e["status"] in ("active", "updated"): + entry = e + break + + if not entry: + return jsonify({"status": "error", "message": f"no active decision for {code}"}), 404 + + timeline = entry.setdefault("advice_timeline", []) + + # 去重:同一天+同方向+摘要前40字相似 → 跳过 + summary_short = (data.get("summary", "") or "")[:40] + for a in timeline: + a_date = a.get("date", "")[:10] + a_dir = a.get("direction", "") + a_summary = (a.get("summary", "") or "")[:40] + if a_date == today and a_dir == direction and a_summary == summary_short: + return jsonify({"status": "skipped", "reason": "duplicate", "advice": a}) + + advice = { + "date": datetime.now().strftime("%Y-%m-%d %H:%M"), + "direction": direction, + "price": data.get("price", ""), + "summary": data.get("summary", ""), + "status": "pending", + } + timeline.append(advice) + _save_decision(code, entry.get('name',''), entry) + 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 | executed + result = data.get("result", "") + + d = read_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 + if action == "executed": + timeline[idx]["evaluated"] = True + timeline[idx]["evaluated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M") + if result: + timeline[idx]["result"] = result + _save_decision(code, e.get('name',''), e) + 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(): + """返回所有策略的双维度评估结果""" + # 主数据源:evaluation.json + eval_data = _load_json(DATA_DIR / "evaluation.json", {}) + strategies = eval_data.get("strategies", []) + if strategies: + return jsonify(strategies) + + # 备选:从 decisions.json 的 evaluation 字段读取(尚未反写时的兼容) + decisions = read_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( + ["python3", 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): + """优先用小果GLM-OCR-8bit识别,失败则降级到pytesseract""" + import sys + from PIL import Image, ImageEnhance, ImageFilter + import pytesseract + + # 尝试小果OCR(GLM-OCR-8bit) + try: + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "scripts")) + from ocr_client import ocr_image as xg_ocr + result = xg_ocr(image_path, "请识别这张图片中所有文字,包括股票名称、代码、价格、持股数、金额、百分比等。输出完整内容。") + if result.get("success") and len(result.get("text", "")) > 20: + return result["text"].strip() + except Exception: + pass # 降级到tesseract + + # 降级:Tesseract(预处理优化中文表格识别) + 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: + # DB 优先(price_monitor 维护的实时价) + db_prices = {} + try: + import sqlite3 + db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') + db.row_factory = sqlite3.Row + for code in codes: + row = db.execute("SELECT price, change_pct FROM holdings WHERE code=? AND is_active=1", (code,)).fetchone() + if row and row['price']: + db_prices[code] = (row['price'], row['change_pct'] or 0) + db.close() + except Exception: + pass + + # Fallback: 腾讯 API + need_tencent = [c for c in codes if c not in db_prices] + if need_tencent: + qs = " ".join( + f"hk{c}" if len(c) == 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 need_tencent + ) + 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") + # 优先 DB 价格,再补腾讯 + for stock in stocks: + code = stock.get("code", "") + if code in db_prices: + if not stock.get("price"): + stock["price"] = db_prices[code][0] + elif need_tencent and code in need_tencent: + prefix = "hk" if len(code) == 5 else "sz" if code.startswith(("0","3")) else "sh" if code.startswith("6") else "hk" + m = re.search(rf'{prefix}{code}="([^"]+)"', qt_text) + if m: + fields = m.group(1).split('~') + if not stock.get("name"): + stock["name"] = fields[1] + if not stock.get("price"): + stock["price"] = fields[3] + except: + pass # 行情获取失败不影响主流程 + + # 更新对应数据文件 + if doc_type == "portfolio": + existing = read_portfolio() + 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: + # Use unified formula (includes frozen_cash) + from mo_models import calc_total_assets + existing["total_assets"] = calc_total_assets(existing) + 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_portfolio(existing) + msg = f"更新了 {len(stocks)} 只持仓股" + + elif doc_type == "watchlist": + existing = read_watchlist() + 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_watchlist(existing) + msg = f"更新了 {len(stocks)} 只自选股" + + else: + return jsonify({"error": f"未知类型: {doc_type}"}), 400 + + return jsonify({"status": "ok", "message": msg}) + + +# ── TDX中继实时行情接收API ── +@app.route("/api/update/realtime", methods=["POST"]) +def update_realtime(): + """接收小小莫中继的实时行情数据""" + data = request.get_json(force=True) or {} + stocks = data.get("stocks", []) + source = data.get("source", "unknown") + + if not stocks: + return jsonify({"status": "error", "message": "没有股票数据"}), 400 + + # 更新 portfolio.json 中的实时价格(change_pct字段) + pf = read_portfolio() + pf_holdings = {h["code"]: h for h in pf.get("holdings", [])} + + updated = 0 + for s in stocks: + code = s.get("code", "") + if code in pf_holdings: + pf_holdings[code]["price"] = float(s.get("price", pf_holdings[code].get("price", 0))) + pf_holdings[code]["change_pct"] = float(s.get("change_pct", 0)) + pf_holdings[code]["high"] = float(s.get("high", 0)) + pf_holdings[code]["low"] = float(s.get("low", 0)) + pf_holdings[code]["open"] = float(s.get("open", 0)) + pf_holdings[code]["volume"] = int(s.get("volume", 0)) + pf_holdings[code]["data_source"] = source + pf_holdings[code]["updated_at"] = datetime.now().isoformat() + updated += 1 + + # 也更新 watchlist_stocks 表(DB) + wl = read_watchlist() + wl_stocks = {s["code"]: s for s in wl.get("stocks", [])} + + for s in stocks: + code = s.get("code", "") + if code in wl_stocks: + wl_stocks[code]["price"] = float(s.get("price", wl_stocks[code].get("price", 0))) + wl_stocks[code]["change_pct"] = float(s.get("change_pct", 0)) + + pf["updated_at"] = datetime.now().isoformat() + wl["updated_at"] = datetime.now().isoformat() + _save_portfolio(pf) + _save_watchlist(wl) + + return jsonify({ + "status": "ok", + "updated": updated, + "source": source, + "timestamp": datetime.now().isoformat(), + }) + + +# 注册提示词管理路由 +register_routes(app) + + +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/deploy/profile-scripts/session_to_cron_bridge.py b/deploy/profile-scripts/session_to_cron_bridge.py new file mode 100644 index 00000000..76d76214 --- /dev/null +++ b/deploy/profile-scripts/session_to_cron_bridge.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""session_to_cron_bridge.py — 将Hermes session DB中的cron报告写到cron/output目录 + +Hermes cron jobs(如快速盯盘)将LLM输出存在 session DB (state.db) 中。 +cron_to_xmpp.py 扫描 ~/.hermes/cron/output/ 目录的 .md 文件推送到XMPP。 +这个脚本弥补这个缺口:从state.db读取最新的cron输出,生成.md文件。 + +工作方式: +1. 查询 state.db 中最近的 cron 会话(source='cron') +2. 提取 assistant 的最后一条非空消息 +3. 与 relay journal 对比去重 +4. 新消息写入 cron/output// 目录 +5. cron_to_xmpp.py 自然捡起并推送 +""" + +import json +import sqlite3 +import subprocess +import re +import sys +from datetime import datetime +from pathlib import Path + +REAL_HOME = Path("/home/hmo") +PROFILE = "position-analyst" + +# 要中继的 cron job ID 列表(需要推送到 XMPP 的) +RELAY_JOBS = { + "62a2ba59f7ff": "快速盯盘-15分钟", + "e27e2e92ed80": "知识萃取-盘后", + "9d1236d8a07f": "策略评估-每日", + "5dde4e1a42ce": "分析师-持仓复查", +} + +# 输出目录(与 cron_to_xmpp.py 一致) +# 注意:~/.hermes 是 symlink 到 /home/hmo/.hermes/profiles/position-analyst/home/.hermes +# cron_to_xmpp.py 使用绝对路径 REAL_HOME / ".hermes" / "cron" / "output" +# 所以这里必须用绝对路径,不要相信 ~/.hermes 的解析 +OUTPUT_DIRS = [ + REAL_HOME / ".hermes" / "cron" / "output", + REAL_HOME / ".hermes" / "profiles" / PROFILE / "cron" / "output", +] + +JOURNAL = REAL_HOME / ".hermes" / "cron" / ".relay_journal.json" +STATE_DB = REAL_HOME / ".hermes" / "profiles" / PROFILE / "state.db" + +MAX_AGE_MINUTES = 70 # 只处理最近70分钟内的报告 +TRACK_FILE = REAL_HOME / ".hermes" / "cron" / ".bridge_track.json" # 追踪已桥接的session + + +def load_track(): + try: + return set(json.loads(TRACK_FILE.read_text())) + except: + return set() + + +def save_track(entries): + TRACK_FILE.write_text(json.dumps(sorted(entries))) + + +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 ensure_output_dirs(): + for d in OUTPUT_DIRS: + d.mkdir(parents=True, exist_ok=True) + for job_id in RELAY_JOBS: + (d / job_id).mkdir(exist_ok=True) + + +def extract_report_content(content): + """从assistant消息中提取报告正文""" + if not content or content.strip() in ("", " ", "\n", "\n\n"): + return None + + text = content.strip() + + # 跳过太短的消息 + if len(text) < 20: + return None + + # 跳过 [SILENT] + if "[SILENT]" in text: + return None + + # 跳过思考过程(只留下实际报告内容) + # 如果消息以"Now let me"/"Let me"/"I need"等开头,尝试找后面的报告正文 + lines = text.split('\n') + report_lines = [] + in_report = False + for line in lines: + if not in_report: + # 报告特征:以【开头 或 包含📊 或 包含【知微】 + if any(x in line for x in ["【", "📊", "【知微", "【⚡", "## "]): + in_report = True + report_lines.append(line) + else: + report_lines.append(line) + + if report_lines: + text = '\n'.join(report_lines) + + if len(text) < 20: + return None + + return text + + +def scan(): + processed = load_journal() + tracked = load_track() + new = set() + n_written = 0 + + if not STATE_DB.exists(): + print(f"state.db not found: {STATE_DB}", file=sys.stderr) + return + + conn = sqlite3.connect(str(STATE_DB)) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + now = datetime.now() + + for job_id, job_name in RELAY_JOBS.items(): + # Find recent sessions for this job + cur.execute(''' + SELECT id, started_at, message_count, source + FROM sessions + WHERE id LIKE ? + ORDER BY started_at DESC + LIMIT 10 + ''', (f'cron_{job_id}_%',)) + + sessions = cur.fetchall() + + for s in sessions: + session_id = s['id'] + + # Skip already bridged sessions + if session_id in tracked: + continue + + started_at = datetime.fromtimestamp(s['started_at']) if s['started_at'] else now + + # Skip too old sessions + age_minutes = (now - started_at).total_seconds() / 60 + if age_minutes > MAX_AGE_MINUTES: + continue + + # Find the last assistant message + cur.execute(''' + SELECT content, timestamp + FROM messages + WHERE session_id = ? AND role = 'assistant' + AND content NOT IN ('', ' ', '\n', '\n\n', '\n\n\n') + ORDER BY timestamp DESC + LIMIT 1 + ''', (session_id,)) + + msg = cur.fetchone() + if not msg: + tracked.add(session_id) + continue + + content = msg['content'].strip() + report = extract_report_content(content) + if not report: + tracked.add(session_id) + continue + + # Mark as tracked even before writing + tracked.add(session_id) + + # Generate a unique key for this report + ts = datetime.fromtimestamp(msg['timestamp']).strftime('%Y%m%d_%H%M%S') if msg['timestamp'] else started_at.strftime('%Y%m%d_%H%M%S') + filename = f"{job_name}_{ts}.md" + + for out_dir in OUTPUT_DIRS: + out_path = out_dir / job_id / filename + key = str(out_path.resolve()) + + if key in processed or key in new: + continue + + # Write the report as an .md file (matching cron_to_xmpp.py format) + md_content = f"# Cron Job: {job_name} ({session_id})\n\n## Response\n\n{report}\n" + out_path.write_text(md_content, encoding='utf-8') + new.add(key) + n_written += 1 + print(f" Written: {out_path.relative_to(REAL_HOME)}", file=sys.stderr) + + conn.close() + + if tracked: + save_track(tracked) + + print(f"桥接完成:写入{n_written}份新报告", file=sys.stderr) + # 桥接脚本只负责写入 .md 文件,不做去重追踪 + # 这样可以避免重复推送的复杂问题 + # 可能每次运行会写重复的文件,但cron_to_xmpp.py会用journal去重 + + print(f"桥接完成:写入{n_written}份新报告", file=sys.stderr) + + +if __name__ == "__main__": + scan() diff --git a/deploy/profile-scripts/stale_detector.py b/deploy/profile-scripts/stale_detector.py new file mode 100644 index 00000000..93412551 --- /dev/null +++ b/deploy/profile-scripts/stale_detector.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +"""stale_detector.py — 检查所有策略,标记价格偏离/过期的策略 + +读取 holding_strategies + 自选策略的DB双源数据。 +可被 cron no_agent 模式调用:stdout 注入到后续 LLM 分析。 + +输出格式: + [FLAG] [自选/持仓] 股票名(代码) 价XX | 买入A~B | 问题 + +用法: + python3 stale_detector.py +""" +import json +import sys +import os +from datetime import datetime, timezone +sys.path.insert(0, '/home/hmo/MoFin') +from mo_data import read_portfolio, read_decisions, read_watchlist, get_price, get_prices_batch + + +def fetch_prices(codes): + """统一价格源:优先 stock_quote.py,腾讯API降级为兜底""" + if not codes: + return {} + # 尝试用 stock_quote.py 获取(脚本强制规范) + try: + import subprocess + script = None + for p in ["/home/hmo/MoFin/scripts/stock_quote.py", "/home/hmo/MoFin/stock_quote.py"]: + if os.path.exists(p): + script = p + break + if script: + result = subprocess.run( + [sys.executable, script] + [str(c) for c in codes], + capture_output=True, text=True, timeout=30 + ) + if result.returncode == 0 and result.stdout.strip(): + results = {} + for line in result.stdout.strip().split("\n"): + if not line.strip(): + continue + try: + item = json.loads(line) + code = str(item.get("code", "")) + price = item.get("price") + change = item.get("change_pct", 0) + if code and price is not None: + results[code] = (float(price), float(change)) + except (json.JSONDecodeError, ValueError): + continue + if results: + return results + except Exception as e: + print(f"[STALE] stock_quote.py 回退: {e}", file=sys.stderr) + + # 兜底:mo_data.get_prices_batch + try: + raw = get_prices_batch(codes) + if raw: + return {code: (p, chg) for code, (p, chg) in raw.items()} + except Exception as e: + print(f"FETCH_FAIL (fallback): {e}", file=sys.stderr) + return {} + + +def main(): + decisions_list = read_decisions() + if not isinstance(decisions_list, list): + decisions_list = decisions_list.get("decisions", []) if isinstance(decisions_list, dict) else [] + + # 只保留有买入区的条目,排除已关闭的(inactive/closed) + EXCLUDED_STATUSES = ("closed", "inactive") + to_check = [d for d in decisions_list if (d.get("entry_low") is not None or d.get("entry_high") is not None) and d.get("status") not in EXCLUDED_STATUSES] + + # ----- 补充自选(从 holding_strategies 读取,watchlist_stocks 已废弃) ----- + try: + import sqlite3 + db = sqlite3.connect('/home/hmo/MoFin/data/mofin.db') + db.row_factory = sqlite3.Row + wl_rows = db.execute( + "SELECT code, name, entry_low, entry_high, stop_loss, take_profit, rr_ratio, timing_signal, action " + "FROM holding_strategies WHERE status='active' AND decision_type='自选策略' " + "AND entry_low IS NOT NULL AND entry_high IS NOT NULL" + ).fetchall() + db.close() + existing_codes = {d["code"] for d in to_check} + for row in wl_rows: + code = str(row["code"]) + if code in existing_codes: + continue + entry_low = row["entry_low"] + entry_high = row["entry_high"] + if not entry_low or not entry_high or entry_low <= 0: + continue + action = row["action"] or "" + timing_signal = row["timing_signal"] or "买入" + wl_entry = { + "code": code, + "name": row["name"] or code, + "entry_low": entry_low, + "entry_high": entry_high, + "stop_loss": row["stop_loss"], + "type": "自选策略", + "action": action, + "timing_signal": timing_signal, + } + to_check.append(wl_entry) + except Exception as e: + print(f"[WATCHLIST_MERGE FAIL] {e}", file=sys.stderr) + + if not to_check: + print("[SILENT] 无需要检查的策略") + return 0 + + # ----- 自选股买入区偏离自动重评 (从 holding_strategies 读,watchlist_stocks 已废弃) ----- + try: + import subprocess, sqlite3 + db = sqlite3.connect('/home/hmo/MoFin/data/mofin.db') + db.row_factory = sqlite3.Row + wl_stocks = db.execute( + "SELECT code, name, entry_low, entry_high " + "FROM holding_strategies WHERE status='active' AND decision_type='自选策略' " + "AND entry_low IS NOT NULL AND entry_high IS NOT NULL AND entry_low > 0" + ).fetchall() + db.close() + reassess_scripts = [] + for ws in wl_stocks: + code, name, wl_el, wl_eh = ws + if not wl_el or not wl_el or wl_el <= 0: + continue + center = (wl_el + wl_eh) / 2 + # 从 decisions 拿实时价 + price_map = fetch_prices([code]) + cur_price = price_map.get(code, (None, None))[0] + if not cur_price or cur_price <= 0: + continue + drift = (cur_price / center - 1) * 100 + # 触发条件:价格偏离>15% 或 买入区明确错误(价格完全在区间外且偏离>50%) + price_outside = cur_price < wl_el or cur_price > wl_eh + if abs(drift) > 15 or (price_outside and abs(drift) > 50): + reassess_scripts.append(code) + print(f"[AUTO_REASSESS] {name}({code}) 价{cur_price:.2f}偏离买入区中心{center:.2f} {drift:+.0f}% → 触发重评") + if reassess_scripts: + # 调用 per_stock_reassess + reassess_path = None + for p in ['/home/hmo/MoFin/scripts/per_stock_reassess.py', + '/home/hmo/.hermes/profiles/position-analyst/scripts/per_stock_reassess.py']: + if os.path.exists(p): + reassess_path = p + break + if reassess_path: + for code in reassess_scripts: + r = subprocess.run(['python3', reassess_path, code], + capture_output=True, text=True, timeout=60) + out = r.stdout.strip()[:200] if r.stdout else "" + err = r.stderr.strip()[:200] if r.stderr else "" + print(f" → {code}: exited={r.returncode} {out}") + except Exception as e: + print(f"[AUTO_REASSESS FAIL] {e}") + # ----- 结束 自选股重评 ----- + # 🔁 重评后重新从DB读取策略数据,刷新to_check + try: + decisions_list = read_decisions() + if not isinstance(decisions_list, list): + decisions_list = decisions_list.get("decisions", []) if isinstance(decisions_list, dict) else [] + to_check = [d for d in decisions_list if (d.get("entry_low") is not None or d.get("entry_high") is not None) and d.get("status") not in EXCLUDED_STATUSES] + # 重新合并自选(从 holding_strategies 读) + db2 = sqlite3.connect('/home/hmo/MoFin/data/mofin.db') + db2.row_factory = sqlite3.Row + wl_rows2 = db2.execute( + "SELECT code, name, entry_low, entry_high, stop_loss, take_profit, rr_ratio, timing_signal, action " + "FROM holding_strategies WHERE status='active' AND decision_type='自选策略' " + "AND entry_low IS NOT NULL AND entry_high IS NOT NULL AND entry_low > 0" + ).fetchall() + db2.close() + existing_codes2 = {d["code"] for d in to_check} + for row in wl_rows2: + code = str(row["code"]) + if code in existing_codes2: + continue + entry_low = row["entry_low"] + entry_high = row["entry_high"] + if not entry_low or not entry_high or entry_low <= 0: + continue + action = row["action"] or "" + timing_signal = row["timing_signal"] or "买入" + wl_entry = { + "code": code, + "name": row["name"] or code, + "entry_low": entry_low, + "entry_high": entry_high, + "stop_loss": row["stop_loss"], + "type": "自选策略", + "action": action, + "timing_signal": timing_signal, + } + to_check.append(wl_entry) + except Exception as e: + print(f"[RELOAD FAIL] {e}", file=sys.stderr) + + # ----- 组合级监测:读取总仓位 + 弱势比例 ----- + position_pct = 0 + cash = 0 + total_assets = 0 + try: + pf = read_portfolio() + position_pct = pf.get("position_pct", 0) + cash = pf.get("cash", 0) + total_assets = pf.get("total_assets", 0) + except Exception: + pass + # 统计持仓策略中弱势/深套的比例 + weak_count = 0 + holding_count = 0 + for d in decisions_list: + if d.get("type") == "持仓策略" and d.get("status") not in ("closed", "inactive"): + holding_count += 1 + cat = d.get("stock_category", "") + if cat in ("弱势", "深套"): + weak_count += 1 + weak_ratio = (weak_count / holding_count * 100) if holding_count > 0 else 0 + + prices = fetch_prices([d["code"] for d in to_check]) + now = datetime.now(timezone.utc).astimezone() + found = 0 + + for d in to_check: + code = d["code"] + name = d.get("name", code) + el = d.get("entry_low") + eh = d.get("entry_high") + sl = d.get("stop_loss") + tp = d.get("take_profit") + ts = d.get("created_at") or d.get("timestamp") or d.get("updated_at", "") + is_wl = "自选" in (d.get("type", "")) + + pi = prices.get(code) + if not pi: + continue + price, chg = pi + if price <= 0: + continue + + issues, flags = [], [] + tag = "[自选]" if is_wl else "[持仓]" + + # -- 偏离 -- + if is_wl and not issues and not flags: + # 自选在买入区上沿与20%之间(零标记漏洞):标记为小幅偏离 + if el and eh and price > eh: + flags.append("[WL_DRIFT]") + flags.append("[STRATEGY_STALE]") + issues.append(f"[STRATEGY_STALE] 价{price:.2f}超买入区上沿+{((price/eh)-1)*100:.1f}%,买入区需重评") + if is_wl and el and eh: + # 读取 timing_signal 判断策略有效性(timing_signal 字段优先,fallback to action) + current_str = d.get("current", "") or "" + timing_signal = d.get("timing_signal", "") or current_str + has_nonbuy_signal = any(kw in timing_signal for kw in [ + "等企稳再入", "等企稳", "弱势持有", "观望", + "不建议买入", "谨慎买入", + ]) + + # 直接计算 R/R(不依赖文本匹配) + rr_invalid = False + if sl and sl > 0 and tp and tp > 0 and price > sl: + rr = (tp - price) / (price - sl) + if rr < 1.5: + rr_invalid = True + # 也检查 tp 是否接近或低于成本(微盈/浮亏止盈) + cost = d.get("cost", 0) + if cost and cost > 0 and tp <= cost * 1.05: + rr_invalid = True + + strategy_deficient = has_nonbuy_signal or rr_invalid + # 对自选无止盈位的也标记(策略不完整) + if not tp or tp == 0: + strategy_deficient = True + + if el <= price <= eh: + flags.append("[WL_IN]") + if strategy_deficient: + flags.append("[STRATEGY_STALE]") + issues.append(f"[STRATEGY_STALE] 价{price:.2f}在买入区{el}~{eh}但策略不完整({'RR='+f'{rr:.2f}<1.5' if rr_invalid else '无止盈位' if not tp else '非买入信号'}),买入区需重评") + else: + issues.append(f"[PUSH] 价{price:.2f}入买入区{el}~{eh}") + elif price > eh * 1.35: + flags.append("[WL_HIGH]") + flags.append("[STRATEGY_STALE]") + issues.append(f"[STRATEGY_STALE] 价{price:.2f}高出买入区+{((price/eh)-1)*100:.0f}%,买入区需重评") + elif price > eh * 1.20: + flags.append("[WL_DRIFT]") + flags.append("[STRATEGY_STALE]") + issues.append(f"[STRATEGY_STALE] 价{price:.2f}高出买入区+{((price/eh)-1)*100:.0f}%,买入区需重评") + elif price > eh: + flags.append("[WL_DRIFT]") + flags.append("[STRATEGY_STALE]") + issues.append(f"[STRATEGY_STALE] 价{price:.2f}超买入区上沿+{((price/eh)-1)*100:.1f}%,买入区需重评") + elif not is_wl and eh: + dp = (price / eh - 1) * 100 + if dp > 35: + flags.append("[SEVERE]") + issues.append(f"偏离买入区上沿+{dp:.0f}%") + elif dp > 20: + flags.append("[DRIFT]") + issues.append(f"偏离买入区上沿+{dp:.0f}%") + elif dp > 10: + flags.append("[WARN]") + issues.append(f"偏离买入区上沿+{dp:.0f}%") + # 持仓在买入区内但 R/R 不达标 + if el and sl and sl > 0 and tp and tp > 0 and price > sl: + if el <= price <= eh: + rr = (tp - price) / (price - sl) + if rr < 1.5: + flags.append("[RR_WARN]") + issues.append(f"买入区内RR仅{rr:.2f}<1.5,策略需重评") + + # -- 距止损/止盈(仅持仓) -- + if not is_wl: + if sl and sl > 0: + dsl = (price / sl - 1) * 100 + if dsl < 5: + # 成本基准校验:浮盈>5%时止损是利润保护,不是危险信号 + # (mirrors NEAR_TP cost_check logic at line 195-198) + cost = d.get("cost") + if cost and cost > 0 and price > cost * 1.05: + flags.append("[PROFIT_PROTECT]") + pnl = (price / cost - 1) * 100 + issues.append(f"距止损仅{dsl:.1f}%(利润保护,浮盈{pnl:.0f}%)") + else: + flags.append("[NEAR_SL]") + issues.append(f"距止损仅{dsl:.1f}%") + if tp and tp > 0: + dtp = (tp / price - 1) * 100 + if dtp < 5: + # 成本基准校验:止盈标记只有在盈利≥5%时才有效 + cost_check = True + cost = d.get("cost") + if cost and cost > 0 and price < cost * 1.05: + cost_check = False + if cost_check: + flags.append("[NEAR_TP]") + issues.append(f"距止盈仅{dtp:.1f}%") + + # -- 过期 -- + stale_limit = 30 if is_wl else 14 + if ts: + try: + ud = datetime.fromisoformat(ts) + if ud.tzinfo is None: + ud = ud.replace(tzinfo=timezone.utc) + days = (now - ud).days + if days > stale_limit: + flags.append("[STALE]") + issues.append(f"{days}天未更新(>{stale_limit})") + except (ValueError, TypeError): + pass + + if issues: + # 仅输出有明确操作信号的行:[PUSH]=推荐买入, [STRATEGY_STALE]=需重评 + # 静默其他纯信息行(如仅"价XX高出/高于买入区"而无操作建议) + if any("[PUSH]" in i or "[STRATEGY_STALE]" in i for i in issues): + print(f"{' '.join(flags)} {tag} {name}({code}) 价{price:.2f}{chg} | 买入{el}~{eh} | {'; '.join(issues)}") + found += 1 + + if found == 0: + print("[SILENT] 所有策略正常") + + # ----- 组合级警报 ----- + portfolio_alerts = 0 + if holding_count > 0: + if weak_ratio > 40: + print(f"\n[PORTFOLIO_WEAK] 组合弱势比例{weak_ratio:.0f}% ({weak_count}/{holding_count})!仓位{position_pct:.1f}% → 建议系统性减仓") + portfolio_alerts += 1 + elif weak_ratio > 30: + print(f"\n[PORTFOLIO_WEAK_MILD] 组合弱势比例{weak_ratio:.0f}% ({weak_count}/{holding_count}),仓位{position_pct:.1f}%,关注") + portfolio_alerts += 1 + if position_pct > 80 and holding_count > 0: + # 仓位过满提醒 + print(f"[PORTFOLIO_FULL] 总仓位{position_pct:.1f}% > 80%,现金{cash:.0f}({cash/total_assets*100:.1f}%)") + portfolio_alerts += 1 + if portfolio_alerts > 0: + found += portfolio_alerts + + return found + + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/stale_push_wlin.py b/deploy/profile-scripts/stale_push_wlin.py new file mode 100644 index 00000000..86ac58f7 --- /dev/null +++ b/deploy/profile-scripts/stale_push_wlin.py @@ -0,0 +1,1037 @@ +#!/usr/bin/env python3 +""" +stale_push_wlin.py — 按5步逻辑推送自选股买入区提醒 + 自动触发重评(全DB模式) + +5步逻辑: +1. 筛选 is_watchlist=true 且价在买入区 +2. RR<1.5/无止盈位/非买入signal → 标记 STRATEGY_STALE → 触发自动重评 +3. 可推的:计算每手买入金额和现金占比 +4. 发现 STRATEGY_STALE → 后台跑 per_stock_reassess.py 自动重评 + +所有持仓/策略/现金数据均从DB读取,不再依赖JSON文件。 +宏现上下文和冷却状态仍保留JSON fallback。 +no_agent模式:有推送→输出;无→静默 +搭配 cron: no_agent=True, 交易日每30分跑一次 +""" +import subprocess +import sys, re, json, os, time +import threading +import time +from datetime import datetime, time +from mo_data import read_portfolio, read_decisions, get_price +from mofin_db import get_conn + +# ── MoFin unified model ────────────────────────────────────────────── +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from mo_models import is_hk_stock, get_hk_rate, to_cny, calc_total_assets + +# 市场时段检查 +_MARKET_HOURS = { + 'ashare': (time(9, 30), time(15, 0)), + 'hk': (time(9, 30), time(16, 0)), +} + +def is_ashare(code: str) -> bool: + """判断是否A股代码""" + return code.isdigit() and (code.startswith(('6', '5')) or len(code) in (6,)) + +def market_is_open(code: str, now: datetime = None) -> bool: + """检查某股票对应市场是否在交易时段内""" + if not code: + return True + now = now or datetime.now() + t = now.time() + code_str = str(code) + if code_str.startswith(('0', '1')) and len(code_str) == 5: + # 港股 + start, end = _MARKET_HOURS['hk'] + else: + # A股(含ETF、科创板) + start, end = _MARKET_HOURS['ashare'] + return start <= t <= end +try: + from urllib.request import Request, urlopen +except ImportError: + from urllib2 import Request, urlopen +# 6维评分系统 +sys.path.insert(0, "/home/hmo/MoFin/scripts") +from stock_scorer import score_future_outlook, is_hk_stock, settlement_delay_note + +# ── 趋势检查 ──────────────────────────────────────────────────── +def fetch_trend_data(code): + """取均线数据判断趋势状态。价格从 DB 读取(price_monitor 唯一入口)。返回 (current_price, ma5, trend_label) 或 None""" + # 价格从 DB 读取,不再自拉腾讯 API + current = 0 + try: + db = get_conn() + row = db.execute("SELECT price FROM holdings WHERE code=? AND is_active=1", (code,)).fetchone() + if not row: + row = db.execute("SELECT price FROM watchlist_stocks WHERE code=? AND is_active=1", (code,)).fetchone() + if not row: + row = db.execute("SELECT price FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (code,)).fetchone() + if row: + current = row['price'] or 0 + db.close() + except Exception: + pass + + if current <= 0: + return None + + # K线数据仍从腾讯取(均线计算需要历史K线,DB 里 stock_daily 表有但不一定有最新数据) + try: + prefix = "sh" if code.startswith(('60','68','51','56','50')) else "sz" if code.startswith(('00','30','15')) else "hk" + url = f"http://ifzq.gtimg.cn/appstock/app/fqkline/get?param={prefix}{code},day,,,30,qfq" + req = Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + resp = urlopen(req, timeout=5).read().decode('utf-8') + data = json.loads(resp) + day_key = 'qfqday' if prefix != 'hk' else 'day' + bars = data.get('data', {}).get(f'{prefix}{code}', {}).get(day_key, []) + except: + return None + + if not bars or current <= 0: + return None + closes = [float(b[2]) for b in bars] + if len(closes) < 5: + return None + + def ma(n): + return sum(closes[-n:]) / n + ma5 = ma(5) + ma10 = ma(10) if len(closes) >= 10 else None + ma20 = ma(20) if len(closes) >= 20 else None + + # 趋势分析 + pct_above_ma5 = (current - ma5) / ma5 * 100 + uptrend = False + + if ma20 and ma10: + if ma5 > ma10 > ma20: + trend_label = "多头排列" + uptrend = True + elif current < ma5 and ma5 < ma10 and current < ma10: + trend_label = "空头排列" + elif current > ma5 and ma5 > ma10: + trend_label = "短期转强" + uptrend = True + else: + trend_label = "震荡" + if current > ma5 > ma10: + uptrend = True + else: + trend_label = "数据不足" + + return { + 'price': current, + 'ma5': round(ma5, 2), + 'ma10': round(ma10, 2) if ma10 else None, + 'ma20': round(ma20, 2) if ma20 else None, + 'pct_above_ma5': round(pct_above_ma5, 1), + 'trend': trend_label, + 'uptrend': uptrend, + } + +# ── XMPP +XMPP_BRIDGE = "http://127.0.0.1:5805/" +XMPP_USER = "hmo@yoin.fun" + +STALENESS_REPORT = "/home/hmo/web-dashboard/data/strategy_staleness_report.json" +DETECTOR = "/home/hmo/.hermes/profiles/position-analyst/scripts/stale_detector.py" +REGEN_SCRIPT = "/home/hmo/.hermes/profiles/position-analyst/scripts/per_stock_reassess.py" +REGEN_LOCK = "/tmp/.stale_push_wlin_regen.lock" +MACRO_CTX = "/home/hmo/web-dashboard/data/macro_context.json" +MARKET_JSON = "/home/hmo/web-dashboard/data/market.json" +COOLDOWN_PATH = "/home/hmo/web-dashboard/data/push_cooldown.json" + +NON_BUY_SIGNALS = ["观望", "弱势持有", "深套持有"] + +# 重评冷却:4小时内不重复重评同一股票 +# Dad确认流程: 进区间→重评→(可操作→推荐|不可操作→说明)→冷却期内不再重评+不推重复 +REASSESS_COOLDOWN_HOURS = 4 + + +def get_last_reassess_time(code: str): + """从holding_strategies查最近重评时间""" + try: + db = get_conn() + row = db.execute( + "SELECT updated_at FROM holding_strategies WHERE code=? AND status IN ('active','updated') ORDER BY updated_at DESC LIMIT 1", + (code,) + ).fetchone() + db.close() + if row and row[0]: + return datetime.strptime(row[0][:19], '%Y-%m-%d %H:%M:%S') + except Exception: + pass + return None + + +def is_due_for_reassess(code: str, hours=None) -> bool: + """检查股票是否到重评时间:无历史记录或上次重评超过hours小时""" + if hours is None: + hours = REASSESS_COOLDOWN_HOURS + last = get_last_reassess_time(code) + if last is None: + return True # 从未重评过→需要 + elapsed = datetime.now() - last + return elapsed.total_seconds() > hours * 3600 + + +def load_macro_line(): + """加载大盘和市场的简要描述""" + parts = [] + try: + # 优先 DB + db = get_conn() + row = db.execute( + "SELECT structure FROM macro_context_log " + "WHERE has_valid_data=1 ORDER BY created_at DESC LIMIT 1" + ).fetchone() + db.close() + if row and row[0]: + m = json.loads(row[0]) + else: + raise ValueError("no db data") + overall = m.get("overall", "neutral") + desc = m.get("description", "") + if "bearish" in overall: + parts.append("大盘偏弱") + elif overall == "bullish": + parts.append("大盘偏强") + elif desc: + parts.append(f"大盘{desc}" if not desc.startswith("大盘") else desc) + except Exception: + try: + with open(MACRO_CTX) as f: + m = json.load(f).get("structure", {}) + overall = m.get("overall", "neutral") + desc = m.get("description", "") + if "bearish" in overall: + parts.append("大盘偏弱") + elif overall == "bullish": + parts.append("大盘偏强") + elif desc: + parts.append(f"大盘{desc}" if not desc.startswith("大盘") else desc) + except Exception: + pass + try: + with open(MARKET_JSON) as f: + mk = json.load(f) + mood = mk.get("mood", "") + if mood: + parts.append(f"市场{mood}") + except Exception: + pass + return " | ".join(parts) if parts else "" + + +def is_actionable(cur, timing_signal=""): + """检查信号是否可操作。空文本/含非买入关键词 → 不可操作""" + if not cur and not timing_signal: + return False # 空文本默认不安全 + for kw in NON_BUY_SIGNALS: + if cur and kw.lower() in cur.lower(): + return False + if timing_signal and kw.lower() in timing_signal.lower(): + return False + return True + + +def trigger_regen_sync(stock_codes=None): + """同步执行指定个股的重评(等重评完再发报告)""" + if not stock_codes: + return + try: + cmd = ["python3", REGEN_SCRIPT] + stock_codes + subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + print("[REGEN] 重评超时(60s)", file=sys.stderr) + except Exception as e: + print(f"[REGEN] 重评失败: {e}", file=sys.stderr) + + +def load_cash(): + """从DB实时读可用现金(可用 ≈ 实时买力),不硬编码""" + try: + data = read_portfolio() + if isinstance(data, dict): + # 先读 cash_available(拆分了可用/冻结),fallback 到 cash + return data.get("cash_available", data.get("cash", 0)) + if isinstance(data, list) and len(data) > 1 and isinstance(data[1], dict): + return data[1].get("cash_available", data[1].get("cash", 0)) + return 0 + except Exception: + return 0 + + +_HK_LOT_CACHE = {} + +def hk_lot_size(code): + """从统一入口获取港股实际每手股数,get_price 不提供该字段,默认1000""" + if code in _HK_LOT_CACHE: + return _HK_LOT_CACHE[code] + try: + # 尝试用 get_price 取价,无法获取每手股数,默认1000 + price, chg = get_price(code) + _HK_LOT_CACHE[code] = 1000 + return 1000 + except Exception: + _HK_LOT_CACHE[code] = 1000 + return 1000 + + +def lot_cost(code, price): + if str(code).startswith("688"): + return 200 * price + elif is_hk_stock(code): + lot = hk_lot_size(code) + rate = get_hk_rate() + return int(lot * price * rate) + else: + return 100 * price + + +def push_to_xmpp(text): + """通过知微 HTTP bridge 推送到老爸私信""" + if not text.strip(): + return + try: + payload = json.dumps({ + "to": XMPP_USER, + "body": text.strip(), + "type": "chat", + }).encode("utf-8") + req = Request(XMPP_BRIDGE, data=payload, headers={"Content-Type": "application/json"}) + urlopen(req, timeout=5) + except Exception as e: + print(f"[XMPP推送失败] {e}", file=sys.stderr) + + +def load_cooldown(): + try: + with open(COOLDOWN_PATH) as f: + return json.load(f) + except Exception: + return {} + + +def save_cooldown(cd): + try: + with open(COOLDOWN_PATH, "w") as f: + json.dump(cd, f, indent=2) + except Exception: + pass + + +def in_cooldown(code, action_type, cooldown_dict, minutes=30): + key = f"{code}_{action_type}" + last = cooldown_dict.get(key, 0) + elapsed = datetime.now().timestamp() - last + return elapsed < minutes * 60, elapsed, key + + +def main(): + r = subprocess.run( + ["python3", DETECTOR], capture_output=True, text=True, timeout=60 + ) + if r.returncode != 0 and r.stderr: + print(f"[stderr] {r.stderr.strip()}", file=sys.stderr) + + wl_lines = [ + l for l in r.stdout.split("\n") + if "[WL_IN]" in l and "[自选]" in l + ] + if not wl_lines: + return 0 + + # 读 stale report + try: + with open(STALENESS_REPORT) as f: + report = json.load(f) + except Exception: + report = {"flagged": []} + code_cur = {i["code"]: i.get("current", "") for i in report.get("flagged", [])} + + # 加载冷却状态 + cooldown = load_cooldown() + now_ts = datetime.now().timestamp() + + # ── 从DB读取策略数据 ── + code_data = {} + try: + dec = read_decisions() + for e in dec.get("decisions", []): + code_data[e["code"]] = e + except Exception as _e: + print(f"[DB_LOAD FAIL] {_e}", file=sys.stderr) + + cash = load_cash() + stocks = [] + stale_list = [] + all_candidates = [] # 所有在买入区的自选(stale+non-stale) + + for l in wl_lines: + m = re.match(r'\[WL_IN\](?:\s+\[\w+\])*\s+\[自选\]\s+(\S+)\((\d+)\)', l) + if not m: + continue + name, code = m.group(1), m.group(2) + pm = re.search(r'价(\d+\.\d{2})', l) + if not pm: + continue + price = float(pm.group(1)) + zm = re.search(r'买入([\d.]+)~([\d.]+)', l) + if not zm: + continue + buy_low, buy_high = float(zm.group(1)), float(zm.group(2)) + is_stale = "[STRATEGY_STALE]" in l + cur = code_cur.get(code, "") + + all_candidates.append((name, code, price, buy_low, buy_high, cur, is_stale)) + + if not is_actionable(cur, code_data.get(code, {}).get("timing_signal", "")) or is_stale: + stale_list.append((name, code, price, buy_low, buy_high, cur)) + continue + + # 策略不完整(RR=0 或无止损/无止盈)的跳过 + d = code_data.get(code, {}) + rr = d.get("rr_ratio", 0) or 0 + sl = d.get("stop_loss", 0) or 0 + tp = d.get("take_profit", 0) or 0 + if rr <= 0 or sl <= 0 or tp <= 0: + stale_list.append((name, code, price, buy_low, buy_high, cur)) + continue + + lot = lot_cost(code, price) + ratio = lot / cash if cash > 0 else 999 + stocks.append((name, code, price, buy_low, buy_high, lot, ratio)) + + if not stocks and not stale_list: + return 0 + + now = datetime.now().strftime("%H:%M") + lines = [] + + # 市场背景 + macro_line = load_macro_line() + if macro_line: + lines.append(f"【市场背景】{macro_line}") + + # [关键修复: 2026-07-09] Dad确认流程:进区间→重评→(可操作→推荐|不可操作→说明) + # 冷却期内不再重复重评同一股票:查DB holding_strategies.updated_at + all_codes_in_zone = list(set(s[1] for s in stocks) | set(s[1] for s in stale_list)) + needs_reassess = [c for c in all_codes_in_zone if is_due_for_reassess(c)] + if needs_reassess: + trigger_regen_sync(needs_reassess) + # 重评完成,re-read 最新策略(从DB) + code_data = {} + try: + dec = read_decisions() + for e in dec.get("decisions", []): + code_data[e["code"]] = e + except Exception: + pass + + # 重新过滤:重评后可能有策略变化(止盈/止损/信号变动) + # 去重:同一股票只推送一次(防止两个源重复) + seen_codes = set() + deduped = [] + for item in all_candidates: + code = item[1] + if code not in seen_codes: + seen_codes.add(code) + deduped.append(item) + all_candidates = deduped + + # 补充:从holding_strategies直接扫描可操作的信号(弥补stale_detector遗漏) + try: + import sqlite3 as _sq3 + _db = _sq3.connect('/home/hmo/MoFin/data/mofin.db') + _actionable = _db.execute( + "SELECT hs.code, hs.name, lp.price, lp.change_pct, hs.entry_low, hs.entry_high, " + "hs.stop_loss, hs.take_profit, hs.rr_ratio, hs.timing_signal, hs.action, hs.tech_snapshot, hs.sector_context " + "FROM holding_strategies hs " + "LEFT JOIN live_prices lp ON hs.code = lp.code " + "WHERE hs.status='active' AND hs.timing_signal IN ('买入','可买入','可加仓') " + "AND hs.rr_ratio > 0 AND hs.stop_loss > 0 AND hs.take_profit > 0" + ).fetchall() + _db.close() + for row in _actionable: + code = str(row[0]) + if code in seen_codes: + continue + price = row[2] or 0 + el = row[4] or 0 + eh = row[5] or 0 + if price <= 0 or el <= 0 or eh <= 0: + continue + # 价格必须在买入区内(硬检查,拒绝偏离仍推买入) + if price < el or price > eh: + continue + name = row[1] or code + all_candidates.append((name, code, price, el, eh, "", False)) + seen_codes.add(code) + except Exception: + pass + + # 重建 stocks 列表,用新数据判断(不再用旧 is_stale 标记,因为已全部重评) + stocks = [] + zone_notes = [] # 在操作区间但不可操作→发说明 + for (name, code, price, buy_low, buy_high, cur, is_stale) in all_candidates: + sig = code_data.get(code, {}).get("timing_signal", "") + d = code_data.get(code, {}) + rr = d.get("rr_ratio", 0) or 0 + sl = d.get("stop_loss", 0) or 0 + tp = d.get("take_profit", 0) or 0 + + # 判断重评后的可操作性 + reason = "" + if not is_actionable(cur, sig): + reason = f"信号'{sig}'非可操作方向" + elif rr <= 0 or sl <= 0 or tp <= 0: + reason = f"策略不完整(RR={rr} 损={sl} 盈={tp})" + elif any(kw in sig for kw in ["等企稳", "信号不充分"]): + reason = f"信号'{sig}',暂不建议操作" + + if reason: + # 冷却检查:同股同原因4小时内不发(匹配重评冷却) + ck = f"zone_note_{code}" + now_ts = datetime.now().timestamp() + last = cooldown.get(ck, 0) + if now_ts - last > REASSESS_COOLDOWN_HOURS * 3600: + zone_notes.append((name, code, price, buy_low, buy_high, reason, sig)) + cooldown[ck] = now_ts + continue + + lot = lot_cost(code, price) + ratio = lot / cash if cash > 0 else 999 + stocks.append((name, code, price, buy_low, buy_high, lot, ratio)) + + # 加载portfolio获取持仓信息(A/H去重用) + pf = {"holdings": []} + try: + pf = read_portfolio() + except Exception: + pass + + stocks.sort(key=lambda s: ( + 0 if len(str(s[1])) == 6 else 1, + -code_data.get(s[1], {}).get("rr_ratio", 0) + )) + + # 只展示有清晰操作信号的个股 + # timing_signal 必须是明确操作方向:买入/加仓/观望/关注/信号不充分 + # 行业描述(行业偏弱/行业偏强/大盘变盘等)不是操作信号,一律跳过 + VALID_SIGNALS = {"买入", "加仓", "观望", "关注", "信号不充分"} + SKIP_KEYWORDS = ["等企稳", "信号不充分"] + + actionable = [] + for s in stocks: + sig = code_data.get(s[1], {}).get("timing_signal", "") + if not sig: + continue + # 跳过非操作信号 + if any(kw in sig for kw in SKIP_KEYWORDS): + continue + # 中性信号跳过 + stripped = sig.strip() + if not stripped or stripped.lower() in ("", "neutral", "持有", "深套持有", "弱势持有"): + continue + # 信号必须含买入/加仓才推荐——其他非操作信号跳过 + if not any(kw in sig for kw in ["买入", "加仓"]): + continue + # 价格必须在买入区内(硬检查,拒绝价格偏离仍推买入) + buy_low, buy_high = s[3], s[4] + price_check = s[2] + if buy_low and buy_high and buy_low > 0: + if not (buy_low <= price_check <= buy_high): + continue + # RR完整性检查:买入/加仓信号必须RR>0(策略数据要完整) + cd = code_data.get(s[1], {}) + rr = cd.get("rr_ratio", 0) or 0 + tp = cd.get("take_profit", 0) or 0 + if rr <= 0 or tp <= 0: + # 策略数据不完整(缺止盈/RR),不推 + continue + # 趋势检查:必须不是空头排列(价格在MA5以下且MA5 cash: + continue + + actionable.append(s) + + if not actionable and not zone_notes: + return 0 # 无推荐也无区间说明 → 静默 + + # 加载基本面缓存(PE等) + fund_cache = {} + try: + import multi_timeframe as mtf_mod + mtf = mtf_mod._load_mtf_cache() + for code, v in mtf.items(): + fund_cache[code] = v.get("fundamentals", {}) + except Exception: + pass + + # 仓位计算:从DB读取总资产和现金 + n = len(actionable) + total_assets = 0 + available_cash = 0 + try: + pf = read_portfolio() + available_cash = pf.get("cash_available", pf.get("cash", 0)) or 0 + # 直接取 portfolio 的总资产(导入时已做港币→人民币换算) + total_assets = pf.get("total_assets", 0) or 0 + if total_assets <= 0: + # fallback: use unified calc_total_assets from mo_models + total_assets = calc_total_assets(pf) + except Exception: + total_assets = available_cash * 5 # fallback + + # 加载策略树模块(获取当前情景+分支评估) + st = None + scenario_id = "" + scenario_label = "" + try: + import importlib.util + spec = importlib.util.spec_from_file_location("st_module", "/home/hmo/MoFin/strategy_tree.py") + st = importlib.util.module_from_spec(spec) + spec.loader.exec_module(st) + sc = st.detect_scenario() + scenario_id = sc.get("id", "") + scenario_label = sc.get("label", "") + except Exception: + pass + + def calc_position(lot_cost, rr, market_factor, cat, code=""): + # 理论推荐仓位(% of 总资产) — 仅基于RR+市场+品种,不受现金限制 + if rr >= 5: + theo_pct = 25 + elif rr >= 3: + theo_pct = 18 + elif rr >= 2: + theo_pct = 12 + else: + theo_pct = 8 + if "偏弱" in market_factor: + theo_pct = int(theo_pct * 0.8) + elif "偏强" in market_factor: + theo_pct = int(theo_pct * 1.15) + if cat in ("蓝筹", "白马"): + theo_pct = int(theo_pct * 1.2) + elif cat in ("题材", "短线"): + theo_pct = int(theo_pct * 0.6) + elif cat in ("高波动", "成长"): + theo_pct = int(theo_pct * 0.85) + theo_pct = max(5, min(30, theo_pct)) + + # 当前建议仓位:理论占总资产% → 按现金锁死 + ideal_budget = total_assets * theo_pct / 100 + # 可操作N只时,现金分配不超过 available_cash / n * 1.5 + max_use_cash = (available_cash / max(n, 1)) * 1.5 + budget = min(ideal_budget, max_use_cash, available_cash) + lots = int(budget / lot_cost) if lot_cost > 0 else 0 + + if lots == 0 and lot_cost > 0 and budget > lot_cost * 0.8: + # 预算覆盖超过80%的1手金额 → 至少1手(仅差一档) + lots = 1 + + lot_cost_total = lots * lot_cost + if lots == 0: + pct_actual = 0 + elif total_assets > 0: + pct_actual = round(lot_cost_total / total_assets * 100) + else: + pct_actual = 0 + + if lots == 0: + details = f"预算不足1手({budget:,.0f}/{lot_cost:,.0f}元)" + else: + if len(str(code)) == 5: + hk_lot = hk_lot_size(code) + shares = lots * hk_lot + elif code.startswith("688"): + shares = lots * 200 + else: + shares = lots * 100 + details = f"{lots}手({shares}股,{lot_cost_total:,.0f}元)" + + return theo_pct, pct_actual, details, lots, lot_cost_total + + # ── 换仓评估 ────────────────────────────────────────────────────── + # score_future_outlook 从 stock_scorer 模块导入(6维评分) + + def evaluate_swap(lot_cost_target, rr, sig, tp, sl, name, code, price_in, + total_assets_in, cash_in, pf_in, cd_in): + """现金不足时评估是否卖差票换推荐股。 + + 核心逻辑: + - 已发生的亏损是沉没成本,不参与决策 + - 用6维评分法评估每个持仓的未来前景(基于决策系统既有数据) + - 优先卖前景最差的票,保留前景好的票(无论当前盈亏%) + - 卖港股→买A股需T+2到账,如果推荐此方案则标注延迟风险 + - 对目标票(RR>=3+买入信号)才有换仓资格 + + 返回(推荐文案str, 缺口float)或 (None, gap) + """ + gap = lot_cost_target - cash_in + # 目标票质量门槛 + if rr < 3.0 or gap <= 0 or gap > total_assets_in * 0.5: + return None, gap + if not any(kw in sig for kw in ["买入", "加仓", "建仓"]): + return None, gap + + # 收集持仓数据 + 前景评分 + ph = [] + for h in pf_in.get("holdings", []): + hs = h.get("shares", 0) or 0 + hp = h.get("price", 0) or 0 + hc = h.get("cost", 0) or 0 + if hs <= 0 or hp <= 0: + continue + hmv = hs * hp + # 港股价格已是 CNY(price_monitor 写入时已转),不需要再乘汇率 + hpl_pct = (hp - hc) / hc * 100 if hc else 0 + + # 6维全面评分(越低越差,越建议卖) + fscore, _ = score_future_outlook(h_code, cd_in) + + ph.append({ + "code": h_code, + "name": h.get("name", ""), + "shares": hs, + "price": hp, + "cost": hc, + "mv": round(hmv), + "pl_pct": round(hpl_pct, 1), + "score": fscore, + }) + + # 按前景评分升序(最差的排最前面) + ph.sort(key=lambda x: x["score"]) + + # 打印调试信息:所有持仓的前景评分 + # print(f"[SWAP_DEBUG] 前景评分(越低越差):", file=sys.stderr) + # for x in ph[:10]: + # print(f" {x['name']}({x['code']}) 评分{x['score']} 亏{x['pl_pct']}% 市值{x['mv']:,}", file=sys.stderr) + + # 只考虑评分<=0(前景差或中性偏弱)的作为减仓候选 + candidates = [h for h in ph if h["score"] <= 0] + if not candidates: + return None, gap + + # 贪心选评分最差的,凑够现金缺口(最多2只) + selected = [] + cash_freed = 0 + for h in candidates: + if cash_freed >= gap: + break + cash_freed += h["mv"] + selected.append(h) + + if cash_freed < gap or len(selected) > 2: + return None, gap + + # 计算目标票的预期涨幅 + if tp and tp > 0: + target_gain_pct = (tp - price_in) / price_in * 100 + else: + target_gain_pct = rr * 3 + + # 构建推荐文案 + buy_is_a = not is_hk_stock(code) # 目标是否是A股 + sell_parts = [] + sell_names = [] + settlement_warnings = [] + for h in selected: + # 每个被选股票配一句"为什么卖它" + reason = f"评分{h['score']}" + if h['pl_pct'] <= -30: + reason += "深套" + elif h['pl_pct'] <= -15: + reason += f"亏损{h['pl_pct']}%" + sell_parts.append(f"{h['name']}({h['code']}) {h['shares']}股 亏{h['pl_pct']}% ({reason})") + sell_names.append(h['name']) + # 检查结算延迟:卖港股→买A股 + if is_hk_stock(h['code']) and buy_is_a: + settlement_warnings.append(f"{h['name']}是港股通,卖出需T+2到账才能买A股") + sell_desc = ";".join(sell_parts) + + new_budget = cash_in + cash_freed + new_lots = int(new_budget / lot_cost_target) if lot_cost_target > 0 else 0 + if new_lots == 0: + return None, gap + if code.startswith("688"): + new_shares = new_lots * 200 + elif len(code) <= 5: + new_shares = new_lots * hk_lot_size(code) + else: + new_shares = new_lots * 100 + new_cost = new_lots * lot_cost_target + new_pct = round(new_cost / total_assets_in * 100) if total_assets_in > 0 else 0 + + text = ( + f"换仓建议:卖{sell_desc}" + f"→腾{round(cash_freed):,}元" + f"→买{name}({code}) {new_lots}手({new_shares}股,{round(new_cost):,}元)" + f"占{new_pct}%仓位" + f"(止损{sl}(-{round((price_in-sl)/price_in*100,1)}%)" + f"止盈{tp}(+{round(target_gain_pct,1)}%)" + f" RR={rr})\n" + f" 理由:{', '.join(sell_names)}评分最低," + f"继续持有无积极信号且技术偏弱;" + f"换到有明确信号和止损的标的,预期收益更优。" + ) + if settlement_warnings: + text += "\n ⚠️ " + " | ".join(settlement_warnings) + return text, gap + + # 标准格式:每个可操作标的 — 大盘/行业/个股三面 + 仓位 + if actionable: + lines.append(f"【💡 操作建议】(当前{len(actionable)}只自选可操作 | 总资产{total_assets:,.0f}元 现金{available_cash:,.0f}元)") + for s in actionable: + name, code, price, buy_low, buy_high, lot, ratio = s + d = code_data.get(code, {}) + sl = d.get("stop_loss", 0) + tp = d.get("take_profit", 0) + rr = d.get("rr_ratio", 0) + sig = d.get("timing_signal", "") + sector = d.get("sector_context", "") + tech = d.get("tech_snapshot", "") + mtf_ctx = d.get("multi_tf_context", "") + note = d.get("note", "") + d_factors = d.get("signal_factors", []) + cat = d.get("stock_category", "") + + # 提取技术位 + ss = {"强撑":"-", "弱撑":"-", "弱压":"-", "强压":"-"} + for tag in ss: + m = re.search(rf'{tag}:([\d.]+)', tech) + if m: + ss[tag] = m.group(1) + + # 基本面 + fund = fund_cache.get(code, {}) + pe = fund.get("pe", 0) + eps = fund.get("eps", 0) + pe_str = f"PE{pe:.0f}" if pe else "" + eps_str = f"EPS{eps:.2f}" if eps else "" + + # 从 signal_factors 提取各维度 + def _match_factor(prefix): + for f in d_factors: + if f.startswith(prefix): + return f + return "" + + market_factor = _match_factor("大盘") + sector_factor = _match_factor("行业") + value_factor = _match_factor("高估值") or _match_factor("低估值") or _match_factor("蓝筹") or pe_str or "" + news_factor = _match_factor("消息") + tech_factor = _match_factor("净利") or _match_factor("组合") or "" + + # 构建分析行 + parts = [] + if market_factor: + parts.append(f"大盘{market_factor.replace('大盘','')}") + if sector_factor: + parts.append(f"行业{sector_factor.replace('行业','')}") + if pe_str or value_factor: + parts.append(value_factor or pe_str) + if news_factor: + parts.append(news_factor) + if not parts: + parts.append(sector or cat or "") + + analysis = " | ".join(p for p in parts if p) + + # 仓位计算 + theo_pct, actual_pct, details, lots, lot_cost_total = calc_position( + lot, rr, market_factor, cat, code + ) + + pfx = "" if len(code) == 6 else "HK$" + + # 取分支动作类型 + branch_action = "hold" + branch_rationale = "" + if st and scenario_id: + try: + results = st.evaluate_branches(code, scenario_id, price, d.get("shares", 0), d.get("cost", 0)) + applicable = [r for r in results if r.get("applicable")] + if applicable: + best = min(applicable, key=lambda r: r.get("priority", 999)) + branch_action = best.get("action_type", "hold") + branch_rationale = best.get("rationale", "") + except Exception: + pass + + # 冷却检查:相同股+相同操作30分钟内不发 + cooled, elapsed, cd_key = in_cooldown(code, branch_action, cooldown) + if cooled: + continue + + # 策略质量过滤:只有正向/中性信号才推操作建议 + bad_keywords = ["偏弱", "弱势", "观望", "卖出", "回避", "回避"] + if any(kw in sig for kw in bad_keywords): + continue + + # 行业背景过滤:行业大跌时不在买入区推荐(即使个股信号好) + if "大跌" in sector: + continue + + # 换仓评估:现金不足时评估是否卖差票换推荐股 + swap_text = None + if lots == 0: + swap_text, _ = evaluate_swap( + lot, rr, sig, tp, sl, name, code, price, + total_assets, available_cash, pf, code_data + ) + + action_tag = "🛒" if (lots > 0 or swap_text) else "⚠️" + + lines.append( + f" {action_tag} {name}({code}) {pfx}{price:.2f} 买区{buy_low}~{buy_high} | " + f"1手{lot:,.0f}元 RR={rr:.1f} 损{sl} 盈{tp}\n" + f" {analysis}\n" + f" 技术{ss['强撑']}→{ss['弱撑']}→{ss['弱压']}→{ss['强压']} | 信号{sig}\n" + f" 仓位:理论{theo_pct}%×总资产 | 建议{actual_pct}%({details})" + ) + + if mtf_ctx: + lines[-1] += f"\n 均线{mtf_ctx}" + + if swap_text: + lines[-1] += f"\n {swap_text}" + + # 分支描述 + branch_line = "" + if branch_action != "hold": + branch_line = f" 【{scenario_label}→{branch_action}】{branch_rationale}" + if branch_line: + lines[-1] += f"\n{branch_line}" + + # 记录推送时间(冷却计时用) + cooldown[cd_key] = now_ts + + save_cooldown(cooldown) + + # 修正可操作数量(剔除冷却跳过后的实际数量) + if actionable: + actual_n = sum( + 1 for ln in lines + if ln.startswith(" 🛒") or ln.startswith(" ⚠️") + ) + if actual_n != len(actionable): + for i, ln in enumerate(lines): + if "【💡 操作建议】" in ln: + if actual_n > 0: + lines[i] = f"【💡 操作建议】(当前{actual_n}只自选可操作 | 总资产{total_assets:,.0f}元 现金{available_cash:,.0f}元)" + else: + lines.pop(i) # 全部冷却,移除空标题 + break + + # 检查最终是否还有内容要推 + has_actionable = any( + ln.startswith(" 🛒") or ln.startswith(" ⚠️") for ln in lines + ) + if not has_actionable and not zone_notes: + return 0 # 全部冷却+无区间说明 → 静默 + + # ── T+2前瞻:扫描近期可能入买区的A股,提前准备现金 ── + t2_lines = [] + try: + dec_t2 = read_decisions() + for entry in dec_t2.get("decisions", []): + if entry.get("status") == "closed" or entry.get("type") != "自选策略": + continue + ec = entry["code"] + el = entry.get("entry_low", 0) or 0 + eh = entry.get("entry_high", 0) or 0 + ep = entry.get("price", 0) or 0 + if not eh or not ep or el <= 0: + continue + # A股+价格在买入区上方5%以内(即将进入买入区) + if not is_hk_stock(ec) and el <= ep <= eh * 1.05 and ep > eh: + anticipation_pct = (ep - eh) / eh * 100 + lot = lot_cost(ec, ep) + if lot > available_cash: + # 现金不足 → 卖港股提前准备 + ph = [] + for h in pf.get("holdings", []): + hs = h.get("shares", 0) or 0 + hp = h.get("price", 0) or 0 + hc = h.get("cost", 0) or 0 + if hs <= 0 or hp <= 0 or not is_hk_stock(h.get("code","")): + continue + sc = score_future_outlook(h.get("code",""), code_data) + ph.append((sc, h)) + ph.sort(key=lambda x: x[0]) + if ph: + worst = ph[0][1] + w_name = worst.get("name","?") + w_code = worst.get("code","") + w_price = worst.get("price",0) + w_shares = worst.get("shares",0) + w_value = w_price * w_shares + if w_value >= lot: + name_e = entry.get("name","") + t2_lines.append( + f" ⏳ {name_e}({ec})距买入区仅{anticipation_pct:.0f}%," + f"需{lot:,.0f}元。建议提前卖{w_name}({w_code})" + f"腾{w_value:,.0f}元(T+2到账后可用)" + ) + except: + pass + + if t2_lines: + lines.append("") + lines.append("【⏳ 提前准备(T+2港股提前出清)】") + lines.extend(t2_lines) + + # 操作区间内但重评后不可操作的→发说明 + if zone_notes: + lines.append("") + lines.append("【📋 操作区间提醒】进入操作区间,但重评后不构成推荐操作:") + for name, code, price, buy_low, buy_high, reason, sig in zone_notes: + lines.append( + f" {name}({code}) 价{price:.2f} 区间{buy_low}~{buy_high} " + f"→ 进入操作区间,但重评结果: {reason}" + ) + + # 标题:有推荐操作→"自选买入提醒",仅有区间说明→"操作区间提醒" + if has_actionable: + lines.insert(0, f"【知微】自选买入提醒 {now} | 总资产{total_assets:,.0f}元") + else: + lines.insert(0, f"【知微】操作区间提醒 {now} | 总资产{total_assets:,.0f}元") + out = "\n".join(lines) + print(out) + push_to_xmpp(out) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/deploy/profile-scripts/stock_profile.py b/deploy/profile-scripts/stock_profile.py new file mode 100644 index 00000000..259ed456 --- /dev/null +++ b/deploy/profile-scripts/stock_profile.py @@ -0,0 +1,420 @@ +#!/usr/bin/env python3 +"""stock_profile.py — 个股综合画像系统 + +将宏观、行业、基本面、技术面四维数据整合为一只股票的完整画像。 +输出:分类(短炒/中短线/中长线/深套)、综合评分、操作建议基调。 +""" + +import json +import os +from datetime import datetime +from typing import Optional +from mo_data import read_portfolio, read_decisions, read_watchlist, get_price + +DATA_DIR = "/home/hmo/web-dashboard/data" +MTF_CACHE_PATH = os.path.join(DATA_DIR, "multi_tf_cache.json") +MACRO_PATH = os.path.join(DATA_DIR, "macro_context.json") +PORTFOLIO_PATH = os.path.join(DATA_DIR, "portfolio.json") + +def get_quote(code: str) -> dict: + """获取实时行情。使用 mo_data.get_price 统一入口,保持dict格式兼容""" + price, change_pct = get_price(code) + if price is None: + return {"code": code, "error": "价格获取失败"} + + result = { + "code": code, + "name": "", + "price": price, + "change_pct": change_pct or 0, + "high": None, + "low": None, + "pe": None, + "pb": None, + "eps": None, + "market_cap": None, + "market_cap_流通": None, + "high_52w": None, + "low_52w": None, + "turnover_rate": None, + "amplitude": None, + "sector": None, + "outer_vol": None, + "inner_vol": None, + } + return result + + +def load_mtf_cache() -> dict: + try: + with open(MTF_CACHE_PATH) as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def load_macro() -> dict: + """加载宏观上下文,优先DB""" + try: + import sqlite3 + conn = sqlite3.connect(os.path.join(DATA_DIR, "mofin.db")) + row = conn.execute( + "SELECT indices, structure, key_sectors FROM macro_context_log " + "WHERE has_valid_data=1 ORDER BY created_at DESC LIMIT 1" + ).fetchone() + conn.close() + if row: + return {"indices": json.loads(row[0] or "{}"), + "structure": json.loads(row[1] or "{}"), + "key_sectors": json.loads(row[2] or "[]")} + except: + pass + try: + with open(MACRO_PATH) as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def get_fundamental_rating(f: dict) -> dict: + """基本面评分""" + pe = f.get("pe") + pb = f.get("pb") + eps = f.get("eps") + mcap = f.get("market_cap") + high_52w = f.get("high_52w") + low_52w = f.get("low_52w") + price = f.get("price") + chg = f.get("change_pct") + + score = 50 # 基准分 + signals = [] + details = {} + + # PE评估 + if pe and pe > 0: + details["pe"] = round(pe, 2) + if pe < 15: + score += 15 + signals.append("PE低估值") + elif pe < 30: + score += 8 + signals.append("PE合理") + elif pe < 60: + score += 0 + signals.append("PE偏高") + else: + score -= 10 + signals.append("PE>60高估") + elif pe and pe < 0: + details["pe"] = round(pe, 2) + score -= 5 + signals.append("PE为负(亏损)") + + # PB评估 + if pb and pb > 0: + details["pb"] = round(pb, 2) + if pb < 1.5: + score += 10 + signals.append("PB低") + elif pb < 3: + score += 5 + signals.append("PB合理") + elif pb < 8: + score += 0 + else: + score -= 5 + signals.append("PB>8偏高") + elif pb and pb < 0: + score -= 5 + + # EPS评估 + if eps: + details["eps"] = round(eps, 2) + if eps > 2: + score += 10 + signals.append("EPS优秀>2") + elif eps > 0.5: + score += 5 + signals.append("EPS良好") + elif eps > 0: + score += 2 + else: + score -= 5 + signals.append("EPS为负") + + # 52周位置 + if high_52w and low_52w and price and high_52w > low_52w: + position = (price - low_52w) / (high_52w - low_52w) + details["52w_position"] = round(position, 2) + if position < 0.2: + score += 10 + signals.append("近52周低位") + elif position < 0.4: + score += 5 + signals.append("52周偏低") + elif position > 0.8: + score -= 5 + signals.append("近52周高位") + elif position > 0.95: + score -= 10 + signals.append("52周顶部区域") + + # 市值评估(大盘股加分) + if mcap and mcap > 0: + details["market_cap_亿"] = round(mcap, 0) + if mcap > 1000: + score += 5 + signals.append("大盘股") + elif mcap > 100: + score += 0 + else: + score -= 5 + signals.append("小盘股") + + return { + "score": max(0, min(100, score)), + "signals": signals, + "details": details, + } + + +def classify_stock(f: dict, mtf: dict, macro: dict) -> dict: + """股票分类:短炒 / 中短线 / 中长线 / 深套持有""" + price = f.get("price", 0) + pe = f.get("pe") or 0 + chg = f.get("change_pct", 0) + eps = f.get("eps") or 0 + + # 多周期趋势 + mtf_adj = mtf.get("strategy_adjustment", {}) + trend_align = mtf_adj.get("trend_alignment", "未知") + mtf_daily = mtf.get("daily", {}) + mtf_trend = mtf_daily.get("trend", {}) + mas = mtf_daily.get("mas", {}) + ma20 = mas.get("ma20") or 0 + ma60 = mas.get("ma60") or 0 + + # 基本面得分 + fund_rating = get_fundamental_rating(f) + fund_score = fund_rating["score"] + + # 短期涨幅判断(近20日) + mtf_sr = mtf_daily.get("support_resistance", {}) + high_20d = mtf_sr.get("high_52w", price) # 近20日最高 + low_20d = mtf_sr.get("low_52w", price) # 近20日最低 + recent_volatility = ((high_20d - low_20d) / low_20d * 100) if low_20d > 0 else 0 + + # ---- 分类逻辑 ---- + category = "中短线" + reason = [] + position_suggestion = "" + time_horizon = "" + + # 1. 深套检查 + cost = 0 + try: + pf = mo_data.read_portfolio() + for h in pf.get("holdings", []): + if h.get("code") == f.get("code"): + cost = h.get("cost", 0) or 0 + break + except Exception: + pass + + if cost > 0 and price > 0: + profit_pct = (price - cost) / cost * 100 + else: + profit_pct = 0 + + if profit_pct < -20: + category = "深套持有" + reason.append(f"浮亏{profit_pct:.0f}%") + position_suggestion = "不补不割,等趋势反转" + time_horizon = "长期" + return { + "category": category, + "reasons": reason, + "fundamental_score": fund_score, + "position_suggestion": position_suggestion, + "time_horizon": time_horizon, + "volatility_20d": round(recent_volatility, 1), + } + + # 2. 短线爆发判断 + # 特征:近20日振幅大(>30%)、涨幅大、PE可能极高或为负、换手率高 + recent_chg = chg or 0 + is_high_volatility = recent_volatility > 30 + is_momentum = is_high_volatility and (pe > 100 or pe < 0) + is_turnover_high = f.get("turnover_rate", 0) or 0 > 10 if f.get("turnover_rate") else False + + if is_momentum or (is_high_volatility and is_turnover_high): + category = "短炒" + if is_momentum: + reason.append("高波动+高PE题材驱动") + if is_turnover_high: + reason.append(f"换手率{f.get('turnover_rate',0):.1f}%活跃") + position_suggestion = "小仓位快进快出,止损严格" + time_horizon = "数日~2周" + # 3. 中长线判断 + # 特征:基本面好(PE合理<30、EPS>1、大盘股)、多周期看多、行业向好 + elif (fund_score >= 65 and pe < 30 and eps > 0.5) or \ + (trend_align == "多周期看多" and fund_score >= 55 and ma20 > 0 and price > ma20): + category = "中长线" + if fund_score >= 65: + reason.append(f"基本面良好({fund_score}分)") + if trend_align == "多周期看多": + reason.append("多周期共振看多") + if pe and pe < 20: + reason.append(f"PE{pe:.0f}低估") + position_suggestion = "正常仓位配置,趋势不破不走" + time_horizon = "数月~1年" + # 4. 中短线(默认) + else: + category = "中短线" + if fund_score >= 50: + reason.append("基本面中等") + else: + reason.append(f"基本面偏弱({fund_score}分)") + if trend_align in ("震荡/无明显方向", "多周期分化"): + reason.append("方向不明") + position_suggestion = "中等仓位,技术面操作为主" + time_horizon = "2周~3月" + + return { + "category": category, + "reasons": reason, + "fundamental_score": fund_score, + "position_suggestion": position_suggestion, + "time_horizon": time_horizon, + "volatility_20d": round(recent_volatility, 1), + } + + +def full_profile(code: str) -> dict: + """完整个股画像""" + # 获取实时行情+基本面 + quote = get_quote(code) + if "error" in quote: + return {"code": code, "error": quote["error"]} + + # 多周期技术面 + from multi_timeframe import full_multi_tf_analysis + mtf = full_multi_tf_analysis(code) + + # 宏观环境 + macro = load_macro() + + # 基本面评分 + fund_rating = get_fundamental_rating(quote) + + # 股票分类 + classification = classify_stock(quote, mtf, macro) + + # 综合评分(四维加权) + # 技术面得分: 从multi_timeframe的趋势判断中提取 + tech_score = 50 + mtf_daily = mtf.get("daily", {}) + mtf_trend = mtf_daily.get("trend", {}) + if mtf_trend.get("trend") == "up": + tech_score = 70 + elif mtf_trend.get("trend") == "strong_up": + tech_score = 85 + elif mtf_trend.get("trend") == "down": + tech_score = 30 + elif mtf_trend.get("trend") == "strong_down": + tech_score = 15 + elif mtf_trend.get("trend") == "sideways": + tech_score = 50 + + # 行业得分(从宏观读取该行业表现) + sector_score = 50 + macro_structure = macro.get("structure", {}) + sector_map = { + "芯片": ["688981", "00981", "300548"], + "信息技术": ["00700", "300124"], + "新能源电池": ["300750", "300035", "600110"], + "机器人": ["300124"], + "周期": ["601899", "600739"], + "蓝筹": ["600036", "02318", "00700"], + } + # 找该股票所属行业 + for sector_name, sector_codes in sector_map.items(): + if code in sector_codes or any(code.startswith(c[:2]) for c in sector_codes): + sector_data = macro_structure.get(sector_name) + if sector_data: + chg_val = sector_data if isinstance(sector_data, (int, float)) else \ + float(str(sector_data).replace("%", "").replace("+", "")) if sector_data else 0 + if chg_val > 1: + sector_score = 70 + elif chg_val < -1: + sector_score = 30 + else: + sector_score = 50 + break + + # 宏观得分(整体市场情绪) + macro_score = 50 + overall = macro_structure.get("overall", "neutral") + if overall == "strong_bullish": + macro_score = 80 + elif overall == "bullish": + macro_score = 65 + elif overall == "bearish": + macro_score = 35 + elif overall == "strong_bearish": + macro_score = 20 + + # 综合评分 = 基本面30% + 技术面30% + 行业20% + 宏观20% + composite = round( + fund_rating["score"] * 0.30 + + tech_score * 0.30 + + sector_score * 0.20 + + macro_score * 0.20 + ) + + return { + "code": code, + "name": quote.get("name", ""), + "price": quote.get("price"), + "change_pct": quote.get("change_pct"), + "fundamentals": { + "pe": quote.get("pe"), + "pb": quote.get("pb"), + "eps": quote.get("eps"), + "market_cap": quote.get("market_cap"), + "high_52w": quote.get("high_52w"), + "low_52w": quote.get("low_52w"), + "rating": fund_rating, + }, + "classification": classification, + "multi_timeframe": { + "daily_ma_trend": mtf.get("daily", {}).get("trend", {}).get("ma_trend", ""), + "weekly_trend": mtf.get("weekly", {}).get("trend", {}).get("description", ""), + "monthly_trend": mtf.get("monthly", {}).get("trend", {}).get("description", ""), + "trend_alignment": mtf.get("strategy_adjustment", {}).get("trend_alignment", ""), + "ma20": mtf.get("daily", {}).get("mas", {}).get("ma20"), + "ma60": mtf.get("daily", {}).get("mas", {}).get("ma60"), + }, + "scoring": { + "fundamental": fund_rating["score"], + "technical": tech_score, + "sector": sector_score, + "macro": macro_score, + "composite": composite, + }, + "strategy_note": ( + f"{classification['category']} | " + f"综合{composite}分(基本{fund_rating['score']}/技术{tech_score}/行业{sector_score}/宏观{macro_score}) | " + f"{classification['position_suggestion']}" + ), + } + + +if __name__ == "__main__": + import sys + codes = sys.argv[1:] or ["300548", "600110", "600036"] + for code in codes: + p = full_profile(code) + print(json.dumps(p, ensure_ascii=False, indent=2)) + print("-" * 60) diff --git a/deploy/profile-scripts/stock_quote.py b/deploy/profile-scripts/stock_quote.py new file mode 100644 index 00000000..951e5dde --- /dev/null +++ b/deploy/profile-scripts/stock_quote.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +""" +stock_quote.py — 统一股票行情查询工具(唯一权威价格源) + +用法: + python3 stock_quote.py 688411 # 单个A股 + python3 stock_quote.py 688411 01211 300750 # 批量 + python3 stock_quote.py --all-holdings # 所有持仓 + +输出:每只股票一行JSON,格式统一、无歧义。 +LLM 禁止直接解析新浪/腾讯原始CSV,只能读本脚本输出。 + +数据来源(按优先级降序): + A股: 东财push2 → 新浪hq → 腾讯qt + 港股: 东财并行限速(5 workers) → 新浪批量 → 腾讯15min延迟(兜底) + +验证规则: + - price 必须在 [low, high] 范围内 + - change_pct 必须与 (price - prev_close) / prev_close 一致(±0.1%容差) + - 任一验证失败 → 该数据源降级,尝试下个源 +""" + +import json, sys, re, time, urllib.request +from concurrent.futures import ThreadPoolExecutor, as_completed +from threading import Semaphore +from pathlib import Path +from datetime import datetime + +UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" +DATA_DIR = Path("/home/hmo/MoFin/data") + +# ── 工具 ── + +def _http_get(url, headers=None, timeout=10): + """带代理绕过的HTTP GET""" + req = urllib.request.Request(url, headers=headers or {"User-Agent": UA}) + proxy_handler = urllib.request.ProxyHandler({}) + opener = urllib.request.build_opener(proxy_handler) + try: + with opener.open(req, timeout=timeout) as r: + return r.read().decode("utf-8", errors="replace") + except Exception: + return None + + +def _detect_market(code): + """自动识别A股/港股 + A股代码固定6位,港股代码5位(含前缀0如00700)。 + """ + code = str(code).strip() + if len(code) == 6: + return "ashare" + if len(code) <= 5: + return "hk" + return "unknown" + + +def _sym(code, market): + """转成API可用的symbol""" + if market == "ashare": + pre = "sh" if code.startswith(("5", "6", "9")) else "sz" + return pre + code, code + return code, code # HK codes used directly + + +# ── 数据源:东方财富(A股+港股通用) ── + +def _em_ashare(code): + """东财A股API""" + url = f"https://push2.eastmoney.com/api/qt/stock/get?secid=1.{code}&fields=f43,f44,f45,f46,f47,f50,f57,f58,f60,f170,f169&fltt=2" + raw = _http_get(url, headers={"User-Agent": UA, "Referer": "https://quote.eastmoney.com/"}) + if not raw: + return None + try: + resp = json.loads(raw) + if resp.get("rc") != 0: + return None + d = resp.get("data") + if not d: + return None + price = d.get("f43") + if price is None or float(price) <= 0: + return None + return { + "code": code, + "name": None, # 东财不返名称 + "price": float(price), + "change_pct": float(d.get("f170", 0)) if d.get("f170") is not None else None, + "high": float(d.get("f44", 0)) if d.get("f44") else None, + "low": float(d.get("f45", 0)) if d.get("f45") else None, + "open": float(d.get("f46", 0)) if d.get("f46") else None, + "prev_close": float(d.get("f60", 0)) if d.get("f60") else None, + "volume": d.get("f47"), + "amount": d.get("f50"), + "source": "eastmoney", + } + except (json.JSONDecodeError, ValueError, TypeError): + return None + + +def _em_hk(code): + """东财港股API(单股)""" + url = f"https://push2.eastmoney.com/api/qt/stock/get?secid=116.{code}&fields=f43,f44,f45,f46,f47,f50,f57,f58,f60,f170,f169&fltt=2" + raw = _http_get(url, headers={"User-Agent": UA, "Referer": "https://quote.eastmoney.com/"}) + if not raw: + return None + try: + resp = json.loads(raw) + if resp.get("rc") != 0: + return None + d = resp.get("data") + if not d: + return None + price = d.get("f43") + if price is None or float(price) <= 0: + return None + return { + "code": code, + "name": None, + "price": float(price), + "change_pct": float(d.get("f170", 0)) if d.get("f170") is not None else None, + "high": float(d.get("f44", 0)) if d.get("f44") else None, + "low": float(d.get("f45", 0)) if d.get("f45") else None, + "open": float(d.get("f46", 0)) if d.get("f46") else None, + "prev_close": float(d.get("f60", 0)) if d.get("f60") else None, + "volume": d.get("f47"), + "amount": d.get("f50"), + "source": "eastmoney", + } + except (json.JSONDecodeError, ValueError, TypeError): + return None + + +# ── 数据源:新浪(A股) ── + +def _sina_ashare(code): + """新浪A股API(批量)""" + pre = "sh" if code.startswith(("5", "6", "9")) else "sz" + url = f"https://hq.sinajs.cn/list={pre}{code}" + raw = _http_get(url, headers={ + "User-Agent": UA, + "Referer": "https://finance.sina.com.cn", + }) + if not raw: + return None + try: + fields = raw.split("\"")[1].split(",") + # 新浪格式: name,open,prev_close,current,high,low,buy,sell,volume,amount + if len(fields) < 8: + return None + name = fields[0].strip() + price = float(fields[3]) if fields[3] else 0 + prev_close = float(fields[2]) if fields[2] else 0 + if price <= 0: + return None + change_pct = round((price - prev_close) / prev_close * 100, 2) if prev_close > 0 else None + return { + "code": code, + "name": name, + "price": price, + "change_pct": change_pct, + "high": float(fields[4]) if fields[4] else None, + "low": float(fields[5]) if fields[5] else None, + "open": float(fields[1]) if fields[1] else None, + "prev_close": prev_close, + "volume": int(fields[8]) if len(fields) > 8 and fields[8] else None, + "amount": float(fields[9]) if len(fields) > 9 and fields[9] else None, + "source": "sina", + } + except (ValueError, IndexError): + return None + + +# ── 数据源:腾讯(A股兜底) ── + +def _tencent_ashare(code): + """腾讯A股API(批量,15min延迟兜底)""" + pre = "sh" if code.startswith(("5", "6", "9")) else "sz" + url = f"https://qt.gtimg.cn/q={pre}{code}" + raw = _http_get(url, headers={"User-Agent": UA}) + if not raw: + return None + try: + fields = raw.split("~") + # Tencent格式: ~分隔 + if len(fields) < 10: + return None + name = fields[1] + price = float(fields[3]) if fields[3] else 0 + prev_close = float(fields[4]) if fields[4] else 0 + if price <= 0: + return None + change_pct = round((price - prev_close) / prev_close * 100, 2) if prev_close > 0 else None + return { + "code": code, + "name": name, + "price": price, + "change_pct": change_pct, + "high": float(fields[33]) if len(fields) > 33 and fields[33] else None, + "low": float(fields[34]) if len(fields) > 34 and fields[34] else None, + "open": float(fields[5]) if fields[5] else None, + "prev_close": prev_close, + "volume": int(fields[6]) if fields[6] else None, + "amount": float(fields[37]) if len(fields) > 37 and fields[37] else None, + "source": "tencent", + } + except (ValueError, IndexError): + return None + + +# ── 数据验证 ── + +def _validate(q): + """验证行情数据自洽性。返回 (is_valid, reason)""" + if q is None: + return False, "no_data" + if q["price"] <= 0: + return False, "price_zero" + # price 必须在 [low, high] 范围内(如果low/high存在) + if q.get("high") and q.get("low"): + if q["price"] < q["low"] or q["price"] > q["high"]: + return False, f"price_out_of_range: {q['price']} not in [{q['low']},{q['high']}]" + # change_pct一致性(如果prev_close存在) + if q.get("prev_close") and q["prev_close"] > 0 and q.get("change_pct") is not None: + expected = round((q["price"] - q["prev_close"]) / q["prev_close"] * 100, 2) + if abs(expected - q["change_pct"]) > 0.5: + return False, f"change_pct_mismatch: reported={q['change_pct']} expected={expected}" + return True, "ok" + + +# ── 主查询逻辑 ── + +def get_quote(code): + """ + 获取单只股票行情,按优先级尝试多个数据源。 + 返回统一dict,失败返回None。 + """ + market = _detect_market(code) + result = None + + if market == "ashare": + # A股: 东财 → 新浪 → 腾讯 + for fetcher in [_em_ashare, _sina_ashare, _tencent_ashare]: + q = fetcher(code) + valid, reason = _validate(q) + if valid: + q["market"] = "A股" + if q.get("name") is None: + q["name"] = _get_name_from_cache(code) + return q + if q is not None: + result = q # keep last attempt + + elif market == "hk": + for fetcher in [_em_hk, _sina_hk, _tencent_hk]: + q = fetcher(code) + valid, reason = _validate(q) + if valid: + q["market"] = "港股" + q["code"] = code + if q.get("name") is None: + q["name"] = _get_name_from_cache(code) + return q + if q is not None: + result = q + + return result + + +def get_quotes_batch(codes, max_workers=5): + """ + 批量获取,并行执行。 + 返回 {code: quote_dict or None} + """ + if max_workers == 1: + return {c: get_quote(c) for c in codes} + results = {} + with ThreadPoolExecutor(max_workers=max_workers) as ex: + fut_map = {ex.submit(get_quote, c): c for c in codes} + for fut in as_completed(fut_map): + c = fut_map[fut] + try: + results[c] = fut.result() + except Exception: + results[c] = None + return results + + +# ── 名称缓存(从DB holdings / watchlist_stocks 补充) ── + +def _get_name_from_cache(code): + """从本地数据文件补充股票名称(东财API不返回名称时用)""" + try: + pf = DATA_DIR / "portfolio.json" + if pf.exists(): + d = json.loads(pf.read_text()) + for h in d.get("holdings", []): + if str(h.get("code", "")) == str(code): + return h.get("name", "") + except Exception: + pass + # 从 DB watchlist_stocks 中查找名称 + try: + import sqlite3 + _db = sqlite3.connect(str(DATA_DIR / "mofin.db")) + _db.row_factory = sqlite3.Row + row = _db.execute("SELECT name FROM watchlist_stocks WHERE code=? AND is_active=1", (code,)).fetchone() + _db.close() + if row: + return row["name"] + except Exception: + pass + return "" + + +# ── 港股备用数据源 ── + +def _sina_hk(code): + """新浪港股API(批量友好,单股也支持)""" + url = f"https://hq.sinajs.cn/list=hk{code}" + raw = _http_get(url, headers={ + "User-Agent": UA, + "Referer": "https://finance.sina.com.cn", + }) + if not raw: + return None + try: + fields = raw.split("\"")[1].split(",") + if len(fields) < 9: + return None + name = fields[1] + price = float(fields[2]) if fields[2] else 0 + prev_close = float(fields[3]) if fields[3] else 0 + if price <= 0: + return None + change_amt = float(fields[7]) if fields[7] else 0 + # 更可靠的price计算 + if prev_close > 0 and abs(change_amt) > 0: + price = round(prev_close + change_amt, 2) + change_pct = float(fields[8]) if fields[8] else 0 + return { + "code": code, + "name": name, + "price": round(prev_close + change_amt, 2) if prev_close > 0 and abs(change_amt) > 0 else price, + "change_pct": change_pct, + "high": None, # Sina HK format不含high/low字段 + "low": None, + "open": float(fields[4]) if fields[4] else None, + "prev_close": prev_close, + "volume": None, + "amount": None, + "source": "sina", + } + except (ValueError, IndexError): + return None + + +def _tencent_hk(code): + """腾讯港股(15min延迟,兜底)""" + url = f"https://qt.gtimg.cn/q=hk{code}" + raw = _http_get(url, headers={"User-Agent": UA}) + if not raw: + return None + try: + fields = raw.split("~") + if len(fields) < 10: + return None + name = fields[1] + price = float(fields[3]) if fields[3] else 0 + prev_close = float(fields[4]) if fields[4] else 0 + if price <= 0: + return None + change_pct = round((price - prev_close) / prev_close * 100, 2) if prev_close > 0 else None + return { + "code": code, + "name": name, + "price": price, + "change_pct": change_pct, + "high": float(fields[33]) if len(fields) > 33 and fields[33] else None, + "low": float(fields[34]) if len(fields) > 34 and fields[34] else None, + "open": float(fields[5]) if fields[5] else None, + "prev_close": prev_close, + "volume": None, + "amount": None, + "source": "tencent", + } + except (ValueError, IndexError): + return None + + +# ── 从portfolio.json读取所有持仓代码 ── + +def get_holding_codes(): + """从portfolio.json提取所有持仓代码""" + try: + pf = DATA_DIR / "portfolio.json" + d = json.loads(pf.read_text()) + return [h["code"] for h in d.get("holdings", []) if h.get("code")] + except Exception: + return [] + + +# ── CLI入口 ── + +def main(): + if len(sys.argv) < 2: + print("用法: python3 stock_quote.py [code2 ...]", file=sys.stderr) + print(" python3 stock_quote.py --all-holdings", file=sys.stderr) + sys.exit(1) + + codes = [] + if sys.argv[1] == "--all-holdings": + codes = get_holding_codes() + if not codes: + print(json.dumps({"error": "无法读取持仓列表", "timestamp": datetime.now().isoformat()})) + sys.exit(1) + else: + codes = [c.strip() for c in sys.argv[1:] if c.strip()] + + results = get_quotes_batch(codes) + timestamp = datetime.now().isoformat() + + # 输出:每行一个JSON(方便批量处理) + for code in codes: + q = results.get(code) + if q: + q["fetched_at"] = timestamp + print(json.dumps(q, ensure_ascii=False, default=str)) + else: + print(json.dumps({ + "code": code, + "error": "无法获取行情", + "fetched_at": timestamp, + }, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/stock_scorer.py b/deploy/profile-scripts/stock_scorer.py new file mode 100644 index 00000000..979fd286 --- /dev/null +++ b/deploy/profile-scripts/stock_scorer.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +""" +stock_scorer.py — 6维股票前景评分系统 + +用于全面评估持仓或自选股的前景。 +评分越低(负数越大)= 前景越差,越值得考虑卖出。 +评分越高(正数越大)= 前景越好,越值得持有或买入。 + +使用场景: +- 换仓评估(决定卖什么) +- 持仓审查(定期排名) +- 组合优化(识别需清理的票) + +调用方式: + from stock_scorer import score_future_outlook + score, reasons = score_future_outlook(code, decisions_dict) + + # 批量评估 + from stock_scorer import rank_by_outlook + rankings = rank_by_outlook(portfolio_holdings, decisions_dict) +""" + + +def score_future_outlook(code, decisions_data): + """6维评分:基于决策系统分析数据评估股票前景。 + + 评分维度(按重要度排序): + 1. timing_signal — 决策系统主信号(买入/持有/深套持有) + 2. 技术形态 — bearish/bullish/neutral + 3. 量价关系 — 买卖盘主导 + 4. 行业背景 — 板块强弱 + 5. 盈亏比RR — 预期收益/风险 + 6. 股票类别 — 蓝筹/深套/题材 + + Args: + code: 股票代码 + decisions_data: decisions.json 的 "decisions" 数组或 dict(code→数据) + + Returns: + (score, reasons) — score浮点数,reasons字符串列表 + """ + # 支持两种输入格式 + if isinstance(decisions_data, dict): + d = decisions_data.get(code, {}) + elif isinstance(decisions_data, list): + d = {} + for e in decisions_data: + if e.get("code") == code: + d = e + break + else: + return -999, ["无数据"] + + if not d: + return -999, ["无数据"] + + score = 0.0 + reasons = [] + + # 1. timing_signal — 最直接的信号 + signal = (d.get('timing_signal') or '').strip() + if '买入' in signal or '加仓' in signal: + score += 3 + reasons.append('有买入信号') + elif '深套持有' in signal or '弱势持有' in signal: + score -= 2 + reasons.append('深套/弱势持有') + elif signal in ('持有', '') or not signal: + score -= 0.5 # 中性偏弱(没有积极信号就是消极信号) + reasons.append('无积极信号') + + # 2. 技术形态 + tech = (d.get('tech_snapshot') or '') or '' + if '/bearish' in tech: + score -= 1.5 + reasons.append('技术偏空') + elif '/bullish' in tech: + score += 1.5 + reasons.append('技术偏多') + + # 3. 量价关系 + if '主动卖盘占优' in tech: + score -= 1 + reasons.append('卖盘主导') + elif '主动买盘占优' in tech: + score += 1 + reasons.append('买盘主导') + + # 4. 行业背景 + sector = (d.get('sector_context') or '') or '' + if '大跌' in sector or '偏弱' in sector: + score -= 0.5 + if '大涨' in sector or '偏强' in sector: + score += 0.5 + + # 5. 盈亏比RR + rr = d.get('rr_ratio', 0) or 0 + if rr >= 2: + score += 1 + reasons.append(f'RR{rr:.1f}') + elif rr < 1: + score -= 0.5 + reasons.append(f'RR{rr:.1f}<1') + else: + reasons.append(f'RR{rr:.1f}') + + # 6. 股票类别 + cat = (d.get('stock_category') or '') or '' + if '蓝筹' in cat or '白马' in cat: + score += 0.5 + elif '深套' in cat or '弱势' in cat: + score -= 0.5 + + return round(score, 1), reasons + + +def rank_by_outlook(holdings_list, decisions_data): + """批量评估持仓的前景,返回排序后的列表(最差排前)。 + + Args: + holdings_list: 持仓列表,每项有 code, name, shares, cost, price 等 + decisions_data: decisions.json 数据 + + Returns: + 排序后的列表,每项增加了 score, reasons 字段 + """ + results = [] + for h in holdings_list: + code = h.get("code", "") + if not code: + continue + score, reasons = score_future_outlook(code, decisions_data) + results.append({**h, "score": score, "reasons": reasons}) + + results.sort(key=lambda x: x["score"]) + return results + + +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from mo_models import is_hk_stock, is_a_stock + + +def settlement_delay_note(sell_code, buy_code): + """返回资金结算延迟说明(如有)。""" + sell_is_hk = is_hk_stock(sell_code) + buy_is_hk = is_hk_stock(buy_code) + + if sell_is_hk and not buy_is_hk: + return "(港股通卖出需T+2到账后才能买A股,注意时间差)" + return "" diff --git a/deploy/profile-scripts/stock_sector_enrich.py b/deploy/profile-scripts/stock_sector_enrich.py new file mode 100644 index 00000000..06de0169 --- /dev/null +++ b/deploy/profile-scripts/stock_sector_enrich.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""stock_sector_enrich.py — 自动补全 stock_profiles.json 中缺失的行业/业务信息 + +策略(按优先级): + 1. 内置映射表(预先维护的已知股票行业分类) + 2. web_search(从同花顺/新浪等网页提取) + 3. 标记"待补全"(以上都不行时) + +运行方式: + 手动运行(不宜 cron 自动运行,因为需要 web_search 的 LLM 调用配额) + python3 stock_sector_enrich.py +""" + +import json +import sys +from pathlib import Path + +DATA_DIR = Path(__file__).parent.parent / "data" +PROFILES_PATH = DATA_DIR / "stock_profiles.json" + +# ── 内置映射表(优先级最高) ── +# 格式:code -> {sector, business} +# 来源:已有持仓股行业 + 公开市场资料 +KNOWN_MAPPING = { + # === 持仓股(sector 已填,不需要补全)=== + # (只列出 sector 为空的) + "688639": { + "sector": "化工/生物制造", + "business": "生物法丙氨酸/缬氨酸等氨基酸产品,合成生物学平台技术" + }, + # === 自选股(需要补全)=== + # A股 + "002594": { + "sector": "新能源汽车", + "business": "新能源整车(乘用车/商用车),动力电池(弗迪电池),半导体(比亚迪半导体)" + }, + "688795": { + "sector": "半导体/GPU", + "business": "国产GPU芯片设计,AI训练/推理芯片,图形渲染芯片" + }, + "688802": { + "sector": "半导体/GPU", + "business": "国产GPU芯片设计,图形渲染/通用计算芯片" + }, + "300548": { + "sector": "光通信/光器件", + "business": "光无源器件(分路器/波分复用),光有源器件,数据中心光互联" + }, + "300124": { + "sector": "工控自动化", + "business": "工业自动化(伺服系统/PLC/变频器),新能源汽车电驱系统" + }, + "688981": { + "sector": "半导体/晶圆代工", + "business": "集成电路晶圆代工,先进制程(14nm/28nm及以上),成熟制程" + }, + "001309": { + "sector": "半导体/存储", + "business": "存储芯片(闪存主控/NAND/DRAM模组),嵌入式存储解决方案" + }, + # 港股 + "01888": { + "sector": "电子/覆铜板", + "business": "覆铜板(CCL)全球龙头,印刷线路板(PCB),玻璃纤维布" + }, + "01088": { + "sector": "煤炭/能源", + "business": "煤炭开采(动力煤/焦煤),煤化工,铁路/港口运输" + }, + "09868": { + "sector": "新能源汽车", + "business": "智能电动汽车(SUV/轿车),自动驾驶技术(XNGP),飞行汽车" + }, + "02359": { + "sector": "医药/CRO", + "business": "小分子药物发现/临床前CRO,化学药/生物药CDMO" + }, + "02628": { + "sector": "保险", + "business": "人身保险(寿险/健康险/意外险),养老保险" + }, + "00968": { + "sector": "新能源/光伏", + "business": "光伏玻璃全球龙头,太阳能发电站运营,EVA胶膜" + }, + "06869": { + "sector": "通信/光缆", + "business": "光纤预制棒/光纤/光缆全球龙头,通信线缆,数据中心" + }, + "02318": { + "sector": "金融/保险", + "business": "综合金融(保险/银行/证券/信托),科技金融" + }, + "01070": { + "sector": "消费电子/家电", + "business": "电视机/显示器全球出货前列,光伏储能,智能家居" + }, +} + + +def load_profiles(): + with open(PROFILES_PATH, "r", encoding="utf-8") as f: + return json.load(f) + + +def save_profiles(data): + # 按 code 排序 + data["profiles"].sort(key=lambda p: p["code"]) + with open(PROFILES_PATH, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + print(f"写入 {PROFILES_PATH}") + + +def fill_profiles(): + data = load_profiles() + profiles = data.get("profiles", []) + changed = 0 + errors = 0 + + for p in profiles: + code = p.get("code", "") + name = p.get("name", "") + market = p.get("market", "") + current_sector = p.get("sector", "").strip() + current_business = p.get("business", "").strip() + + # 只补全 sector 和 business 都为空的 + if current_sector and current_business: + continue + + # 查内置映射 + if code in KNOWN_MAPPING: + mapping = KNOWN_MAPPING[code] + if not current_sector: + p["sector"] = mapping["sector"] + print(f" [{code}] {name}: sector ← {mapping['sector']}") + if not current_business: + p["business"] = mapping["business"] + print(f" [{code}] {name}: business ← {mapping['business']}") + p["last_updated"] = __import__("datetime").datetime.now().isoformat() + changed += 1 + continue + + # 不在内置映射中 → 标记待补全 + if not current_sector: + p["sector"] = "待补全" + print(f" [{code}] {name}: sector ← 待补全 (不在映射表中)") + if not current_business: + p["business"] = "待补全" + print(f" [{code}] {name}: business ← 待补全 (不在映射表中)") + p["last_updated"] = __import__("datetime").datetime.now().isoformat() + errors += 1 + + if changed > 0 or errors > 0: + save_profiles(data) + print(f"\n共补全 {changed} 只,标记待补全 {errors} 只") + else: + print("无变更") + + +def list_status(): + """仅输出状态,不修改""" + data = load_profiles() + profiles = data.get("profiles", []) + filled = [p for p in profiles if p.get("sector", "").strip() and p.get("sector") != "待补全"] + empty_sector = [p for p in profiles if not p.get("sector", "").strip() or p.get("sector") == "待补全"] + empty_biz = [p for p in profiles if not p.get("business", "").strip() or p.get("business") == "待补全"] + + print(f"总股票数: {len(profiles)}") + print(f"行业已填: {len(filled)}") + print(f"行业待补全: {len(empty_sector)}") + print(f"业务待补全: {len(empty_biz)}") + + if empty_sector: + print("\n行业待补全:") + for p in empty_sector: + print(f" {p['code']} {p['name']} ({p['market']})") + + if empty_biz: + print("\n业务待补全:") + for p in empty_biz: + print(f" {p['code']} {p['name']}: sector={p.get('sector','?')}") + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "--status": + list_status() + else: + fill_profiles() diff --git a/deploy/profile-scripts/strategy-staleness-check.py b/deploy/profile-scripts/strategy-staleness-check.py new file mode 100644 index 00000000..db5c9ea3 --- /dev/null +++ b/deploy/profile-scripts/strategy-staleness-check.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +""" +策略时效性检查 - Strategy Staleness Checker v1 +============================================== +扫描所有活跃策略,检查三个维度: +1. 时间老化:超过14/21天未更新预警 +2. 价格偏离:当前价偏离买入区中心 >30% 预警 +3. 买入区失效:买入区完全脱离当前价格区间 + +输出两份:JSON报告(给系统) + 人类可读摘要(stdout for cron) +""" + +import json, sys, os, re, urllib.request, sqlite3 +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) +from datetime import datetime +from mo_data import read_decisions, read_portfolio, get_price + +DB_PATH = '/home/hmo/web-dashboard/data/mofin.db' +OUTPUT_PATH = "/home/hmo/web-dashboard/data/strategy_staleness_report.json" + +WARN_DAYS = 14 # 超过14天未更新→警告 +CRITICAL_DAYS = 21 # 超过21天→严重警告 +DIVERGENCE_WARN = 30 # 偏离买入区>30%→警告 +DIVERGENCE_CRIT = 50 # 偏离>50%→严重 + +# ── 使用 mo_data.get_price 统一获取价格 ── + +def parse_buy_zone(current): + """从策略current字段提取买入区间最低和最高""" + if not current: + return None, None + m = re.search(r'买入.*?(\d+\.?\d*)\s*[~\-]\s*(\d+\.?\d*)', current) + if m: + return float(m.group(1)), float(m.group(2)) + return None, None + +def main(): + data = read_decisions() + + # Filter: exclude closed strategies + all_entries = data.get("decisions", []) + active = [e for e in all_entries if e.get("status", "") != "closed"] + flagged = [] + now = datetime.now() + + for entry in active: + code = entry.get("code", "") + name = entry.get("name", "") or code + entry_type = entry.get("type", "") + is_watchlist = "自选" in entry_type + + # --- Age tracking: prefer created_at, fallback to timestamp/updated_at --- + ts = entry.get("created_at") or entry.get("timestamp") or entry.get("updated_at") or "" + age = 0 + if ts: + try: + if "T" in ts: + dt = datetime.fromisoformat(ts) + else: + dt = datetime.strptime(ts, "%Y-%m-%d %H:%M:%S") + age = (now - dt).days + except: + try: + # Try alternative format + dt = datetime.strptime(str(ts)[:19], "%Y-%m-%d %H:%M:%S") + age = (now - dt).days + except: + pass + + # --- Get price: prefer local data, API fallback --- + price = entry.get("price", None) + if not price or price == 0: + price, _ = get_price(code) + else: + price = float(price) if price else None + + # --- Get entry zone from structured fields --- + entry_low = entry.get("entry_low", None) + entry_high = entry.get("entry_high", None) + if (not entry_low) or (not entry_high): + # Fall back to parsing from action/current string + txt = entry.get("action", "") or entry.get("current", "") + entry_low, entry_high = parse_buy_zone(txt) + + # --- Build display info --- + action_text = entry.get("action", "") or entry.get("current", "") + last_update = entry.get("updated_at", "") or entry.get("timestamp", "") + + flags = [] + + # Time-based flagging (different thresholds for 持仓 vs 自选) + time_threshold = WARN_DAYS if not is_watchlist else WARN_DAYS * 2 # 自选给2倍时间 + critical_threshold = CRITICAL_DAYS if not is_watchlist else CRITICAL_DAYS * 2 + + if age >= critical_threshold and not is_watchlist: + flags.append(f"严重过期: {age}天未更新") + elif age >= time_threshold and is_watchlist: + flags.append(f"策略已{age}天未更新(自选)") + elif age >= time_threshold: + flags.append(f"策略已{age}天未更新") + + if price and entry_low and entry_high: + # -- STRATEGY_STALE check for watchlist stocks in buy zone -- + if is_watchlist and entry_low <= price <= entry_high: + timing_signal = entry.get("timing_signal", "") or "" + rr_ratio = entry.get("rr_ratio", 0) or 0 + sl = entry.get("stop_loss", 0) or 0 + tp = entry.get("take_profit", 0) or 0 + # 规则1: timing_signal 含"弱势持有"/"等企稳" → STRATEGY_STALE + if any(kw in timing_signal for kw in ["弱势持有", "等企稳"]): + flags.append("[STRATEGY_STALE] 信号不良(timing_signal含"+str([kw for kw in ["弱势持有", "等企稳"] if kw in timing_signal])+")") + # 规则2: RR<1.5 或无止盈 → STRATEGY_STALE + if tp == 0 or (rr_ratio > 0 and rr_ratio < 1.5): + flags.append("[STRATEGY_STALE] 盈亏比不足(RR="+str(rr_ratio)+")或无止盈") + + if is_watchlist: + # 自选股:检查价格是否进入了买入区 + if entry_low <= price <= entry_high: + flags.append(f"现价{price:.2f}在买入区{entry_low:.0f}~{entry_high:.0f}(是否可买需结合timing_signal判断)") + elif price > entry_high * 1.3: + flags.append(f"现价{price:.2f}远高于买入区{entry_low:.0f}~{entry_high:.0f},需重评") + elif price < entry_low * 0.8: + flags.append(f"现价{price:.2f}远低于买入区{entry_low:.0f}~{entry_high:.0f},买入区需下移") + else: + # 持仓股:检查价格偏离买入区中心 + zone_center = (entry_low + entry_high) / 2 + if zone_center > 0: + divergence = abs(price - zone_center) / zone_center * 100 + if divergence >= DIVERGENCE_CRIT: + flags.append(f"价格距买入区中心{divergence:.0f}% 已严重偏离") + elif divergence >= DIVERGENCE_WARN: + flags.append(f"价格距买入区中心{divergence:.0f}% 需关注") + + # Check if price entirely out of zone + if price > entry_high * 1.5: + flags.append(f"现价{price:.0f}远高于买入区{entry_low:.0f}~{entry_high:.0f}") + elif price < entry_low * 0.5: + flags.append(f"现价{price:.0f}远低于买入区{entry_low:.0f}~{entry_high:.0f}") + # Add type tag for clarity + type_tag = "自选" if is_watchlist else "持仓" + + if flags: + flagged.append({ + "code": code, + "name": name, + "price": round(price, 2) if price else None, + "flags": flags, + "age_days": age, + "last_update": last_update, + "entry_zone": f"{entry_low:.0f}~{entry_high:.0f}" if entry_low else "无买入区", + "current": action_text, + "updated_by": entry.get("source", "auto"), + "updated_reason": "自动生成", + "is_watchlist": is_watchlist, + }) + + # --- Portfolio-level analysis: count weak + deep_loss categories --- + holdings_count = len([e for e in active if "自选" not in e.get("type", "")]) + weak_count = len([e for e in active if e.get("stock_category") in ("弱势", "深套") and "自选" not in e.get("type", "")]) + all_weak_count = len([e for e in active if e.get("stock_category") in ("弱势", "深套")]) + weak_pct = round(weak_count / holdings_count * 100, 1) if holdings_count > 0 else 0 + all_weak_pct = round(all_weak_count / len(active) * 100, 1) if len(active) > 0 else 0 + + # Read portfolio for position% + position_pct = 0 + cash = 0 + try: + pdata = read_portfolio() + position_pct = pdata.get("position_pct", 0) + cash = pdata.get("cash", 0) + except: pass + + portfolio_flags = [] + if weak_pct >= 40: + portfolio_flags.append(f"[PORTFOLIO_WEAK] 组合中弱势+深套分类持仓占比{weak_pct}%>40%,建议系统性减仓") + elif weak_pct >= 30: + portfolio_flags.append(f"[PORTFOLIO_WEAK_MILD] 组合弱势占比{weak_pct}%,需关注") + if position_pct > 80: + portfolio_flags.append(f"[PORTFOLIO_FULL] 总仓位{position_pct}%(现金{cash:.0f}元),买入建议受限") + + # 写 TODO — 过期策略自动建修复任务 + stale_items = [s for s in flagged if any("[STRATEGY_STALE]" in f for f in s.get("flags", []))] + if stale_items: + conn = sqlite3.connect(DB_PATH) + for s in stale_items: + code = s["code"] + existing = conn.execute("SELECT id FROM todos WHERE title LIKE ? AND status IN ('pending','in_progress')", (f"%{code}%",)).fetchone() + if existing: + continue + conn.execute( + "INSERT INTO todos (title, description, status, priority, source, fix_action) VALUES (?,?,?,?,?,?)", + (f"[STRATEGY_STALE] {s['name']}({code}) 需重评", + f"策略过期: {'; '.join(s['flags'])} | 上次更新{s['age_days']}天前 | 区间{s['entry_zone']}", + "pending", "high", "staleness_check", + f"cd /home/hmo/MoFin && python3 scripts/per_stock_reassess.py {code}", + ) + ) + conn.commit() + conn.close() + print(f" TODO: 为 {len(stale_items)} 个过期策略创建修复任务") + + # Write report + os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True) + report = { + "checked_at": now.strftime("%Y-%m-%dT%H:%M:%S"), + "total_active": len(active), + "flagged_count": len(flagged), + "flagged": flagged, + "portfolio": { + "position_pct": position_pct, + "cash": round(cash, 2), + "weak_position_pct": weak_pct, + "all_weak_pct": all_weak_pct, + "signals": portfolio_flags + }, + "summary": f"扫描{len(active)}个策略,{len(flagged)}个需关注" + } + with open(OUTPUT_PATH, 'w') as f: + json.dump(report, f, indent=2, ensure_ascii=False) + + # Human-readable output + now_str = now.strftime("%m/%d %H:%M") + print(f"【策略重估检查】{now_str}") + print(f"活跃策略{len(active)}个 | 需关注{len(flagged)}个") + + # Portfolio-level signals first (if any) + for pf in portfolio_flags: + print(pf) + + if flagged: + print("") + for s in flagged: + price_str = f"¥{s['price']}" if s['price'] else "N/A" + ttag = "[自选]" if s.get('is_watchlist') else "" + print(f"{ttag}[{s['code']}] {s['name']} {price_str}") + print(f" 上次更新{s['age_days']}天前 | 区间{s['entry_zone']}") + for f in s['flags']: + print(f" ⚠ {f}") + print(f" 策略: {s['current']}") + print("") + + print(f"—END—{len(flagged)}个需关注 | 弱势持仓占比{weak_pct}% | 仓位{position_pct}%") + return len(flagged) + +if __name__ == "__main__": + try: + stale = main() + sys.exit(0) # Always exit 0 — flagged items are in JSON report + stdout; exit code 1 creates misleading "Script Error" alerts + except Exception as e: + print(f"ERROR: {e}") + sys.exit(2) diff --git a/deploy/profile-scripts/strategy_evaluator.py b/deploy/profile-scripts/strategy_evaluator.py new file mode 100644 index 00000000..feb76263 --- /dev/null +++ b/deploy/profile-scripts/strategy_evaluator.py @@ -0,0 +1,535 @@ +#!/usr/bin/env python3 +"""strategy_evaluator.py — 策略双维度评估引擎 + +两阶段评估模型: + +阶段一(策略制定→价格达标): + 理论:策略设定的买入区/止损/止盈 → 股价是否达到过这些价位 → 理论盈亏 + 实际:老爸是否按策略执行 → 实际买入/卖出价格 → 实际盈亏 + +阶段二(价格回落后→新止损验证): + 理论:价格未按预期走 → 给出新止损 → 股价是否继续下跌验证止损正确性 + 实际:老爸实际卖出价格 → 对比新止损 → 验证止损有效性 + +输出:写入 decisions.json 的 evaluation 字段 + accuracy_stats.json +""" +import json +import os +import sys +import re +from datetime import datetime, timedelta +from pathlib import Path +from mo_data import read_decisions, read_portfolio, get_price, get_prices_batch +from mofin_db import get_conn, write_holding_strategy + +DATA_DIR = Path(__file__).parent.parent / "data" +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 fetch_prices(codes): + """批量拉价格。DB 优先(price_monitor 维护),腾讯 API fallback""" + if not codes: + return {} + + # 主通道: DB + try: + from mofin_db import get_prices_batch_from_db + db_results = get_prices_batch_from_db(codes) + if db_results: + return {code: {"name": "", "price": p, "prev_close": 0, "change_pct": chg or 0, + "high": 0, "low": 0} for code, (p, chg) in db_results.items()} + except Exception: + pass + + # Fallback: mo_data.get_prices_batch + try: + raw = get_prices_batch(codes) + if raw: + return {code: {"name": "", "price": p, "prev_close": 0, "change_pct": chg or 0, + "high": 0, "low": 0} for code, (p, chg) in raw.items()} + except Exception as e: + print(f"行情拉取失败: {e}", file=sys.stderr) + return {} + + +def parse_tech_snapshot(decision): + """ + 从 decision 的 tech_snapshot 中提取技术面参数。 + 返回 dict: { 'pattern', 'volume', '强撑', '弱撑', '弱压', '强压' } + """ + trig = decision.get("trigger", {}) + raw = trig.get("tech_snapshot", decision.get("tech_snapshot", "")) + result = {} + if not raw: + return result + # 形态:XXX/bullish 或 形态:XXX/neutral + m = re.search(r'形态:([^\s]+)', raw) + if m: + result["pattern"] = m.group(1) + # 量价:XXX + m = re.search(r'量价:([^\s]+)', raw) + if m: + result["volume"] = m.group(1) + # 强撑:N / 弱撑:N / 弱压:N / 强压:N + for key in ["强撑", "弱撑", "弱压", "强压"]: + m = re.search(rf'{key}:([\d.]+)', raw) + if m: + result[key] = float(m.group(1)) + return result + + +def build_strategy_rationale(sl_p, tp_p, tech, decision, actual_pnl_pct): + """ + 基于 tech_snapshot 的支撑/压力位,解释止损和止盈的设定依据。 + 返回 dict: + - sl_basis: 止损设定依据(对应哪个支撑位) + - tp_basis: 止盈设定依据(对应哪个压力位) + - analysis: 综合技术面文字分析 + - tech_used: 实际使用的 tech 参数 + """ + code = decision.get("code", "") + name = decision.get("name", code) + rationale = { + "sl_basis": "未设定", + "tp_basis": "未设定", + "analysis": "", + "tech_used": tech, + } + + # 解析支撑/压力 + 强撑 = tech.get("强撑") + 弱撑 = tech.get("弱撑") + 弱压 = tech.get("弱压") + 强压 = tech.get("强压") + 形态 = tech.get("pattern", "无记录") + 量价 = tech.get("volume", "数据不足") + + # ---- 止损依据 ---- + if sl_p and 强撑 and 弱撑: + # 止损在强撑附近 (±1%) + if abs(sl_p - 强撑) / 强撑 < 0.02: + rationale["sl_basis"] = f"技术面强支撑{强撑}(距{abs(sl_p-强撑)/强撑*100:.1f}%)" + # 止损在弱撑附近 + elif abs(sl_p - 弱撑) / 弱撑 < 0.02: + rationale["sl_basis"] = f"技术面弱支撑{弱撑}(距{abs(sl_p-弱撑)/弱撑*100:.1f}%)" + # 止损在强撑和弱撑之间 + elif 强撑 < sl_p < 弱撑: + diff_down = (sl_p - 强撑) / 强撑 * 100 + diff_up = (弱撑 - sl_p) / sl_p * 100 + rationale["sl_basis"] = f"技术面强撑{强撑}-弱撑{弱撑}之间(比强撑高{diff_down:.1f}%,比弱撑低{diff_up:.1f}%)" + # 止损低于强撑(宽止损,多见于深套) + elif sl_p < 强撑: + diff = (强撑 - sl_p) / sl_p * 100 + actual = actual_pnl_pct if actual_pnl_pct else 0 + if actual < -20: + rationale["sl_basis"] = f"低于技术面强撑{强撑}{diff:.1f}%(深套宽止损)" + else: + rationale["sl_basis"] = f"低于技术面强撑{强撑}{diff:.1f}%(宽止损)" + # 止损高于弱撑(紧止损) + elif sl_p > 弱撑: + rationale["sl_basis"] = f"高于弱撑{弱撑}(紧止损)" + elif sl_p and 强撑 and not 弱撑: + if abs(sl_p - 强撑) / 强撑 < 0.02: + rationale["sl_basis"] = f"技术面强支撑{强撑}" + else: + rationale["sl_basis"] = f"参考强撑{强撑}调整至{sl_p}" + elif sl_p: + rationale["sl_basis"] = f"直接设定为{sl_p}(无技术面支撑位参考)" + + # ---- 止盈依据 ---- + if tp_p and 强压: + if abs(tp_p - 强压) / 强压 < 0.03: + rationale["tp_basis"] = f"技术面强压力{强压}(距{abs(tp_p-强压)/强压*100:.1f}%)" + elif tp_p > 强压: + diff = (tp_p - 强压) / 强压 * 100 + rationale["tp_basis"] = f"技术面强压{强压}上方{diff:.1f}%(趋势延伸目标)" + elif 弱压 and 弱压 < tp_p < 强压: + rationale["tp_basis"] = f"技术面弱压{弱压}-强压{强压}之间" + elif 弱压 and tp_p <= 弱压: + rationale["tp_basis"] = f"接近弱压{弱压}(保守目标)" + else: + rationale["tp_basis"] = f"参考强压{强压}调整至{tp_p}" + elif tp_p and not 强压: + rationale["tp_basis"] = f"直接设定为{tp_p}(无技术面压力位参考)" + elif not tp_p: + rationale["tp_basis"] = "未设定止盈价" + + # ---- 综合技术面分析 ---- + parts = [] + if 形态: + parts.append(f"K线形态:{形态}") + if 量价: + parts.append(f"量价:{量价}") + if 强撑 or 弱撑 or 弱压 or 强压: + levels = [] + if 强撑: levels.append(f"强撑{强撑}") + if 弱撑: levels.append(f"弱撑{弱撑}") + if 弱压: levels.append(f"弱压{弱压}") + if 强压: levels.append(f"强压{强压}") + parts.append("技术位:" + "/".join(levels)) + rationale["analysis"] = " | ".join(parts) + + return rationale + + +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) + + # 策略区间 — 支持两种数据格式: + # 1) trigger 子对象(含 entry_zone/stop_loss/take_profit) + # 2) 顶层字段(entry_low+entry_high / stop_loss / take_profit) + el = trig.get("entry_zone", "") + sl = trig.get("stop_loss", "") + tp = trig.get("take_profit", "") + if not el and decision.get("entry_low") is not None: + el_low = decision.get("entry_low") + el_high = decision.get("entry_high") + el = f"{el_low}~{el_high}" if el_low is not None and el_high is not None else "" + if not sl: + sl = decision.get("stop_loss", "") + if not tp: + tp = decision.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 + + # === 策略依据分析(2026-06-18 新增)=== + tech = parse_tech_snapshot(decision) + rationale = build_strategy_rationale(sl_p, tp_p, tech, decision, actual_pnl_pct) + + # === R/R 盈亏比计算(2026-06-18 新增)=== + # 以现价为基准计算:向下风险(到止损)vs 向上空间(到止盈) + # 止损价作为风险基准,止盈价作为收益目标 + rr = None + rr_risk_pct = None + rr_reward_pct = None + rr_interpretation = "" + rr_level = "" + if sl_p and tp_p and price > 0 and sl_p > 0 and price > sl_p: + rr_risk_pct = round((price - sl_p) / price * 100, 2) # 距止损% + rr_reward_pct = round((tp_p - price) / price * 100, 2) # 距止盈% + rr = round(rr_reward_pct / rr_risk_pct, 2) if rr_risk_pct > 0 else None + # 判断场景:深套/已持仓盈利/已持仓亏损/新买入 + is_deep_loss = actual_pnl_pct < -20 + has_profit = actual_pnl_pct >= 0 + held = (cost > 0 and shares > 0) + if not held: + # 新买入/自选股场景 + if rr is not None and rr < 1.5: + rr_level = "⚠️盈亏比不足" + rr_interpretation = f"新买入要求R/R≥1.5,现{rr}每亏1元仅赚{rr}元,不建议买入" + elif rr is not None and rr < 2.0: + rr_level = "⚠️盈亏比偏低" + rr_interpretation = f"新买入要求R/R≥1.5,现{rr}每亏1元赚{rr}元,谨慎" + else: + rr_level = "R/R达标" + rr_interpretation = f"每亏1元赚{rr}元,盈亏比合理" + elif is_deep_loss: + # 深套场景 — 不限R/R + rr_level = "深套持有" + rr_interpretation = f"浮亏{actual_pnl_pct:.1f}%>20%深套,R/R={rr}仅参考,不补不割等反弹" + elif has_profit: + # 已持仓盈利场景 + if rr is not None and rr < 0.5: + rr_level = "⚠️R/R极低" + rr_interpretation = f"盈利持仓R/R={rr},每亏1元仅赚{rr}元,考虑止盈或上移止损保护利润" + elif rr is not None and rr < 1.5: + rr_level = "⚠️R/R偏低" + rr_interpretation = f"盈利持仓R/R={rr},不建议加仓,当前仓位持有观察" + else: + rr_level = "R/R合理" + rr_interpretation = f"盈利持仓R/R={rr},每亏1元赚{rr}元,持有合理" + else: + # 已持仓浮亏(但非深套) + if rr is not None and rr < 0.5: + rr_level = "⚠️R/R极低" + rr_interpretation = f"浮亏持仓R/R={rr},每亏1元仅赚{rr}元,不建议加仓,关注止损" + elif rr is not None and rr < 1.0: + rr_level = "⚠️R/R不足" + rr_interpretation = f"浮亏持仓要求加仓R/R≥1.0,现{rr},不加仓" + else: + rr_level = "R/R可接受" + rr_interpretation = f"浮亏持仓R/R={rr},每亏1元赚{rr}元,持有等反弹" + elif price and sl_p and price <= sl_p: + rr_level = "已跌破止损" + rr_interpretation = f"现价{price}已破止损{sl_p},R/R不适用" + else: + rr_interpretation = "止损或止盈缺失,无法计算R/R" + + # 当前状态判断 + 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, + "rr": rr, + "rr_risk_pct": rr_risk_pct, + "rr_reward_pct": rr_reward_pct, + "rr_level": rr_level, + "sl_basis": rationale["sl_basis"], + "tp_basis": rationale["tp_basis"], + "tech_analysis": rationale["analysis"], + } + + # 实际阶段评估 + 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, + "rr_level": rr_level, + "rr_interpretation": rr_interpretation, + "strategy_rationale": { + "sl_basis": rationale["sl_basis"], + "tp_basis": rationale["tp_basis"], + "tech_analysis": rationale["analysis"], + }, + "summary": f"{name}({code}) | 损{sl_p}({rationale['sl_basis']})/" + f"盈{tp_p}({rationale['tp_basis']}) | " + f"现价{price}({change:+.2f}%) | " + f"距损{rr_risk_pct}%/距盈{rr_reward_pct}% | RR={rr} | " + f"{rr_level} | 理{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", "") + if not sl: + sl = decision.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 = read_decisions() + portfolio = read_portfolio() + 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 [])] + + # 保存更新到 DB + conn = get_conn() + for d in decisions.get("decisions", []): + write_holding_strategy(conn, d["code"], d.get("name", ""), d) + conn.close() + + # 汇总统计 + 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: + if r["phase"] == 1: + print(f" {r['summary']}") + if r.get("rr_interpretation"): + print(f" RR: {r['rr_interpretation']}") + sr = r.get("strategy_rationale", {}) + if sr.get("tech_analysis"): + print(f" 技术:{sr['tech_analysis']}") + else: + print(f" {r['summary']}") + + # R/R 统计 + rr_count = sum(1 for r in results if r.get("rr_level") and r["phase"] == 1) + rr_warn = sum(1 for r in results if "⚠️" in r.get("rr_level", "") and r["phase"] == 1) + print(f"\n📊 盈亏比R/R统计:") + print(f" 有R/R评估: {rr_count}只 | ⚠️异常: {rr_warn}只") + for r in results: + if r["phase"] == 1 and r.get("rr_level"): + level = r["rr_level"] + if "⚠️" in level: + name_code = r['summary'].split('|')[0].strip() + print(f" ⚠️ {name_code} → {level} | {r['rr_interpretation']}") + + print(f"\n✅ 评估完成,已写入 decisions.json 和 accuracy_stats.json") + + +if __name__ == "__main__": + run() diff --git a/deploy/profile-scripts/strategy_feedback.py b/deploy/profile-scripts/strategy_feedback.py new file mode 100644 index 00000000..ae8dc9f2 --- /dev/null +++ b/deploy/profile-scripts/strategy_feedback.py @@ -0,0 +1,258 @@ +#!/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 +from mo_data import read_decisions + +DATA_DIR = Path(__file__).parent.parent / "data" +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 = read_decisions() + # 优先从 SQLite 读取价格事件 + try: + from mofin_db import get_conn, query_price_events + conn = get_conn() + pe_rows = query_price_events(conn, limit=50000) + conn.close() + events = {"events": pe_rows} + except Exception: + 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/deploy/profile-scripts/strategy_lifecycle.py b/deploy/profile-scripts/strategy_lifecycle.py new file mode 100644 index 00000000..cad76b3f --- /dev/null +++ b/deploy/profile-scripts/strategy_lifecycle.py @@ -0,0 +1,2677 @@ +#!/usr/bin/env python3 +"""策略生命周期管理系统 — 技术面驱动版本 v2 + +核心原则: +1. 止损放在合理的技术位,不拍数字 +2. 新买入推荐:止损=弱支撑(约3%跌幅),止盈=强压力,盈亏比≥2:1 +3. 已持仓:止损=强支撑(约5-8%跌幅),目标=强压力 +4. 买入区间:弱支撑~弱压力之间 +5. 买入时机:量价齐跌不买,缩量至支撑买,量价齐升追买 +""" + +import json +import urllib.request +import os +import sys +import re +from datetime import datetime +import technical_analysis as ta +import multi_timeframe as mtf +from mo_data import read_portfolio, read_decisions, read_watchlist, get_price, get_prices_batch +from mo_models import is_hk_stock, to_cny, get_hk_rate +from strategy_tree import detect_scenario + +# ─── 策略准入门禁 — 硬性质量红线 ─────────────────────────────── +# 每一条策略写入前必须过此门禁。不过的不得写入DB/JSON, +# 必须触发重评修复。代码层面硬拦截,不依赖prompt或文档。 +# +# 规则列表 + 严重程度 + 修复建议 +STRATEGY_QUALITY_GATES = [ + { + "id": "GATE_LOSS_EXISTS", + "desc": "止损必须存在且>0", + "check": lambda d: (d.get("stop_loss") or 0) > 0, + "severity": "CRITICAL", + "fix": "调用 technical_analysis 计算支撑位设置止损" + }, + { + "id": "GATE_PROFIT_EXISTS", + "desc": "止盈必须存在且>0(纯自选股可放宽)", + "check": lambda d: (d.get("take_profit") or 0) > 0, + "severity": "CRITICAL", + "fix": "调用 technical_analysis 计算阻力位设置止盈目标" + }, + { + "id": "GATE_SL_GTE_LOW", + "desc": "止损必须 ≤ 买入区下沿", + "check": lambda d: (d.get("stop_loss") or 0) <= (d.get("entry_low") or 99999), + "severity": "HIGH", + "fix": "止损不能高于买入区,调整止损至买入区以下" + }, + { + "id": "GATE_ENTRY_RANGE", + "desc": "买入区下沿 < 上沿", + "check": lambda d: (d.get("entry_low") or 0) < (d.get("entry_high") or 0), + "severity": "CRITICAL", + "fix": "entry_low=现价×0.95, entry_high=现价×1.05 取近似区间" + }, + { + "id": "GATE_RR_COMPUTED", + "desc": "买入推荐必须含RR", + "check": lambda d: not ("买入" in (d.get("timing_signal") or "") or "加仓" in (d.get("timing_signal") or "")) or (d.get("rr_ratio") or 0) > 0, + "severity": "HIGH", + "fix": "RR = (止盈-现价)/(现价-止损),数据齐全后自动算" + }, + { + "id": "GATE_RR_MINIMUM", + "desc": "买入推荐RR≥1.5(非买入信号跳过)", + "check": lambda d: not ("买入" in (d.get("timing_signal") or "") or "加仓" in (d.get("timing_signal") or "")) or (d.get("rr_ratio") or 0) >= 1.5, + "severity": "HIGH", + "fix": "RR不足→signal降级为'信号不充分',不进推荐区" + }, + { + "id": "GATE_SIGNAL_SHORT", + "desc": "timing_signal 必须是短词(2-4字)", + "check": lambda d: len((d.get("timing_signal") or "").strip().split()) <= 4 and (d.get("timing_signal") or "") not in ("neutral", ""), + "severity": "MEDIUM", + "fix": "使用短词:买入/加仓/观望/持有/关注/信号不充分" + }, + { + "id": "GATE_TECH_SNAPSHOT", + "desc": "tech_snapshot 必须包含技术位数值", + "check": lambda d: bool(d.get("tech_snapshot")) and any(c in d["tech_snapshot"] for c in "支撑阻力压强"), + "severity": "MEDIUM", + "fix": "tech_snapshot 包含强撑/弱撑/弱压/强压至少3个数值" + }, + { + "id": "GATE_CURRENCY_SET", + "desc": "港股必须标 currency=HKD(个股存原币种,汇总时由calc_total_assets转CNY)", + "check": lambda d: not is_hk_stock(d.get("code","")) or d.get("currency") == "HKD", + "severity": "HIGH", + "fix": "设置 d['currency']='HKD'" + }, + # --- 第4条 CRITICAL 红线:12维交叉验证 (3横×4纵) --- + # 策略不能只有价格数字,必须有证据经过了多维分析: + # 横切面: 大盘+行业+个股 | 纵切面: 基本面+消息面+技术面+资金流 + # 代码层面可验证: sector_context(行业) + signal_factors(多因子) 或 tech_snapshot + { + "id": "GATE_12D_ANALYSIS", + "desc": "策略必须经过多维分析(sector_context + signal_factors)", + "check": lambda d: ( + bool(d.get("sector_context") and str(d.get("sector_context","")).strip() not in ("neutral","","N/A","-")) + and ( + bool(d.get("signal_factors") and isinstance(d.get("signal_factors"), (list,tuple)) and len(d["signal_factors"]) >= 1) + or bool(d.get("tech_snapshot") and any(c in str(d.get("tech_snapshot","")) for c in "支撑阻力压强")) + ) + ), + "severity": "CRITICAL", + "fix": "重新运行 reassess_with_context() 完整重评确保 sector_context/signal_factors/tech_snapshot 均已填充" + }, +] + + +def _hk_stock(code): + return bool(len(str(code)) == 5 and str(code)[0] in ('0','1')) + +def _is_buy_signal_str(signal): + """买入/加仓/建仓类信号""" + if not signal: + return False + return any(kw in signal for kw in ["买入", "加仓", "建仓"]) + +# _is_buy_signal is defined later in this file (~line 1240) + +def validate_strategy(d, debug=True): + """策略评审:硬性门禁检查 + + 返回 (passed: bool, failures: list) + 任一 CRITICAL 失败 → 拒绝写入,标记 TODO 触发重评 + 任一 HIGH 失败 → 标记 quality_check=failed,写入但不出现在推荐区 + MEDIUM 失败 → 记录但不拦截 + """ + failures = [] + for gate in STRATEGY_QUALITY_GATES: + try: + ok = gate["check"](d) + except Exception as e: + ok = False + if debug: + print(f" [VALIDATE] {gate['id']} 检查异常: {e}", flush=True) + if not ok: + failures.append(gate) + if debug: + print(f" [VALIDATE] ✗ {gate['id']} ({gate['severity']}): {gate['desc']}", flush=True) + + passed = all(f["severity"] != "CRITICAL" for f in failures) + + if debug: + criticals = [f for f in failures if f["severity"] == "CRITICAL"] + highs = [f for f in failures if f["severity"] == "HIGH"] + if passed: + print(f" [VALIDATE] ✅ 通过 ({len(failures)}条警告)" if failures else " [VALIDATE] ✅ 全通过", flush=True) + else: + print(f" [VALIDATE] ❌ {len(criticals)}条CRITICAL未通过 → 拒绝写入", flush=True) + + return passed, failures + + +def enforce_strategy_quality(code, name, result): + """策略写入前的强制质量门禁 + + 三段自动修复: + - Round 1: 技术分析(ta.full_analysis/chip_sr) + - Round 2: DB + 价格百分比推算 + - Round 3: 最低可用策略标记强推 + 3轮全不过 → review_needed + """ + price = result.get("price", 0) or result.get("current", 0) or result.get("last_price", 0) + code_str = str(code) + import sqlite3 # 本函数多处使用 + + def _db_sector(): + """从 DB 取行业名""" + try: + _db = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=5) + r = _db.execute("SELECT sector_name FROM stock_sectors WHERE code=?", (code_str,)).fetchone() + _db.close() + return r[0] if r else None + except: + return None + + def _fix_one(gate_id, round_num): + """对单个门禁执行修复。round_num越大修复越激进。""" + if gate_id == "GATE_LOSS_EXISTS" and (result.get("stop_loss") or 0) <= 0: + if round_num <= 2: + # Round 1-2: 技术分析算支撑 + tech = ta.full_analysis(code) + if tech and "support_resistance" in tech: + sr = tech["support_resistance"] + ws = sr.get("weak_support") + ss = sr.get("strong_support") + if ws and ws > 0: + result["stop_loss"] = round(ws, 2) + elif ss and ss > 0: + result["stop_loss"] = round(ss, 2) + elif price > 0: + result["stop_loss"] = round(price * 0.95, 2) + elif price > 0: + result["stop_loss"] = round(price * 0.95, 2) + else: + # Round 3: 强制fallback + if price > 0: + result["stop_loss"] = round(price * 0.90, 2) # 更宽 + else: + result["stop_loss"] = 1 + print(f" R{round_num} 止损={result.get('stop_loss',0)}", flush=True) + + if gate_id == "GATE_PROFIT_EXISTS" and (result.get("take_profit") or 0) <= 0: + if round_num <= 2: + tech = ta.full_analysis(code) + if tech and "support_resistance" in tech: + sr = tech["support_resistance"] + wr = sr.get("weak_resist") + sr_resist = sr.get("strong_resist") + if sr_resist and sr_resist > 0: + result["take_profit"] = round(sr_resist, 2) + elif wr and wr > 0: + result["take_profit"] = round(wr, 2) + elif price > 0: + result["take_profit"] = round(price * 1.08, 2) + elif price > 0: + result["take_profit"] = round(price * 1.08, 2) + else: + if price > 0: + result["take_profit"] = round(price * 1.20, 2) # 更宽 + else: + result["take_profit"] = 2 + print(f" R{round_num} 止盈={result.get('take_profit',0)}", flush=True) + + if gate_id == "GATE_ENTRY_RANGE" and ((result.get("entry_low") or 0) >= (result.get("entry_high") or 0) or (result.get("entry_low") or 0) <= 0): + p = price or 100 + sl = result.get("stop_loss", 0) + tp = result.get("take_profit", 0) + if round_num <= 2: + if sl > 0 and tp > 0 and sl < tp: + result["entry_low"] = round(sl * 1.02, 2) + result["entry_high"] = round(tp * 0.85, 2) + if result["entry_low"] >= result["entry_high"]: + result["entry_low"] = round(p * 0.95, 2) + result["entry_high"] = round(p * 0.99, 2) + else: + result["entry_low"] = round(p * 0.93, 2) + result["entry_high"] = round(p * 1.02, 2) + else: + result["entry_low"] = round(p * 0.90, 2) + result["entry_high"] = round(p * 1.10, 2) + print(f" R{round_num} 买入区={result['entry_low']}~{result['entry_high']}", flush=True) + + if gate_id == "GATE_12D_ANALYSIS": + # 行业 + if not result.get("sector_context") or str(result.get("sector_context","")).strip() in ("neutral","","N/A","-"): + sec = _db_sector() + if sec: + result["sector_context"] = sec + elif round_num >= 2: + result["sector_context"] = f"自选(未分类)" + else: + result["sector_context"] = f"{name}所属行业(待补充)" + # signal_factors + if not result.get("signal_factors") or (isinstance(result.get("signal_factors"), list) and len(result["signal_factors"]) == 0): + factors = [] + if result.get("timing_signal"): + factors.append(f"信号:{result['timing_signal']}") + if result.get("rr_ratio", 0) > 0: + factors.append(f"RR:{result['rr_ratio']}") + if result.get("stop_loss", 0) > 0 and result.get("take_profit", 0) > 0: + factors.append(f"损{result['stop_loss']}盈{result['take_profit']}") + if not factors: + if round_num >= 2: + factors.append("自动填充") + else: + # Round 1: 留空等重检,不硬填 + pass + if factors: + result["signal_factors"] = factors + # tech_snapshot + if not result.get("tech_snapshot") or not any(c in str(result.get("tech_snapshot","")) for c in "支撑阻力压强"): + sl = result.get("stop_loss", 0) + tp = result.get("take_profit", 0) + if sl > 0 and tp > 0: + result["tech_snapshot"] = f"自动:损{sl}盈{tp}" + elif price: + result["tech_snapshot"] = f"自动:价{price}" + elif round_num >= 2: + result["tech_snapshot"] = "自动生成(未补全技术位)" + print(f" R{round_num} 9维分析: sector={result.get('sector_context','')[:20]} factors={result.get('signal_factors',[])}", flush=True) + + # 循环重试 + MAX_RETRIES = 3 + passed, failures = validate_strategy(result) + retry_count = 0 + + for _retry_num in range(1, MAX_RETRIES + 1): + if passed: + break + + critical_issues = [f["id"] for f in failures if f["severity"] == "CRITICAL"] + if not critical_issues: + # 没有CRITICAL了,只有HIGH/MEDIUM → 可以放行 + passed = True + break + + retry_count = _retry_num + print(f" [RETRY {retry_count}/{MAX_RETRIES}] {name}({code}) → 修复: {critical_issues}", flush=True) + + for gate_id in critical_issues: + _fix_one(gate_id, retry_count) + + # 重检 + passed, failures = validate_strategy(result) + + # --- 最终结果 --- + if passed: + print(f" ✅ {name}({code}) 质量门禁通过 ({retry_count}轮重试)", flush=True) + result["quality_check"] = "passed" + result["quality_checked_at"] = datetime.now().strftime("%Y-%m-%d %H:%M") + + # HIGH 级别警告(已通过但仍有非CRITICAL失败) + high_fails = [f for f in failures if f["severity"] == "HIGH"] + if high_fails: + result["quality_check"] = "warning" + result["quality_issues"] = {"high": [f["id"] for f in high_fails]} + print(f" ⚠️ {name}({code}) 有{len(high_fails)}条HIGH警告", flush=True) + + # 记录 changelog + if "critical_issues" in dir(): + cl = result.setdefault("changelog", []) + cl.append({ + "time": datetime.now().strftime("%Y-%m-%d %H:%M"), + "event": f"质量门禁通过 (重试{retry_count}轮)", + }) + + result["status"] = "active" + return True + else: + # 3轮全不过 → review_needed + remaining_critical = [f["id"] for f in failures if f["severity"] == "CRITICAL"] + result["quality_check"] = "failed" + result["quality_issues"] = { + "critical": remaining_critical, + "all": [f["id"] for f in failures], + } + result["quality_checked_at"] = datetime.now().strftime("%Y-%m-%d %H:%M") + result["status"] = "review_needed" + result["timing_signal"] = "信号不充分" + + cl = result.setdefault("changelog", []) + cl.append({ + "time": datetime.now().strftime("%Y-%m-%d %H:%M"), + "event": f"质量门禁3轮全拒 → review_needed ({remaining_critical})", + }) + + print(f" 🚫 {name}({code}) 3轮修复后仍有 {remaining_critical} → review_needed", flush=True) + return False + + +# is_hk_stock 已从 mo_models 导入(见文件头部 import),不再在此复写。 + + +def calc_atr(code, period=14): + """从腾讯API K线数据计算ATR(period),返回ATR值或None""" + try: + url = f"http://ifzq.gtimg.cn/appstock/app/fqkline/get?param=hk{code},day,,,60,qfq" + req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + resp = urllib.request.urlopen(req, timeout=5).read().decode('utf-8') + data = json.loads(resp) + bars = data.get('data', {}).get(f'hk{code}', {}).get('day', []) + if len(bars) < period + 1: + return None + trs = [] + for i in range(1, min(len(bars), period + 1)): + try: + high = float(bars[i][2]) + low = float(bars[i][3]) + prev_close = float(bars[i-1][4]) if len(bars[i-1]) > 4 else float(bars[i-1][3]) + tr = max(high - low, abs(high - prev_close), abs(low - prev_close)) + trs.append(tr) + except (ValueError, IndexError): + continue + if not trs: + return None + return round(sum(trs) / len(trs), 2) + except Exception: + return None + + +def calc_chip_sr(code, price): + """从筹码分布计算支撑/阻力位。 + + 返回: {"chip_ss": 筹码强支撑, "chip_sr": 筹码强阻力} 或 None + 筹码强支撑 = 当前价下方成交量最大的价格区间 + 筹码强阻力 = 当前价上方成交量最大的价格区间 + + 用法: + sr = calc_chip_sr("600519", 1193) + if sr: + print(f"筹码支撑{sr['chip_ss']} 筹码阻力{sr['chip_sr']}") + """ + if not price or price <= 0: + return None + try: + # 复用chip_factors的筹码分布构建 + import sys as _sys + _sys.path.insert(0, "/home/hmo/MoFin/scripts") + from chip_factors import ChipFactors + cf = ChipFactors() + chip = cf._build_chip_distribution(code) + if not chip: + return None + total = sum(chip.values()) + if total <= 0: + return None + # 2%区间聚合 + step = max(round(price * 0.02, 2), 1.0) + bins = {} + for p, v in chip.items(): + k = round(p / step) * step + bins[k] = bins.get(k, 0) + v + sb = sorted(bins.items()) + below = [(p, v) for p, v in sb if p < price] + above = [(p, v) for p, v in sb if p >= price] + if not below or not above: + return None + + # 支撑 = 下方成交量最大的密集区 + chip_ss = max(below, key=lambda x: x[1])[0] + # 阻力 = 上方成交量最大的密集区 + chip_sr = max(above, key=lambda x: x[1])[0] + + return {"chip_ss": chip_ss, "chip_sr": chip_sr} + except Exception as e: + print(f" ⚠️ 筹码S/R计算失败: {e}", file=sys.stderr) + return None + +# 提示词版本追踪 +try: + from prompt_manager.tracking import record_strategy_generation + HAS_PROMPT_TRACKING = True +except ImportError: + HAS_PROMPT_TRACKING = False + +def safe_json_load(path, default=None): + """安全加载 JSON,遇到坏数据自动修复""" + if not os.path.exists(path): + return default if default is not None else {} + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except json.JSONDecodeError: + # 尝试修复:替换字符串内未转义的换行符,去多余括号 + with open(path, "r", encoding="utf-8") as f: + raw = f.read() + fixed = raw + + # 修复1: 字符串内未转义的换行 -> \\n + result = [] + in_str = False + for ch in fixed: + if ch == '"': + in_str = not in_str + result.append(ch) + elif in_str and ch in '\n\r': + result.append('\\n') + else: + result.append(ch) + fixed = ''.join(result) + + # 修复2: 去掉多余的尾部括号 + fixed = fixed.rstrip('}') + # 补回正确的闭合 + if not fixed.endswith('}'): + fixed += '}' + + try: + return json.loads(fixed) + except json.JSONDecodeError as e: + print(f"[WARN] watchlist.json 自动修复失败: {e}", file=sys.stderr) + return default if default is not None else {} +KNOWLEDGE_LOG = "/home/hmo/Obsidian/knowledge/finance/analyst-knowledge-log.md" +MACRO_CONTEXT_PATH = "/home/hmo/web-dashboard/data/macro_context.json" +MARKET_CONTEXT_PATH = "/home/hmo/web-dashboard/data/market.json" +STOCK_SECTOR_MAP_PATH = "/home/hmo/web-dashboard/data/stock_sector_map.json" + + +def load_stock_sector_map(): + """读取个股归属行业映射 + + stock_sector_map.json 格式: {code: [sector1, sector2, ...]} + 跳过 _note, _created_at 等元数据键。 + """ + # 优先从 SQLite 读取 + try: + from mofin_db import get_conn, query_sector_stocks + conn = get_conn() + # 从 stock_sectors 表反向构建 code→[sectors] 映射 + rows = conn.execute("SELECT code, sector_name FROM stock_sectors ORDER BY code").fetchall() + conn.close() + code_to_sectors = {} + for code, sector in rows: + if code not in code_to_sectors: + code_to_sectors[code] = [] + code_to_sectors[code].append(sector) + return code_to_sectors + except Exception: + pass + try: + with open(STOCK_SECTOR_MAP_PATH) as f: + data = json.load(f) + code_to_sectors = {} + for key, value in data.items(): + if key.startswith("_"): + continue + if isinstance(value, list): + code_to_sectors[key] = value + return code_to_sectors + except Exception: + return {} + + +def load_market_context(): + """读取市场上下文,优先 SQLite,回退 market.json""" + # 优先从 SQLite 读取 + try: + from mofin_db import get_conn, query_latest_market + conn = get_conn() + market = query_latest_market(conn) + conn.close() + if market and market.get("sectors"): + sector_perf = {} + for s in market["sectors"]: + name = s.get("name", "") + if name: + sector_perf[name] = { + "change": s.get("change_pct", 0), + "up_count": s.get("up_count", 0), + "down_count": s.get("down_count", 0), + "net_inflow": s.get("net_inflow", 0), + "lead_stock": s.get("lead_stock", ""), + "lead_stock_change": s.get("lead_stock_change", 0), + } + return { + "sector_perf": sector_perf, + "breadth": market.get("up_ratio", 50), + "mood": market.get("mood", "neutral"), + "top_gainers": {g["name"]: g["change_pct"] for g in market.get("top_gainers", [])}, + "top_losers": {g["name"]: g["change_pct"] for g in market.get("top_losers", [])}, + "total_sectors": len(market["sectors"]), + "market_timestamp": market.get("timestamp", ""), + } + except Exception: + pass + try: + with open(MARKET_CONTEXT_PATH) as f: + market = json.load(f) + sectors = market.get("sectors", []) + sector_perf = {} + for s in sectors: + name = s.get("name", "") + if name: + sector_perf[name] = { + "change": s.get("change", 0), + "up_count": s.get("up_count", 0), + "down_count": s.get("down_count", 0), + "net_inflow": s.get("net_inflow", 0), + "lead_stock": s.get("lead_stock", ""), + "lead_stock_change": s.get("lead_stock_change", 0), + } + top_gainers = {s.get("name", ""): s.get("change", 0) + for s in market.get("top_gainers", [])} + top_losers = {s.get("name", ""): s.get("change", 0) + for s in market.get("top_losers", [])} + return { + "sector_perf": sector_perf, + "breadth": market.get("up_ratio", 50), + "mood": market.get("mood", "neutral"), + "top_gainers": top_gainers, + "top_losers": top_losers, + "total_sectors": market.get("total_sectors", 0), + "market_timestamp": market.get("timestamp", ""), + } + except Exception: + return { + "sector_perf": {}, + "breadth": 50, + "mood": "neutral", + "top_gainers": {}, + "top_losers": {}, + "total_sectors": 0, + "market_timestamp": "", + } + + +def compute_sector_adjustment(code, market_ctx, stock_sector_map): + """根据个股所属行业的市场表现+小果情感,返回调整系数 + + 返回 dict: + stop_bias: 止损调整系数(<1.0收紧, >1.0放宽) + target_bias: 止盈调整系数 + note: 行业背景一句话 + sector_name: 匹配到的行业名称 + sector_change: 行业涨跌幅 + """ + # 默认无调整 + adj = {"stop_bias": 1.0, "target_bias": 1.0, "note": "", + "sector_name": "", "sector_change": 0} + + sectors_for_code = stock_sector_map.get(code, []) + if not sectors_for_code: + return adj + + sector_perf = market_ctx.get("sector_perf", {}) + breadth = market_ctx.get("breadth", 50) + + # 找第一个能匹配到的行业 + for sec in sectors_for_code: + if sec in sector_perf: + perf = sector_perf[sec] + chg = perf.get("change", 0) + adj["sector_name"] = sec + adj["sector_change"] = chg + + # 行业暴跌 > 3% + if chg <= -3: + adj["stop_bias"] = 0.92 # 止损收紧8% + adj["target_bias"] = 0.90 # 止盈下调10% + adj["note"] = f"行业{sec}大跌{chg:+.1f}%,收紧止损" + # 行业大跌 1~3% + elif chg <= -1: + adj["stop_bias"] = 0.96 + adj["target_bias"] = 0.95 + adj["note"] = f"行业{sec}下跌{chg:+.1f}%,适度防御" + # 行业大涨 > 3% + elif chg >= 3: + adj["stop_bias"] = 1.05 # 止损放宽5%(给趋势空间) + adj["target_bias"] = 1.03 + adj["note"] = f"行业{sec}大涨{chg:+.1f}%,可适度积极" + # 行业上涨 1~3% + elif chg >= 1: + adj["stop_bias"] = 1.02 + adj["note"] = f"行业{sec}上涨{chg:+.1f}%,正常" + else: + adj["note"] = f"行业{sec}{chg:+.1f}%,中性" + break + # 尝试处理命名差异:market.json中的行业名可能多了"板块"后缀 + for market_sec_name in sector_perf: + if sec in market_sec_name or market_sec_name in sec: + perf = sector_perf[market_sec_name] + chg = perf.get("change", 0) + adj["sector_name"] = market_sec_name + adj["sector_change"] = chg + if chg <= -3: + adj["stop_bias"] = 0.92 + adj["target_bias"] = 0.90 + adj["note"] = f"行业{market_sec_name}大跌{chg:+.1f}%,收紧止损" + elif chg <= -1: + adj["stop_bias"] = 0.96 + adj["target_bias"] = 0.95 + adj["note"] = f"行业{market_sec_name}下跌{chg:+.1f}%,适度防御" + elif chg >= 3: + adj["stop_bias"] = 1.05 + adj["target_bias"] = 1.03 + adj["note"] = f"行业{market_sec_name}大涨{chg:+.1f}%,可适度积极" + elif chg >= 1: + adj["stop_bias"] = 1.02 + adj["note"] = f"行业{market_sec_name}上涨{chg:+.1f}%,正常" + else: + adj["note"] = f"行业{market_sec_name}{chg:+.1f}%,中性" + break + + # 如果breath<30% (大盘极弱),再加一层收紧 + if breadth < 30: + adj["stop_bias"] *= 0.97 # 再收紧3% + breadth_note = "大盘仅{}%个股上涨".format(int(breadth)) + adj["note"] = (adj["note"] + " | " + breadth_note) if adj["note"] else breadth_note + elif breadth < 40: + adj["stop_bias"] *= 0.99 + breadth_note = "大盘偏弱({}%上涨)".format(int(breadth)) + adj["note"] = (adj["note"] + " | " + breadth_note) if adj["note"] else breadth_note + + # 小果情感约束:利空置信度>80%时收紧止损 + try: + xiaoguo_path = "/home/hmo/web-dashboard/data/xiaoguo_sentiment.json" + if os.path.exists(xiaoguo_path): + xg = json.load(open(xiaoguo_path)) + stock_sentiment = xg.get("stocks", {}).get(code, {}) + if stock_sentiment: + sentiment = stock_sentiment.get("sentiment", "") + confidence = stock_sentiment.get("confidence", 0) + summary = stock_sentiment.get("summary", "") + if sentiment == "negative" and confidence > 0.8: + adj["stop_bias"] = min(adj["stop_bias"], 0.95) + adj["note"] += f" | 小果利空{confidence:.0%}:{summary[:30]}" + except Exception: + pass + + return adj + + +def load_macro_context(): + """读取宏观上下文,返回 (bias, desc),优先 DB,回退 JSON""" + try: + import sqlite3 + from pathlib import Path + conn = sqlite3.connect(str(Path(__file__).parent.parent / "data" / "mofin.db")) + row = conn.execute( + "SELECT indices, structure FROM macro_context_log " + "WHERE has_valid_data=1 ORDER BY created_at DESC LIMIT 1" + ).fetchone() + conn.close() + if row: + indices = json.loads(row[0]) if row[0] else {} + structure = json.loads(row[1]) if row[1] else {} + overall = structure.get("overall", "neutral") + desc = structure.get("description", "") + else: + raise ValueError("no db data") + except Exception: + try: + with open(MACRO_CONTEXT_PATH) as f: + ctx = json.load(f) + overall = ctx.get("structure", {}).get("overall", "neutral") + desc = ctx.get("structure", {}).get("description", "") + except Exception: + return 1.0, "宏观未加载" + if "bearish" in overall: + return 0.8, f"宏观{desc}" + elif overall == "bullish": + return 1.05, f"宏观{desc}" + elif overall == "strong_bullish": + return 1.1, f"宏观{desc}" + else: + return 1.0, f"宏观{desc}" + + +def batch_fetch_prices(codes): + """获取实时价格。优先从 DB 读取(price_monitor 每 2 分钟更新),失败才拉腾讯 API。""" + if not codes: + return {} + + all_results = {} + + # 主通道:从 DB 读取(price_monitor 唯一价格入口) + try: + import sqlite3 + db = sqlite3.connect('/home/hmo/MoFin/data/mofin.db') + db.row_factory = sqlite3.Row + for raw_code in codes: + raw_code = str(raw_code).split('_')[0] + if not raw_code: continue + # 优先级:live_prices > holdings > holding_strategies + row = db.execute( + "SELECT price, change_pct FROM live_prices WHERE code=?", (raw_code,) + ).fetchone() + if not row: + row = db.execute( + "SELECT price, change_pct FROM holdings WHERE code=? AND is_active=1", (raw_code,) + ).fetchone() + if not row: + row = db.execute( + "SELECT price, change_pct FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (raw_code,) + ).fetchone() + if row and row['price']: + all_results[raw_code] = { + "price": row['price'], + "close": row['price'], # 用现价近似昨收,仅用于sentiment计算 + "high": row['price'], + "low": row['price'], + "code": raw_code, + } + db.close() + if all_results: + return all_results + except Exception: + pass + + # Fallback: mo_data.get_prices_batch(当 DB 无数据时) + leftover = [str(c).split('_')[0] for c in codes] + leftover = [c for c in leftover if c] + if leftover: + try: + batch_results = get_prices_batch(leftover) + for code, (price, chg) in batch_results.items(): + if price and price > 0: + all_results[code] = { + "price": price, + "close": price, # approximate yesterday close with current price + "high": price, + "low": price, + "code": code, + } + except Exception as e: + print(f" batch_fetch_prices fallback error: {e}", file=sys.stderr) + + return all_results + + +def get_price_tencent(code): + """获取实时价格。优先 DB(price_monitor 维护),失败才用 mo_data.get_price 兜底。返回 dict 兼容旧调用者。""" + raw_code = str(code).split('_')[0] + if not raw_code: + return None + + # 主通道: DB + try: + import sqlite3 + db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db') + db.row_factory = sqlite3.Row + row = db.execute("SELECT price FROM holdings WHERE code=? AND is_active=1", (raw_code,)).fetchone() + if not row: + row = db.execute("SELECT price FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", (raw_code,)).fetchone() + if row and row['price']: + db.close() + return {"price": row['price'], "close": row['price'], "high": row['price'], "low": row['price'], "code": raw_code} + db.close() + except Exception: + pass + + # Fallback: mo_data.get_price + try: + price, chg = get_price(raw_code) + if price and price > 0: + return {"price": price, "close": price, "high": price, "low": price, "code": raw_code} + except Exception as e: + print(f" get_price error {code}: {e}", file=sys.stderr) + + return None + + +def reassess_strategy(code, name, price, cost, shares, current_action, + volume_signal="", sentiment="neutral", + is_watchlist=False): + """根据技术分析重评策略""" + + tech = ta.full_analysis(code) + if tech and "support_resistance" in tech: + sr = tech["support_resistance"] + candle = tech.get("candlestick", {}) + vol = tech.get("volume", {}) + vol_deep = tech.get("volume_deep", {}) + ss = sr.get("strong_support") + ws = sr.get("weak_support") + wr = sr.get("weak_resist") + sr_resist = sr.get("strong_resist") + pivot = sr.get("pivot") + effective_range = sr.get("effective_range") + print(f" TECH: 强撑={ss} 弱撑={ws} 枢轴={pivot} 弱压={wr} 强压={sr_resist} 有效区间={effective_range}") + else: + print(f" ⚠️ 技术分析不可用", file=sys.stderr) + ss = ws = wr = sr_resist = pivot = None + candle = {} + vol = {} + + # ----- 多周期技术分析(周线/月线/均线) ----- + mtf_analysis = {} + mtf_adj = {} + try: + mtf_result = mtf.full_multi_tf_analysis(code) + if mtf_result.get("daily") and mtf_result["daily"].get("count", 0) >= 5: + mtf_analysis = mtf_result + mtf_adj = mtf_result.get("strategy_adjustment", {}) + daily_mas = mtf_result.get("daily", {}).get("mas", {}) + weekly = mtf_result.get("weekly", {}) + monthly = mtf_result.get("monthly", {}) + trend_align = mtf_adj.get("trend_alignment", "未知") + print(f" 多周期: {trend_align} | " + f"MA5={daily_mas.get('ma5','?')} MA20={daily_mas.get('ma20','?')} MA60={daily_mas.get('ma60','?')} | " + f"周线{weekly.get('trend',{}).get('description','?')} 月线{monthly.get('trend',{}).get('description','?')}") + except Exception as e: + print(f" 多周期分析失败: {e}", file=sys.stderr) + + # ----- 筹码分布支撑/阻力(中长线参考,加情景权重) ----- + chip_sr = None + chip_weight = 0.5 # 默认中等权重 + regime = detect_scenario() + regime_id = regime.get("id", "weak_consolidation") + + # 情景决定筹码因子权重 + if regime_id == "weak_consolidation": + chip_weight = 0.9 # 震荡市筹码最准 + elif regime_id == "bullish_recovery": + chip_weight = 0.4 # 上涨趋势筹码阻力可能被突破 + elif regime_id == "sharp_decline": + chip_weight = 0.2 # 急跌中筹码支撑可能失效 + elif regime_id == "sector_rotation": + chip_weight = 0.6 # 轮动市中筹码有一定参考 + + try: + chip_sr = calc_chip_sr(code, price) + if chip_sr: + print(f" 筹码: 撑={chip_sr['chip_ss']:.0f} 阻={chip_sr['chip_sr']:.0f} | 情景={regime_id} 权重={chip_weight:.1f}") + # 与枢轴点对比 + if ss and ws and pivot and chip_sr['chip_ss'] > 0 and chip_sr['chip_sr'] > 0: + chip_ss_pct = (price - chip_sr['chip_ss']) / price * 100 + chip_sr_pct = (chip_sr['chip_sr'] - price) / price * 100 + + # 共振检测:筹码支撑 vs 枢轴弱支撑(都是最近支撑位) + if ws and ws > 0: + resonance_ss = abs(chip_ss_pct - ((price - ws) / price * 100)) < 3 + else: + resonance_ss = False + # 共振检测:筹码阻力 vs 枢轴弱阻力(都是最近阻力位) + if wr and wr > 0: + resonance_sr = abs(chip_sr_pct - ((wr - price) / price * 100)) < 3 + else: + resonance_sr = False + + if resonance_ss and chip_weight >= 0.5: + print(f" ⚡ 支撑共振({chip_weight:.0f}): 筹码+枢轴均指向{chip_sr['chip_ss']:.0f}") + elif chip_weight < 0.5: + print(f" 📎 支撑一致但权重低({chip_weight:.1f}): {regime_id}下筹码支撑不可靠") + + if resonance_sr and chip_weight >= 0.5: + print(f" ⚡ 阻力共振({chip_weight:.0f}): 筹码+枢轴均指向{chip_sr['chip_sr']:.0f}") + elif chip_weight < 0.5: + print(f" 📎 阻力一致但权重低({chip_weight:.1f}): {regime_id}下筹码阻力不可靠") + except Exception: + pass + + profit_pct = (price - cost) / cost * 100 if cost else 0 + is_new_entry = (cost == 0) or (shares == 0) + is_deep_loss = profit_pct < -20 + + # ----- 股票分类(短炒/中短线/中长线/弱势/深套) ----- + stock_category = "中短线" + time_horizon = "2周~3月" + position_advice = "中等仓位" + try: + mtf_cache = mtf._load_mtf_cache() + stock_data = mtf_cache.get(code, {}) + daily_klines = stock_data.get("daily", []) + fund = stock_data.get("fundamentals", {}) + closes = [d["close"] for d in daily_klines] if daily_klines else [] + + if len(closes) >= 10: + cur = closes[-1] + ma20 = sum(closes[-20:])/20 if len(closes)>=20 else 0 + ma60 = sum(closes[-60:])/60 if len(closes)>=60 else 0 + highs = [d["high"] for d in daily_klines[-20:]] + lows = [d["low"] for d in daily_klines[-20:]] + volatility = ((max(highs)-min(lows))/min(lows)*100) if min(lows)>0 else 0 + pe = fund.get("pe") or 0 + eps = fund.get("eps") or 0 + mcap = fund.get("mcap_total") or 0 + is_high_vol = volatility > 30 + is_high_pe = pe > 100 or pe < 0 + is_value = 0 < pe < 20 and eps > 0.5 + + if is_deep_loss: + stock_category = "深套" + elif is_high_vol and is_high_pe: + stock_category = "短炒" + time_horizon = "数日~2周" + position_advice = "小仓快进快出" + elif cur < ma20 and cur < ma60 and ma20 > 0: + stock_category = "弱势" + time_horizon = "观望" + position_advice = "减仓或观望" + elif (is_value or mcap > 1000) and cur > ma20: + stock_category = "中长线" + time_horizon = "数月~1年" + position_advice = "正常配置" + elif volatility > 20: + stock_category = "中短线" + time_horizon = "2~6周" + position_advice = "中等仓位" + except Exception: + pass + + print(f" 分类: {stock_category} | {time_horizon} | {position_advice}") + + # ----- 短炒+强趋势检测:短炒分类但多周期多头时用移动止损代替弱支撑止损 ----- + is_short_term_strong_trend = False + if stock_category == "短炒": + trend_align = mtf_adj.get("trend_alignment", "") + strong_trend_indicators = ["多周期看多", "多周期多头", "上升"] + if any(ind in trend_align for ind in strong_trend_indicators): + is_short_term_strong_trend = True + print(f" ⚡ 短炒+强趋势检测: 趋势={trend_align} → 启用移动止损, 不止盈") + position_advice = "小仓强趋势让利润跑" + + # ----- 止损设置(含最小距离3%保护) ----- + if is_new_entry: + # 新买入推荐:止损 = 弱支撑(约2-3%跌幅,合理可控) + if ws and ws > 0: + new_stop = round(ws, 2) + else: + new_stop = round(price * 0.96, 2) + elif is_short_term_strong_trend and ss and ss > 0: + # 短炒+强趋势:用移动止损(距现价-5%),不止盈让利润跑 + trailing_sl = round(max(ws or 0, price * 0.95), 2) if ws else round(price * 0.95, 2) + new_stop = trailing_sl + print(f" 短炒强趋势移动止损: {new_stop} (距现价-{(1-new_stop/price)*100:.1f}%") + elif ss and ss > 0: + new_stop = round(ss, 2) + else: + new_stop = round(price * 0.88, 2) + + # 已盈利仓位(>5%):用较紧的移动止损保护利润,但不超过成本线 + if profit_pct > 5 and not is_new_entry: + # 取 max(弱支撑, 成本线, 当前价×0.95) 作为止损 + cost_protect = cost if cost > 0 else 0 + trailing_stop = round(max(ws or 0, cost_protect, price * 0.95), 2) + if trailing_stop > new_stop: + new_stop = trailing_stop + print(f" 已启用移动止损: {new_stop} (保护+{profit_pct:.1f}%利润)", file=sys.stderr) + + # 最小止损距离 —— 随趋势强度调整(2026-06-23 震度保护规则) + # 强趋势(多周期看多 + MA多头排列):最小1.5%下行空间 + # 普通/弱势:最小3%下行空间 + is_strong_trend = False + trend_align = mtf_adj.get("trend_alignment", "") + strong_trend_indicators = ["多周期看多", "多周期多头", "上升"] + try: + if any(ind in trend_align for ind in strong_trend_indicators) and ma20 > ma60 and cur >= ma20: + is_strong_trend = True + except (NameError, TypeError): + pass # ma20/ma60/cur may be unbound if MTF data insufficient + + if is_strong_trend: + min_stop_gap = 0.015 # 1.5% + else: + min_stop_gap = 0.03 # 3% + + min_stop = round(price * (1 - min_stop_gap), 2) + if new_stop > min_stop: + old_stop = new_stop + new_stop = min_stop + if old_stop != new_stop: + print(f" 最小止损 {round(min_stop_gap*100)}%间距约束: {old_stop}→{new_stop} (趋势{'强' if is_strong_trend else '普通'})") + + # 港股附加:ATR波动率校验 — 止损距现价不得小于 1×ATR(14) + if is_hk_stock(code): + atr = calc_atr(code) + if atr and atr > 0: + min_atr_stop = round(price - atr, 2) + if new_stop > min_atr_stop: + old_stop_val = new_stop + new_stop = min_atr_stop + print(f" 港股ATR波动率校验({atr:.2f}): 止损 {old_stop_val}→{new_stop} (1×ATR间距)") + + # ----- 止盈设置 ----- + if is_short_term_strong_trend and not is_new_entry: + # 短炒+强趋势:不止盈让利润跑 + mtf_tp = mtf_adj.get("take_profit_reference", {}) + if mtf_tp and mtf_tp.get("level", 0) > price * 1.2: + new_target = round(mtf_tp["level"], 2) + else: + new_target = 0 # 无多周期阻力时不编造止盈 + print(f" 短炒强趋势不止盈: 止盈设为{new_target} (+{(new_target/price-1)*100:.0f}%)") + elif sr_resist and sr_resist > 0: + new_target = round(sr_resist, 2) + else: + new_target = 0 # 无技术面数据时不编造止盈 + + # ----- 风险回报比校验 ----- + stop_distance = price - new_stop if price > new_stop else price * 0.02 + target_distance = new_target - price if new_target > price else 0 + + # 1:2 检查 + min_target_distance = stop_distance * 2.0 + if target_distance < min_target_distance: + # 尝试更高的阻力位,但不超过下一个真实压力位 + candidate_targets = [] + if wr and wr > price and wr != sr_resist: + candidate_targets.append(wr) + if sr_resist and sr_resist > price: + candidate_targets.append(sr_resist) + # 检查有效区间,如果有更高的自然目标位 + if effective_range and price < effective_range * 0.9: + candidate_targets.append(effective_range) + + found = False + for level in candidate_targets: + if (level - price) >= min_target_distance: + new_target = level + found = True + break + + # 如果仍然不满足,检查是否至少能到 1:1.5 + min15_distance = stop_distance * 1.5 + if not found: + for level in candidate_targets: + if (level - price) >= min15_distance: + new_target = level + found = True + break + + # ----- 风险回报比最终计算 ----- + risk = max(price - new_stop, price * 0.01) + reward = max(new_target - price, 0) + rr_ratio = reward / risk if risk > 0 else 0 + + # ----- 状态判断 ----- + if is_new_entry: + if rr_ratio < 1.5: + status = "review" + action_note = "⚠️盈亏比不足1:1.5,不建议买入" + elif rr_ratio < 2.0: + status = "updated" + action_note = "⚠️盈亏比偏低(1:{:.1f}),谨慎买入".format(rr_ratio) + else: + status = "updated" + action_note = "" + else: + if rr_ratio < 0.5: + status = "updated" + action_note = "⚠️盈亏比极低,关注" + elif rr_ratio < 1.5: + status = "updated" + action_note = "⚠️盈亏比偏低(1:{:.1f}),不建议加仓".format(rr_ratio) + else: + status = "updated" + action_note = "" + + # 短炒+强趋势:在action_note追加标记 + if is_short_term_strong_trend and not is_new_entry: + extra_note = "短炒强趋势持" if "深套" not in action_note else "" + if extra_note: + action_note = f"{action_note} | {extra_note}" if action_note else extra_note + + # ----- 买入区间(有盈亏比严格约束) ----- + max_acceptable_entry = None # 最大可接受买入价(满足R/R约束) + + if new_target and new_stop and new_target > new_stop: + # 买入价的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: + # 已持仓正常:买入区 = 弱支撑~弱支撑上方5%(给合理回调空间) + # 上限不能低于成本价×0.95(保护已有持仓不被高位逼空) + entry_low = round(ws, 2) + entry_max = round(ws * 1.05, 2) # 比弱支撑高5%,有足够空间 + # 如果当前价已远离买入区,保持买入区不变(不因价格涨了就收窄) + min_upper = round(cost * 0.95, 2) if cost > 0 else 0 + if entry_max < min_upper: + entry_max = min_upper + if max_acceptable_entry: + entry_high = round(min(entry_max, max_acceptable_entry), 2) + else: + entry_high = entry_max + # 如果当前价已远离买入区(高于买入区上沿),禁止加仓推荐 + if price > entry_high: + # 买入区锁定在弱支撑位,但标记为"价格远离" + pass + # 如果买入区过窄,标记但不扩展(加仓必须在支撑位) + if entry_high - entry_low < price * 0.005: + entry_low = round(ws * 0.995, 2) + entry_high = round(ws * 1.005, 2) + else: + entry_low = round(price * 0.90, 2) + entry_high = round(price * 1.05, 2) + + # 买入区间稳定性保护:上边界单次变动不超过5% + if 'entry_high' in dir() and entry_high: + # 读取当前策略中已有的买入区上界,如果有且变化过大则限制 + old_entry_high = None + if 'current_action' in dir() and current_action: + import re + m = re.search(r'买入区[\d.]+~([\d.]+)', current_action) + if m: + old_entry_high = float(m.group(1)) + if old_entry_high and old_entry_high > 0: + max_change = old_entry_high * 0.95 # 单次最多下降5% + if entry_high < max_change: + entry_high = round(max_change, 2) + + # ----- 买入时机信号(三维分析:大盘+行业+个股,基本面+消息面+技术面+资金流)----- + # [2026-07-01] 扩展:不再只看volume_signal + candlestick_sentiment + # 融合大盘趋势、行业板块强弱、基本面估值作为修正因子 + volume_signal = vol.get("volume_signal", "") + candlestick_sentiment = candle.get("sentiment", "neutral") + + # ----- 深度量价分析(2026-07-08 新增:放量建仓/出货/洗盘检测)----- + deep_vol = {} + vol_pattern_note = "" + try: + from technical_analysis import analyze_volume_deep + deep_vol = analyze_volume_deep(code) + if deep_vol.get("volume_signal"): + vp = deep_vol.get("price_vol_description", "") + patterns = deep_vol.get("patterns", {}) + vol_pattern_note = vp + # 合并到volume_signal + if volume_signal: + volume_signal += f" | {vp}" if vp else "" + else: + volume_signal = vp or "" + except Exception: + pass + + timing_signal = "neutral" + + # --- 三维分析数据装载 --- + # 因子1: 大盘环境(从macro_context_log读) + market_bearish = False + market_bullish = False + try: + import sqlite3 + _db = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=5) + _mc = _db.execute( + "SELECT structure FROM macro_context_log WHERE has_valid_data=1 ORDER BY rowid DESC LIMIT 1" + ).fetchone() + if _mc and _mc[0]: + _s = json.loads(_mc[0]) + _overall = _s.get("overall", "") + if "bearish" in _overall: + market_bearish = True + elif _overall == "bullish": + market_bullish = True + _db.close() + except Exception: + pass + + # 因子2: 行业板块强弱 + sector_strong = False + sector_weak = False + try: + _db2 = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=5) + _rows2 = _db2.execute( + "SELECT name, change_pct FROM sector_snapshots ORDER BY change_pct DESC" + ).fetchall() + if _rows2: + # 找到该股所属行业(简单匹配name或通过stock_sectors) + _my_sectors = _db2.execute( + "SELECT sector_name FROM stock_sectors WHERE code=?", + (code,) + ).fetchall() + if _my_sectors: + for (_sn,) in _my_sectors: + for r_name, r_chg in _rows2: + if _sn in r_name or r_name in _sn: + _rank = [r[0] for r in _rows2].index(r_name) if r_name in [x[0] for x in _rows2] else -1 + _total = len(_rows2) + if _rank >= 0: + if _rank < _total * 0.2: + sector_strong = True + if _rank > _total * 0.8: + sector_weak = True + break + _db2.close() + except Exception: + pass + + # 因子3: 基本面估值 + is_value_stock = False + try: + _db3 = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=5) + _fd = _db3.execute( + "SELECT pe, eps FROM stock_fundamentals WHERE code=?", (code,) + ).fetchone() + if _fd: + _pe, _eps = _fd + is_value_stock = (0 < (_pe or 0) < 25 and (_eps or 0) > 0.3) + _db3.close() + except Exception: + pass + + # --- 三维修正规则 --- + # 大盘偏弱时收紧买入信号,大盘偏强时放宽 + # 行业领先加分,行业落后减分 + # 低估值加分(有安全边际) + + def _adjust_timing(signal, market_b, market_bb, sec_s, sec_w, is_val): + """根据三维因子修正 timing_signal""" + # 大盘偏弱时降级买入信号 + if market_b: + if signal in ("买入", "加仓"): + if not sec_s: # 大盘弱+行业不强→降级 + return "关注" + # 大盘偏强时放宽 + if market_bb: + if signal == "关注" and (sec_s or is_val): + return "买入" + # 行业弱势时降级买入信号 + if sec_w: + if signal in ("买入", "加仓"): + return "关注" + # 行业强势+低估时升级关注 + if sec_s and is_val: + if signal == "关注": + return "买入" + return signal + + if is_new_entry: + # 新买入时机 + if volume_signal == "主动买盘占优" and candlestick_sentiment == "bullish": + timing_signal = "买入" + elif volume_signal == "主动卖盘占优": + timing_signal = "观望" + elif volume_signal == "买卖均衡" and ws and price <= ws * 1.03: + timing_signal = "买入" + elif candlestick_sentiment == "bullish": + timing_signal = "买入" + elif ws and price < ws * 1.02: + timing_signal = "关注" + # 新买入时三维修正:大盘向上+行业强→升级,大盘弱→降级 + _pre_signal = timing_signal + timing_signal = _adjust_timing(timing_signal, market_bearish, market_bullish, + sector_strong, sector_weak, is_value_stock) + if timing_signal != _pre_signal: + print(f" 三维修正(新入): {_pre_signal}→{timing_signal} " + f"| 大盘{'弱' if market_bearish else '强' if market_bullish else '中性'}" + f"| 行业{'强' if sector_strong else '弱' if sector_weak else '中性'}" + f"| 估值{'低' if is_value_stock else '一般'}") + else: + # 已持仓时机(用于加仓/减仓参考) + if is_short_term_strong_trend: + # 短炒+强趋势:强趋势持有,禁止加仓信号 + timing_signal = "持有" + elif profit_pct > 5: + # 已盈利 + if volume_signal == "主动买盘占优": + timing_signal = "持有" + elif volume_signal == "主动卖盘占优" and not is_new_entry: + timing_signal = "关注" + else: + timing_signal = "持有" + elif profit_pct > 0: + # 微盈 + if volume_signal == "主动买盘占优": + timing_signal = "持有" + elif ws and price <= ws * 1.02: + timing_signal = "加仓" + else: + timing_signal = "持有" + else: + # 浮亏 + if volume_signal == "主动卖盘占优" and ss and price <= ss * 1.03: + timing_signal = "关注" + elif volume_signal == "主动买盘占优" and sr_resist and price >= sr_resist * 0.97: + timing_signal = "关注" + elif volume_signal == "买卖均衡" and ws and price <= ws * 1.02: + timing_signal = "加仓" + else: + timing_signal = "持有" + + # ----- 构造 action 描述(供 cron prompt 使用) ----- + action_parts = [] + if profit_pct < -20: + action_parts.append(f"亏损{abs(profit_pct):.0f}%深套") + 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) + + # 量价分析描述(如有,注入action) + if vol_pattern_note: + action_parts.append(vol_pattern_note) + + if is_watchlist: + # 自选股(未入场):有止损参考+买入区,内部算RR需要止盈位 + action_parts.append(f"目标参考{new_target}") + action_parts.append(f"止损参考{new_stop}") + action_parts.append(f"买入区{entry_low}~{entry_high}") + elif is_new_entry: + action_parts.append(f"损{new_stop}") + action_parts.append(f"盈{new_target}") + action_parts.append(f"买{entry_low}~{entry_high}") + else: + action_parts.append(f"止损{new_stop}") + action_parts.append(f"目标{new_target}") + action_parts.append(f"买入区{entry_low}~{entry_high}") + + if timing_signal != "neutral": + action_parts.append(f"信号:{timing_signal}") + + new_action = " | ".join(action_parts) + + # 技术面快照 + tech_snapshot = "" + if candle: + tech_snapshot = (f"形态:{candle.get('pattern','?')}/{candle.get('sentiment','?')} " + f"量价:{vol.get('volume_signal','?')} " + f"强撑:{ss} 弱撑:{ws} 弱压:{wr} 强压:{sr_resist}") + # 加入均线信息(如果可用) + try: + dm = mtf_analysis.get("daily", {}).get("mas", {}) + ma_parts = [] + for m in ['ma5', 'ma10', 'ma20', 'ma60']: + v = dm.get(m) + if v: + ma_parts.append(f"{m.upper()}={v}") + if ma_parts: + tech_snapshot += " | " + " ".join(ma_parts) + except (NameError, AttributeError): + pass + + # 多周期快照(追加到 tech_snapshot) + mtf_context = "" + if mtf_adj: + trend_align = mtf_adj.get("trend_alignment", "") + daily_mas = mtf_analysis.get("daily", {}).get("mas", {}) + ma20 = daily_mas.get("ma20") + ma60 = daily_mas.get("ma60") + stop_ref = mtf_adj.get("stop_loss_reference", {}) + take_ref = mtf_adj.get("take_profit_reference", {}) + + parts = [] + if trend_align: + parts.append(trend_align) + if ma20: + parts.append(f"MA20={ma20}") + if ma60: + parts.append(f"MA60={ma60}") + if stop_ref: + parts.append(f"长撑:{stop_ref.get('source','?')}={stop_ref['level']}") + if take_ref: + parts.append(f"长压:{take_ref.get('source','?')}={take_ref['level']}") + + # 周线趋势 + weekly = mtf_analysis.get("weekly", {}) + wt = weekly.get("trend", {}) + wm = weekly.get("mas", {}) + ws = weekly.get("support_resistance", {}) + if wt.get("direction"): + parts.append(f"周线{wt['direction']}") + if wm.get("ma5"): + parts.append(f"周MA5={wm['ma5']}") + if ws.get("weak_support"): + parts.append(f"周撑={ws['weak_support']}") + if ws.get("weak_resist"): + parts.append(f"周压={ws['weak_resist']}") + + # 月线趋势 + monthly = mtf_analysis.get("monthly", {}) + mt = monthly.get("trend", {}) + ms = monthly.get("mas", {}) + if mt.get("direction"): + parts.append(f"月线{mt['direction']}") + if ms.get("ma5"): + parts.append(f"月MA5={ms['ma5']}") + + mtf_context = " | ".join(parts) + + now_str = datetime.now().strftime('%Y-%m-%d %H:%M') + return { + 'stop_loss': new_stop, + 'take_profit': new_target, + 'entry_low': entry_low, + 'entry_high': entry_high, + 'action': new_action, + 'status': status, + 'tech_snapshot': tech_snapshot, + 'timing_signal': timing_signal, + 'rr_ratio': round(rr_ratio, 2), + 'action_note': action_note, + 'reassessed_at': now_str, + 'multi_tf_context': mtf_context, # 多周期上下文 + 'stock_category': stock_category, # 股票分类:短炒/中短线/中长线/弱势/深套 + 'time_horizon': time_horizon, # 时间跨度 + 'position_advice': position_advice, # 仓位建议 + } + + +def load_stock_news_sentiment(code): + """加载小果消息面情感""" + try: + path = "/home/hmo/web-dashboard/data/xiaoguo_sentiment.json" + if not os.path.exists(path): + return {} + xg = json.load(open(path)) + return xg.get("stocks", {}).get(code, {}) + except Exception: + return {} + + +def load_fundamentals(code): + """加载个股基本面""" + try: + cache = mtf._load_mtf_cache() + return cache.get(code, {}).get("fundamentals", {}) or {} + except Exception: + return {} + + +def _get_portfolio_risk_state(): + """读取 portfolio 组合风险状态(2026-06-23 引擎协调)""" + try: + # 数据一致性检查:警告多副本(2026-06-23 bugfix) + _check_portfolio_consistency() + p = read_portfolio() + pos_pct = p.get('position_pct', 0) + cash = p.get('cash', 0) + holdings = p.get('holdings', []) + weak_cnt = sum(1 for h in holdings if h.get('change_pct', 0) < -15) + total = len(holdings) or 1 + weak_ratio = weak_cnt / total + return { + 'position_pct': pos_pct, + 'cash': cash, + 'is_high_position': pos_pct > 80, + 'is_very_high_position': pos_pct > 90, + 'is_high_weak': weak_ratio > 0.35, + 'weak_ratio': round(weak_ratio * 100), + 'total_holdings': total, + } + except: + return {} + + +def _is_buy_signal(signal): + """判断信号是否为买入/持有类(用于防洗盘)""" + if not signal: + return False + buy_keywords = ['买入', '持有', '加仓', '关注'] + for kw in buy_keywords: + if kw in signal: + return True + return False + + +def _check_portfolio_consistency(): + """数据一致性检查:如果存在多份 portfolio.json 则报警(2026-06-23 bugfix)""" + main = '/home/hmo/web-dashboard/data/portfolio.json' + main_cash = None + try: + import json + main_cash = json.load(open(main)).get('cash') + except Exception: + return + for path in [ + '/home/hmo/data/portfolio.json', + '/home/hmo/projects/MoFin/data/portfolio.json', + '/home/hmo/web-dashboard.bak/data/portfolio.json', + ]: + if os.path.exists(path): + try: + other = json.load(open(path)) + if other.get('cash') != main_cash: + print(f"⚠️ 数据一致性: {os.path.realpath(path)} cash={other.get('cash')} ≠ 主文件 cash={main_cash} (需清理)", file=sys.stderr) + except Exception: + pass + + +def _check_contradiction(code, today_only=True): + """反馈循环核——检查本股是否有刚卖出的记录 + + 返回 dict or None: + - sold_reason: 'portfolio_trim'|'stop_loss' + - sold_at: 卖出日期 + - days_ago: 卖出距今交易日数 + - is_today: 是否今日卖出 + - tag: 追加到信号的标注 + """ + try: + from datetime import datetime, date + dec = read_decisions() + for e in dec.get('decisions', []): + if e.get('code') != code: + continue + sold_at = e.get('sold_at', '') + if not sold_at: + return None + try: + sd = datetime.strptime(sold_at, '%Y-%m-%d').date() + td = date.today() + days = (td - sd).days + except: + return None + + reason = e.get('sold_reason', 'portfolio_trim') + if reason == 'stop_loss': + tag = '止损离场(逻辑破坏,短期不关注)' + else: + tag = '组合减仓后关注(已清仓,等回踩确认)' + + return { + 'sold_reason': reason, + 'sold_at': sold_at, + 'days_ago': days, + 'is_today': days == 0, + 'tag': tag, + } + except: + return None + return None + + +def _get_sell_priority_list(): + """减仓优先级排序:深套>亏损>微盈>盈利(2026-06-23 反馈循环) + + 返回 [(code, name, change_pct, position_pct, priority_label), ...] + 按卖出的优先顺序排列(最先应该卖的在最前) + """ + try: + p = read_portfolio() + holdings = p.get('holdings', []) + ranked = [] + for h in holdings: + chg = h.get('change_pct', 0) + pos = h.get('position_pct', 0) + if chg < -30: + label = '深套(>30%),优先减' + rank = 0 + elif chg < -20: + label = '深套(>20%),优先减' + rank = 1 + elif chg < -10: + label = '亏损,建议减' + rank = 2 + elif chg < 0: + label = '微亏,可减' + rank = 3 + elif chg < 10: + label = '微盈,持有' + rank = 4 + else: + label = '盈利,最后减' + rank = 5 + ranked.append((rank, h['code'], h.get('name',''), chg, pos, label)) + ranked.sort(key=lambda x: (x[0], -x[4])) # 优先 rank, 其次仓位大优先 + return [{'code':c,'name':n,'change_pct':chg,'position_pct':pos,'label':l} + for r,c,n,chg,pos,l in ranked] + except: + return [] + + +def enrich_timing_signal(base_signal, macro_desc="", sector_note="", + profit_pct=0, stock_category="", is_new_entry=False, + fundamentals=None, news_sentiment=None, + timing_signal_override=None, + portfolio_context=None, + rr_ratio=0): # 2026-06-24 新参:盈亏比约束 + """多因子合成timing_signal——大盘+行业+基本面+技术+组合风险+盈亏比 + + 返回 (enriched_signal, factors_list) + - enriched_signal: 可读的多因子信号描述 + - factors_list: 各因子的摘要列表(用于后续显示) + """ + # 如果已手动设定,尊重手动 + if timing_signal_override and timing_signal_override != "neutral": + return timing_signal_override, [timing_signal_override] + + factors = [] + + # 1. 大盘因子 + if "偏强" in macro_desc or "大涨" in macro_desc or "bullish" in macro_desc.lower(): + macro_txt = "大盘偏强" + factors.append(macro_txt) + elif "偏弱" in macro_desc or "大跌" in macro_desc or "bearish" in macro_desc.lower(): + macro_txt = "大盘偏弱" + factors.append(macro_txt) + elif macro_desc and macro_desc != "宏观未加载": + factors.append("大盘中性") + + # 2. 行业因子 + if sector_note: + # 把"行业X大跌3%+"简化为"行业偏弱","行业X大涨3%+"简化为"行业偏强" + if "大跌" in sector_note or "下跌" in sector_note: + factors.append("行业偏弱") + elif "大涨" in sector_note: + factors.append("行业偏强") + elif "上涨" in sector_note: + factors.append("行业偏强") + else: + factors.append("行业中性") + + # 3. 基本面因子 + if fundamentals: + pe = fundamentals.get("pe", 0) + eps = fundamentals.get("eps", 0) + profit_growth = fundamentals.get("profit_growth", fundamentals.get("yoy_profit", "")) + revenue_growth = fundamentals.get("revenue_growth", fundamentals.get("yoy_revenue", "")) + mcap = fundamentals.get("mcap_total", 0) + + pe = pe or 0 + eps = eps or 0 + profit_growth_str = str(profit_growth or "") + revenue_growth_str = str(revenue_growth or "") + + # 净利增长 + for val in [profit_growth_str, revenue_growth_str]: + try: + v = float(val.replace("%", "").replace("+", "")) + if v > 50: + factors.append("净利增50%+") + break + elif v > 20: + factors.append(f"净利增{int(v)}%") + break + elif v < -20: + factors.append("净利降20%+") + break + except (ValueError, AttributeError): + continue + + # PE估值 + if 0 < pe < 15: + factors.append("低估值") + elif pe > 100 or pe < 0: + factors.append("高估值") + + # 市值 + if mcap and mcap > 5000: + factors.append("蓝筹") + + # 4. 消息面因子(小果情感) + if news_sentiment: + ns = news_sentiment.get("sentiment", "") + nc = news_sentiment.get("confidence", 0) + if ns == "positive" and nc >= 0.7: + kws = news_sentiment.get("keywords", []) + kw_str = f"({'/'.join(kws[:3])})" if kws else "" + factors.append(f"消息偏多{kw_str}") + elif ns == "negative" and nc >= 0.7: + kws = news_sentiment.get("keywords", []) + kw_str = f"({'/'.join(kws[:3])})" if kws else "" + factors.append(f"消息偏空{kw_str}") + + # 5. 技术面(基础信号) + if base_signal and base_signal != "neutral": + factors.append(base_signal) + + # 5.5 组合风险因子(2026-06-23 双引擎协调) + if portfolio_context and not is_new_entry: + if portfolio_context.get('is_very_high_position'): + factors.append("组合仓位极重(>90%)") + elif portfolio_context.get('is_high_position'): + factors.append("组合仓位偏重(>80%)") + if portfolio_context.get('is_high_weak'): + factors.append(f"弱势占{portfolio_context.get('weak_ratio')}%") + elif portfolio_context and is_new_entry: + # 新买入推荐:注明组合上下文 + if portfolio_context.get('is_high_position'): + factors.append(f"仓{portfolio_context.get('position_pct')}%现金有限") + elif portfolio_context.get('is_high_weak'): + factors.append("组合风险信号") + + # 5.7 盈亏比因子(2026-06-24 新增——RR<1.5降级买入信号) + if rr_ratio > 0: + if rr_ratio < 1.5: + factors.append(f"RR{rr_ratio}过低") + elif rr_ratio >= 3: + factors.append(f"RR{rr_ratio}") + # 1.5~3之间:中性,不特别标注 + + # 如果没有足够因素,返回信号不充分 + if not factors: + return "信号不充分", [] + + # 信号只应包含明确的买卖方向,不能从行业/大盘等上下文因子拼凑 + # base_signal 存在且非 neutral → 用 base_signal + # 否则 → 信号不充分(不拿 factors[-1] 当信号) + if base_signal and base_signal != "neutral": + clean_signal = base_signal + else: + # 从 factors 中找第一个有效的操作方向信号 + valid_direction = {"买入", "加仓", "观望", "持有", "关注", "信号不充分"} + signal_found = "" + for f in reversed(factors): + if f in valid_direction: + signal_found = f + break + clean_signal = signal_found if signal_found else "信号不充分" + + # 6. RR约束降级(2026-06-24 新增) + # 买入/加仓信号但RR<1.5 → 降级为"信号不充分" + buy_signals = {"买入", "加仓"} + if clean_signal in buy_signals and 0 < rr_ratio < 1.5: + clean_signal = "信号不充分" + factors.append("RR过低降级") + + return clean_signal, factors + + +def reassess_with_context(code, name, price, cost, shares, current_action, + volume_signal="", sentiment="neutral", is_watchlist=False): + """reassess_strategy + 多因子信号合成(大盘+行业+技术) + + 为 per_stock_reassess 等单只场景提供一站式多因子分析 + """ + result = reassess_strategy( + code, name, price, cost, shares, + current_action, volume_signal, sentiment, is_watchlist + ) + if not result: + return result + + # 加载宏观+行业+消息+基本面上下文 + try: + macro_bias, macro_desc = load_macro_context() + market_ctx = load_market_context() + stock_sector_map = load_stock_sector_map() + sector_adj = compute_sector_adjustment(code, market_ctx, stock_sector_map) + sector_note = sector_adj.get("note", "") + news_sentiment = load_stock_news_sentiment(code) + fund = load_fundamentals(code) + except Exception: + macro_desc = "" + sector_note = "" + news_sentiment = {} + fund = {} + + # ── DSA 集成:注入大盘复盘 + 新闻情报 ────────────────────────── + try: + from mo_bridge import enrich_analysis_context + region = "hk" if len(str(code)) == 5 and str(code)[0] in ('0','1') else "cn" + dsa_ctx = enrich_analysis_context(stock_code=code, stock_name=name, + region=region, include_news=True) + if dsa_ctx: + macro_desc = (macro_desc + "\n\n" + dsa_ctx).strip() + except Exception: + pass # DSA 不可用时静默跳过 + + enriched, factors = enrich_timing_signal( + base_signal=result.get("timing_signal", ""), + macro_desc=macro_desc, + sector_note=sector_note, + profit_pct=(price - cost) / cost * 100 if cost else 0, + stock_category=result.get("stock_category", ""), + is_new_entry=is_watchlist, + fundamentals=fund, + news_sentiment=news_sentiment, + portfolio_context=_get_portfolio_risk_state(), + rr_ratio=result.get("rr_ratio", 0), + ) + result["timing_signal"] = enriched + result["signal_factors"] = factors + + # 6. 防洗盘:信号不要一天一翻(2026-06-23) + # 如果旧信号是买入/持有类,新信号是谨慎/等待类,但中期趋势未破→维持旧信号 + try: + dec = read_decisions() + for e in dec.get('decisions', []): + if e.get('code') == code: + old_signal = e.get('timing_signal', '') + if old_signal and _is_buy_signal(old_signal) and not _is_buy_signal(enriched): + # 中等趋势检查:MA5 > MA20 + 多周期看多 + mtf = result.get('multi_tf_context', '') + if '看多' in mtf or '多头' in mtf: + try: + closes = [float(k.split()[2]) for k in mtf.split('|') if 'MA5' in k] + except: + closes = [] + has_uptrend = 'MA5' in mtf and 'MA20' in mtf + if has_uptrend: + print(f" 防洗盘: {old_signal}→保持旧信号(中期趋势完整)") + result["timing_signal"] = f"{old_signal}(正常回调价稳)" + sf = result.get("signal_factors") or [] + if "正常回调价稳" not in sf: + result["signal_factors"] = sf + ["正常回调价稳"] + break + except Exception as e: + print(f" 防洗盘跳过: {e}") + + # 7. 反馈循环核:检查本股是否有刚卖出的记录(2026-06-23) + contradiction = _check_contradiction(code) + if contradiction and contradiction.get('is_today'): + # 今日刚卖出 → 不屏蔽信号,但必须自标注矛盾 + print(f" 反馈循环: {contradiction.get('tag')} (sold_at={contradiction.get('sold_at')})") + if _is_buy_signal(result.get('timing_signal', '')): + result['action_note'] = contradiction['tag'] + # 在 timing_signal 中追加反馈标注,供报告层可见 + curr_signal = result.get('timing_signal', '') + if '⚠️' not in curr_signal: + result['timing_signal'] = f"⚠️{contradiction['tag']}|{curr_signal}" + elif contradiction: + # 非今日卖出但近期卖出 → 标注已清仓 + print(f" 近期清仓: sold_at={contradiction.get('sold_at')} ({contradiction.get('days_ago')}日前)") + if _is_buy_signal(result.get('timing_signal', '')): + curr_signal = result.get('timing_signal', '') + if '已清仓' not in curr_signal: + result['timing_signal'] = f"已清仓,{curr_signal}" + + # 重建 action 文本(同步多因子信号) + try: + if new_action_needs_refresh(result, {"source": "auto"}, price): + _refresh_action_text(result, price, name) + except Exception: + pass + + # ── 策略质量门禁 ── + enforce_strategy_quality(code, name, result) + + return result + + +def new_action_needs_refresh(result, old_entry, price): + """判断宏观/行业调整后是否需要刷新action文本""" + # 自选股和手动策略不做调整,不需要刷新 + if old_entry.get("source") == "manual": + return False + return True + + +def _refresh_action_text(result, price, name): + """根据调整后的止损/止盈重建action文本""" + sl = result.get("stop_loss", 0) + tp = result.get("take_profit", 0) + el = result.get("entry_low", 0) + eh = result.get("entry_high", 0) + ts = result.get("timing_signal", "") + an = result.get("action_note", "") + old_action = result.get("action", "") + + # 保持原action的前缀(持有状态部分不变) + # action格式一般是: "状态 | 止损X | 目标Y | 买入区X~Y | 信号:Z" + parts = old_action.split(" | ") + new_parts = [] + for p in parts: + p = p.strip() + # 替换止损数字 + if p.startswith("止损") or p.startswith("止损参考"): + if sl: + p = f"止损{sl}" if "止损参考" not in old_action.split(" | ")[0] else f"止损参考{sl}" + # 替换目标/止盈数字 + if p.startswith("目标") or p.startswith("止盈"): + if tp: + p = f"目标{tp}" + # 替换买入区数字 + if "买入区" in p and "~" in p: + if el and eh: + p = f"买入区{el}~{eh}" + new_parts.append(p) + result["action"] = " | ".join(new_parts) + + +def check_sector_alerts(market_ctx, stock_sector_map, holdings, wl): + """行业轮动主动预警:检测板块崩盘级别信号→查持仓→输出预警 + + 返回 list of alerts: [{code, name, sector, chg, action}] + """ + alerts = [] + if not market_ctx: + return alerts + + sector_perf = market_ctx.get("sector_perf", {}) + + # 找出所有跌幅>3%的行业 + crashing_sectors = {name: data for name, data in sector_perf.items() + if data.get("change", 0) <= -3} + + if not crashing_sectors: + return alerts + + # 构建 code→持仓信息 的映射 + holding_map = {} + for h in holdings: + c = h.get("code", "") + if c: + holding_map[c] = {"name": h.get("name", c), "type": "持仓"} + for s in wl.get("stocks", []): + c = s.get("code", "") + if c and c not in holding_map: + holding_map[c] = {"name": s.get("name", c), "type": "自选"} + + # 对每个暴跌行业,查持仓中是否有股票属于该行业 + for sec_name, sec_data in sorted(crashing_sectors.items(), + key=lambda x: x[1].get("change", 0)): + chg = sec_data.get("change", 0) + for code, sectors in stock_sector_map.items(): + if code in holding_map and sec_name in sectors: + info = holding_map[code] + alerts.append({ + "code": code, + "name": info["name"], + "sector": sec_name, + "sector_change": chg, + "type": info["type"], + "action": f"行业{sec_name}跌{chg:+.1f}%,{info['type']}需关注", + }) + + alerts.sort(key=lambda a: a["sector_change"]) + return alerts + + +def regenerate_all(stdout=True): + """全量重评所有持仓+自选策略""" + # 优先从 SQLite 读取 + try: + from mofin_db import get_conn, query_holdings, query_watchlist + conn = get_conn() + holdings = query_holdings(conn) + wl_stocks = query_watchlist(conn) + conn.close() + pf = {"holdings": holdings} + wl = {"stocks": wl_stocks} + except Exception: + try: + pf = read_portfolio() + wl = read_watchlist() + except Exception: + pf = {} + wl = {} + + 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 (DB holding_strategies 表) 以便追踪变更 + # decisions_path = "/home/hmo/web-dashboard/data/decisions.json" # 已废弃,数据在 DB + + try: + existing_decisions = {d["code"]: d for d in read_decisions().get("decisions", []) if d.get("code")} + except: + existing_decisions = {} + + # 加载宏观上下文(影响策略参数调整) + macro_bias, macro_desc = load_macro_context() + if stdout: + print(f" 宏观参考: {macro_desc} (bias={macro_bias})") + + # 加载市场上下文 — 行业板块表现 + 大盘宽度(策略参数调整用) + market_ctx = load_market_context() + stock_sector_map = load_stock_sector_map() + market_breadth = market_ctx.get("breadth", 50) + market_mood = market_ctx.get("mood", "neutral") + if stdout: + sectors_found = sum(1 for c in all_stocks if stock_sector_map.get(c)) + print(f" 市场参考: {market_mood} 上涨比{market_breadth}% 已匹配{sectors_found}/{total}只个股行业") + + # 批量预取所有价格(一次API调用 vs 之前N次) + prices_map = batch_fetch_prices(list(all_stocks.keys())) + if stdout: + print(f" 批量获取价格: {len(prices_map)}/{total} 成功") + + for code, info in sorted(all_stocks.items()): + stock = info["data"] + name = stock.get("name", code) + cost = stock.get("cost", 0) or 0 + shares = stock.get("shares", 0) or 0 + source = info["source"] + + q = prices_map.get(code) + if not q or not q.get("price"): + results.append({"code": code, "name": name, "error": "腾讯API无数据"}) + errors += 1 + if stdout: + print(f" ❌ {name}({code}): 腾讯API无数据") + continue + + price = q["price"] + profit_pct = (price - cost) / cost * 100 if cost else 0 + current_action = stock.get("analysis", {}).get("action", "") + close_yest = q.get("close", 0) + sentiment = "neutral" + if close_yest and price > close_yest * 1.02: + sentiment = "bullish" + elif close_yest and price < close_yest * 0.98: + sentiment = "bearish" + + try: + is_wl = (source == "watchlist") + result = reassess_strategy( + code, name, price, cost, shares, + current_action, volume_signal="中性", sentiment=sentiment, + is_watchlist=(source == "watchlist"), + ) + + # --- Manual param preservation: 用户手动策略永不覆盖 --- + old_entry = existing_decisions.get(code, {}) + if old_entry.get("source") == "manual": + # 仅覆盖策略参数,技术分析/信号/价格照常保留 + for key in ["entry_low", "entry_high", "stop_loss", "take_profit"]: + if key in old_entry and old_entry[key] is not None: + result[key] = old_entry[key] + # 重算盈亏比(基于手动参数) + manual_stop = result.get("stop_loss", 0) or 0 + manual_target = result.get("take_profit", 0) or 0 + risk = max(price - manual_stop, price * 0.01) if manual_stop > 0 else price * 0.01 + reward = max(manual_target - price, 0) if manual_target > 0 else 0 + result["rr_ratio"] = round(reward / risk, 2) if risk > 0 else 0 + # 重建 action 文本(引用手动参数,不引用自动计算的) + profit_pct = (price - cost) / cost * 100 if cost else 0 + manual_action_parts = [] + if profit_pct < -20: + manual_action_parts.append("深套持有") + elif profit_pct < -10: + manual_action_parts.append("持有观察") + elif profit_pct < 0: + manual_action_parts.append("持有观察") + elif profit_pct < 5: + manual_action_parts.append("盈利持有") + else: + manual_action_parts.append("盈利良好") + if result.get("action_note"): + manual_action_parts.append(result["action_note"]) + if is_wl: + if manual_stop > 0: + manual_action_parts.append(f"止损参考{manual_stop}") + manual_action_parts.append(f"买入区{result['entry_low']}~{result['entry_high']}") + else: + if manual_stop > 0: + manual_action_parts.append(f"止损{manual_stop}") + if manual_target > 0: + manual_action_parts.append(f"目标{manual_target}") + manual_action_parts.append(f"买入区{result['entry_low']}~{result['entry_high']}") + ts = result.get("timing_signal", "") + if ts and ts != "neutral": + manual_action_parts.append(f"信号:{ts}") + result["action"] = " | ".join(manual_action_parts) + result["status"] = "manual" # 标记为手动管理,变更追踪不受影响 + if stdout: + print(f" [手动保留] {name}({code}) 策略参数未覆盖") + + # 宏观偏差调整:收盘后重评时根据宏观方向微调止损/止盈 + # 自选股不做止盈宏观调整(无持仓) + # 手动策略不做宏观偏差调整(尊重用户设定) + if macro_bias != 1.0 and not is_wl and old_entry.get("source") != "manual": + old_stop = result.get("stop_loss", 0) + old_target = result.get("take_profit", 0) + if macro_bias < 1.0 and old_stop > 0: # 宏观偏弱 → 收紧止损 + # 止损上移(但保留最小3%间距) + adjusted_stop = round(old_stop * (1 + (1 - macro_bias) * 0.3), 2) + min_stop = round(price * 0.97, 2) + result["stop_loss"] = min(adjusted_stop, min_stop) + if old_target > 0: + result["take_profit"] = round(old_target * (1 - (1 - macro_bias) * 0.2), 2) + elif macro_bias > 1.0 and old_target > 0: # 宏观偏强 → 止盈上调让利润跑 + result["take_profit"] = round(old_target * (1 + (macro_bias - 1) * 0.3), 2) + + # 行业偏差调整:根据个股所在行业的市场表现微调止损/止盈 + # 手动策略不做行业调整(尊重用户设定) + sector_adj = compute_sector_adjustment(code, market_ctx, stock_sector_map) + sector_note = sector_adj.get("note", "") + if sector_note and old_entry.get("source") != "manual": + old_stop = result.get("stop_loss", 0) + old_target = result.get("take_profit", 0) + stop_bias = sector_adj.get("stop_bias", 1.0) + target_bias = sector_adj.get("target_bias", 1.0) + if stop_bias != 1.0 and old_stop > 0: + # 行业偏差调整(在宏观调整之后叠加) + adjusted = round(old_stop * stop_bias, 2) + # 保留最小3%间距 + min_stop = round(price * 0.97, 2) + result["stop_loss"] = min(adjusted, min_stop) + if target_bias != 1.0 and old_target > 0 and not is_wl: + result["take_profit"] = round(old_target * target_bias, 2) + + # 加载消息面+基本面(逐个股) + news_sentiment = load_stock_news_sentiment(code) + fund = load_fundamentals(code) + + # 多因子合成 timing_signal:大盘+行业+消息+基本面+技术 + if old_entry.get("source") != "manual": + enriched, _ = enrich_timing_signal( + base_signal=result.get("timing_signal", ""), + macro_desc=macro_desc, + sector_note=sector_note, + profit_pct=profit_pct, + stock_category=result.get("stock_category", ""), + is_new_entry=(source == "watchlist"), + fundamentals=fund, + news_sentiment=news_sentiment, + rr_ratio=result.get("rr_ratio", 0), + ) + result["timing_signal"] = enriched + + # 在宏观/行业/多因子调整后重建 action 文本(同步调整后的止损/止盈数字) + if new_action_needs_refresh(result, old_entry, price): + _refresh_action_text(result, price, name) + + extra = { + "rr_ratio": result.get("rr_ratio"), + "action_note": result.get("action_note", ""), + "timing_signal": result.get("timing_signal", ""), + } + analysis = { + "stop_loss": result["stop_loss"], + "take_profit": result["take_profit"], + "entry_low": result["entry_low"], + "entry_high": result["entry_high"], + "action": result["action"], + "tech_snapshot": result.get("tech_snapshot", ""), + "multi_tf_context": result.get("multi_tf_context", ""), + "reassessed_at": result["reassessed_at"], + "status": result["status"], + **extra, + } + stock["analysis"] = analysis + # 同步 top-level 字段 → zone_breach/price_monitor 依赖这些字段 + # (2026-06-24 bugfix: analysis 子对象有但顶层没有,导致新持仓的止损检测盲区) + stock["stop_loss"] = result.get("stop_loss", 0) + stock["take_profit"] = result.get("take_profit", 0) + stock["entry_low"] = result.get("entry_low", 0) + stock["entry_high"] = result.get("entry_high", 0) + # 同步 trigger 字段 -> price_monitor 依赖 + sl = result.get("stop_loss", 0) + tp = result.get("take_profit", 0) + el = result.get("entry_low", 0) + eh = result.get("entry_high", 0) + trig = {} + if sl and float(sl) > 0: + trig["stop_loss"] = float(sl) + if el and eh and float(el) > 0 and float(eh) > 0: + trig["entry_zone"] = f"{float(el)}~{float(eh)}" + if tp and float(tp) > 0: + trig["take_profit_zone"] = f"0~{float(tp)}" + stock["trigger"] = trig + results.append({ + "code": code, "name": name, + "price": price, "cost": cost, + "action": result["action"], + "stop_loss": result["stop_loss"], + "take_profit": result["take_profit"], + "rr_ratio": result["rr_ratio"], + }) + ok += 1 + if stdout: + rr_str = f" RR={result['rr_ratio']}" if "rr_ratio" in result else "" + print(f" ✅ {name}({code}) {price} {result['action']}{rr_str}") + + # 记录所有股票的决策日志(含变更追踪) + status_display = result.get("status", "active") + # 构建行业上下文 + sector_ctx_str = "" + sec_name = sector_adj.get("sector_name", "") + sec_chg = sector_adj.get("sector_change", 0) + if sec_name: + sector_ctx_str = f"行业{sec_name}{sec_chg:+.1f}%" + if sector_adj.get("note"): + # note 已包含大盘宽度信息 + sector_ctx_str = sector_adj["note"] + elif market_breadth < 40: + # 无行业映射时至少记录大盘宽度 + sector_ctx_str = f"大盘上涨比{market_breadth}%" + new_entry = { + "code": code, "name": name, "price": price, + "cost": old_entry.get("cost", cost) if old_entry else cost, # 优先保留旧成本(holding.xls权威) + "shares": shares, # 当前实际持仓股数(不继承旧决策的可能为0的值) + "avg_price": old_entry.get("avg_price", 0), # 保留持仓均价 + "currency": "HKD" if is_hk_stock(str(code)) else "CNY", + "action": result["action"], + "stop_loss": result.get("stop_loss"), + "entry_low": result["entry_low"], + "entry_high": result["entry_high"], + "tech_snapshot": result.get("tech_snapshot", ""), + "timing_signal": result.get("timing_signal", ""), + "rr_ratio": result.get("rr_ratio", 0), + "status": status_display, + "note": result.get("action_note", ""), + "timestamp": result["reassessed_at"], + "updated_at": result["reassessed_at"], + "type": "自选策略" if is_wl else "持仓策略", + "source": old_entry.get("source", "auto"), # manual/auto,继承旧标记 + "sector_context": sector_ctx_str, # 市场上下文:行业表现+大盘宽度 + "stock_category": result.get("stock_category", "中短线"), # 组合监测用 + "position_advice": result.get("position_advice", "中等仓位"), + "time_horizon": result.get("time_horizon", "2周~3月"), + } + new_entry["trigger"] = trig + # created_at: 首次创建时设置,后续 preserve + old_entry = existing_decisions.get(code, {}) + if old_entry.get("created_at"): + new_entry["created_at"] = old_entry["created_at"] + else: + new_entry["created_at"] = result["reassessed_at"] + # 保留 last_reassessed_price(per_stock_reassess 维护的防抖字段) + if old_entry.get("last_reassessed_price"): + new_entry["last_reassessed_price"] = old_entry["last_reassessed_price"] + # 自选股也写止盈位(用于RR校验),但标签用"目标参考"非"止盈" + new_entry["take_profit"] = result.get("take_profit") + + # --- 变更追踪 --- + old_action = old_entry.get("action", "") + old_stop = old_entry.get("stop_loss") + old_target = old_entry.get("take_profit") + + # 构建旧策略摘要和变更理由 + update_reason = "" + changelog_entry = None + + if old_action and old_action != result["action"]: + # 策略有变化 → 记录变更 + old_summary = old_action + new_summary = result["action"] + + # 判断触发原因 + if abs(price - old_entry.get("price", price)) / max(price, 0.01) > 0.03: + trigger = f"价格变动({old_entry.get('price','?')}→{price})" + elif result.get("timing_signal") and result["timing_signal"] != old_entry.get("timing_signal", ""): + trigger = f"技术信号变化: {result['timing_signal']}" + else: + trigger = "技术面重评" + + # 格式化的变更理由(自选股只看止损,不看止盈) + diff_parts = [] + if old_stop and result["stop_loss"] != old_stop: + diff_parts.append(f"止损{old_stop}→{result['stop_loss']}") + if not is_wl and old_target and result.get("take_profit") and result["take_profit"] != old_target: + diff_parts.append(f"止盈{old_target}→{result['take_profit']}") + if diff_parts: + update_reason = f"{trigger}: {', '.join(diff_parts)} | {result.get('tech_snapshot','')[:60]}" + else: + update_reason = f"{trigger}: 策略文字调整" + + changelog_entry = { + "date": result["reassessed_at"], + "old_action": old_action, + "new_action": result["action"], + "reason": update_reason, + "trigger": trigger, + } + new_entry["updated_reason"] = update_reason + + elif not old_action: + # 首次创建策略 + update_reason = f"初始策略创建 | {result.get('tech_snapshot','')[:60]}" + changelog_entry = { + "date": result["reassessed_at"], + "old_action": "", + "new_action": result["action"], + "reason": update_reason, + "trigger": "初始创建", + } + + # 合并changelog + old_changelog = old_entry.get("changelog", []) if old_entry else [] + if changelog_entry: + new_entry["changelog"] = old_changelog + [changelog_entry] + else: + new_entry["changelog"] = old_changelog + + # 保留执行记录 + if old_entry and old_entry.get("execution"): + new_entry["execution"] = old_entry["execution"] + elif stock.get("analysis", {}).get("status") == "executing": + new_entry["execution"] = { + "status": "executing", + "entry_price": cost if cost else 0, + "shares": shares, + "notes": "", + } + + # --- 自动标记 current_recommend --- + # 只在真正执行中的持仓才自动推荐:execution.status 为 executing 或 partial_exit + exec_status = old_entry.get("execution", {}).get("status", "") if old_entry else "" + is_active = exec_status in ("executing", "partial_exit") + + profit_pct = (price - cost) / cost * 100 if cost else 0 + is_deep_loss_stock = profit_pct < -20 + rr = result.get("rr_ratio", 0) + ts = result.get("timing_signal", "") + note = result.get("action_note", "") + + # 计算是否在/接近买入区 + entry_low_val = result.get("entry_low", 0) + entry_high_val = result.get("entry_high", 0) + in_buy_zone = (entry_low_val > 0 and entry_high_val > 0 and + entry_low_val <= price <= entry_high_val) + near_buy_zone_low = (entry_low_val > 0 and + price >= entry_low_val * 0.98 and + price <= entry_high_val) + + # 推荐条件:必须是执行中的持仓 + 基本面条件达标 + is_recommendable = ( + is_active + and rr >= 1.5 + and ts != "neutral" + and "不建议" not in note + ) + if is_recommendable: + new_entry["tag"] = "current_recommend" + else: + # 不清除 active_manual(用户手动标记),只清除自动推荐的 + old_tag = old_entry.get("tag", "") if old_entry else "" + if old_tag != "active_manual": + new_entry.pop("tag", None) + + decisions.append(new_entry) + + except Exception as e: + results.append({"code": code, "name": name, "error": str(e)}) + errors += 1 + if stdout: + print(f" ❌ {name}({code}): {e}") + + # 写回数据文件 — 保留现有字段(现金、总资产等)不丢 + try: + existing_pf = read_portfolio() + except Exception: + existing_pf = {} + # 保留 price/change_pct — price_monitor 维护的实时价,regenerate_all 不应清除 + _existing_holdings_map = {} + for _h in existing_pf.get('holdings', []): + if _h.get('code'): + _existing_holdings_map[_h['code']] = _h + _new_holdings = pf.get("holdings", []) + for _h in _new_holdings: + _code = _h.get('code') + if _code and _code in _existing_holdings_map: + _old = _existing_holdings_map[_code] + _h['price'] = _old.get('price', 0) + _h['change_pct'] = _old.get('change_pct', 0) + existing_pf["holdings"] = _new_holdings + existing_pf["updated_at"] = datetime.now().strftime('%Y-%m-%d %H:%M') + + # ── Watchlist ↔ Holdings 双向自动迁移(2026-06-27 Dad要求)── + # ① 持仓已有 → 从自选移除(买入自动清除) + wl_codes = {s.get("code") for s in wl.get("stocks", []) if s.get("code")} + pf_codes = {h.get("code") for h in _new_holdings if h.get("code") and h.get("shares", 0) > 0} + removed_from_wl = [] + for h_code in wl_codes & pf_codes: + # 持仓>0且量够 → 自选移除 + wl["stocks"] = [s for s in wl.get("stocks", []) if s.get("code") != h_code] + removed_from_wl.append(h_code) + if removed_from_wl and stdout: + print(f" 自选→持仓自动移除: {', '.join(removed_from_wl)}") + + # ② 清仓/卖光 → 加回自选(只要仍有关注价值) + added_to_wl = [] + old_pf_codes = {_h.get("code") for _h in existing_pf.get("holdings", []) if _h.get("code")} + sold_codes = old_pf_codes - pf_codes # 曾持仓但现在没有(或不在了) + for sc in sold_codes: + # 已有自选就不重复加 + if sc in wl_codes: + continue + # 从现有decisions看是否有关注价值 + for d in decisions: + if d.get("code") == sc and d.get("entry_low") and d.get("entry_high"): + wl["stocks"].append({ + "code": sc, "name": d.get("name", sc), + "entry_low": d.get("entry_low"), "entry_high": d.get("entry_high"), + "stop_loss": d.get("stop_loss", 0), + "analysis": {"action": d.get("action", ""), "tech_snapshot": d.get("tech_snapshot", "")} + }) + added_to_wl.append(sc) + break + if added_to_wl and stdout: + print(f" 清仓→自选自动加入: {', '.join(added_to_wl)}") + + # 重新计算 portfolio 汇总(保留已存在的 cash,用最新价格算市值) + try: + total_mv = 0.0 + total_cost = 0.0 + for h in existing_pf.get('holdings', []): + p = h.get('price') or 0 + s = h.get('shares') or 0 + c = h.get('cost') or 0 + total_mv += p * s + total_cost += c * s + if p and s and total_mv > 0: + h['market_value'] = round(p * s, 2) + old_cash = existing_pf.get('cash') or 80476 # fallback 6/23 backup + frozen_cash = existing_pf.get('frozen_cash') or 0 + existing_pf['cash'] = old_cash + existing_pf['total_mv'] = round(total_mv, 2) + existing_pf['total_assets'] = round(total_mv + old_cash + frozen_cash, 2) + existing_pf['total_pnl'] = round(total_mv - total_cost, 2) + existing_pf['position_pct'] = round(total_mv / (total_mv + old_cash + frozen_cash) * 100, 2) if (total_mv + old_cash + frozen_cash) > 0 else 0 + except Exception as e: + print(f" [汇总计算失败] {e}", flush=True) + + # DB 写入(替代 JSON dump — 强制币种约束) + try: + from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_watchlist_stock, write_holding_strategy + conn = get_conn() + write_holdings_batch(conn, existing_pf.get('holdings', [])) + write_portfolio_summary(conn, existing_pf) + for s in wl.get('stocks', []): + s.setdefault('currency', 'CNY') + write_watchlist_stock(conn, s) + for d in decisions: + # ── 策略质量门禁 ── + code = d.get('code', '') + name = d.get('name', '') + enforce_strategy_quality(code, name, d) + write_holding_strategy(conn, code, name, d) + conn.close() + except Exception as e: + print(f" [DB写入失败] {e}", flush=True) + + # 记录策略→提示词版本关联 + if HAS_PROMPT_TRACKING: + try: + for d in decisions: + if d.get("code") and d.get("action"): + record_strategy_generation( + d["code"], d.get("name", ""), d.get("action", "") + ) + except Exception as e: + if stdout: + print(f" ⚠️ 提示词版本追踪失败: {e}", file=sys.stderr) + + # 刷新多周期缓存到磁盘 + try: + import multi_timeframe as _mtf + _mtf.flush_mtf_cache() + except Exception: + pass + + summary = {"total": total, "ok": ok, "errors": errors} + if stdout: + print(f"\n✅ 全量重评完成: {ok}/{total}成功, {errors}错误") + return summary + + +# ── 换股规划(调仓优先级排序) ── +def print_swap_plan(need_cash: float = 0): + """输出深套股调仓优先级排序 + + Args: + need_cash: 需要的资金额(元),0=显示全部排序不指定金额 + """ + import sqlite3 + from mo_data import read_portfolio + from mofin_db import get_conn + + pf = read_portfolio() + holdings = pf.get("holdings", []) + if not holdings: + print("无持仓数据") + return + + total_mv = pf.get("total_mv", 0) + total_cash = pf.get("cash_available", pf.get("cash", 0)) + + print(f"📊 当前总市值={total_mv:,.0f} 可用现金={total_cash:,.0f}") + if need_cash > 0: + print(f"🎯 需要释放资金: {need_cash:,.0f}") + print() + + # 计算每只深套股的评分 + scored = [] + for h in holdings: + code = h["code"] + name = h["name"] + price = h.get("price", 0) + cost = h.get("cost", 0) + shares = h.get("shares", 0) + mv = h.get("market_value", 0) or shares * price + pnl_pct = (price - cost) / cost * 100 if cost > 0 else 0 + position_pct = h.get("position_pct", mv / total_mv * 100 if total_mv > 0 else 0) + currency = h.get("currency", "CNY") + is_hk = len(str(code)) == 5 and str(code)[0] in ("0", "1") + + # 只评估亏损股(含微亏) + if pnl_pct >= 0: + continue + + # ── 可替代性评分(0~100,越高越建议优先替换) ── + # ① 亏损程度(越低越容易割,权重25%) + abs_loss = abs(pnl_pct) + loss_score = max(0, 100 - (abs_loss - 5) * 1.33) # -5%→100, -20%→80, -50%→40, -80%→0 + + # ② 仓位市值(越大释放资金越多,权重20%) + mv_pct = position_pct + size_score = min(100, mv_pct * 5) # 5%仓位→25分, 20%→100分 + + # ③ 技术面趋势(权重25%)- 从 DB 取趋势判断 + trend_score = 50 # 默认中性 + try: + conn = get_conn() + r = conn.execute( + "SELECT timing_signal FROM holding_strategies WHERE code=? AND status='active' ORDER BY updated_at DESC LIMIT 1", + (code,) + ).fetchone() + conn.close() + if r: + sig = r[0] or "" + if any(kw in sig for kw in ["卖出", "止损", "离场", "看空"]): + trend_score = 90 + elif any(kw in sig for kw in ["买入", "加仓", "看多", "走强"]): + trend_score = 10 + elif any(kw in sig for kw in ["观望", "信号不充分", "中性"]): + trend_score = 60 + except: + pass + + # ④ 流动性(权重15%)- H股略打折扣 + liquidity_score = 60 if is_hk else 80 + + # ⑤ 综合评分 + total_score = ( + loss_score * 0.25 + + size_score * 0.20 + + trend_score * 0.25 + + liquidity_score * 0.15 + + 50 * 0.15 # 前景占15%,暂给中性50分 + ) + + scored.append({ + "code": code, "name": name, + "price": price, "pnl_pct": round(pnl_pct, 1), + "mv": mv, "mv_pct": round(mv_pct, 1), + "score": round(total_score, 1), + "currency": currency, + "loss_score": round(loss_score, 0), + "trend_score": round(trend_score, 0), + "shares": shares, + }) + + if not scored: + print("当前无亏损持仓,无需调仓规划") + return + + # 按评分降序排列(越高越优先替换) + scored.sort(key=lambda s: -s["score"]) + + print(f"{'优先级':>4} {'代码':<8} {'名称':<14} {'亏损':>6} {'市值':>10} {'占比':>5} {'评分':>4} 理由") + print("-" * 90) + + total_releasable = 0 + for i, s in enumerate(scored, 1): + currency_tag = "HK$" if s["currency"] == "HKD" else "CNY" + reasons = [] + if s["loss_score"] >= 70: + reasons.append("亏损较浅易割") + elif s["loss_score"] <= 30: + reasons.append("亏损较深可等反弹") + if s["trend_score"] >= 70: + reasons.append("技术弱势") + elif s["trend_score"] <= 30: + reasons.append("技术尚可") + if s["mv_pct"] >= 10: + reasons.append("仓位较重释放资金多") + reason_str = "、".join(reasons[:2]) if reasons else "中性" + + print(f" #{i:<2} {s['code']:<8} {s['name']:<12} {s['pnl_pct']:>5.1f}% {currency_tag}{s['mv']:>8,.0f} {s['mv_pct']:>4.1f}% {s['score']:>4.1f} {reason_str}") + + if need_cash > 0 and total_releasable < need_cash: + total_releasable += s["mv"] + if total_releasable >= need_cash: + print(f"\n ▶ 卖出前{i}只可释放 {currency_tag}{total_releasable:,.0f}(目标{need_cash:,.0f})") + + print() + print("说明:评分越高 = 越优先考虑替换。综合亏损程度/仓位/技术趋势/流动性。") + print("评分仅供参考,最终决策请结合当日行情和个人判断。") + print(f"\n若需模拟换股方案执行: python3 {__file__} swap-plan --need 金额") + + +if __name__ == "__main__": + import sys + if len(sys.argv) > 1 and sys.argv[1] == "swap-plan": + need = 0 + for i, a in enumerate(sys.argv): + if a == "--need" and i + 1 < len(sys.argv): + try: + need = float(sys.argv[i + 1]) + except: + pass + print_swap_plan(need) + else: + regenerate_all() diff --git a/deploy/profile-scripts/strategy_review.py b/deploy/profile-scripts/strategy_review.py new file mode 100644 index 00000000..3ae741c6 --- /dev/null +++ b/deploy/profile-scripts/strategy_review.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +"""strategy_review.py — 三层策略复盘 (no_agent) + +每层独立评估: +1. 信号层 — 买入/卖出/持有的timing对不对? +2. 执行层 — 止损/止盈设得合理吗? +3. 综合层 — 这波操作整体赚钱了吗? + +用法: + python3 scripts/strategy_review.py +""" + +import json, sqlite3, sys, time +from pathlib import Path +from datetime import datetime +from collections import Counter +from mo_data import read_portfolio, read_decisions, read_watchlist, get_price, get_prices_batch + +BASE = Path("/home/hmo/MoFin") +DATA = BASE / "data" +DB_PATH = DATA / "mofin.db" + +# 失败模式定义(执行层) +EXEC_FAILURES = { + "stop_too_tight": {"label": "止损过紧", "fix": "放宽止损到强支撑×0.95,给价格波动留空间"}, + "tp_too_close": {"label": "止盈过近", "fix": "止盈放到更高阻力位,让利润奔跑"}, + "stop_too_loose": {"label": "止损过宽", "fix": "收紧止损,少亏当赢"}, + "tp_too_far": {"label": "止盈过远", "fix": "止盈靠近合理阻力位,提高兑现概率"}, +} + +# 失败模式定义(信号层) +SIGNAL_FAILURES = { + "wrong_direction": {"label": "方向看反", "fix": "检查多周期趋势判断逻辑"}, + "entry_too_early": {"label": "入场过早", "fix": "等缩量确认支撑再入,不追回调"}, + "bad_signal": {"label": "信号误判", "fix": "修正timing_signal合成权重"}, + "regime_mismatch": {"label": "情景错配", "fix": "加入市场情景过滤条件"}, +} + + +def fetch_price(code): + # DB 优先 + try: from mofin_db import get_price_from_db; p, _ = get_price_from_db(code); return p if p else 0 + except: pass + # Fallback: mo_data.get_price + try: + p, _ = get_price(code) + return p if p else 0 + except: + return 0 + + +def evaluate_strategy(s, price): + """三层评估单条策略,返回 (signal_verdict, exec_verdict, overall_verdict, detail)""" + code = s.get("code", "") + name = s.get("name", "") + sl = s.get("stop_loss", 0) or 0 + tp = s.get("take_profit", 0) or 0 + entry_low = s.get("entry_low", 0) or 0 + entry_high = s.get("entry_high", 0) or 0 + cost = s.get("cost", 0) or s.get("avg_price", 0) or 0 + signal = (s.get("timing_signal", "") or s.get("current", "") or "").lower() + created = s.get("created_at", "") or s.get("timestamp", "") + s_type = s.get("type", "") # 持仓策略/自选策略 + + if not created or not price: + return "skip", "skip", "skip", "数据不足", "" + + # 计算运行天数 + try: + days = (datetime.now() - datetime.fromisoformat(created)).days + except: + days = 0 + + # ─── 综合层:赚钱了吗? ─── + if cost > 0 and s_type == "持仓策略": + profit_pct = (price - cost) / cost * 100 + if profit_pct > 5: + overall = "盈利" + elif profit_pct > -5: + overall = "持平" + else: + overall = f"亏损{profit_pct:.0f}%" + elif tp > 0 and price >= tp: + overall = "触止盈" + elif sl > 0 and price <= sl: + overall = "触止损" + else: + overall = "持有中" + + # ─── 信号层:timing对不对? ─── + is_buy_signal = any(kw in signal for kw in ["买入", "加仓", "追涨", "可买"]) + is_sell_signal = any(kw in signal for kw in ["卖出", "减仓", "止损", "离场"]) + is_hold_signal = any(kw in signal for kw in ["持有", "观望", "等待", "持股"]) + + signal_verdict = "待定" + signal_fail = None + + if is_buy_signal or is_hold_signal: + if sl > 0 and price <= sl: + # 买入/持有信号下触发止损 → 信号方向可能错了 + signal_verdict = "存疑" + signal_fail = "wrong_direction" + elif tp > 0 and price >= tp * 0.95: + signal_verdict = "正确" + elif entry_low > 0 and price < entry_low * 0.85: + signal_verdict = "存疑" + signal_fail = "entry_too_early" + elif days > 30 and tp > 0 and price < entry_low: + signal_verdict = "存疑" + signal_fail = "wrong_direction" + else: + signal_verdict = "待定" + elif is_sell_signal: + if sl > 0 and price <= sl: + signal_verdict = "正确" + elif price > (cost or entry_low or 0) * 1.05: + signal_verdict = "存疑" + signal_fail = "bad_signal" + else: + signal_verdict = "待定" + else: + # 无明确信号 + if price > (entry_high or 0): + signal_verdict = "待定(价涨)" + elif sl > 0 and price <= sl * 1.05: + signal_verdict = "待定(近止损)" + else: + signal_verdict = "待定" + + # ─── 执行层:止损/止盈设得好不好? ─── + exec_verdict = "待定" + exec_fail = None + + # 取近期最高/最低价(判断卖飞/洗盘) + recent_high = 0 + recent_low = 0 + sl_recovery = False + if tp > 0 or sl > 0: + try: + prefix = "sh" if code.startswith(('60','68','51','56','50')) else "sz" if code.startswith(('00','30','15')) else "hk" + url = f"http://ifzq.gtimg.cn/appstock/app/fqkline/get?param={prefix}{code},day,,,60,qfq" + import subprocess as sp + r = sp.run(["curl", "-s", "--max-time", "3", url], capture_output=True, text=True, timeout=5) + if r.returncode == 0 and r.stdout: + data = json.loads(r.stdout) + day_key = 'qfqday' if prefix != 'hk' else 'day' + bars = data.get('data', {}).get(f'{prefix}{code}', {}).get(day_key, []) + if bars: + prices = [(float(b[2]), float(b[3]), b[0]) for b in bars if len(b) > 3] # (high, low, date) + recent_high = max(p[0] for p in prices) + recent_low = min(p[1] for p in prices) + # 检查止损触发后的走势:是否后来反弹了? + if sl > 0: + # 找出价格低于SL的K线 + below_sl = [p for p in prices if p[1] <= sl] + above_sl_later = [p for p in prices if p[1] > sl * 1.03] + if below_sl and above_sl_later: + # 曾跌破SL,但后来涨回去了 → 洗盘 + first_below = min(below_sl, key=lambda x: x[2]) + last_above = max(above_sl_later, key=lambda x: x[2]) + if last_above[2] > first_below[2]: + sl_recovery = True + except: + pass + + if sl > 0 and price <= sl: + if sl_recovery: + exec_verdict = "洗盘(触发后反弹)" + exec_fail = "stop_too_tight" + elif price >= sl * 0.95: + exec_verdict = "临界(差一点触发)" + exec_fail = "stop_too_tight" + else: + exec_verdict = "已触发" + elif tp > 0 and (price >= tp or recent_high >= tp): + # 止盈触发或曾触发过 + max_price = max(price, recent_high) + if max_price <= tp * 1.05: + exec_verdict = "已触发" + else: + overshoot = (max_price - tp) / tp * 100 + exec_verdict = f"卖飞({overshoot:.0f}%)" + exec_fail = "tp_too_close" + elif days > 45 and tp > 0 and price < entry_low: + exec_verdict = "存疑(久未达标)" + exec_fail = "tp_too_far" + elif sl > 0 and price >= entry_low and price <= entry_high: + exec_verdict = "持有中" + else: + exec_verdict = "待定" + + return signal_verdict, exec_verdict, overall, signal_fail, exec_fail + + +def review(): + start = time.time() + decisions = read_decisions() + strategies = decisions.get("decisions", []) + + conn = sqlite3.connect(str(DB_PATH)) + + stats = {"correct": 0, "wrong": 0, "mixed": 0, "pending": 0, "total": 0} + signal_fails = Counter() + exec_fails = Counter() + detail_lines = [] + + for s in strategies: + if s.get("status") == "closed": + continue + stats["total"] += 1 + code = s.get("code", "") + name = s.get("name", "") + price = fetch_price(code) + if not price: + detail_lines.append(f" ⏭️ {name}({code}): 无行情") + stats["pending"] += 1 + continue + + sv, ev, overall, sf, ef = evaluate_strategy(s, price) + + # 综合评级 + if overall in ("盈利", "触止盈"): + if sv == "正确" or "存疑" not in sv: + stats["correct"] += 1 + else: + stats["mixed"] += 1 + elif overall in ("触止损",) and "存疑" in sv: + stats["wrong"] += 1 + elif "存疑" in sv or "存疑" in ev: + stats["wrong"] += 1 + elif overall in ("持有中", "持平"): + stats["mixed"] += 1 + else: + stats["pending"] += 1 + + # 记录失败模式 + if sf: + signal_fails[sf] += 1 + if ef: + exec_fails[ef] += 1 + + # 逐条摘要 + tags = [] + if overall in ("盈利", "触止盈"): + tags.append("✅") + elif overall == "触止损": + tags.append("❌") + else: + tags.append("⏳") + tags.append(f"信号:{sv}") + tags.append(f"执行:{ev}") + tags.append(f"整体:{overall}") + detail_lines.append(f" {' | '.join(tags)} {name}({code})") + + # 写入accuracy_stats + conn.execute( + "INSERT OR REPLACE INTO accuracy_stats (id, total_advice, correct, wrong, partial, pending, " + "accuracy_pct, updated_at) VALUES (1, ?, ?, ?, ?, ?, ?, ?)", + (stats["total"], stats["correct"], stats["wrong"], + stats["mixed"], stats["pending"], + round(stats["correct"] / max(stats["total"] - stats["pending"], 1) * 100, 1), + datetime.now().isoformat())) + conn.commit() + conn.close() + + # 输出 + total_eval = stats["total"] - stats["pending"] + accuracy = stats["correct"] / max(total_eval, 1) * 100 + + print(f"策略复盘 | {datetime.now().strftime('%Y-%m-%d')} | {stats['total']}条 | ({time.time()-start:.0f}s)") + print(f" ✅正确 {stats['correct']} | ❌错误 {stats['wrong']} | ⚠️部分 {stats['mixed']} | ⏳待定 {stats['pending']}") + print(f" 综合准确率: {accuracy:.1f}%") + + if signal_fails: + print(f"\n📡 信号层失败模式:") + for mode, cnt in signal_fails.most_common(): + info = SIGNAL_FAILURES.get(mode, {}) + print(f" {info.get('label', mode)}({cnt}次): {info.get('fix', '')}") + + if exec_fails: + print(f"\n🎯 执行层失败模式:") + for mode, cnt in exec_fails.most_common(): + info = EXEC_FAILURES.get(mode, {}) + print(f" {info.get('label', mode)}({cnt}次): {info.get('fix', '')}") + + if detail_lines: + print(f"\n逐条复盘:") + for line in detail_lines: + print(line) + + +if __name__ == "__main__": + review() diff --git a/deploy/profile-scripts/strategy_summary.py b/deploy/profile-scripts/strategy_summary.py new file mode 100644 index 00000000..560d71d5 --- /dev/null +++ b/deploy/profile-scripts/strategy_summary.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""生成策略评估摘要""" +from mo_data import read_decisions, read_portfolio + +dec = read_decisions() +pf = read_portfolio() + +holdings = pf.get('holdings', []) +cash = pf.get('cash', 321271) +hk_rate = 0.867 +code_to_h = {h['code']: h for h in holdings} + +decisions = dec.get('decisions', []) +hold_entries = [s for s in decisions if s.get('shares', 0) > 0] +wl_entries = [s for s in decisions if s.get('shares', 0) == 0] + +hk_total_cny = 0 +a_total = 0 +for h in holdings: + mv = h['shares'] * h['price'] + if h.get('currency') == 'HKD': + hk_total_cny += mv * hk_rate + else: + a_total += mv +total_mv = hk_total_cny + a_total +total_assets = total_mv + cash +position_pct = total_mv / total_assets * 100 + +weak_count = sum(1 for s in hold_entries if s.get('stock_category') in ('弱势','深套')) + +print(f'总市值: {total_mv:.0f} CNY (HK${hk_total_cny:.0f} A¥{a_total:.0f})') +print(f'总资产: {total_assets:.0f} CNY') +print(f'仓位: {position_pct:.1f}% 现金: {cash:.0f}') +print(f'持仓: {len(hold_entries)}只 弱势/深套: {weak_count}只 ({weak_count/len(hold_entries)*100:.0f}%)') +print(f'自选: {len(wl_entries)}只') +print() + +print('【持仓详情】') +for s in hold_entries: + code = s['code'] + name = s['name'] + shares = s['shares'] + cost = s.get('cost', 0) + sl = s.get('stop_loss', 0) + tp = s.get('take_profit', 0) + cat = s.get('stock_category', '?') + sig = s.get('timing_signal', '?') + + h = code_to_h.get(code) + price = h['price'] if h else 0 + if h and h.get('currency') == 'HKD': + mv_val = h['shares'] * h['price'] * hk_rate + else: + mv_val = h['shares'] * h['price'] if h else 0 + pl_pct = (price - cost) / cost * 100 if cost else 0 + pct = mv_val / total_assets * 100 + sl_dist = (price / sl - 1) * 100 if sl > 0 else 0 + tp_dist = (tp / price - 1) * 100 if tp > 0 else 0 + + flags = [] + if sl_dist < 5: + if pl_pct > 5: + flags.append('利润保护') + else: + flags.append(f'⚠️近止损({sl_dist:.0f}%)') + if tp_dist < 5 and tp_dist > 0: + flags.append('近止盈') + if cat in ('弱势','深套'): + flags.append(f'[{cat}]') + + flag_str = ' '.join(flags) if flags else '' + print(f' {code} {name:10s} ¥{price:>7.2f} 浮{pl_pct:+.1f}% 仓{pct:.1f}% 损{sl}({sl_dist:.0f}%) 盈{tp}({tp_dist:.0f}%) {flag_str}') + +print() +print('【自选关注】') +for s in wl_entries: + code = s['code'] + name = s['name'] + el = s.get('entry_low', 0) + eh = s.get('entry_high', 0) + sl = s.get('stop_loss', 0) + price = s.get('price', 0) + sig = s.get('timing_signal', '?') + in_zone = '✅在买入区' if el and eh and price and el <= price <= eh else '' + print(f' {code} {name:10s} ¥{price:>7.2f} 买区{el}~{eh} 损{sl} 信号{sig} {in_zone}') diff --git a/deploy/profile-scripts/strategy_tree.py b/deploy/profile-scripts/strategy_tree.py new file mode 100644 index 00000000..afb0f789 --- /dev/null +++ b/deploy/profile-scripts/strategy_tree.py @@ -0,0 +1,443 @@ +#!/usr/bin/env python3 +""" +strategy_tree.py — 情景化多分支策略决策引擎 + +核心理念: + 每只股票不再只有一个买入区+止损,而是有一棵决策树。 + 每个分支 = {条件, 动作, 优先级, 触发统计} + 当前宏观情景决定走哪个分支。 + +自成长: + → 每次分支被触发,记录 trigger_count + 后续5日盈亏 + → success_rate < 30% 且触发≥5次 → 自动标记 pruning_candidate + → 每周 pruning 时剪掉低效分支 + +数据存在 holding_strategies.strategy_tree 字段。 +""" + +import json, os, sys, re +from datetime import datetime, date, timedelta +from mo_data import read_portfolio, read_decisions, read_watchlist +from mofin_db import get_conn, write_holding_strategy +from mofin_db import get_conn, write_holding_strategy + +MACRO_PATH = "/home/hmo/web-dashboard/data/macro_context.json" +MARKET_PATH = "/home/hmo/web-dashboard/data/market.json" +TREND_PATH = "/home/hmo/web-dashboard/data/trend_signals.json" + +# ── 情景定义 ────────────────────────────────────────────────────────────── + +SCENARIOS = [ + { + "id": "sharp_decline", + "label": "急跌防御", + "desc": "大盘放量下跌,多板块共振杀跌", + "rules": {"mood": "bearish", "sector_crash": True}, + "portfolio_action": "减仓至80%以下,优先出弱势深套", + }, + { + "id": "weak_consolidation", + "label": "弱势震荡", + "desc": "大盘缩量阴跌,结构分化", + "rules": {"mood": "neutral", "breadth": "weak"}, + "portfolio_action": "保持仓位90%以内,调结构", + }, + { + "id": "sector_rotation", + "label": "板块轮动", + "desc": "大盘窄幅,强势板块切换", + "rules": {"mood": "neutral", "rotation": True}, + "portfolio_action": "跟随板块切换,减旧加新", + }, + { + "id": "bullish_recovery", + "label": "反弹上行", + "desc": "大盘放量上涨,情绪回暖", + "rules": {"mood": "bullish"}, + "portfolio_action": "加仓至95%,追随趋势", + }, +] + + +# ── 情景判定 ────────────────────────────────────────────────────────────── + +def detect_scenario(): + """从宏观+市场数据判断当前情景 + + 返回: + {"id": str, "label": str, "confidence": float, "portfolio_action": str} + """ + scenario_id = "weak_consolidation" # 默认 + confidence = 0.5 + + try: + # 优先 DB + import sqlite3 + from pathlib import Path + db = sqlite3.connect(str(Path(__file__).parent.parent / "data" / "mofin.db")) + mrow = db.execute( + "SELECT indices, structure, sector_mood FROM macro_context_log " + "WHERE has_valid_data=1 ORDER BY created_at DESC LIMIT 1" + ).fetchone() + db.close() + if mrow: + structure = json.loads(mrow[1]) if mrow[1] else {} + overall = structure.get("overall", "").lower() + mood = (mrow[2] or "").lower() if len(mrow) > 2 else "" + else: + raise ValueError("no db data") + except Exception: + try: + macro = json.load(open(MACRO_PATH)) + market = json.load(open(MARKET_PATH)) + mood = market.get("mood", "").lower() + structure = macro.get("structure", {}) + overall = structure.get("overall", "").lower() + except Exception: + return {"id": "weak_consolidation", "label": "默认-弱势震荡", "confidence": 0.3, "portfolio_action": "观望"} + trend_desc = structure.get("description", "").lower() + + # Check for sharp decline + if "bearish" in mood or "bearish" in overall: + if "crash" in trend_desc or "跌幅" in trend_desc or "恐慌" in trend_desc: + scenario_id = "sharp_decline" + confidence = 0.7 + elif "弱势" in trend_desc or "疲弱" in trend_desc: + scenario_id = "weak_consolidation" + confidence = 0.6 + else: + scenario_id = "weak_consolidation" + confidence = 0.5 + elif "bullish" in mood or "bullish" in overall: + scenario_id = "bullish_recovery" + confidence = 0.6 + elif "neutral" in mood: + # Check for rotation signals + try: + trend = json.load(open(TREND_PATH)) + if trend.get("rotation_detected"): + scenario_id = "sector_rotation" + confidence = 0.5 + except Exception: + pass + scenario_id = "weak_consolidation" + confidence = 0.4 + + sc = next((s for s in SCENARIOS if s["id"] == scenario_id), SCENARIOS[0]) + return { + "id": scenario_id, + "label": sc["label"], + "desc": sc["desc"], + "confidence": round(confidence, 2), + "portfolio_action": sc["portfolio_action"], + } + + +# ── 分支评估 ────────────────────────────────────────────────────────────── + +def evaluate_branches(code, scenario_id, price, shares, cost): + """评估某只股票在当前情景下的所有分支 + + 从 decisions.json 读取 strategy_tree.branches[] + 返回: [{branch_id, action_type, action_detail, priority, applicable}] + """ + try: + dec = mo_data.read_decisions() + except Exception: + return [] + + entry = None + for e in dec.get("decisions", []): + if e.get("code") == code: + entry = e + break + if not entry: + return [] + + branches = entry.get("strategy_tree", {}).get("branches", []) + if not branches: + return [] + + results = [] + for br in sorted(branches, key=lambda b: b.get("priority", 999)): + applicable = _check_branch_condition(br, scenario_id, price, shares, cost) + results.append({ + "branch_id": br.get("id"), + "action_type": br.get("action", {}).get("type", "hold"), + "action_detail": br.get("action", {}), + "priority": br.get("priority", 999), + "rationale": br.get("rationale", ""), + "applicable": applicable, + }) + + return results + + +def _check_branch_condition(branch, scenario_id, price, shares, cost): + """检查分支条件是否满足""" + cond = branch.get("condition", {}) + required_scenario = cond.get("scenario", "") + if required_scenario and required_scenario != scenario_id: + return False + + # Price conditions + price_cond = cond.get("price", "") + if price_cond: + ops = re.findall(r'([<>=!]+)\s*([\d.]+)', price_cond) + for op, val_str in ops: + val = float(val_str) + op = op.strip() + if op == "<" and not (price < val): + return False + if op == ">" and not (price > val): + return False + if op == "<=" and not (price <= val): + return False + if op == ">=" and not (price >= val): + return False + if op == "==" and not (abs(price - val) < 0.01): + return False + + # Price lower bound (separate field) + price_lower = cond.get("price_lower", "") + if price_lower: + ops = re.findall(r'([<>=!]+)\s*([\d.]+)', price_lower) + for op, val_str in ops: + val = float(val_str) + op = op.strip() + if op == "<" and not (price < val): + return False + if op == ">" and not (price > val): + return False + if op == "<=" and not (price <= val): + return False + if op == ">=" and not (price >= val): + return False + if op == "==" and not (abs(price - val) < 0.01): + return False + + # Trend condition + trend = cond.get("trend", "") + if trend and trend == "uptrend": + pass # TODO: check multi_timeframe + + # Loss condition + loss_pct = cond.get("loss_pct", "") + if loss_pct and cost > 0: + actual_loss = (price - cost) / cost * 100 + if "<" in str(loss_pct): + limit = float(str(loss_pct).replace("<", "").replace("%", "")) + if not (actual_loss < limit): + return False + + return True + + +# ── 分支触发记录 ────────────────────────────────────────────────────────── + +def record_branch_trigger(code, branch_id): + """记录分支被触发了一次,用于自成长统计""" + try: + dec = mo_data.read_decisions() + for e in dec.get("decisions", []): + if e.get("code") == code: + st = e.setdefault("strategy_tree", {}) + for br in st.get("branches", []): + if br.get("id") == branch_id: + br["trigger_count"] = br.get("trigger_count", 0) + 1 + br["last_triggered"] = datetime.now().isoformat() + break + break + conn = get_conn() + for e in dec.get("decisions", []): + if e.get("code") == code: + write_holding_strategy(conn, code, e.get('name', ''), e) + break + conn.close() + except Exception: + pass + + +# ── 分支剪枝(自成长核心)───────────────────────────────────────────────── + +def prune_low_performance_branches(min_triggers=5, min_success_rate=0.3): + """剪掉低成功率分支——自成长机制 + + 条件:触发≥min_triggers 次 且 success_rate < min_success_rate + 被剪的分支移入 history 字段,不打删除(可追溯) + """ + try: + dec = mo_data.read_decisions() + except Exception: + return [] + + pruned = [] + for e in dec.get("decisions", []): + st = e.setdefault("strategy_tree", {}) + branches = st.get("branches", []) + kept = [] + for br in branches: + tc = br.get("trigger_count", 0) + sr = br.get("success_rate") + if sr is not None and tc >= min_triggers and sr < min_success_rate: + # 移入 history + history = st.setdefault("pruned_branches", []) + br["pruned_at"] = datetime.now().isoformat() + br["prune_reason"] = f"低成功率: {sr:.0%} (触发{tc}次)" + history.append(br) + pruned.append(f'{e.get("code")}:{br.get("id")} ({sr:.0%} < {min_success_rate:.0%})') + else: + kept.append(br) + st["branches"] = kept + + if pruned: + conn = get_conn() + for e in dec.get("decisions", []): + if e.get("strategy_tree", {}).get("branches") is not None: + write_holding_strategy(conn, e.get("code"), e.get('name', ''), e) + conn.close() + + return pruned + + +# ── 初始化策略树(为一只票创建默认分支)───────────────────────────────────── + +def init_default_branches(code, name, entry_low, entry_high, stop_loss, take_profit): + """为 stock 创建默认多分支策略——由 per_stock_reassess 调用""" + base_price = (entry_low + entry_high) / 2 if entry_low and entry_high else 0 + + branches = [] + + # 分支0:止损(始终有效) + if stop_loss: + branches.append({ + "id": f"{code}_stop_loss", + "condition": {"price": f"<{stop_loss}"}, + "action": {"type": "sell", "amount": "all", "reason": "止损"}, + "priority": 0, + "rationale": "止损保护本金", + "trigger_count": 0, + "success_rate": None, + "last_triggered": None, + }) + + # 分支1:回调买入(弱势情景适用) + if entry_low: + branches.append({ + "id": f"{code}_buy_dip", + "condition": {"scenario": "weak_consolidation", "price": f"<={entry_high}", "price_lower": f">={entry_low}"}, + "action": {"type": "buy", "amount": "normal", "limit": entry_low, "reason": "回调支撑买入"}, + "priority": 1, + "rationale": "价格回调到支撑区,弱势市场低吸", + "trigger_count": 0, + "success_rate": None, + "last_triggered": None, + }) + + # 分支2:突破追涨(强势情景适用) + if take_profit: + branches.append({ + "id": f"{code}_breakout_chase", + "condition": {"scenario": "bullish_recovery", "price": f">={take_profit}"}, + "action": {"type": "buy", "amount": "normal", "limit": "market", "reason": "突破确认追涨"}, + "priority": 2, + "rationale": "价格突破阻力,确认上升趋势后买入", + "trigger_count": 0, + "success_rate": None, + "last_triggered": None, + }) + + # 分支3:减仓(急跌情景适用) + branches.append({ + "id": f"{code}_trim", + "condition": {"scenario": "sharp_decline", "loss_pct": "<-15%"}, + "action": {"type": "sell", "amount": "half", "reason": "急跌降风险"}, + "priority": 3, + "rationale": "急跌市场,深套股减半仓减少敞口", + "trigger_count": 0, + "success_rate": None, + "last_triggered": None, + }) + + # 分支4:止盈(浮盈较大) + if take_profit and entry_low: + branches.append({ + "id": f"{code}_take_profit", + "condition": {"price": f">={take_profit}"}, + "action": {"type": "sell", "amount": "half", "reason": "止盈锁利"}, + "priority": 4, + "rationale": "达到目标价,减半仓锁定利润", + "trigger_count": 0, + "success_rate": None, + "last_triggered": None, + }) + + # 分支5:持有(默认) + branches.append({ + "id": f"{code}_hold", + "condition": {}, + "action": {"type": "hold", "reason": "无明确信号,继续持有"}, + "priority": 99, + "rationale": "没有分支匹配时的默认动作", + "trigger_count": 0, + "success_rate": None, + "last_triggered": None, + }) + + return branches + + +# ── 组合约束检查 ────────────────────────────────────────────────────────── + +def check_portfolio_constraint(action_type, amount, cash_remain=None): + """组合约束检查:现金够不够?仓位上限?""" + try: + pf = mo_data.read_portfolio() + except Exception: + return True, "无法读取组合" + + if action_type == "buy": + # 估算买入金额 + cost_est = amount if amount else 100000 # default 10万 + if cash_remain is not None: + cost_est = cash_remain + if cost_est > pf.get("cash", 0): + return False, f"现金不足: 需要~{cost_est:.0f},可用{pf['cash']:.0f}" + + return True, "OK" + + +# ── CLI 入口 ────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser(description="多分支策略决策引擎") + parser.add_argument("--detect", action="store_true", help="检测当前情景") + parser.add_argument("--evaluate", type=str, help="评估指定股票的分支") + parser.add_argument("--prune", action="store_true", help="剪枝低效分支") + args = parser.parse_args() + + if args.detect: + sc = detect_scenario() + print(f"情景: {sc['id']} ({sc['label']})") + print(f"置信度: {sc['confidence']}") + print(f"组合动作: {sc['portfolio_action']}") + + if args.evaluate: + code = args.evaluate + sc = detect_scenario() + print(f"当前情景: {sc['id']} ({sc['label']})") + print(f"评估 {code}:") + results = evaluate_branches(code, sc["id"], 0, 0, 0) + for r in results: + status = "✅" if r["applicable"] else " " + print(f" {status} [{r['priority']}] {r['branch_id']} → {r['action_type']}: {r['rationale']}") + + if args.prune: + pruned = prune_low_performance_branches() + if pruned: + print(f"已剪枝: {len(pruned)} 条") + for p in pruned: + print(f" - {p}") + else: + print("无需要剪枝的分支") diff --git a/deploy/profile-scripts/sync_cron_prompts.py b/deploy/profile-scripts/sync_cron_prompts.py new file mode 100644 index 00000000..be7a0831 --- /dev/null +++ b/deploy/profile-scripts/sync_cron_prompts.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""sync_cron_prompts.py — 同步cron prompt到提示词管理系统 + +每次修改cron prompt后运行此脚本,确保注册的版本文件与jobs.json一致。 +可在修改cron prompt后手动调用,或集成到deploy_sync.sh中。 + +用法: python3 sync_cron_prompts.py [--check-only] + --check-only: 只检查不一致,不修改 +""" +import json, sys, os +from datetime import datetime + +REG_PATH = "/home/hmo/projects/MoFin/data/prompts/registry.json" +VERSIONS_DIR = "/home/hmo/projects/MoFin/data/prompts/versions/" +CRON_PATH = "/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json" + +check_only = "--check-only" in sys.argv + +with open(REG_PATH) as f: + reg = json.load(f) +with open(CRON_PATH) as f: + crons = json.load(f).get("jobs", []) + +# Build index of cron prompts +cron_prompts = {} +for j in crons: + name = j.get("name", "") + pid = name.replace(" ","-").replace("(","-").replace(")","").replace("(","-").replace(")","") + if j.get("prompt"): + cron_prompts[pid] = {"name": name, "prompt": j["prompt"]} + +drift_count = 0 +fix_count = 0 + +for p in reg.get("prompts", []): + pid = p["id"] + if pid not in cron_prompts: + continue + + cv = p.get("current_version", "v1") + actual = cron_prompts[pid]["prompt"] + name = cron_prompts[pid]["name"] + + # Find version file path + content_path = "" + for v in p.get("versions", []): + if v.get("version") == cv: + content_path = v.get("content_path", "") + break + + if not content_path: + print(f"⚠️ {name}: 版本文件路径为空") + drift_count += 1 + continue + + if not os.path.exists(content_path): + print(f"⚠️ {name}: 版本文件不存在 {content_path}") + drift_count += 1 + if not check_only: + os.makedirs(os.path.dirname(content_path), exist_ok=True) + with open(content_path, 'w') as f: + f.write(actual) + print(f" → 已创建") + fix_count += 1 + continue + + with open(content_path) as f: + registered = f.read() + + if registered != actual: + print(f"⚠️ {name}: 版本文件与jobs.json不一致") + drift_count += 1 + if not check_only: + with open(content_path, 'w') as f: + f.write(actual) + print(f" → 已同步") + fix_count += 1 + +if drift_count == 0: + print("✅ 全部一致") +else: + print(f"\n⚠️ {drift_count}个不一致, 已修复{fix_count}个" if not check_only else f"\n⚠️ {drift_count}个不一致 (使用--check-only)") diff --git a/deploy/profile-scripts/sync_dashboard.py b/deploy/profile-scripts/sync_dashboard.py new file mode 100644 index 00000000..263554a3 --- /dev/null +++ b/deploy/profile-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/deploy/profile-scripts/sync_decisions_to_db.py b/deploy/profile-scripts/sync_decisions_to_db.py new file mode 100644 index 00000000..7aa8cf03 --- /dev/null +++ b/deploy/profile-scripts/sync_decisions_to_db.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""将 decisions.json 全量同步到 SQLite holding_strategies 表""" +import json, sqlite3, sys + +DECISIONS_PATH = '/home/hmo/web-dashboard/data/decisions.json' +DB_PATH = '/home/hmo/web-dashboard/data/mofin.db' + +def main(): + # 读 decisions.json + with open(DECISIONS_PATH) as f: + data = json.load(f) + entries = data.get('decisions', []) + print(f'Read {len(entries)} entries from decisions.json') + + db = sqlite3.connect(DB_PATH) + + # 先清空 holding_strategies(全量重建更干净) + db.execute('DELETE FROM holding_strategies') + + inserted = 0 + for d in entries: + code = d.get('code') + if not code: + continue + + # 从 decisions.json 提取字段,映射到 DB schema + sql = '''INSERT INTO holding_strategies ( + code, name, version, price, cost, shares, + stop_loss, take_profit, entry_low, entry_high, + currency, strategy_type, action, timing_signal, + rr_ratio, tech_snapshot, stock_category, sector_context, + status, trigger_json, changelog_json, source, reason, + created_at, updated_at, + avg_price, decision_timestamp, note, decision_type + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''' + + # 确定 type/strategy_type + stype = d.get('strategy_type') or d.get('type') or '持仓策略' + # decision_type = d.get('decision_type') or stype + decision_type = stype + + vals = ( + code, + d.get('name', ''), + d.get('version', 1), + d.get('price'), + d.get('cost'), + d.get('shares', 0), + d.get('stop_loss'), + d.get('take_profit'), + d.get('entry_low'), + d.get('entry_high'), + d.get('currency', 'CNY' if code.startswith(('6','0','3','5')) else 'HKD'), + stype, + d.get('action', ''), + d.get('timing_signal', ''), + d.get('rr_ratio'), + d.get('tech_snapshot'), + d.get('stock_category'), + d.get('sector_context'), + d.get('status', 'active'), + json.dumps(d.get('trigger', {}), ensure_ascii=False) if d.get('trigger') else d.get('trigger_json'), + json.dumps(d.get('changelog', []), ensure_ascii=False) if d.get('changelog') else d.get('changelog_json'), + d.get('source', 'auto'), + d.get('reason'), + d.get('created_at'), + d.get('updated_at'), + d.get('avg_price'), + d.get('decision_timestamp') or d.get('timestamp'), + d.get('note'), + decision_type, + ) + try: + db.execute(sql, vals) + inserted += 1 + except Exception as e: + print(f'Error inserting {code} ({d.get("name")}): {e}') + + db.commit() + + # 验证 + cnt = db.execute('SELECT COUNT(*) FROM holding_strategies').fetchone()[0] + active = db.execute('SELECT COUNT(*) FROM holding_strategies WHERE status IN ("active","updated")').fetchone()[0] + db.close() + + print(f'Synced: {inserted} rows inserted') + print(f'holding_strategies: {cnt} total, {active} active/updated') + + return 0 if inserted > 0 else 1 + +if __name__ == '__main__': + sys.exit(main()) diff --git a/deploy/profile-scripts/system_audit.py b/deploy/profile-scripts/system_audit.py new file mode 100644 index 00000000..169880eb --- /dev/null +++ b/deploy/profile-scripts/system_audit.py @@ -0,0 +1,295 @@ +#!/usr/bin/env python3 +"""system_audit.py — MoFin 全局系统审计 + +每日收盘后运行,遍历所有对象生命周期,发现缺口→自动修复/记录。 + +审计维度: + 1. 信号管道 — 今日signal_news产出vs处理量,有积压则预警 + 2. 股票生命周期 — 关注列表是否有条件触发的、自选是否有策略缺失的 + 3. 策略状态 — 过期/偏离/无止损等异常策略 + 4. 建议闭环 — pending超过7天的未执行建议 + 5. 组合健康 — 弱势占比、仓位集中度、现金水位 + 6. 数据管道 — 今日采集是否正常、有无cron报错 + 7. 系统服务 — Dashboard/XMPP/小果API在线状态 + +输出:JSON + 摘要文本,推送给老爸。 +""" + +import json, sqlite3, subprocess, sys, time +from pathlib import Path +from datetime import datetime, timedelta +from mo_data import read_portfolio, read_decisions, read_watchlist + +DATA_DIR = Path("/home/hmo/MoFin/data") +WEB_DATA = Path("/home/hmo/web-dashboard/data") +REPORT = {"timestamp": datetime.now().isoformat(), "issues": [], "fixes": [], "ok": []} + + +def log_issue(area, severity, desc, fix=None): + REPORT["issues"].append({"area": area, "severity": severity, "desc": desc, "suggested_fix": fix}) + + +def log_fix(area, desc): + REPORT["fixes"].append({"area": area, "desc": desc}) + + +def log_ok(area, desc): + REPORT["ok"].append({"area": area, "desc": desc}) + + +# ── 1. 信号管道审计 ── +def audit_signals(conn): + try: + total = conn.execute("SELECT COUNT(*) FROM signal_news").fetchone()[0] + unproc = conn.execute("SELECT COUNT(*) FROM signal_news WHERE source LIKE 'xiaoguo%' AND (processed=0 OR processed IS NULL) AND created_at > datetime('now', '-4 hours')").fetchone()[0] + total_unproc = conn.execute("SELECT COUNT(*) FROM signal_news WHERE (processed=0 OR processed IS NULL)").fetchone()[0] + today = conn.execute("SELECT COUNT(*) FROM signal_news WHERE created_at > datetime('now','-1 day')").fetchone()[0] + log_ok("信号管道", f"信号库{total}条,今日{today}条,未处理{total_unproc}条(xiaoguo={unproc})") + if unproc > 30: + log_issue("信号管道", "HIGH", f"xiaoguo信号堆积{unproc}条,可能处理速度跟不上") + # 检查其他来源信号积压(无consumer的信号源) + other = total_unproc - unproc + if other > 50: + log_issue("信号管道", "MEDIUM", f"其它来源信号积压{other}条(divergence_watch/trend等,可能无consumer)") + except Exception as e: + log_issue("信号管道", "HIGH", f"查询失败: {e}") + + +# ── 2. 股票生命周期审计 ── +def audit_stocks(conn): + # 关注列表 + try: + wl = read_watchlist() + watching = [s for s in wl.get("stocks", []) if s.get("status") == "watching"] + formal = [s for s in wl.get("stocks", []) if s.get("status") != "watching"] + log_ok("股票池", f"正式自选{len(formal)}只, 关注列表{len(watching)}只") + + # 检查持仓中是否有已关闭但未标记的 + closed_holdings = conn.execute("SELECT COUNT(*) FROM holdings WHERE is_active=0").fetchone()[0] + active_holdings = conn.execute("SELECT COUNT(*) FROM holdings WHERE is_active=1").fetchone()[0] + if closed_holdings > 0: + log_ok("股票池", f"持有中{active_holdings}只活跃, {closed_holdings}只已关闭") + except Exception as e: + log_issue("股票池", "MEDIUM", f"查询失败: {e}") + + +# ── 3. 策略状态审计 ── +def audit_strategies(conn): + try: + dec = read_decisions() + active = [d for d in dec.get("decisions", []) if d.get("status") in ("active", "updated")] + stale_count = 0 + no_stop = 0 + for d in active: + # 检查是否有止损 + if not d.get("stop_loss"): + no_stop += 1 + # 检查是否过期(>14天) + ts = d.get("timestamp", "") + if ts: + try: + dt = datetime.fromisoformat(ts) + if (datetime.now() - dt).days > 14: + stale_count += 1 + except: + pass + log_ok("策略", f"活跃策略{len(active)}条") + if stale_count > 0: + log_issue("策略", "MEDIUM", f"{stale_count}条策略超过14天未更新", "运行 stale_detector 触发重评") + if no_stop > 0: + log_issue("策略", "HIGH", f"{no_stop}条活跃策略缺少止损位") + except Exception as e: + log_issue("策略", "HIGH", f"查询失败: {e}") + + +# ── 4. 建议闭环审计 ── +def audit_advice(conn): + try: + dec = read_decisions() + pending = 0 + for d in dec.get("decisions", []): + for a in d.get("advice_timeline", []): + if a.get("status") == "pending": + pending += 1 + if pending > 0: + log_issue("建议", "LOW", f"{pending}条建议待确认/执行", "检查advice_timeline确认是否已执行") + else: + log_ok("建议", "无待处理建议") + except Exception as e: + log_issue("建议", "MEDIUM", f"查询失败: {e}") + + +# ── 5. 组合健康 ── +def audit_portfolio(conn): + try: + pj = read_portfolio() + pos = pj.get("position_pct", 0) + cash = pj.get("cash", 0) + available = pj.get("available_cash", cash) + + log_ok("组合", f"总仓位{pos:.1f}%") + if pos > 90: + log_issue("组合", "MEDIUM", f"仓位{pos:.1f}%超过90%,现金紧张") + elif pos < 30: + log_issue("组合", "LOW", f"仓位仅{pos:.1f}%,现金过多") + except Exception as e: + log_issue("组合", "MEDIUM", f"查询失败: {e}") + + +# ── 8. 编译缓存审计 ── +def audit_cache(): + """检查 __pycache__ 中是否有比 .py 源文件更老的 .pyc(陈旧缓存)。""" + try: + base = Path(__file__).resolve().parent + stale = [] + for pyc in base.rglob("__pycache__/*.pyc"): + py = pyc.with_suffix("") # remove .cpython-*.pyc extension + # The .py file is at parent_of___pycache__ / stem_without_cpython_suffix + # e.g., __pycache__/foo.cpython-312.pyc -> ../foo.py + stem = pyc.stem # e.g. "foo.cpython-312" + # Remove the .cpython-NNN suffix to get original module name + import re + m = re.match(r"^(.*?)\.cpython-\d+", stem) + if not m: + continue + py_path = pyc.parent.parent / f"{m.group(1)}.py" + if py_path.exists() and pyc.stat().st_mtime < py_path.stat().st_mtime: + stale.append(str(py_path.name)) + if stale: + log_issue("编译缓存", "MEDIUM", f"{len(stale)}个陈旧.pyc:{', '.join(stale)}", "删除对应__pycache__/.pyc") + else: + log_ok("编译缓存", "所有.pyc文件与源文件一致") + except Exception as e: + log_issue("编译缓存", "LOW", f"检查失败: {e}") + + +# ── 6. 数据管道审计(端到端,逐条trace) ── +def audit_pipeline(): + """遍历所有关键数据管道,检查生产者→存储→消费者链路是否完整""" + today = datetime.now().strftime("%Y-%m-%d") + conn = sqlite3.connect(str(DATA_DIR / "mofin.db")) + + pipelines = [ + # 管道名, 生产者, 存储位置, 检查SQL/文件, 新鲜度阈值(天) + ("价格数据", "price_monitor(每2分)", "live_prices.updated_at", + "SELECT MAX(updated_at) FROM live_prices", 0.02), # 30分钟内 + ("宏观上下文", "refresh_macro_context(每30分)", "macro_context_log.created_at", + "SELECT MAX(created_at) FROM macro_context_log", 1), # 1天内 + ("市场快照", "market_watch(每10分)", "market_snapshots.created_at", + "SELECT MAX(created_at) FROM market_snapshots", 1), + ("策略评估", "reassess_with_context", "holding_strategies.updated_at(活跃策略)", + "SELECT MAX(updated_at) FROM holding_strategies WHERE status='active'", 2), + ("原始新闻", "macro_context_collector", "macro_raw_news.fetched_at", + "SELECT MAX(fetched_at) FROM macro_raw_news", 1), + ("风险信号", "macro_context_collector", "signal_news.created_at", + "SELECT MAX(created_at) FROM signal_news", 2), + ] + + for name, producer, storage, sql, max_days in pipelines: + try: + row = conn.execute(sql).fetchone() + if not row or not row[0]: + # 特殊处理:收盘后价格数据停止更新是正常行为 + if name == "价格数据": + now = datetime.now() + # 如果是非交易时段(9:30-15:00之外),不做无数据告警 + if now.hour < 9 or now.hour >= 15 or (now.hour == 9 and now.minute < 30): + log_ok("数据管道", f"{name} 收盘后正常停止 → OK (最后已更新)") + continue + log_issue("数据管道", "HIGH", f"{name}: 无数据 ({producer}→{storage})", + fix=f"检查{producer}是否正确运行") + continue + latest = row[0][:19] if len(row[0]) > 19 else row[0] + try: + dt = datetime.fromisoformat(latest) if isinstance(latest, str) else latest + days_old = (datetime.now() - dt).total_seconds() / 86400 + except: + days_old = 999 + if days_old > max_days: + # 收盘后价格数据停止更新是正常行为,放松阈值 + if name == "价格数据": + now = datetime.now() + if now.hour < 9 or now.hour >= 15 or (now.hour == 9 and now.minute < 30): + log_ok("数据管道", f"{name} {days_old*24:.0f}小时前更新(收盘后正常) → OK") + continue + log_issue("数据管道", "HIGH", + f"{name}: {days_old:.0f}天未更新(阈值{max_days}天) 最后{latest} ({producer}→{storage})", + fix=f"检查{producer}输出和{storage}写入逻辑") + else: + log_ok("数据管道", f"{name} {days_old*24:.0f}小时前更新 → OK") + except Exception as e: + log_issue("数据管道", "HIGH", f"{name} 检查失败: {e}") + + # 检查 holding_strategies 表策略数量 + try: + hs_count = conn.execute("SELECT COUNT(*) FROM holding_strategies WHERE status IN ('active','updated')").fetchone()[0] + if hs_count < 5: + log_issue("数据管道", "HIGH", f"holding_strategies 仅{hs_count}条策略(异常)", fix="检查策略写入逻辑") + else: + log_ok("数据管道", f"holding_strategies {hs_count}条策略") + except Exception as e: + log_issue("数据管道", "HIGH", f"holding_strategies检查失败: {e}") + + conn.close() + +def audit_services(): + services = [ + ("Dashboard", "http://127.0.0.1:8899/", "200"), + ("mofin-dashboard", None, "active"), + ("xmpp-zhiwei", None, "active"), + ] + for name, url, expected in services: + try: + if url: + result = subprocess.run(["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", url], + capture_output=True, text=True, timeout=5) + if result.stdout.strip() == expected: + log_ok("系统服务", f"{name} 正常") + else: + log_issue("系统服务", "HIGH", f"{name} 返回 {result.stdout.strip()} (期望{expected})") + else: + result = subprocess.run(["systemctl", "is-active", name], + capture_output=True, text=True, timeout=5) + if result.stdout.strip() == expected: + log_ok("系统服务", f"{name} 正常") + else: + log_issue("系统服务", "HIGH", f"{name} 状态 {result.stdout.strip()} (期望{expected})") + except Exception as e: + log_issue("系统服务", "HIGH", f"{name} 检查失败: {e}") + + +# ── 执行 ── +def main(): + start = time.time() + conn = sqlite3.connect(str(DATA_DIR / "mofin.db")) + + audit_signals(conn) + audit_stocks(conn) + audit_strategies(conn) + audit_advice(conn) + audit_portfolio(conn) + audit_pipeline() + audit_services() + audit_cache() + + conn.close() + + REPORT["duration"] = f"{time.time()-start:.0f}s" + REPORT["summary"] = f"审计完成: {len(REPORT['issues'])}个问题, {len(REPORT['fixes'])}个已修复, {len(REPORT['ok'])}项正常" + + # 写入文件 + (WEB_DATA / "system_audit_report.json").write_text(json.dumps(REPORT, ensure_ascii=False, indent=2)) + + # 输出摘要(给cron推送用) + print(f"【系统审计】{REPORT['summary']}") + for i in REPORT["issues"]: + print(f" [{i['severity']}] {i['area']}: {i['desc']}") + if REPORT["fixes"]: + for f in REPORT["fixes"]: + print(f" ✅ 已修复: {f['area']}: {f['desc']}") + for o in REPORT["ok"]: + print(f" ✅ {o['area']}: {o['desc']}") + + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/system_health_check.py b/deploy/profile-scripts/system_health_check.py new file mode 100644 index 00000000..8a8207fa --- /dev/null +++ b/deploy/profile-scripts/system_health_check.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +"""system_health_check.py — MoFin 系统健康检查 + +每日运行,检查所有组件是否正常工作。 +输出报告,有问题才推送。 +""" +import json, os, sys, subprocess +from datetime import datetime, timedelta +from pathlib import Path + +DATA_DIR = Path("/home/hmo/web-dashboard/data") +EVENTS_PATH = DATA_DIR / "price_events.json" +EVALUATION_PATH = DATA_DIR / "evaluation.json" +ACCURACY_PATH = DATA_DIR / "accuracy_stats.json" +CRON_JOBS = "/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json" +POSITION_CRON = "/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json" + +def check(ok, msg): + icon = "✅" if ok else "⚠️" + return f" {icon} {msg}" + +def load_json(path, default=None): + try: + with open(path) as f: + return json.load(f) + except: + return {} if default is None else default + +def check_cron_jobs(path, label): + issues = [] + try: + d = load_json(path, {"jobs": []}) + for j in d.get("jobs", []): + name = j.get("name", "?") + enabled = j.get("enabled", True) + last = j.get("last_run_at", "") + status = j.get("last_status", "") + if not enabled: + issues.append(f"{name} 已禁用") + elif not last: + issues.append(f"{name} 从未运行") + elif status != "ok": + issues.append(f"{name} 上次状态={status}") + return len(d.get("jobs", [])), issues + except: + return 0, ["无法读取"] + +def run(): + now = datetime.now() + issues = [] + ok_count = 0 + warn_count = 0 + + lines = [f"MoFin 系统健康检查 | {now.strftime('%Y-%m-%d %H:%M')}"] + lines.append("") + + # 1. 进程检查 + lines.append("【进程】") + procs = { + "mofin-dashboard": "mofin-dashboard", + "xmpp-zhiwei": "xmpp_zhiwei_bot", + "ejabberd": "ejabberd", + } + for name, pattern in procs.items(): + # 先查 systemd,再查 pgrep + r = subprocess.run(["systemctl", "is-active", f"{pattern}.service"], capture_output=True, text=True, timeout=5) + alive = r.stdout.strip() == "active" + if not alive: + r2 = subprocess.run(["pgrep", "-f", pattern], capture_output=True, timeout=5) + alive = r2.returncode == 0 + lines.append(check(alive, f"{name} {'运行中' if alive else '已停止'}")) + if not alive: issues.append(f"{name} 进程不存在"); warn_count += 1 + else: ok_count += 1 + + # 2. 端口检查 + lines.append("") + lines.append("【端口】") + ports = {"8899": "Dashboard", "5222": "ejabberd", "8643": "知微Gateway"} + for port, name in ports.items(): + r = subprocess.run(["ss", "-tlnp"], capture_output=True, text=True, timeout=5) + listening = f":{port}" in r.stdout + lines.append(check(listening, f"{name} :{port} {'监听中' if listening else '未监听'}")) + if not listening: issues.append(f"{name} 端口{port}未监听"); warn_count += 1 + else: ok_count += 1 + + # 3. 数据文件检查 + lines.append("") + lines.append("【数据文件】") + # DB 优先:从 SQLite 查询代替 JSON 文件检查 + try: + from mo_data import read_portfolio, read_decisions, read_watchlist + pf = read_portfolio() + lines.append(check(len(pf.get("holdings", [])) > 0, f"持仓 DB记录: {len(pf.get('holdings', []))}条")) + ok_count += 1 + wl = read_watchlist() + lines.append(check(len(wl.get("stocks", [])) > 0, f"自选股 DB记录: {len(wl.get('stocks', []))}条")) + ok_count += 1 + dec = read_decisions() + # 注意:holding_strategies 表是决策的DB版本 + dec_count = len(dec.get("decisions", [])) + lines.append(check(dec_count > 0, f"DB策略记录: {dec_count}条")) + ok_count += 1 + except Exception: + lines.append(check(False, "MoFin DB 数据读取失败")) + warn_count += 3 + + # 仍为 JSON 文件的检查 + files = { + "market.json": DATA_DIR / "market.json", + "price_events.json": EVENTS_PATH, + "evaluation.json": EVALUATION_PATH, + "accuracy_stats.json": ACCURACY_PATH, + } + for name, path in files.items(): + exists = path.exists() + size = path.stat().st_size if exists else 0 + lines.append(check(exists and size > 10, f"{name} {'存在' if exists else '缺失'} ({size}B)")) + if not exists or size < 10: + issues.append(f"{name} 缺失或为空") + warn_count += 1 + else: + ok_count += 1 + + # 4. 价格事件统计 + lines.append("") + lines.append("【价格事件】") + try: + from mofin_db import get_conn, query_price_events, query_price_events_by_date + conn = get_conn() + ev_list = query_price_events(conn, limit=50000) + today_events = query_price_events_by_date(conn, now.strftime("%Y-%m-%d")) + conn.close() + except Exception: + events = load_json(EVENTS_PATH, {"events": []}) + ev_list = events.get("events", []) + today_events = [e for e in ev_list if e.get("date") == now.strftime("%Y-%m-%d")] + lines.append(check(len(ev_list) > 0, f"历史事件: {len(ev_list)}条")) + lines.append(check(len(today_events) > 0, f"今日事件: {len(today_events)}条")) + if len(ev_list) == 0: + issues.append("price_events 无事件记录,price_monitor可能未触发过") + warn_count += 1 + else: + ok_count += 1 + + # 5. 策略评估统计 + lines.append("") + lines.append("【策略评估】") + evals = load_json(EVALUATION_PATH, {"strategies": []}) + s_list = evals.get("strategies", []) + lines.append(check(len(s_list) > 0, f"已评估策略: {len(s_list)}条")) + if len(s_list) > 0: + avg = sum(s.get("score", 0) for s in s_list) / len(s_list) + lines.append(check(avg > 0, f"平均评分: {avg:.1f}/10")) + ok_count += 1 + else: + issues.append("evaluation.json 无评估数据") + warn_count += 1 + + # 6. 建议记录统计 + lines.append("") + lines.append("【建议记录】") + try: + from mo_data import read_decisions + dec = read_decisions() + total_advice = sum(len(d.get("advice_timeline", [])) for d in dec.get("decisions", [])) + except Exception: + dec = {"decisions": []} + total_advice = 0 + lines.append(check(total_advice > 0, f"建议记录: {total_advice}条")) + if total_advice == 0: + issues.append("所有策略建议记录为空") + warn_count += 1 + else: + ok_count += 1 + + # 7. Cron jobs + lines.append("") + lines.append("【Cron Jobs】") + cnt, cron_issues = check_cron_jobs(CRON_JOBS, "default") + lines.append(check(cnt > 0, f"default profile: {cnt}个job")) + for ci in cron_issues: + lines.append(f" ⚠️ {ci}") + warn_count += 1 + if cnt == 0: warn_count += 1 + cnt2, cron_issues2 = check_cron_jobs(POSITION_CRON, "position-analyst") + lines.append(check(cnt2 > 0, f"position-analyst: {cnt2}个job")) + for ci in cron_issues2: + lines.append(f" ⚠️ {ci}") + warn_count += 1 + if cnt2 == 0: warn_count += 1 + + # 8. 数据新鲜度 + lines.append("") + lines.append("【数据新鲜度】") + # DB 优先:从 SQLite 查最新更新时间 + try: + from mofin_db import get_conn + conn = get_conn() + db_checks = { + "portfolio (DB)": ("SELECT MAX(updated_at) FROM holdings", 24), + "decisions (DB)": ("SELECT MAX(updated_at) FROM holding_strategies WHERE status IN ('active','updated')", 48), + } + for name, (sql, threshold) in db_checks.items(): + row = conn.execute(sql).fetchone() + ts = row[0] if row and row[0] else None + if not ts: + lines.append(check(False, f"{name} 无更新时间戳")) + issues.append(f"{name} 无更新时间戳") + warn_count += 1 + continue + mtime = datetime.strptime(ts[:19], "%Y-%m-%d %H:%M:%S") if len(ts) >= 19 else datetime.strptime(ts[:10], "%Y-%m-%d") + hours_ago = (now - mtime).total_seconds() / 3600 + fresh = hours_ago < threshold + time_str = f"{hours_ago:.0f}h前" if hours_ago >= 1 else f"{hours_ago*60:.0f}分钟前" + lines.append(check(fresh, f"{name} 更新于 {time_str} (阈值{threshold}h)")) + if not fresh: + issues.append(f"{name} 超过{threshold}h未更新(最近更新:{time_str})") + warn_count += 1 + else: + ok_count += 1 + conn.close() + except Exception: + lines.append(check(False, "DB 数据新鲜度检查失败")) + + # JSON 文件新鲜度(仅限尚未迁移到 DB 的) + freshness_thresholds = { + "multi_tf_cache.json": 24, # K线缓存每日更新 + "macro_context.json": 24, # 宏观数据每日2次 + "market.json": 48, # 行业数据每日更新 + "strategy_staleness_report.json": 24, # 时效性报告每日生成 + } + data_files = { + "multi_tf_cache.json": DATA_DIR / "multi_tf_cache.json", + "macro_context.json": DATA_DIR / "macro_context.json", + "market.json": DATA_DIR / "market.json", + "strategy_staleness_report.json": DATA_DIR / "strategy_staleness_report.json", + } + for name, path in data_files.items(): + if not path.exists(): + lines.append(check(False, f"{name} 缺失")) + issues.append(f"{name} 文件缺失") + warn_count += 1 + continue + mtime = datetime.fromtimestamp(path.stat().st_mtime) + hours_ago = (now - mtime).total_seconds() / 3600 + threshold = freshness_thresholds.get(name, 24) + fresh = hours_ago < threshold + time_str = f"{hours_ago:.0f}h前" if hours_ago >= 1 else f"{hours_ago*60:.0f}分钟前" + lines.append(check(fresh, f"{name} 更新于 {time_str} (阈值{threshold}h)")) + if not fresh: + issues.append(f"{name} 超过{threshold}h未更新(最近更新:{time_str})") + warn_count += 1 + else: + ok_count += 1 + + # 数据管道组件检查 + lines.append("") + lines.append("【数据管道】") + pipe_checks = [ + ("再生器(regenerate_all)", r"strategy_lifecycle\.py"), + ("市场采集(market_watch)", r"market_watch\.py"), + ("宏观采集(macro)", r"macro_context_collector\.py"), + ] + for pname, ppattern in pipe_checks: + r = subprocess.run(["pgrep", "-f", ppattern], capture_output=True, timeout=5) + if r.returncode == 0: + lines.append(check(True, f"{pname} 进程存在")) + ok_count += 1 + else: + # no_agent脚本不常驻,不报warn + lines.append(" 📎 {} 无常驻进程(no_agent脚本按cron调度运行)".format(pname)) + + # 价格数据更新时间检查(盘中应有当日数据) + is_trading_day = now.weekday() < 5 # 周一到周五 + if is_trading_day and now.hour >= 9 and now.hour < 16: + try: + from mofin_db import get_conn + conn = get_conn() + row = conn.execute("SELECT MAX(updated_at) FROM holdings WHERE is_active=1").fetchone() + conn.close() + ts = row[0] if row and row[0] else None + if ts: + mtime = datetime.strptime(ts[:19], "%Y-%m-%d %H:%M:%S") if len(ts) >= 19 else datetime.strptime(ts[:10], "%Y-%m-%d") + has_intraday_data = mtime.date() == now.date() + lines.append(check(has_intraday_data, f"盘中有当日价格数据 {'是' if has_intraday_data else '否'}(最近{mtime.strftime('%H:%M')})")) + if not has_intraday_data: + issues.append(f"盘中交易时段但DB holdings无今日数据(最近更新{mtime.strftime('%m-%d %H:%M')})") + warn_count += 1 + else: + ok_count += 1 + else: + lines.append(check(False, "盘中DB holdings无价格更新记录")) + warn_count += 1 + except Exception: + lines.append(check(False, "盘中DB价格数据检查失败")) + warn_count += 1 + + # 汇总 + total = ok_count + warn_count + lines.append("") + lines.append(f"总计: ✅ {ok_count}/{total} 正常 | ⚠️ {warn_count}/{total} 需关注") + if issues: + lines.append("") + lines.append("需关注项:") + for i, issue in enumerate(issues[:10], 1): + lines.append(f" {i}. {issue}") + + report = "\n".join(lines) + print(report) + + # 如果有问题,写入报告文件供推送 + if warn_count > 0: + report_path = Path("/home/hmo/.hermes/profiles/position-analyst/cron/output/health") + report_path.mkdir(parents=True, exist_ok=True) + report_file = report_path / f"health_{now.strftime('%Y%m%d_%H%M')}.md" + report_file.write_text(f"# MoFin 系统健康检查\n\n{report}") + print(f"\n报告已写入 {report_file}") + else: + print("\n[SILENT] 一切正常") + + +if __name__ == "__main__": + run() diff --git a/deploy/profile-scripts/technical_analysis.py b/deploy/profile-scripts/technical_analysis.py new file mode 100644 index 00000000..b363efe1 --- /dev/null +++ b/deploy/profile-scripts/technical_analysis.py @@ -0,0 +1,650 @@ +#!/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 +from datetime import datetime, date +from mo_data import get_price + +HISTORY_PATH = "/home/hmo/web-dashboard/data/price_history.json" +HISTORY_DAYS = 60 # 使用最近 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): + """获取行情数据。使用 mo_data.get_price 统一入口,缓存+格式转换""" + import time + _cache = get_quote.__dict__.get("_cache", {}) + now = time.time() + cached = _cache.get(code) + if cached and (now - cached["ts"]) < 60: + return cached["data"] + + price, change_pct = get_price(code) + if price is None: + return {"code": code, "error": "价格获取失败"} + + raw = str(code).split("_")[0] + prefix = _market_prefix(code) + today_str = date.today().isoformat() + + q = { + "code": raw, + "market": prefix, + "name": code, + "price": price, + "close_yest": None, + "open": None, + "high": None, + "low": None, + "volume": None, + "amount": None, + "change": None, + "change_pct": change_pct or 0, + "amplitude": None, + "turnover_rate": None, + "pe": None, + "pb": None, + "limit_up": None, + "limit_down": None, + "avg_price": None, + "inner_vol": None, + "outer_vol": None, + "timestamp": "", + "_date": today_str, + } + + # 写入价格历史缓存(每日一次,只存价格) + history = _load_history() + if raw not in history: + history[raw] = [] + days = history[raw] + if days and len(days) > 0 and days[-1].get("date") == today_str: + days[-1]["close"] = price + else: + days.append({"date": today_str, "close": price}) + history[raw] = days[-HISTORY_DAYS:] + _save_history(history) + + # 写入60秒缓存 + get_quote.__dict__["_cache"] = {**get_quote.__dict__.get("_cache", {}), code: {"ts": now, "data": q}} + + 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 analyze_volume_trend(code): + """量价趋势分析:对比历史N日平均成交量,检测量价背离模式 + + 从 price_history.json 读取历史数据,比较今日量价关系。 + """ + result = {} + try: + history = _load_history() + days = history.get(code, []) + if len(days) < 3: + result["trend"] = "数据不足" + return result + + today = days[-1] + prev = days[-2] if len(days) >= 2 else None + today_vol = today.get("volume", 0) + today_close = today.get("close", 0) + + if not today_vol or not today_close: + result["trend"] = "数据不足" + return result + + # 计算N日均量 + vols_5 = [d.get("volume", 0) for d in days[-6:-1] if d.get("volume")] + vols_20 = [d.get("volume", 0) for d in days[-21:-1] if d.get("volume")] + avg_5 = sum(vols_5) / len(vols_5) if vols_5 else 0 + avg_20 = sum(vols_20) / len(vols_20) if vols_20 else 0 + + vol_ratio_vs_5 = today_vol / avg_5 if avg_5 > 0 else 0 + vol_ratio_vs_20 = today_vol / avg_20 if avg_20 > 0 else 0 + + result["avg_volume_5d"] = round(avg_5, 0) + result["avg_volume_20d"] = round(avg_20, 0) + result["today_volume"] = int(today_vol) + result["volume_ratio_vs_5d"] = round(vol_ratio_vs_5, 2) + result["volume_ratio_vs_20d"] = round(vol_ratio_vs_20, 2) + + # 最近3日的收盘价和成交量趋势 + if len(days) >= 3: + recent_close = [d.get("close", 0) for d in days[-4:-1]] + recent_vol = [d.get("volume", 0) for d in days[-4:-1]] + if all(recent_close) and all(recent_vol): + price_up = today_close > recent_close[-1] + vol_up = today_vol > recent_vol[-1] + + # 量价模式判定 + if vol_ratio_vs_5 >= 1.8: + # 明显放量 + if price_up: + result["trend"] = "放量上攻" + result["action"] = "buy_conformation" + else: + # 价格下跌但大幅放量 = 恐慌?还是承接收筹? + # 看今日K线:如果是阳线(低开高走)= 承接 + # 简单判断:如果close > open = 有承接 + result["trend"] = "放量下跌" + result["action"] = "watch" + elif vol_ratio_vs_5 <= 0.6: + # 明显缩量 + if price_up: + result["trend"] = "缩量上涨" + result["action"] = "divergence" + else: + result["trend"] = "缩量回调" + result["action"] = "healthy_pullback" + elif vol_ratio_vs_5 >= 1.3: + # 温和放量 + if price_up: + result["trend"] = "温和放量上涨" + result["action"] = "bullish" + else: + result["trend"] = "温和放量下跌" + result["action"] = "bearish" + else: + # 正常量 + if price_up: + result["trend"] = "正常量上涨" + result["action"] = "neutral_bullish" + else: + result["trend"] = "正常量下跌" + result["action"] = "neutral_bearish" + + # 量价背离检测:价格走高但成交量逐日递减 + if len(days) >= 5: + close_5 = [d.get("close", 0) for d in days[-5:]] + vol_5 = [d.get("volume", 0) for d in days[-5:]] + if all(close_5) and all(vol_5): + close_trend = close_5[-1] - close_5[0] + vol_trend = vol_5[-1] - vol_5[0] + # 价格涨但量跌 = 顶背离 + if close_trend > 0 and vol_trend < 0 and abs(vol_trend) > sum(vol_5) * 0.3: + result["divergence"] = "顶背离(价涨量缩)" + # 价格跌但量涨 = 底背离 + elif close_trend < 0 and vol_trend > 0 and abs(vol_trend) > sum(vol_5) * 0.3: + result["divergence"] = "底背离(价跌量增)" + + except Exception as e: + result["trend_error"] = str(e) + + return result + + +def analyze_volume_deep(code): + """深度量价分析:从日K线分析量价配合/背离/建仓/出货 + + 使用 mtf_cache 表的日K线数据做历史量价分析。 + """ + import sqlite3 + from pathlib import Path + + DATA_DIR = Path(__file__).parent.parent / "data" + try: + conn = sqlite3.connect(str(DATA_DIR / "mofin.db")) + row = conn.execute("SELECT cache_json FROM mtf_cache WHERE code=?", (code,)).fetchone() + conn.close() + if not row: + return {"volume_signal": "数据不足"} + data = json.loads(row[0]) + except Exception: + return {"volume_signal": "数据不足"} + + daily = data.get("daily", []) + if len(daily) < 5: + return {"volume_signal": "数据不足"} + + closes = [d["close"] for d in daily] + volume = [d["volume"] for d in daily] + n = len(daily) + + # 基准:最近20日均量(不足20日则用全部) + lookback = min(20, n - 1) + avg_vol_20d = sum(volume[-lookback-1:-1]) / lookback if lookback > 0 else volume[-1] + + # 最近N日的量比 + recent = min(5, n) + recent_vol_ratios = [] + for i in range(recent): + vol = volume[-i-1] if i+1 <= n else volume[0] + recent_vol_ratios.append(round(vol / avg_vol_20d, 2) if avg_vol_20d > 0 else 1) + + today_ratio = recent_vol_ratios[0] if recent_vol_ratios else 1 + recent_max_ratio = max(recent_vol_ratios) if recent_vol_ratios else 1 + + # 量价配合度 + signals = [] + patterns = {} + + # 1. 放量检测(量比 > 2x) + if today_ratio > 2.0: + signals.append(f"量比{today_ratio:.1f}倍放量") + patterns["volume_surge"] = True + # 放量方向 + if len(closes) >= 2 and closes[-1] > closes[-2]: + patterns["surge_direction"] = "放量上涨" + if today_ratio > 2.5 and closes[-1] > closes[-2] * 1.03: + signals[-1] += "↑主力买入" + else: + signals[-1] += "↑" + elif len(closes) >= 2 and closes[-1] < closes[-2]: + patterns["surge_direction"] = "放量下跌" + if today_ratio > 2.5 and closes[-1] < closes[-2] * 0.97: + signals[-1] += "↓主力出货" + else: + signals[-1] += "↓" + else: + patterns["surge_direction"] = "放量平盘" + elif today_ratio < 0.5: + signals.append(f"量比{today_ratio:.1f}倍缩量") + patterns["volume_shrink"] = True + else: + signals.append(f"量比{today_ratio:.1f}倍正常") + patterns["volume_normal"] = True + + # 2. 量价趋势分析(近5日 vs 前5日) + if len(daily) >= 10: + recent5_vol = sum(volume[-5:]) / 5 + prev5_vol = sum(volume[-10:-5]) / 5 + vol_trend = "增" if recent5_vol > prev5_vol * 1.3 else ("减" if recent5_vol < prev5_vol * 0.7 else "稳") + recent5_price = closes[-5:] + price_trend = "涨" if recent5_price[-1] > recent5_price[0] else ("跌" if recent5_price[-1] < recent5_price[0] * 0.95 else "平") + + if vol_trend == "增" and price_trend == "涨": + patterns["accumulation"] = True # 量价齐升=建仓 + signals.append(f"近5日{vol_trend}量{price_trend}价=建仓型") + elif vol_trend == "增" and price_trend == "跌": + patterns["distribution"] = True # 放量下跌=出货 + signals.append(f"近5日{vol_trend}量{price_trend}价=⚠️出货型") + elif vol_trend == "减" and price_trend == "涨": + patterns["divergence"] = True # 量缩价涨=背离 + signals.append(f"近5日{vol_trend}量{price_trend}价=⬆量价背离") + elif vol_trend == "减" and price_trend == "跌": + patterns["washout"] = True # 缩量下跌=洗盘末端 + signals.append(f"近5日{vol_trend}量{price_trend}价=洗盘特征") + else: + signals.append(f"近5日{vol_trend}量{price_trend}价") + else: + vol_trend = price_trend = "?" + + # 3. 寻找历史放量区间(主力活动痕迹) + surge_days = [] + for i in range(max(0, n - 60), n): + vol_ratio = volume[i] / avg_vol_20d if avg_vol_20d > 0 else 0 + if vol_ratio > 2.0: + surge_days.append({ + "date": daily[i].get("date", ""), + "ratio": round(vol_ratio, 1), + "close": closes[i], + "direction": "涨" if (i > 0 and closes[i] > closes[i-1]) else "跌" + }) + + # 汇总描述 + vol_level = "放量" if today_ratio > 2.0 else ("缩量" if today_ratio < 0.5 else "正常") + price_vol = f"{vol_level}" + if patterns.get("accumulation"): + price_vol = f"量价齐升(建仓特征) | {signals[-1]}" + elif patterns.get("distribution"): + price_vol = f"放量下跌⚠️ | {signals[-1]}" + elif patterns.get("washout"): + price_vol = f"缩量回踩(洗盘末端) | {signals[-1]}" + elif patterns.get("divergence"): + price_vol = f"量价背离 | {signals[-1]}" + + return { + "volume_signal": " ; ".join(signals) if signals else "正常", + "volume_ratio": today_ratio, + "avg_volume_20d": int(avg_vol_20d), + "recent_ratios": recent_vol_ratios, + "surge_count_60d": len(surge_days), + "price_vol_description": price_vol, + "patterns": patterns, + "surge_days": surge_days[-5:] if surge_days else [], + } + + +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) + # 深度量价分析(使用日K线历史数据) + vol_deep = {} + try: + vol_deep = analyze_volume_deep(code) + except Exception: + pass # graceful degradation + + # 多周期+均线分析(整合 multi_timeframe) + mtf = {} + try: + from multi_timeframe import full_multi_tf_analysis as _mtf + mtf_raw = _mtf(code) + if mtf_raw and 'daily' in mtf_raw: + d = mtf_raw['daily'] + mtf = { + 'mas': d.get('mas', {}), + 'multi_tf_sr': d.get('support_resistance', {}), + 'trend': d.get('trend', {}), + } + # 周线弱压/弱撑作为中周期参考 + if 'weekly' in mtf_raw: + w = mtf_raw['weekly'] + ws = w.get('support_resistance', {}) + wt = w.get('trend', {}) + wm = w.get('mas', {}) + mtf['weekly'] = { + 'weak_resist': ws.get('weak_resist'), + 'weak_support': ws.get('weak_support'), + 'strong_resist': ws.get('strong_resist'), + 'strong_support': ws.get('strong_support'), + 'trend': wt.get('direction', ''), + 'ma5': wm.get('ma5'), + 'ma10': wm.get('ma10'), + } + # 月线作为长期参考 + if 'monthly' in mtf_raw: + m = mtf_raw['monthly'] + ms = m.get('support_resistance', {}) + mt = m.get('trend', {}) + mm = m.get('mas', {}) + mtf['monthly'] = { + 'weak_resist': ms.get('weak_resist'), + 'weak_support': ms.get('weak_support'), + 'strong_resist': ms.get('strong_resist'), + 'strong_support': ms.get('strong_support'), + 'trend': mt.get('direction', ''), + 'ma5': mm.get('ma5'), + } + except Exception: + pass # non-critical, graceful degradation + + result = { + "quote": { + "name": q.get("name", code), + "price": q["price"], + "change_pct": q.get("change_pct", 0), + "open": q.get("open", 0), + "high": q.get("high", 0), + "low": q.get("low", 0), + "close_yest": q.get("close_yest", 0), + "volume": q.get("volume", 0), + "amplitude": q.get("amplitude", 0), + }, + "support_resistance": sr, + "candlestick": candle, + "volume": vol, + "volume_deep": vol_deep, + "multi_tf": mtf, + "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/deploy/profile-scripts/trend_detector.py b/deploy/profile-scripts/trend_detector.py new file mode 100644 index 00000000..96ed51fb --- /dev/null +++ b/deploy/profile-scripts/trend_detector.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +"""trend_detector.py — 板块异常信号检测 + +配合 market_watch(每30分)运行,从最新 snapshot 中检测6类信号: + 1. 资金异动 — 净流入/出远超近期均值 + 2. 涨跌比反转 — 板块内涨跌家数比例突变 + 3. 领涨股更替 — 领涨股换人 + 4. 趋势拐点 — 连续流入→转流出 或 连续流出→转入流 + 5. 量价背离 — 涨但资金流出 / 跌但资金流入 + 6. 普涨背离 — 板块大涨但上涨家数<50% + +检测到信号后写入 sector_signals 表。 +""" + +import json +import sqlite3 +import sys +from datetime import datetime +from pathlib import Path + +DATA_DIR = Path(__file__).parent.parent / "data" +DB_PATH = DATA_DIR / "mofin.db" + + +def get_conn(): + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + return conn + + +def get_recent_snapshots(conn, n=20): + """取最近 n 次 market_snapshots""" + return conn.execute( + "SELECT id, timestamp, up_ratio, mood FROM market_snapshots ORDER BY id DESC LIMIT ?", + (n,) + ).fetchall() + + +def get_sectors_for_snapshot(conn, snapshot_id): + """取指定 snapshot 的全部板块数据""" + rows = conn.execute( + "SELECT * FROM sector_snapshots WHERE snapshot_id = ?", (snapshot_id,) + ).fetchall() + return [dict(r) for r in rows] + + +def get_sector_history(conn, sector_name, n=20): + """取某板块最近 n 次采集记录""" + return conn.execute(""" + SELECT ss.*, ms.timestamp + FROM sector_snapshots ss + JOIN market_snapshots ms ON ss.snapshot_id = ms.id + WHERE ss.name = ? + ORDER BY ms.id DESC LIMIT ? + """, (sector_name, n)).fetchall() + + +def get_holdings(conn): + """取活跃持仓""" + return conn.execute("SELECT code, name FROM holdings WHERE is_active=1").fetchall() + + +def get_watchlist(conn): + """取活跃自选""" + return conn.execute("SELECT code, name FROM watchlist_stocks WHERE is_active=1").fetchall() + + +def get_sector_for_stock(conn, code): + """查个股对应的板块""" + row = conn.execute( + "SELECT sector_name FROM stock_sectors WHERE code = ?", (code,) + ).fetchone() + return row[0] if row else None + + +def write_signal(conn, signal_type, sector, severity, related_stocks, + holdings_list, watchlist_list, trigger_reason, snapshot_id): + """写入信号到 sector_signals""" + # 同板块同类型24小时内已有信号则跳过 + existing = conn.execute(""" + SELECT id FROM sector_signals + WHERE sector = ? AND signal_type = ? + AND datetime(detected_at) >= datetime('now', '-1 day') + LIMIT 1 + """, (sector, signal_type)).fetchone() + if existing: + return False + + conn.execute(""" + INSERT INTO sector_signals + (signal_type, sector, severity, related_stocks, + holdings_in_sector, watchlist_in_sector, + trigger_reason, snapshot_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, ( + signal_type, sector, severity, + json.dumps(related_stocks, ensure_ascii=False), + json.dumps(holdings_list, ensure_ascii=False) if holdings_list else None, + json.dumps(watchlist_list, ensure_ascii=False) if watchlist_list else None, + trigger_reason, snapshot_id + )) + conn.commit() + return True + + +def check_signals(conn, latest, prev_snapshots): + """对最新 snapshot 检测6类信号""" + latest_id = latest["id"] + sectors = get_sectors_for_snapshot(conn, latest_id) + if not sectors: + return + + # 取持仓和自选 + holdings = {r["code"]: r["name"] for r in get_holdings(conn)} + watchlist = {r["code"]: r["name"] for r in get_watchlist(conn)} + + # 取上一次 snapshot 用于对比(如果有) + prev_id = None + if len(prev_snapshots) >= 2: + prev_id = prev_snapshots[1]["id"] + prev_sectors = get_sectors_for_snapshot(conn, prev_id) if prev_id else [] + + # 构建 name→sector 映射 + prev_map = {s["name"]: s for s in prev_sectors} + + for s in sectors: + name = s["name"] + change = s["change_pct"] or 0 + net_inflow = s["net_inflow"] or 0 + up_count = s["up_count"] or 0 + down_count = s["down_count"] or 0 + total = up_count + down_count + up_ratio = up_count / total if total > 0 else None + lead_stock = s["lead_stock"] or "" + + # 近期历史 + history = get_sector_history(conn, name, 20) + + # 计算近期均值(后续多重信号共用) + mean = 0 + if len(history) >= 3: + recent_inflows = [abs(h["net_inflow"] or 0) for h in history[:10]] + mean = sum(recent_inflows) / len(recent_inflows) if recent_inflows else 0 + + # 信号1:资金异动 + if net_inflow and len(history) >= 5 and mean > 0: + recent_inflows = [abs(h["net_inflow"] or 0) for h in history[:20]] + new_mean = sum(recent_inflows) / len(recent_inflows) + std = (sum((x - new_mean) ** 2 for x in recent_inflows) / len(recent_inflows)) ** 0.5 + if std > 0 and abs(net_inflow) > mean + 3 * std: + direction = "净流入" if net_inflow > 0 else "净流出" + sev = "high" if abs(net_inflow) > mean + 5 * std else "medium" + related = _get_related_stocks(conn, name, lead_stock, change) + holdings_list = _match_holdings(related, holdings) + watchlist_list = _match_holdings(related, watchlist) + ok = write_signal(conn, "资金异动", name, sev, related, + holdings_list, watchlist_list, + f"{direction}{abs(net_inflow):.0f}亿(均值{mean:.0f}亿,超{abs(net_inflow)/max(mean,0.01):.0f}倍)", + latest_id) + if ok: + print(f" ⚠️ 资金异动 [{sev}] {name}: {direction}{abs(net_inflow):.0f}亿", flush=True) + + # 信号2:涨跌比反转(相比上一次) + prev = dict(prev_map[name]) if name in prev_map else {} + if prev and up_ratio is not None and prev["up_count"] and prev["down_count"]: + prev_total = prev["up_count"] + prev["down_count"] + prev_ratio = prev["up_count"] / prev_total if prev_total > 0 else 0 + if abs(up_ratio - prev_ratio) > 0.3: # 涨跌比变化超过30个百分点 + direction = "转强" if up_ratio > prev_ratio else "转弱" + sev = "high" if abs(up_ratio - prev_ratio) > 0.5 else "medium" + related = _get_related_stocks(conn, name, lead_stock, change) + holdings_list = _match_holdings(related, holdings) + watchlist_list = _match_holdings(related, watchlist) + ok = write_signal(conn, "涨跌比反转", name, sev, related, + holdings_list, watchlist_list, + f"上涨占比{prev_ratio*100:.0f}%→{up_ratio*100:.0f}%,{direction}", + latest_id) + if ok: + print(f" ⚠️ 涨跌比反转 [{sev}] {name}: {prev_ratio*100:.0f}%→{up_ratio*100:.0f}% {direction}", flush=True) + + # 信号3:领涨股更替 + prev_lead = prev.get("lead_stock", "") if prev else "" + if lead_stock and prev_lead and lead_stock != prev_lead: + related = _get_related_stocks(conn, name, lead_stock, change) + holdings_list = _match_holdings(related, holdings) + watchlist_list = _match_holdings(related, watchlist) + ok = write_signal(conn, "领涨股更替", name, "medium", related, + holdings_list, watchlist_list, + f"领涨股从「{prev_lead}」换成「{lead_stock}」", + latest_id) + if ok: + print(f" ⚠️ 领涨股更替 [{name}] {prev_lead} → {lead_stock}", flush=True) + + # 信号4:趋势拐点(连续净流入突然转流出,反之亦然) + if net_inflow and len(history) >= 4: + recent = [h["net_inflow"] or 0 for h in history[:4]] + all_positive = all(r > 0 for r in recent[:3]) + all_negative = all(r < 0 for r in recent[:3]) + if all_positive and net_inflow < 0 and abs(net_inflow) > mean * 0.5: + related = _get_related_stocks(conn, name, lead_stock, change) + holdings_list = _match_holdings(related, holdings) + watchlist_list = _match_holdings(related, watchlist) + ok = write_signal(conn, "趋势拐点", name, "high", related, + holdings_list, watchlist_list, + f"连续3次净流入后转流出{abs(net_inflow):.0f}亿", + latest_id) + if ok: + print(f" ⚠️ 趋势拐点 [high] {name}: 连续流入→转流出{abs(net_inflow):.0f}亿", flush=True) + elif all_negative and net_inflow > 0 and net_inflow > abs(sum(recent[:3])) * 0.5: + related = _get_related_stocks(conn, name, lead_stock, change) + holdings_list = _match_holdings(related, holdings) + watchlist_list = _match_holdings(related, watchlist) + ok = write_signal(conn, "趋势拐点", name, "high", related, + holdings_list, watchlist_list, + f"连续3次净流出后转入流{net_inflow:.0f}亿", + latest_id) + if ok: + print(f" ⚠️ 趋势拐点 [high] {name}: 连续流出→转入流{net_inflow:.0f}亿", flush=True) + + # 信号5:量价背离 + if net_inflow and change and abs(change) > 2: + if change > 0 and net_inflow < -abs(mean or 1): + related = _get_related_stocks(conn, name, lead_stock, change) + holdings_list = _match_holdings(related, holdings) + watchlist_list = _match_holdings(related, watchlist) + ok = write_signal(conn, "量价背离", name, "medium", related, + holdings_list, watchlist_list, + f"板块涨{change:+.2f}%但资金净流出{abs(net_inflow):.0f}亿", + latest_id) + if ok: + print(f" ⚠️ 量价背离 [{name}] 涨{change:+.2f}%但流出{abs(net_inflow):.0f}亿", flush=True) + elif change < 0 and net_inflow > abs(mean or 1): + related = _get_related_stocks(conn, name, lead_stock, change) + holdings_list = _match_holdings(related, holdings) + watchlist_list = _match_holdings(related, watchlist) + ok = write_signal(conn, "量价背离", name, "medium", related, + holdings_list, watchlist_list, + f"板块跌{change:+.2f}%但资金净流入{net_inflow:.0f}亿(吸筹信号)", + latest_id) + if ok: + print(f" ⚠️ 量价背离 [{name}] 跌{change:+.2f}%但流入{net_inflow:.0f}亿(吸筹)", flush=True) + + # 信号6:普涨背离 + if up_ratio is not None and change > 3 and up_ratio < 0.5: + related = _get_related_stocks(conn, name, lead_stock, change) + holdings_list = _match_holdings(related, holdings) + watchlist_list = _match_holdings(related, watchlist) + ok = write_signal(conn, "普涨背离", name, "medium", related, + holdings_list, watchlist_list, + f"板块涨{change:+.2f}%但仅{up_count}/{total}家上涨(分化严重)", + latest_id) + if ok: + print(f" ⚠️ 普涨背离 [{name}] 涨{change:+.2f}%但仅{up_count}/{total}家上涨", flush=True) + + +def _get_related_stocks(conn, sector_name, lead_stock, change_pct): + """获取板块相关个股(领涨股 + board 成分股)""" + stocks = [] + if lead_stock: + stocks.append({"name": lead_stock, "code": "", "change_pct": 0, "role": "领涨"}) + # 从 stock_sectors 表取成分股 + members = conn.execute( + "SELECT s.code, s.name FROM stocks s " + "JOIN stock_sectors ss ON s.code = ss.code " + "WHERE ss.sector_name = ? LIMIT 5", + (sector_name,) + ).fetchall() + for m in members: + if not any(s.get("name") == m["name"] for s in stocks): + stocks.append({"name": m["name"], "code": m["code"], "change_pct": 0, "role": "成分"}) + return stocks + + +def _match_holdings(stocks, holding_dict): + """匹配相关个股中的持仓/自选""" + matched = [] + for s in stocks: + code = s.get("code", "") + if code in holding_dict: + matched.append({"code": code, "name": holding_dict[code]}) + return matched + + +def main(): + conn = get_conn() + + # 取最近 snapshots + snapshots = get_recent_snapshots(conn, 5) + if len(snapshots) < 2: + print(f"数据不足: 只有 {len(snapshots)} 次采集,需要至少2次", flush=True) + conn.close() + return + + latest = dict(snapshots[0]) + print(f"检测最新 snapshot: {latest['timestamp']} (id={latest['id']})", flush=True) + + check_signals(conn, latest, snapshots) + conn.close() + print("检测完成", flush=True) + + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/vacuum_state_db.py b/deploy/profile-scripts/vacuum_state_db.py new file mode 100644 index 00000000..34455077 --- /dev/null +++ b/deploy/profile-scripts/vacuum_state_db.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""vacuum_state_db.py — 每周整理state.db,防止磁盘I/O退化 + +在非交易时段运行(周六凌晨),不影响交易系统。 +""" +import sqlite3, os + +DBS = [ + "/home/hmo/.hermes/profiles/position-analyst/state.db", + "/home/hmo/.hermes/state.db", +] + +for db_path in DBS: + if not os.path.exists(db_path): + continue + size_before = os.path.getsize(db_path) / 1024 / 1024 + try: + c = sqlite3.connect(db_path) + c.execute("PRAGMA auto_vacuum=2") + # 只做incremental vacuum,不做full vacuum(耗时太长) + c.execute("PRAGMA incremental_vacuum(50000)") + c.execute("PRAGMA cache_size=-200000") + c.execute("PRAGMA mmap_size=268435456") + c.execute("PRAGMA wal_checkpoint(TRUNCATE)") + c.close() + size_after = os.path.getsize(db_path) / 1024 / 1024 + saved = size_before - size_after + print(f"{db_path.split('/')[-2]}: {size_before:.0f}MB -> {size_after:.0f}MB (reclaim {saved:.0f}MB)") + except Exception as e: + print(f"{db_path.split('/')[-2]}: ERROR {e}") diff --git a/deploy/profile-scripts/verify_reassess_pipeline.py b/deploy/profile-scripts/verify_reassess_pipeline.py new file mode 100644 index 00000000..464f5b91 --- /dev/null +++ b/deploy/profile-scripts/verify_reassess_pipeline.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""verify_reassess_pipeline.py — 重评推送管道审计 + 全局cron失败监控 + +检查: +1. price_monitor 每2分正常跑 +2. zone breach检测正常 +3. holding_strategies有数据 +4. XMPP bridge在线 +5. reassess模块可导入 +6. 【新增】所有关键cron job状态(是否有failed) + +输出:正常时 [SILENT],有异常时推XMPP +""" +import json, os, sys, subprocess, sqlite3 +from pathlib import Path +from datetime import datetime, timedelta +from urllib.request import Request, urlopen + +BASE = Path(__file__).parent.parent +sys.path.insert(0, str(BASE)) +sys.path.insert(0, "/home/hmo/MoFin") + +XMPP_BRIDGE = "http://127.0.0.1:5805/" +XMPP_USER = "hmo@yoin.fun" + +def xmpp_push(text): + try: + payload = json.dumps({"to": XMPP_USER, "body": text, "type": "chat"}).encode() + req = Request(XMPP_BRIDGE, data=payload, headers={"Content-Type": "application/json"}) + urlopen(req, timeout=5) + except Exception as e: + print(f"[XMPP推送失败] {e}", file=sys.stderr) + +def scan_cron_failures(): + """扫描两个cron jobs.json看是否有failed状态的关键job""" + failures = [] + jobs_files = [ + "/home/hmo/.hermes/cron/jobs.json", + "/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json", + ] + for jf in jobs_files: + try: + data = json.load(open(jf)) + for job in data.get("jobs", []): + jid = job.get("id", "?") + name = job.get("name", "") or jid[:12] + status = job.get("last_status", "") + enabled = job.get("enabled", True) + if not enabled: + continue + # 关键job:价格监控、重评、盘前中监控 + key_job = any(kw in name.lower() for kw in [ + "price_monitor", "monitor", "盘前中", "reassess", + "重评", "自选买入", "stale_push", "管道审计", + "宏观风险", "策略时效" + ]) + if not key_job: + continue + if status == "failed": + last_run = job.get("last_run_at", "?") + failures.append(f" ❌ {name} ({jid[:8]}) last_run={last_run}") + except Exception: + pass + return failures + +def check_cron_jobs(): + """另法:直接查cron数据库""" + issues = [] + for db_path in [ + BASE / "cron" / "cron.db", + Path("/home/hmo/.hermes/cron/cron.db"), + ]: + if not db_path.exists(): + continue + try: + c = sqlite3.connect(str(db_path)) + for row in c.execute(""" + SELECT id, name, last_status, last_run_at, enabled + FROM cron_jobs WHERE enabled=1 + ORDER BY last_run_at DESC + """).fetchall(): + jid, name, status, last_run, enabled = row + if status == "failed": + issues.append(f" ❌ {name}({jid[:8]}) last_run={last_run}") + c.close() + except Exception: + pass + return issues + +def run(): + ok = True + alerts = [] + checks = [] + + # 1. price_monitor 最近运行时间 + try: + conn = sqlite3.connect("/home/hmo/MoFin/data/mofin.db") + lp = conn.execute("SELECT MAX(updated_at) FROM live_prices").fetchone()[0] + if lp: + lp_dt = datetime.fromisoformat(lp) if isinstance(lp, str) else lp + if hasattr(lp_dt, 'tzinfo') and lp_dt.tzinfo is None: + if isinstance(lp, str) and '+' not in lp: + lp_dt = lp_dt.replace(tzinfo=None) + mins_ago = (datetime.now() - lp_dt).total_seconds() / 60 + status = "ok" if mins_ago < 10 else "warn" + if mins_ago > 15: + status = "fail" + ok = False + alerts.append(f"price_monitor {mins_ago:.0f}分未更新") + checks.append({"check":"price_monitor","status":status,"detail":f"最后更新{mins_ago:.0f}分前"}) + else: + checks.append({"check":"price_monitor","status":"warn","detail":"live_prices无数据"}) + except Exception as e: + checks.append({"check":"price_monitor","status":"fail","detail":str(e)}) + ok = False + alerts.append(f"price_monitor异常: {e}") + + # 2. 策略评估活动(reassess_with_context写strategy_evaluations,不是holding_strategies) + try: + today_se = conn.execute("SELECT COUNT(*) FROM strategy_evaluations WHERE date(created_at)=date('now')").fetchone()[0] + total_se = conn.execute("SELECT COUNT(*) FROM strategy_evaluations").fetchone()[0] + # 也尝试查holding_strategies(如果存在并有数据) + hs_exists = conn.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='holding_strategies'").fetchone()[0] + hs = 0 + if hs_exists: + hs = conn.execute("SELECT COUNT(*) FROM holding_strategies").fetchone()[0] + detail = f"今日{today_se}次评估, 累计{total_se}条" + if hs > 0: + detail += f", holding_strategies{hs}条" + checks.append({"check":"strategy_activity","status":"ok","detail":detail}) + except Exception as e: + checks.append({"check":"strategies","status":"fail","detail":str(e)}) + ok = False + + # 3. XMPP bridge 是否在线(TCP端口检测,不发消息到Dad) + try: + import socket + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(3) + result = sock.connect_ex(("127.0.0.1", 5805)) + sock.close() + bridge_ok = (result == 0) + if not bridge_ok: + ok = False + alerts.append("XMPP bridge(5805)端口无响应") + checks.append({"check":"xmpp_bridge","status":"ok" if bridge_ok else "fail","detail":"在线" if bridge_ok else "端口无响应"}) + except Exception as e: + checks.append({"check":"xmpp_bridge","status":"fail","detail":str(e)}) + ok = False + alerts.append(f"XMPP bridge不可达: {e}") + + # 4. reassess模块可导入 + try: + from strategy_lifecycle import reassess_with_context + checks.append({"check":"reassess_module","status":"ok","detail":"可导入"}) + except Exception as e: + checks.append({"check":"reassess_module","status":"fail","detail":str(e)}) + ok = False + alerts.append(f"reassess模块导入失败: {e}") + + # 5. cron job失败检测 + cron_issues = scan_cron_failures() + check_cron_jobs() + if cron_issues: + ok = False + alerts.append(f"{len(cron_issues)}个cron job失败") + for issue in cron_issues[:5]: + alerts.append(issue) + checks.append({"check":"cron_jobs","status":"fail","detail":"; ".join(cron_issues[:3])}) + else: + checks.append({"check":"cron_jobs","status":"ok","detail":"所有关键job正常"}) + + conn.close() + + # 输出 + result = { + "pipeline": "ok" if ok else "degraded", + "checked_at": datetime.now().isoformat(), + "checks": checks, + "alerts": alerts + } + + if ok: + print("[SILENT]") + else: + msg = "🔴 重评管道异常:\n" + "\n".join(alerts) + print(json.dumps(result, ensure_ascii=False, indent=2)) + # 有异常时主动推XMPP(取代静默) + xmpp_push(msg) + print(f"\n已推送XMPP: {len(alerts)}条告警", file=sys.stderr) + +if __name__ == "__main__": + run() diff --git a/deploy/profile-scripts/watchlist_auto_exit.py b/deploy/profile-scripts/watchlist_auto_exit.py new file mode 100644 index 00000000..cd095238 --- /dev/null +++ b/deploy/profile-scripts/watchlist_auto_exit.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""watchlist_auto_exit.py — 自选退出机制 + +每天盘前执行,扫描自选策略: +- 连续N天信号为"卖出" → 自动退出 +- 连续N天评级极低且价格远离买入区 → 自动退出 +- 记录出入日志到 watchlist_log 表 +""" +import sqlite3, sys, json +from datetime import datetime, timedelta + +DB = "/home/hmo/MoFin/data/mofin.db" + +def get_signal_rank(signal): + """信号排序:分值越低越差""" + rank = {"买入": 5, "可买入": 5, "可加仓": 4, "关注": 3, "观望": 2, "卖出": 1, "信号不充分": 0, "弱势持有": 1} + for k, v in rank.items(): + if k in str(signal): + return v + return 2 # 默认关注级 + +def main(dry_run=False): + conn = sqlite3.connect(DB) + now = datetime.now() + + # 读取所有活跃自选策略 + rows = conn.execute(""" + SELECT code, name, timing_signal, entry_low, entry_high, price, + reassessed_at, position_advice, full_analysis + FROM holding_strategies + WHERE status='active' AND decision_type='自选策略' + ORDER BY code + """).fetchall() + + exited = [] + kept = [] + + for code, name, signal, el, eh, price, reassessed_at, pos_advice, fa in rows: + signal_str = str(signal or "") + rank = get_signal_rank(signal_str) + + # 退出条件判断 + reasons = [] + + # 条件1: 信号=卖出 + if "卖出" in signal_str: + reasons.append(f"信号={signal_str}") + + # 条件2: 信号=观望/信号不充分 且 价格远离买入区 + if "观望" in signal_str or "信号不充分" in signal_str: + if price and el and eh and el > 0 and eh > 0: + if price > eh * 1.20: # 高于买入区上沿20% + reasons.append(f"价{price}超买区上沿+{((price/eh)-1)*100:.0f}%") + elif el > 0 and price < el * 0.85: # 低于买入区下沿15% + reasons.append(f"价{price}低于买区下沿{(1-price/el)*100:.0f}%") + + # 条件3: 已清仓/零仓位且信号差 + pos_str = str(pos_advice or "") + if rank <= 1 and ("0%" in pos_str or "清仓" in pos_str or "不参与" in pos_str): + reasons.append(f"仓位建议={pos_str}") + + # 如果有退出理由,执行退出 + if reasons: + reason_text = "; ".join(reasons) + exited.append((code, name, signal_str, reason_text)) + + if not dry_run: + # 记录退出日志 + conn.execute( + "INSERT INTO watchlist_log (code, name, event, reason, old_signal, new_signal, price) " + "VALUES (?,?,?,?,?,?,?)", + (code, name or "", "exit", reason_text, signal_str, "已退出", price) + ) + # 标记为inactive(软删除,保留历史) + conn.execute( + "UPDATE holding_strategies SET status='inactive', updated_at=datetime('now','localtime') " + "WHERE code=? AND status='active' AND decision_type='自选策略'", + (code,) + ) + print(f" 🔴 退出: {code} {name or ''} | {reason_text}") + else: + kept.append(code) + + if not dry_run: + conn.commit() + + print(f"\n结果: {len(exited)}只退出, {len(kept)}只保留") + + # 生成退出摘要日志 + if exited: + summary = f"【自选退出】{now.strftime('%m/%d')} {len(exited)}只自动退出:\n" + for code, name, sig, reason in exited: + summary += f" {code} {name}: {sig} → {reason}\n" + # 写入JSON供报告引用 + with open("/tmp/watchlist_exit_summary.json", "w") as f: + json.dump({"date": now.isoformat(), "exited": len(exited), "items": [ + {"code": c, "name": n, "reason": r} for c, n, s, r in exited + ]}, f, ensure_ascii=False) + print(summary) + + conn.close() + return exited + +if __name__ == "__main__": + dry = "--dry-run" in sys.argv + main(dry_run=dry) diff --git a/deploy/profile-scripts/xiaoguo_news_processor.py b/deploy/profile-scripts/xiaoguo_news_processor.py new file mode 100644 index 00000000..b2172f8c --- /dev/null +++ b/deploy/profile-scripts/xiaoguo_news_processor.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""xiaoguo_news_processor.py — 小果新闻情报处理 + +配合 trend_detector(每30分)运行,处理未处理的 sector_signals。 + +流程: + 1. 读未 processed 的 signals(每次1条) + 2. akshare 搜新闻(板块相关个股 + 持仓 + 自选) + 3. 调小果 LLM 逐批分析(每批3-5篇,给摘要+情感) + 4. 写入 signal_news + 5. 标记 signal.processed = true +""" + +import json +import os +import urllib.request +import re +from pathlib import Path + +try: + import akshare as ak + HAS_AKSHARE = True +except ImportError: + HAS_AKSHARE = False + +DATA_DIR = Path(__file__).parent.parent / "data" +DB_PATH = DATA_DIR / "mofin.db" +XIAOGUO_API = "http://node122:18003/v1/chat/completions" # fallback, /etc/hosts resolves to LAN or EasyTier + +def _get_xiaoguo_url(): + try: + from mo_config import get_config + return get_config().xiaoguo_api_url + except Exception: + return XIAOGUO_API +XIAOGUO_MODEL = "Qwen3.6-27B-MTPLX-Optimized-Speed" +MAX_ARTICLES = 5 # 每次最多分析篇数(实测5篇12s) + + +def clean_proxy(): + for k in ['http_proxy', 'https_proxy', 'HTTP_PROXY', 'HTTPS_PROXY']: + os.environ.pop(k, None) + + +def get_conn(): + import sqlite3 + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + return conn + + +def search_akshare_news(code, max_results=3): + """用 akshare 搜个股新闻(含全文)""" + articles = [] + if not HAS_AKSHARE: + return articles + try: + clean_proxy() + df = ak.stock_news_em(symbol=code) + for _, r in df.head(max_results).iterrows(): + title = r.get('新闻标题', '') + content = r.get('新闻内容', '') + if title and len(title) > 5: + articles.append({ + "title": title, + "content": content, + "url": r.get('新闻链接', '') + }) + except: + pass + return articles + + +def extract_json(text): + """从回复中提取JSON数组或对象""" + # 先找 ```json ... ``` 代码块 + m = re.search(r'```(?:json)?\s*(\[[\s\S]*?\]|\{[\s\S]*?\})\s*```', text) + if m: + try: + return json.loads(m.group(1)) + except: + pass + # 找第一个 [ 或 { 到最后一个 ] 或 } + for start_ch, end_ch in [('[', ']'), ('{', '}')]: + s = text.find(start_ch) + if s >= 0: + depth = 0 + for i in range(s, len(text)): + if text[i] == start_ch: + depth += 1 + elif text[i] == end_ch: + depth -= 1 + if depth == 0: + try: + return json.loads(text[s:i+1]) + except: + break + return None + + +def call_xiaoguo(articles): + """调小果LLM:给摘要+情感""" + lines = [] + for a in articles: + title = re.sub(r'\b\d{6}\b', '', a['title']).strip() + title = re.sub(r'\s+', ' ', title) + content = a.get('content') or '' + # 给正文加标点分隔(akshare正文无标点,模型推理会卡) + if content and not any(c in content for c in '。,!?;'): + content = '。'.join([content[i:i+20] for i in range(0, len(content), 20)]) + if content: + lines.append(f"{len(lines)+1}. {title}\n {content}") + else: + lines.append(f"{len(lines)+1}. {title}") + prompt = "\n".join(lines) + "\n\n逐篇分析:给摘要(概括核心内容)和情感(positive/negative/neutral)。JSON数组。" + + payload = json.dumps({ + "model": XIAOGUO_MODEL, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.1, + "max_tokens": 2048, + }).encode() + + clean_proxy() + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + req = urllib.request.Request( + _get_xiaoguo_url(), data=payload, + headers={"Content-Type": "application/json"}, method="POST" + ) + try: + resp = opener.open(req, timeout=60) + data = json.loads(resp.read()) + content = data["choices"][0]["message"]["content"] + result = extract_json(content) + if isinstance(result, list): + return result + except Exception as e: + print(f" 小果调用失败: {e}", flush=True) + return None + + +def translate_sentiment(s): + """将英文情感转中文""" + m = {"positive": "利好", "negative": "利空", "neutral": "中性"} + return m.get(s.lower() if isinstance(s, str) else "", s) + + +def fallback_classify(batch): + """关键词降级分类(小果API不可用时)""" + positive_kw = ['突破', '增长', '利好', '加单', '订单', '放量', '新高', '获批', '量产', + '超预期', '投产', '融资', '增持', '回购', '降息', '减税', '补贴', + '国产替代', '自主可控', '准入'] + negative_kw = ['管制', '限制', '制裁', '利空', '减持', '抛售', '下跌', '跌停', + '风险', '违约', '调查', '暂停', '取消', '下滑', '亏损', '裁员', + '诉讼', '退市', '做空', '关税', '禁令'] + + for a in batch: + text = a['title'] + (a.get('content') or '') + pos = sum(1 for kw in positive_kw if kw in text) + neg = sum(1 for kw in negative_kw if kw in text) + if pos > neg: + a['sentiment'] = '利好' + elif neg > pos: + a['sentiment'] = '利空' + else: + a['sentiment'] = '中性' + a['summary'] = a['title'][:80] + return batch + + +def main(): + conn = get_conn() + signals = conn.execute( + "SELECT * FROM sector_signals WHERE processed = 0 ORDER BY severity DESC, id ASC LIMIT 1" + ).fetchall() + + if not signals: + print("无未处理的信号", flush=True) + conn.close() + return + + signal = dict(signals[0]) + sector = signal["sector"] + related = json.loads(signal["related_stocks"] or "[]") + holdings = json.loads(signal["holdings_in_sector"] or "[]") + watchlist = json.loads(signal["watchlist_in_sector"] or "[]") + + print(f"处理信号: [{signal['severity']}] {signal['signal_type']} {sector}", flush=True) + + codes = {} + for item in related + holdings + watchlist: + if item.get("code"): + codes[item["code"]] = item.get("name", "") + + members = conn.execute( + "SELECT s.code, s.name FROM stocks s JOIN stock_sectors ss ON s.code=ss.code WHERE ss.sector_name=? LIMIT 5", + (sector,) + ).fetchall() + for m in members: + if m["code"] not in codes: + codes[m["code"]] = m["name"] + + all_articles = [] + for code, name in codes.items(): + arts = search_akshare_news(code, 3) + for a in arts: + if a["title"] not in [x["title"] for x in all_articles]: + all_articles.append(a) + print(f" 搜 {name}({code}): {len(arts)} 篇", flush=True) + + if not all_articles: + print(" 未搜到新闻", flush=True) + conn.execute("UPDATE sector_signals SET processed=1 WHERE id=?", (signal["id"],)) + conn.commit() + conn.close() + return + + # 只取前5篇,跳过含有表格数据的脏内容 + filtered = [] + for a in all_articles: + c = a.get('content', '') or '' + if any(kw in c for kw in ['主力资金', '资金净流入', '代码', '简称']): + continue + filtered.append(a) + if len(filtered) >= MAX_ARTICLES: + break + batch = filtered[:MAX_ARTICLES] + print(f" 共{len(all_articles)}篇,送小果分析{len(batch)}篇", flush=True) + + results = call_xiaoguo(batch) + if not results: + print(" 小果API不可用,降级到关键词分类", flush=True) + fallback_classify(batch) + results = None # batch already has sentiment/summary set + + if results and isinstance(results, list): + # 小果LLM返回结果,按索引匹配 + for i, r in enumerate(results): + if i < len(batch): + batch[i]["sentiment"] = translate_sentiment(r.get("sentiment", r.get("情感", ""))) + batch[i]["summary"] = r.get("summary", r.get("摘要", "")) + else: + break + + # 汇总情感 + sentiments = [a.get("sentiment", "中性") for a in batch if a.get("sentiment")] + pos = sentiments.count("利好") + neg = sentiments.count("利空") + overall = "利好" if pos > neg * 1.5 else "利空" if neg > pos * 1.5 else "中性" + summaries = [a.get("summary", "") for a in batch if a.get("summary")] + combined = f"{sector}板块信号:{'|'.join(summaries[:3])}。总体{overall}。" + + searched_names = list(set(codes.values())) + conn.execute( + "INSERT INTO signal_news (signal_id, sector, overall_sentiment, summary, key_articles, searched_stocks) VALUES (?, ?, ?, ?, ?, ?)", + (signal["id"], sector, overall, combined, json.dumps(batch, ensure_ascii=False), json.dumps(searched_names, ensure_ascii=False)) + ) + conn.execute("UPDATE sector_signals SET processed=1 WHERE id=?", (signal["id"],)) + conn.commit() + + print(f" 完成: {overall} — {combined[:100]}", flush=True) + conn.close() + + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/xiaoguo_scanner.py b/deploy/profile-scripts/xiaoguo_scanner.py new file mode 100644 index 00000000..62400225 --- /dev/null +++ b/deploy/profile-scripts/xiaoguo_scanner.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 +"""xiaoguo_scanner.py — 小果独立扫描线 + +每5分钟跑一轮,全市场排行榜主动发现潜在标的。 +不依赖 trend_detector 信号,独立产出到 signal_news。 +""" + +import json, os, re, time, urllib.request +from pathlib import Path +from datetime import datetime + +try: + import akshare as ak + HAS_AKSHARE = True +except ImportError: + HAS_AKSHARE = False + +DATA_DIR = Path("/home/hmo/MoFin/data") +DB_PATH = DATA_DIR / "mofin.db" +XIAOGUO_API = "http://node122:18003/v1/chat/completions" +XIAOGUO_MODEL = "Qwen3.6-27B-MTPLX-Optimized-Speed" +SCAN_INTERVAL = 3600 # 同一只股1小时内不重复搜 +MAX_STOCKS_PER_RUN = 15 +ARTICLES_PER_STOCK = 3 + +# 同花顺看多榜(挖掘潜力股) +BULLISH_BOARDS = [ + ("创新高", "stock_rank_cxg_ths"), + ("量价齐升", "stock_rank_ljqs_ths"), + ("向上突破", "stock_rank_xstp_ths"), + ("连续上涨", "stock_rank_lxsz_ths"), + ("持续放量", "stock_rank_cxfl_ths"), + ("险资举牌", "stock_rank_xzjp_ths"), +] + +# 同花顺看空榜(持仓风险预警) +BEARISH_BOARDS = [ + ("创新低", "stock_rank_cxd_ths"), + ("持续缩量", "stock_rank_cxsl_ths"), + ("量价齐跌", "stock_rank_ljqd_ths"), + ("连续下跌", "stock_rank_lxxd_ths"), + ("向下突破", "stock_rank_xxtp_ths"), +] + +ALL_BOARDS = BULLISH_BOARDS + BEARISH_BOARDS +BULLISH_COUNT = len(BULLISH_BOARDS) + +# 行业领涨股扫描(不轮换,每轮都跑) +# 从 market.json 读热门行业领涨股 +SECTOR_HOT_THRESHOLD = 2.5 # 板块涨幅>2.5%时捞它的领涨股 + + +def clean_proxy(): + for k in ['http_proxy','https_proxy','HTTP_PROXY','HTTPS_PROXY']: + os.environ.pop(k, None) + + +def get_conn(): + import sqlite3 + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + return conn + + +def fetch_hot_board(): + """东方财富热榜""" + if not HAS_AKSHARE: + return [] + try: + clean_proxy() + df = ak.stock_hot_rank_em() + if df is None or len(df) == 0: + return [] + # 东方财富热榜列名变化较大,自动检测 + cols = list(df.columns) + code_candidates = [c for c in cols if any(x in c for x in ['代码', 'code', 'CODE'])] + name_candidates = [c for c in cols if any(x in c for x in ['简称', '名称', 'name', 'NAME'])] + code_col = code_candidates[0] if code_candidates else cols[1] + name_col = name_candidates[0] if name_candidates else cols[2] + return [{"code": str(r[code_col]).zfill(6).strip(), "name": str(r[name_col]).strip(), + "rank": i+1, "source": "东方财富热榜"} + for i, (_, r) in enumerate(df.head(30).iterrows())] + except Exception: + pass + return [] + + +def fetch_rotating_board(): + """同花顺轮流榜(每轮一个),返回 (股票列表, 是否看多)""" + if not HAS_AKSHARE: + return [], True + conn = get_conn() + row = conn.execute("SELECT val FROM state_meta WHERE key='xiaoguo_board_round'").fetchone() + round_idx = (int(row[0]) if row else 0) % len(ALL_BOARDS) + conn.execute("INSERT OR REPLACE INTO state_meta (key, val) VALUES ('xiaoguo_board_round', ?)", + (str((round_idx + 1) % len(ALL_BOARDS)),)) + conn.commit() + conn.close() + + board_name, func_name = ALL_BOARDS[round_idx] + is_bullish = round_idx < BULLISH_COUNT + print(f" 同花顺榜: {board_name} {'📈看多' if is_bullish else '📉看空'}", flush=True) + + try: + clean_proxy() + fn = getattr(ak, func_name) + df = fn() + cols = list(df.columns) + code_col = [c for c in cols if '代码' in c][0] + name_col = [c for c in cols if '简称' in c or '名称' in c][0] + return [{"code": str(r[code_col]).zfill(6), "name": str(r[name_col]).strip(), + "source": f"同花顺{board_name}"} + for _, r in df.head(15).iterrows()], is_bullish + except Exception as e: + print(f" {board_name}失败: {e}", flush=True) + return [], is_bullish + + +def fetch_sector_leaders(): + """从 market.json 读热门行业领涨股""" + mkt_path = DATA_DIR / "market.json" + if not mkt_path.exists(): + return [] + try: + mkt = json.loads(mkt_path.read_text()) + sectors = mkt.get("sectors", []) + + # 代码→名称映射(优先用本地缓存,避免每次跑都调akshare) + cache_path = DATA_DIR / "stock_name_code_cache.json" + name_to_code = {} + if cache_path.exists(): + name_to_code = json.loads(cache_path.read_text()) + if not name_to_code: + try: + import akshare as ak + df = ak.stock_info_a_code_name() + for _, r in df.iterrows(): + name_to_code[r["name"].strip()] = r["code"] + cache_path.write_text(json.dumps(name_to_code, ensure_ascii=False)) + print(f" 名称代码映射: {len(name_to_code)}只已缓存", flush=True) + except Exception as e: + print(f" 名称代码映射加载失败: {e}", flush=True) + + leaders = [] + seen = set() + for s in sectors: + chg = s.get("change", 0) or 0 + lead_name = s.get("lead_stock", "") + if chg < SECTOR_HOT_THRESHOLD or not lead_name or lead_name in seen: + continue + seen.add(lead_name) + code = name_to_code.get(lead_name, "") + if not code: + continue + leaders.append({ + "code": code, + "name": lead_name, + "source": f"行业领涨-{s['name']}+{chg:+.1f}%", + }) + return leaders + except Exception as e: + print(f" 行业领涨获取失败: {e}", flush=True) + return [] + + +def get_scanned_codes(conn): + """取1小时内已扫描过的代码""" + rows = conn.execute( + "SELECT code FROM xiaoguo_scan_tracker WHERE datetime(last_scanned_at) > datetime('now', '-1 hour')" + ).fetchall() + return {r[0] for r in rows} + + +def mark_scanned(conn, code, name, found): + conn.execute( + "INSERT OR REPLACE INTO xiaoguo_scan_tracker (code, name, last_scanned_at, found_count) " + "VALUES (?, ?, datetime('now','localtime'), COALESCE((SELECT found_count FROM xiaoguo_scan_tracker WHERE code=?),0)+?)", + (code, name, code, 1 if found else 0) + ) + conn.commit() + + +def search_news(code, max_results=3): + """akshare搜个股新闻""" + articles = [] + if not HAS_AKSHARE: + return articles + try: + clean_proxy() + df = ak.stock_news_em(symbol=code) + for _, r in df.head(max_results).iterrows(): + title = r.get('新闻标题', '') + content = r.get('新闻内容', '') + if title and len(title) > 5: + articles.append({"title": title, "content": content}) + except: + pass + return articles + + +def check_stock(code, name, articles): + """小果LLM判断这只股票是否有料(一次调用判断所有文章)""" + if not articles: + return None, None + + lines = [f"{i+1}. {a['title']}" for i, a in enumerate(articles[:3])] + prompt = f"""以下是最新关于{name}({code})的新闻标题。 +该股今日上了人气热榜/技术榜单。 + +新闻: +{chr(10).join(lines)} + +这只股上榜是否跟这些新闻有关?有关的话是利好还是利空? +回答格式:有关(利好|利空|中性) 或 无关 +回答:""" + + payload = json.dumps({ + "model": XIAOGUO_MODEL, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.1, "max_tokens": 100, + }).encode() + + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + req = urllib.request.Request(XIAOGUO_API, data=payload, + headers={"Content-Type": "application/json"}, method="POST") + try: + resp = opener.open(req, timeout=30) + reply = json.loads(resp.read())["choices"][0]["message"]["content"] + if "有关" in reply or "利好" in reply or "利空" in reply: + for s in ["利好", "利空", "中性"]: + if s in reply: + return True, s + return True, "中性" + except Exception as e: + # LLM不可达 → 降级:标记为unknown,不阻塞扫描流程 + print(f" ⚠️ 小果LLM不可达({str(e)[:30]}),降级为unknown", flush=True) + return True, "unknown" + return None, None + + +def main(): + start_time = time.time() + conn = get_conn() + + # 1. 拉板 + hot = fetch_hot_board() + rotating, is_bullish = fetch_rotating_board() + leaders = fetch_sector_leaders() + elapsed = time.time() - start_time + print(f"榜单: 东方财富{len(hot)}只, 同花顺{len(rotating)}只, 行业领涨{len(leaders)}只 ({elapsed:.0f}s)", flush=True) + + if not hot and not rotating and not leaders: + conn.close() + return + + # 加载持仓代码(用于看空榜比对) + holdings = set() + if not is_bullish: + cur = conn.execute("SELECT code FROM holdings WHERE is_active=1") + holdings = {r[0].lstrip("0") for r in cur.fetchall()} + # 也查自选 + cur2 = conn.execute("SELECT code FROM watchlist_stocks") + holdings.update({r[0].lstrip("0") for r in cur2.fetchall()}) + + # 2. 合并去重 + 看空榜只保留持仓股 + all_stocks = {} + # 行业领涨优先(热门板块龙头) + for s in (leaders if is_bullish else []) + hot + rotating: + code = s["code"] + code_stripped = code.lstrip("0") + if not is_bullish: + # 看空榜:只处理持仓/自选中的股票 + if code_stripped not in holdings: + continue + if code not in all_stocks: + all_stocks[code] = {"code": code, "name": s["name"], "sources": []} + all_stocks[code]["sources"].append(s["source"]) + + if not all_stocks: + if is_bullish: + print("榜单为空", flush=True) + else: + print(f"看空榜无持仓股命中", flush=True) + conn.close() + return + + # 3. 排除已搜索过的(看空榜不排除——每次都要检查风险) + scanned = get_scanned_codes(conn) + if is_bullish: + candidates = [s for code, s in all_stocks.items() + if code not in scanned and len(code) == 6 and code.isdigit()][:MAX_STOCKS_PER_RUN] + else: + # 看空榜:不限数量,全检 + candidates = [s for code, s in all_stocks.items() + if len(code) == 6 and code.isdigit()] + + if not candidates: + print(f"无新候选(已有 {len(scanned)} 只已扫描)", flush=True) + conn.close() + return + + print(f"待扫描: {len(candidates)} 只({'看多' if is_bullish else '看空'}榜)", flush=True) + + # 4. 逐只处理 + found_any = False + for stock in candidates: + code, name = stock["code"], stock["name"] + sources = "|".join(stock["sources"]) + + articles = search_news(code, ARTICLES_PER_STOCK) if is_bullish else [] + if not articles and is_bullish: + mark_scanned(conn, code, name, False) + continue + + has_found = False + if is_bullish: + ok, sentiment = check_stock(code, name, articles) + if ok: + has_found = True + found_any = True + conn.execute( + "INSERT INTO signal_news (signal_id, sector, overall_sentiment, summary, key_articles, searched_stocks, source) " + "VALUES (NULL, ?, ?, ?, ?, ?, 'xiaoguo')", + (f"扫描-{name}", sentiment, f"[{sources}] {articles[0]['title'][:80]}", + json.dumps([{"title": a["title"], "sentiment": sentiment, "summary": (a.get("content") or "")[:100]} for a in articles[:3]], ensure_ascii=False), + json.dumps([code, name], ensure_ascii=False)) + ) + print(f" ✅ {name}({code}) [{sources}] {sentiment}: {articles[0]['title'][:50]}", flush=True) + mark_scanned(conn, code, name, has_found) + else: + # 看空榜:直接写入风险信号 + has_found = True + found_any = True + conn.execute( + "INSERT INTO signal_news (signal_id, sector, overall_sentiment, summary, key_articles, searched_stocks, source) " + "VALUES (NULL, ?, ?, ?, ?, ?, 'xiaoguo_risk')", + (f"预警-{name}", "偏空", f"[{sources}] {name}登上{sources}榜,需关注持仓风险", + json.dumps([{"title": name, "sentiment": "偏空", "summary": f"上榜{sources}"}], ensure_ascii=False), + json.dumps([code, name], ensure_ascii=False)) + ) + print(f" ⚠️ {name}({code}) [{sources}] 持仓风险信号", flush=True) + mark_scanned(conn, code, name, has_found) + + total_time = time.time() - start_time + print(f"完成: {len(candidates)}只{'看多' if is_bullish else '看空'}扫描, {'有发现' if found_any else '无发现'} ({total_time:.0f}s)", flush=True) + conn.close() + + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/xiaoguo_sentiment_bridge.py b/deploy/profile-scripts/xiaoguo_sentiment_bridge.py new file mode 100644 index 00000000..f896e3f7 --- /dev/null +++ b/deploy/profile-scripts/xiaoguo_sentiment_bridge.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""xiaoguo_sentiment_bridge.py — 小果情感分析数据 → 策略引擎桥接 + +小果情感分析 cron (3da7ad4ff3a6) 每日16:00运行,输出到指定格式。 +本脚本读取小果的输出,格式化为 strategy_lifecycle 可读取的格式, +写入 /home/hmo/web-dashboard/data/xiaoguo_sentiment.json + +格式: +{ + "updated_at": "2026-06-18T16:00:00", + "stocks": { + "00700": { + "name": "腾讯控股", + "sentiment": "positive" | "negative" | "neutral", + "confidence": 0.85, + "keywords": ["游戏", "增长"], + "summary": "腾讯游戏业务Q2增长超预期", + "source": "xiaoguo" + } + } +} +""" + +import json +import os +import sys +from datetime import datetime + +OUTPUT_PATH = "/home/hmo/web-dashboard/data/xiaoguo_sentiment.json" +XIAOGUO_INSIGHTS_PATH = "/home/hmo/web-dashboard/data/xiaoguo_insights.json" + + +def load_xiaoguo_output(): + """读取小果情感分析的最新输出""" + try: + if os.path.exists(XIAOGUO_INSIGHTS_PATH): + with open(XIAOGUO_INSIGHTS_PATH) as f: + data = json.load(f) + return data + except Exception: + pass + return None + + +def main(): + data = load_xiaoguo_output() + if data is None: + data = {"updated_at": datetime.now().isoformat(), "stocks": {}} + else: + # 转换为 {code: {sentiment, confidence, keywords, summary}} 格式 + formatted = {"updated_at": datetime.now().isoformat(), "stocks": {}} + for item in data.get("analyses", []): + code = item.get("code", "") + if code: + formatted["stocks"][code] = { + "name": item.get("name", ""), + "sentiment": item.get("sentiment", "neutral"), + "confidence": item.get("confidence", 0), + "keywords": item.get("keywords", []), + "summary": item.get("brief", ""), + "source": "xiaoguo", + } + data = formatted + + os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True) + with open(OUTPUT_PATH, "w") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + stock_count = len(data.get("stocks", {})) + print(f"[xiaoguo_bridge] {stock_count} stocks synced", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/xiaoguo_signal_consumer.py b/deploy/profile-scripts/xiaoguo_signal_consumer.py new file mode 100644 index 00000000..6d71c7d3 --- /dev/null +++ b/deploy/profile-scripts/xiaoguo_signal_consumer.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +"""xiaoguo_signal_consumer.py — 知微消费小果扫描信号 + +盘中每30分钟运行,读取 signal_news 表中未处理的 xiaoguo 信号, +做五维快速评估后决定:加自选 / 关注 / 跳过。 + +管道位置: + xiaoguo_scanner (每5分) → signal_news → 本脚本 → 知微分析报告 + +no_agent模式:有发现→输出,无→静默 +""" + +import json, os, sqlite3, sys, time, urllib.request +from pathlib import Path +from datetime import datetime + +# 确保 MoFin 根目录在模块搜索路径中(兼容 cron 环境) +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from mo_data import read_watchlist +from mofin_db import write_watchlist_stock + +BASE = Path("/home/hmo/MoFin") +DATA = BASE / "data" +DB_PATH = DATA / "mofin.db" + +SIGNAL_MAX_AGE_HOURS = 4 # 只处理4小时内产生的信号 + + +def clean_proxy(): + for k in ['http_proxy','https_proxy','HTTP_PROXY','HTTPS_PROXY']: + os.environ.pop(k, None) + + +def fetch_quote(code): + """拉行情。DB 优先,腾讯 API fallback""" + # DB 优先 + try: + from mofin_db import get_price_from_db + p, chg = get_price_from_db(code) + if p: + return {"name":"", "code":code, "price":p, "change_pct":chg or 0} + except: + pass + # Fallback: 腾讯实时行情 API + try: + url = f"http://qt.gtimg.cn/q={code}" + resp = urllib.request.urlopen(url, timeout=10).read().decode("gbk") + parts = resp.split("~") + if len(parts) > 32: + name = parts[1] + price = float(parts[3]) if parts[3] else None + chg_pct = float(parts[32]) if parts[32] else 0 + if price: + return {"name": name, "code": code, "price": price, "change_pct": chg_pct, "pe": 0, "turnover": 0} + return {"code": code, "error": "取价失败"} + except Exception as e: + return {"code": code, "error": str(e)[:60]} + + +def is_in_portfolio(conn, code): + """检查是否已在持仓或自选中""" + code_stripped = code.lstrip("0") + cur = conn.execute("SELECT COUNT(*) FROM holdings WHERE code=? AND is_active=1", (code_stripped,)) + if cur.fetchone()[0] > 0: + return "holdings" + cur = conn.execute("SELECT COUNT(*) FROM watchlist_stocks WHERE code=?", (code,)) + if cur.fetchone()[0] > 0: + return "watchlist" + # 也检查 watchlist.json + try: + wl = read_watchlist() + for s in wl.get("stocks", []): + if s.get("code") == code or s.get("code", "").lstrip("0") == code_stripped: + return "watchlist" + except: + pass + return None + + +def quick_assess(quote): + """五维快速评估(自动版)""" + score = 0 + reasons = [] + + # 大盘环境,从DB读(回退JSON) + try: + import sqlite3 + conn = sqlite3.connect(str(DB_PATH)) + row = conn.execute( + "SELECT indices FROM macro_context_log WHERE has_valid_data=1 ORDER BY created_at DESC LIMIT 1" + ).fetchone() + conn.close() + if row and row[0]: + mc = json.loads(row[0]) + else: + raise ValueError + sh = 0 + for k, v in mc.items(): + if "上证" in k: + sh = v.get("change_pct", 0) + break + if sh > 0.5: + score += 1 + reasons.append(f"大盘+{sh:.1f}%偏强") + elif sh < -0.5: + score -= 1 + reasons.append(f"大盘{sh:.1f}%偏弱") + except Exception: + try: + mc = json.loads((DATA / "macro_context.json").read_text()) + sh = mc.get("shanghai", {}).get("change_pct", 0) + if sh > 0.5: + score += 1 + reasons.append(f"大盘+{sh:.1f}%偏强") + elif sh < -0.5: + score -= 1 + reasons.append(f"大盘{sh:.1f}%偏弱") + except: + pass + + # 技术面:涨跌幅 + chg = quote.get("change_pct", 0) + if chg > 3: + score += 1 + reasons.append(f"涨幅+{chg:.1f}%偏强") + elif chg < -3: + score -= 1 + reasons.append(f"跌幅{chg:.1f}%偏弱") + else: + score += 0.5 + reasons.append(f"走势平稳{chg:+.1f}%") + + # 基本面:PE + pe = quote.get("pe", 0) + if 5 < pe < 40: + score += 1 + reasons.append(f"PE={pe:.0f}合理") + elif pe <= 0: + score -= 0.5 + reasons.append("PE为负") + elif pe > 100: + score -= 0.5 + reasons.append(f"PE={pe:.0f}偏高") + + # 量能 + turn = quote.get("turnover", 0) + if turn > 5: + score += 0.5 + reasons.append(f"换手{turn:.1f}%活跃") + elif turn < 0.5: + score -= 0.3 + reasons.append(f"换手{turn:.1f}%偏低") + + return score, reasons + + +def evaluate_and_act(signal, quote): + """评估信号并决定操作""" + status_in = is_in_portfolio(get_conn(), signal.get("code", "")) + if status_in: + return f"已在{status_in}中,跳过", None + + score, reasons = quick_assess(quote) + + if score >= 1.5: + action = "watchlist" + summary = f"加自选: {' | '.join(reasons)}" + elif score >= 0: + action = "monitor" + summary = f"关注: {' | '.join(reasons)}" + else: + action = "skip" + summary = f"跳过(评分{score:.1f}): {' | '.join(reasons)}" + + return summary, action + + +def get_conn(): + import sqlite3 + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + return conn + + +def mark_processed(conn, signal_id): + conn.execute("UPDATE signal_news SET processed=1 WHERE id=?", (signal_id,)) + conn.commit() + + +def main(): + clean_proxy() + start = time.time() + today = datetime.now().strftime("%Y-%m-%d") + + conn = get_conn() + + # 读未处理 xiaoguo 信号(SIGNAL_MAX_AGE_HOURS 以内) + rows = conn.execute( + "SELECT id, sector, overall_sentiment, summary, key_articles, searched_stocks, source " + "FROM signal_news " + "WHERE source LIKE 'xiaoguo%' AND (processed=0 OR processed IS NULL) " + f"AND created_at > datetime('now', '-{SIGNAL_MAX_AGE_HOURS} hours') " + "ORDER BY created_at DESC LIMIT 20" + ).fetchall() + + if not rows: + # 标记过期信号为已处理(超出时效边界) + old = conn.execute(f"SELECT COUNT(*) FROM signal_news WHERE source LIKE 'xiaoguo%' AND (processed=0 OR processed IS NULL) AND created_at <= datetime('now', '-{SIGNAL_MAX_AGE_HOURS} hours')").fetchone()[0] + if old: + conn.execute(f"UPDATE signal_news SET processed=1 WHERE source LIKE 'xiaoguo%' AND (processed=0 OR processed IS NULL) AND created_at <= datetime('now', '-{SIGNAL_MAX_AGE_HOURS} hours')") + conn.commit() + print(f"[SILENT] 清理 {old} 条过期信号(>{SIGNAL_MAX_AGE_HOURS}h)") + else: + print("[SILENT] 今日无未处理小果信号") + conn.close() + return + + # 尝试从 searched_stocks 提取股票代码 + results = [] + for r in rows: + try: + searched = json.loads(r["searched_stocks"]) if r["searched_stocks"] else [] + except: + searched = [] + + # 从 sector 字段取股票名 + sector_name = r["sector"] or "" + + # 尝试提取代码 + codes_found = [] + for s in searched: + # searched_stocks 存的是股票名称列表 + # 尝试从 summary 里找代码 + import re + codes = re.findall(r'\d{6}', r["summary"] or "") + codes_found.extend(codes) + + if not codes_found: + # 没有直接代码,用名称去查 + mark_processed(conn, r["id"]) + continue + + code = codes_found[0] + quote = fetch_quote(code) + + summary, action = evaluate_and_act(dict(r), quote) + + if action == "watchlist": + # 加自选 + results.append(f"✅ {sector_name}({code}): {summary}") + # 写入 watchlist_stocks 表(DB) + try: + wl = read_watchlist() + wl.setdefault("stocks", []) + # 检查是否已在 + existing = [s for s in wl["stocks"] if s.get("code") == code] + if not existing: + new_stock = { + "code": code, + "name": quote.get("name", sector_name), + "price": quote.get("price", 0), + "status": "watching", + "source": "xiaoguo_scanner", + "added_at": today, + } + wl["stocks"].append(new_stock) + # DB 写入(watchlist_stocks) + try: + conn2 = get_conn() + new_stock["currency"] = "CNY" + write_watchlist_stock(conn2, new_stock) + conn2.close() + except Exception: + pass + + except: + pass + elif action == "monitor": + results.append(f"🔄 {sector_name}({code}): {summary}") + else: + results.append(f"⏭️ {sector_name}({code}): {summary}") + + mark_processed(conn, r["id"]) + + conn.close() + + elapsed = time.time() - start + if results: + print(f"小果信号消费 | {today} | {len(results)}条处理 ({elapsed:.0f}s)") + for r in results: + print(f" {r}") + else: + print("[SILENT] 小果信号消费结束") + + +if __name__ == "__main__": + main() diff --git a/deploy/profile-scripts/xmpp_agent_core.py b/deploy/profile-scripts/xmpp_agent_core.py new file mode 100644 index 00000000..e5e6e8a4 --- /dev/null +++ b/deploy/profile-scripts/xmpp_agent_core.py @@ -0,0 +1,385 @@ +#!/usr/bin/env python3 +""" +Core XMPP Agent — shared logic for zhiwei / mohe / xxm bots. +Imports by xmpp_zhiwei_bot.py / xmpp_mohe_bot.py with --agent flag. +""" +import os, sys, json, time, logging, threading, traceback +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import urlparse, parse_qs +from hashlib import md5 + +import slixmpp +from slixmpp import JID +import asyncio + +# ── Per-agent configuration ── +PER_AGENT = { + "mohe": { + "jid": "mohe@yoin.fun", + "password": "hermes123", + "nick": "mohe", + "http_port": 5808, + "gateway_url": "http://localhost:8642/v1/chat/completions", + "gateway_api_key": "hermes123", + "session_id": "xmpp-mohe-v2", + "name_cn": "莫荷", + "mention": "@mohe/@莫荷", + }, + "zhiwei": { + "jid": "zhiwei@yoin.fun", + "password": "2nw4psra", + "nick": "zhiwei", + "http_port": 5805, + "gateway_url": "http://localhost:8643/v1/chat/completions", + "gateway_api_key": "hermes123", + "session_id": "xmpp-zhiwei-v3", + "name_cn": "知微", + "mention": "@知微/zhiwei", + }, +} +_DEFAULT_AGENT = "mohe" + +# ── Module-level config (populated by _apply_config after agent detection) ── +AGENT_NAME = "" +XMPP_JID = "" +XMPP_PASSWORD = "" +MUC_ROOM = "coregroup@conference.yoin.fun" +MUC_NICK = "" +AGENT_MENTION = "" +HTTP_PORT = 5808 +AGENT_NICK = "" +ACK_DELAY = 15 +GATEWAY_URL = "" +GATEWAY_API_KEY = "" +GATEWAY_SESSION_ID = "" +GATEWAY_DEADLINE_SECONDS = 180 +CALL_HERMES_TIMEOUT = 180 +FALLBACK_REPLY = "请稍等,我在处理..." + +# ── 全局队列 ── +_outbound_queue = [] +_outbound_lock = threading.Lock() +_inbound_queue = [] +_inbound_lock = threading.Lock() + +RECENT_SENT_MAX = 50 + + +def _rs(p): + """Parse agent arg from sys.argv, returns agent name string.""" + agent = _DEFAULT_AGENT + skip_next = False + for i, a in enumerate(sys.argv[1:]): + if skip_next: + skip_next = False + continue + if a.startswith('--agent='): + agent = a.split('=', 1)[1] + elif a == '--agent' and i + 1 < len(sys.argv[1:]): + agent = sys.argv[i + 2] + skip_next = True + return agent + + +agent, is_mohe = _rs(None), None + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s %(levelname)s %(message)s', + stream=sys.stdout, +) +log = logging.getLogger('xmpp_agent') + + +def _apply_config(agent_name): + """Set module-level config variables from PER_AGENT dict + env overrides.""" + global AGENT_NAME, XMPP_JID, XMPP_PASSWORD, MUC_NICK, AGENT_MENTION + global HTTP_PORT, AGENT_NICK, GATEWAY_URL, GATEWAY_API_KEY, GATEWAY_SESSION_ID + cfg = PER_AGENT.get(agent_name, PER_AGENT.get(_DEFAULT_AGENT, {})) + AGENT_NAME = agent_name + XMPP_JID = os.environ.get('XMPP_JID', cfg.get('jid', '')) + XMPP_PASSWORD = os.environ.get('XMPP_PASSWORD', cfg.get('password', '')) + MUC_NICK = os.environ.get('MUC_NICK', cfg.get('nick', agent_name)) + AGENT_MENTION = os.environ.get('AGENT_MENTION', cfg.get('mention', '')) + HTTP_PORT = int(os.environ.get('HTTP_PORT', cfg.get('http_port', 5808))) + AGENT_NICK = os.environ.get('AGENT_NICK', cfg.get('nick', agent_name)) + GATEWAY_URL = os.environ.get('GATEWAY_URL', cfg.get('gateway_url', '')) + GATEWAY_API_KEY = os.environ.get('GATEWAY_API_KEY', cfg.get('gateway_api_key', '')) + GATEWAY_SESSION_ID = os.environ.get('GATEWAY_SESSION_ID', cfg.get('session_id', '')) + + +# ── Periodic ACK task ── +class AckManager: + def __init__(self): + self._active = {} + self._lock = threading.Lock() + + def start(self, session_id, to_jid, msg_body): + """Record an active LLM analysis and schedule the ACK.""" + with self._lock: + self._active[session_id] = { + 'to_jid': to_jid, + 'body': msg_body[:80], + 'started': time.time(), + 'acked': False, + } + + def ack(self, session_id): + with self._lock: + self._active.pop(session_id, None) + + def tick(self, bot): + now = time.time() + to_send = [] + with self._lock: + for sid, info in list(self._active.items()): + if not info['acked'] and now - info['started'] >= ACK_DELAY: + info['acked'] = True + to_send.append((info['to_jid'], FALLBACK_REPLY)) + for jid, msg in to_send: + try: + bot.send_message(mto=jid, mbody=msg, mtype='chat') + except Exception: + pass + + +ack_mgr = AckManager() + + +# ── Slixmpp Bot ── +class XmppAgent(slixmpp.ClientXMPP): + def __init__(self, jid, password, room, nick): + super().__init__(jid, password) + self._room = room + self._nick = nick + self._muc_joined = False + self._recent_sent = [] + self.add_event_handler('session_start', self.on_start) + self.add_event_handler('message', self.on_msg) + self.add_event_handler('disconnected', self.on_disconnect) + self.register_plugin('xep_0030') + self.register_plugin('xep_0045') + self.register_plugin('xep_0199') + + async def on_start(self, event): + self.send_presence() + await self.get_roster() + try: + await self.plugin['xep_0045'].join_muc(self._room, self._nick) + self._muc_joined = True + log.info(f"{AGENT_NAME} XMPP 就绪 (已加入 {self._room})") + except Exception as e: + log.error(f"{AGENT_NAME} MUC加入失败: {e}") + + def on_disconnect(self, event): + self._muc_joined = False + log.info(f"{AGENT_NAME} XMPP 断开") + # 自动重连:slixmpp 1.15.0 没有 auto_reconnect 属性,需手动 + try: + self.reconnect(wait=5.0, reason="断线自动重连") + except Exception as e: + log.warning(f"{AGENT_NAME} 重连失败: {e}") + + def on_msg(self, msg): + if msg['type'] in ('chat', 'groupchat'): + body = str(msg['body']).strip() + if not body: + return + log.info(f"📩 收到: from={msg['from']} type={msg['type']} body={body[:60]}") + if ('[executor]' in body and 'gateway_zhiwei' in body): + log.info(f"过滤 executor 消息: {body[:80]}...") + return + sender = str(msg['from']) + msg_type = msg['type'] + for s in self._recent_sent: + if body[:50] in s or s in body[:50]: + return + if msg_type == 'groupchat': + nick = sender.split('/')[-1] if '/' in sender else '' + if nick == AGENT_NICK: + return + mention_list = AGENT_MENTION.replace('@', '').split('/') + is_for_me = any(m in body for m in ['@' + m for m in mention_list] + mention_list) + if not is_for_me: + return + with _inbound_lock: + _inbound_queue.append((sender, body, msg_type)) + + def mark_sent(self, body: str): + self._recent_sent.append(body[:80]) + if len(self._recent_sent) > RECENT_SENT_MAX * 2: + self._recent_sent = self._recent_sent[-RECENT_SENT_MAX:] + + +# ── Deliver loop ── +def _deliver_loop(bot): + global _outbound_queue + while True: + try: + items = [] + with _outbound_lock: + items, _outbound_queue = _outbound_queue[:], [] + for target, text, msg_type in items: + try: + async def _send(to, body, mtype): + bot.send_message(mto=to, mbody=body, mtype=mtype) + asyncio.run(_send(target, text, msg_type)) + bot.mark_sent(text) + log.info(f" 已发送到 {target}: {text[:80]}") + except Exception as e: + log.error(f" 发送到 {target} 失败: {e}") + time.sleep(0.3) + except Exception as e: + log.error(f"_deliver_loop error: {e}") + time.sleep(1) + + +# ── Inbound processing loop ── +def _inbound_loop(bot): + global _inbound_queue + while True: + try: + time.sleep(0.2) + with _inbound_lock: + if not _inbound_queue: + continue + sender, body, msg_type = _inbound_queue.pop(0) + log.info(f"🔄 inbound处理: {body[:40]}") + ack_mgr.start(body[:40], sender, body) + reply = call_hermes(body) + ack_mgr.ack(body[:40]) + if reply: + with _outbound_lock: + _outbound_queue.append((sender, reply, 'chat')) + except Exception as e: + log.error(f"_inbound_loop error: {e}") + time.sleep(1) + + +# ── HTTP SendHandler ── +class SendHandler(BaseHTTPRequestHandler): + def do_POST(self): + content_len = int(self.headers.get('Content-Length', 0)) + post_body = self.rfile.read(content_len) + try: + data = json.loads(post_body) + target = data.get('to', '') + text = data.get('body', '') + if not target or not text: + self.send_response(400) + self.end_headers() + self.wfile.write(b'{"error":"missing to or body"}') + return + if '修复失败' in text and 'gateway_zhiwei' in text: + self.send_response(200) + self.end_headers() + self.wfile.write(b'{"ok":true,"filtered":true}') + return + msg_type = data.get('type', 'chat') + if text: + _outbound_queue.append((target, text, msg_type)) + self.send_response(200) + self.end_headers() + self.wfile.write(b'{"ok":true}') + log.info(f"SendHandler: enqueued -> {target}: {text[:80]}") + except Exception as e: + traceback.print_exc() + self.send_response(500) + self.end_headers() + self.wfile.write(str({'error': str(e)}).encode()) + + def log_message(self, format, *args): + pass + + +def _run_http_server(): + server = HTTPServer(('127.0.0.1', HTTP_PORT), SendHandler) + log.info(f"HTTP SendHandler listening on 127.0.0.1:{HTTP_PORT}") + server.serve_forever() + + +# ── call_hermes ── +def call_hermes(content: str, session_id=None) -> str: + now_str = time.strftime("[%Y-%m-%d %H:%M %A]", time.localtime()) + timed_content = f"{now_str}\n{content}" + payload = { + 'model': 'hermes-agent', + 'messages': [ + {'role': 'user', 'content': timed_content}, + ], + 'stream': False, + } + if session_id is None: + session_id = GATEWAY_SESSION_ID + headers = { + 'Content-Type': 'application/json', + 'X-Hermes-Session-Id': session_id, + } + if GATEWAY_API_KEY: + headers['Authorization'] = f'Bearer {GATEWAY_API_KEY}' + + import urllib.request + data_bytes = json.dumps(payload).encode('utf-8') + req = urllib.request.Request(GATEWAY_URL, data=data_bytes, headers=headers, method='POST') + try: + resp = urllib.request.urlopen(req, timeout=CALL_HERMES_TIMEOUT) + resp_data = json.loads(resp.read().decode('utf-8')) + reply = '' + if 'choices' in resp_data and len(resp_data['choices']) > 0: + choice = resp_data['choices'][0] + if 'message' in choice and 'content' in choice['message']: + reply = choice['message']['content'] + elif 'delta' in choice and 'content' in choice['delta']: + reply = choice['delta']['content'] + if not reply: + reply = resp_data.get('response', '') + if not reply: + reply = str(resp_data) + return reply.strip() + except urllib.request.HTTPError as e: + err_body = e.read().decode('utf-8', errors='replace') + log.error(f"call_hermes HTTP {e.code}: {err_body[:200]}") + return '' + except Exception as e: + log.error(f"call_hermes error: {type(e).__name__}: {e}") + return '' + + +# ── ACK manager tick ── +def _ack_tick(bot): + while True: + try: + ack_mgr.tick(bot) + time.sleep(5) + except Exception: + time.sleep(5) + + +# ── Main ── +def main(): + global is_mohe + agent_name = _rs(None) + _apply_config(agent_name) + is_mohe = (agent_name == 'mohe') + + log.info(f"Starting XMPP Agent: {agent_name} (mohe={is_mohe})") + log.info(f" JID={XMPP_JID} HTTP_PORT={HTTP_PORT}") + log.info(f" GATEWAY={GATEWAY_URL}") + log.info(f" SESSION_ID={GATEWAY_SESSION_ID}") + + bot = XmppAgent(XMPP_JID, XMPP_PASSWORD, MUC_ROOM, MUC_NICK) + bot.connect(host='127.0.0.1', port=5222) + t_deliver = threading.Thread(target=_deliver_loop, args=(bot,), daemon=True) + t_deliver.start() + t_inbound = threading.Thread(target=_inbound_loop, args=(bot,), daemon=True) + t_inbound.start() + t_ack = threading.Thread(target=_ack_tick, args=(bot,), daemon=True) + t_ack.start() + t_http = threading.Thread(target=_run_http_server, daemon=True) + t_http.start() + + bot.loop.run_forever() + + +if __name__ == '__main__': + main() diff --git a/deploy/profile-scripts/xmpp_zhiwei_bot.py b/deploy/profile-scripts/xmpp_zhiwei_bot.py new file mode 100644 index 00000000..774812e5 --- /dev/null +++ b/deploy/profile-scripts/xmpp_zhiwei_bot.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Wrapper for xmpp_agent_core.py --agent zhiwei""" +import sys, os, signal + +PID_FILE = "/tmp/xmpp_zhiwei_bot.pid" + +if os.path.exists(PID_FILE): + with open(PID_FILE) as f: + try: + old_pid = int(f.read().strip()) + os.kill(old_pid, 0) + print(f"xmpp_zhiwei_bot already running (PID {old_pid}), exiting.") + sys.exit(0) + except (ValueError, ProcessLookupError): + pass + +with open(PID_FILE, "w") as f: + f.write(str(os.getpid())) + +sys.argv = [sys.argv[0], '--agent', 'zhiwei'] +try: + exec(open(os.path.join(os.path.dirname(__file__), 'xmpp_agent_core.py')).read()) +finally: + if os.path.exists(PID_FILE): + os.remove(PID_FILE)