From b3cfe5977033e03c180e1b98021e94297babe80c Mon Sep 17 00:00:00 2001 From: xxm Date: Fri, 21 Aug 2026 11:08:00 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E9=87=8D=E6=96=B0=E5=BD=92=E6=A1=A3me?= =?UTF-8?q?ta=5Fgrowth/meta=5Fwatchdog/ab=5Fresearch=5Fdaily(=E4=B8=8A?= =?UTF-8?q?=E6=AC=A1=E8=A2=ABdeploy=5Fguard=E5=9B=9E=E6=BB=9A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- archive/b-cleanup-20260820/stale_push_wlin.py | 1084 +++++++++++++++++ .../d-cleanup-20260820/ab_research_daily.py | 95 ++ archive/d-cleanup-20260820/meta_growth.py | 250 ++++ archive/d-cleanup-20260820/meta_watchdog.py | 121 ++ docs/cleansweep-20260820.md | 80 ++ 5 files changed, 1630 insertions(+) create mode 100644 archive/b-cleanup-20260820/stale_push_wlin.py create mode 100644 archive/d-cleanup-20260820/ab_research_daily.py create mode 100644 archive/d-cleanup-20260820/meta_growth.py create mode 100644 archive/d-cleanup-20260820/meta_watchdog.py create mode 100644 docs/cleansweep-20260820.md diff --git a/archive/b-cleanup-20260820/stale_push_wlin.py b/archive/b-cleanup-20260820/stale_push_wlin.py new file mode 100644 index 00000000..89be7ec2 --- /dev/null +++ b/archive/b-cleanup-20260820/stale_push_wlin.py @@ -0,0 +1,1084 @@ +#!/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 +from market_config import kline_symbol + +# 市场时段检查 +_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: + sym = kline_symbol(code) + if not sym: + return None + url = f"http://ifzq.gtimg.cn/appstock/app/fqkline/get?param={sym},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 not sym.startswith('hk') else 'day' + bars = data.get('data', {}).get(sym, {}).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) + + # 2026-08-13 温区自适应:当前温区激活策略集合(regime_weights.active) + _active_strats = None + try: + from regime_gate import _load_weights + _w = _load_weights() + if _w and _w.get("weights"): + _active_strats = {k for k, v in _w["weights"].items() if v.get("matched")} + except Exception: + pass # 无温区数据 → 不过滤(兼容旧逻辑) + + def _is_active(code): + """该自选股的策略是否在当前温区激活""" + if _active_strats is None: + return True + e = code_data.get(code, {}) + for key in ("tag", "version", "strategy_type", "decision_type"): + v = e.get(key) + if v and str(v) in _active_strats: + return True + # 不在激活集(或无标识)→ 视为非激活(保守不推) + return False + + 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 "⚠️" + + # 2026-08-13 信号溯源:记录策略/版本/温区 + 共振标记(多策略同推加关注) + strat_id = d.get("tag") or d.get("version") or d.get("strategy_type") or d.get("decision_type") or "" + regime_now = "" + temp_now = "" + try: + # 2026-08-14 阶段4:温区溯源按标的市场(A股=原行为, 港股=港股温区) + from market_config import get_regime_temp + regime_now, temp_now = get_regime_temp(code) + except Exception: + pass + # 溯源记录 + 共振检测 + resonance_tag = "" + try: + from signal_ledger import record_signal + _res = record_signal( + code=code, name=name, strategy=strat_id, version=strat_id, + regime=regime_now, temp_band=temp_now, + reason=f"进买入区{buy_low}~{buy_high} + 重评{sig}", + source_module="stale_push_wlin", + ) + if _res and _res.get("resonance_count", 1) > 1: + resonance_tag = f" 🔥多策略共振({_res['resonance_count']}策略: {_res['resonance_strategies']})" + except Exception: + pass + strat_tag = f" [{strat_id}]" if strat_id else "" + regime_tag = f"[{regime_now}]" if regime_now else "" + + lines.append( + f" {action_tag} {name}({code}) {pfx}{price:.2f} 买区{buy_low}~{buy_high} | " + f"1手{lot:,.0f}元 RR={rr:.1f} 损{sl} 盈{tp}{strat_tag}{regime_tag}{resonance_tag}\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: + return 0 # 2026-08-19 落实老莫8/14决定:无操作直接静默(zone_notes只记日志不推送) + + # ── 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) + + # 2026-08-14 删除:操作区间内但重评后不可操作的推送(垃圾消息,不推荐不推) + # zone_notes 仅记日志,不推送(老莫:进入区间但重评不推荐=垃圾消息) + if zone_notes: + print(f"[LOG] {len(zone_notes)} 只进入区间但重评不推荐(不推送): {[z[0] for z in zone_notes[:5]]}", flush=True) + + # 标题:有推荐操作→"自选买入提醒",仅有区间说明→"操作区间提醒" + 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/archive/d-cleanup-20260820/ab_research_daily.py b/archive/d-cleanup-20260820/ab_research_daily.py new file mode 100644 index 00000000..109c2504 --- /dev/null +++ b/archive/d-cleanup-20260820/ab_research_daily.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""AB路线每日LLM主导研究 v2(老莫2026-08-18) +在原规则化分析基础上,集成 LLM 生成深度研究结论(真正"LLM主导") +1. 读温区覆盖 + 进化中心 + B组候选 +2. LLM 分析薄弱环节 → 建议尝试 +3. 写 strategy_research_log 表 +""" +import sys, os, json, sqlite3 +from datetime import datetime + +sys.path.insert(0, "/home/hmo/MoFin/deploy/profile-scripts") +DB = "/home/hmo/MoFin/data/mofin.db" +CENTER = "/home/hmo/MoFin/data/evolution_center.json" + +def ensure_table(conn): + conn.execute("""CREATE TABLE IF NOT EXISTS strategy_research_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, log_date TEXT NOT NULL, market TEXT, + weak_regime TEXT, finding TEXT, experiment TEXT, result TEXT, + produced_strategy TEXT, produced_verified INTEGER DEFAULT 0, llm_model TEXT, created_at TEXT)""") + conn.commit() + +def load_center(): + if not os.path.exists(CENTER): return {} + try: return json.load(open(CENTER)) + except: return {} + +def build_prompt(coverage, center): + """构造 LLM 研究 prompt""" + line = [] + line.append("你是MoFin策略研究员。分析当前策略覆盖,找出薄弱环节并给出研究建议。") + line.append("温区覆盖(trades>=30,2y):") + for c in coverage: + line.append(f"- {c['market']}/{c['regime']}: {c['count']}个策略") + bg = center.get("b_group") or [] + if bg: + line.append(f"B组候选: {len(bg)}条") + for b in bg[:3]: + line.append(f" - {str(b)[:80]}") + line.append("\n请输出:") + line.append("1. 最薄弱的温区/环节(策略匮乏或合格策略少)") + line.append("2. 具体研究建议(做什么尝试)") + line.append("3. 预期成果类型") + line.append("格式:发现|建议|预期") + return "\n".join(line) + +def analyze_llm(coverage, center): + """LLM 生成研究结论""" + try: + from llm_client import call_llm + prompt = build_prompt(coverage, center) + res = call_llm(prompt) + return str(res)[:400] if res else None + except Exception as e: + return f"[LLM调用失败: {e}]" + +def main(): + conn = sqlite3.connect(DB, timeout=30) + conn.execute("PRAGMA busy_timeout=30000") + ensure_table(conn) + today = datetime.now().strftime("%Y-%m-%d") + if conn.execute("SELECT COUNT(*) FROM strategy_research_log WHERE log_date=?", (today,)).fetchone()[0]: + print(f"[AB研究] {today} 已有记录"); conn.close(); return + # 读取覆盖 + rows = conn.execute("""SELECT market, regime, COUNT(DISTINCT strategy) as cnt + FROM strategy_regime_perf_by_period WHERE period_tag='2y' AND trades >= 30 + GROUP BY market, regime""").fetchall() + coverage = [{"market": r[0], "regime": r[1], "count": r[2]} for r in rows] + center = load_center() + # 基础规则发现 + findings = [] + weak = [] + if coverage: + c_sorted = sorted(coverage, key=lambda x: x["count"]) + weak = c_sorted[:2] + findings.append("覆盖最少的温区: " + "; ".join(f"{c['market']}/{c['regime']}({c['count']})" for c in weak)) + # LLM 深度分析 + if coverage: + llm_res = analyze_llm(coverage, center) + if llm_res: + findings.append("LLM分析: " + llm_res) + finding_text = "; ".join(findings) or "无明显薄弱点" + weak_rg = weak[0]["regime"] if weak else "" + weak_mkt = weak[0]["market"] if weak else "a" + conn.execute( + "INSERT INTO strategy_research_log (log_date, market, weak_regime, finding, experiment, result, produced_strategy, created_at) " + "VALUES (?,?,?,?,?,?,?,?)", + (today, weak_mkt, weak_rg, finding_text, "LLM主导温区覆盖+B组分析", "记录待验证", "", + datetime.now().isoformat())) + conn.commit() + print(f"[AB研究] {today} 记录完成 (LLM主导)") + conn.close() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/archive/d-cleanup-20260820/meta_growth.py b/archive/d-cleanup-20260820/meta_growth.py new file mode 100644 index 00000000..173f4349 --- /dev/null +++ b/archive/d-cleanup-20260820/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/deploy/profile-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/archive/d-cleanup-20260820/meta_watchdog.py b/archive/d-cleanup-20260820/meta_watchdog.py new file mode 100644 index 00000000..9aee6ae9 --- /dev/null +++ b/archive/d-cleanup-20260820/meta_watchdog.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""meta_watchdog.py — L4 自检系统的自检(看门狗的看门狗) + +检查 L1-L3 各自检组件本身是否在正常运转: +- L1 functional_health_check: functional_health.json 是否 <20min(交易时段) +- L2 system_hygiene_audit: hygiene_report.json 是否 <26h(每日) +- L3 self_repair: repair_state.json 存在性 + cron 是否注册 +- mofin_health 采集: mofin_health.json 是否 <20min(交易时段) +- XMPP 桥: :5805 是否可发(self_repair 的报备通道) + +(2026-08-13 删除 L0 agents_health_check 检查项:MoFin 无该组件,且 L1 functional_health_check 已覆盖健康检查功能,检查项是死代码) + +任何一层死了 → 推 XMPP 点名(这是最后的兜底,必须直达用户)。 +频率:每小时(cron)。输出 gateway/logs/meta_watchdog.json。 +""" +import os, sys, json, subprocess +from datetime import datetime + +OUT = '/home/hmo/MoFin/gateway/logs/meta_watchdog.json' + +LAYERS = [ + {"layer": "L1 functional_health", "file": "/home/hmo/MoFin/gateway/logs/functional_health.json", + "max_age_min": 25, "when": "trading", + "repair": "L1 cron 停摆,检查 hermes cron 引擎"}, + {"layer": "L2 hygiene_audit", "file": "/home/hmo/MoFin/gateway/logs/hygiene_report.json", + "max_age_min": 26 * 60, "when": "always", + "repair": "L2 每日审计未跑,检查 hermes cron"}, + {"layer": "L1.5 mofin_health采集", "file": "/home/hmo/web-dashboard/static/mofin_health.json", + "max_age_min": 25, "when": "trading", + "repair": "mofin_health.py 采集停摆"}, + {"layer": "L3 self_repair", "file": "/home/hmo/MoFin/gateway/logs/repair_log.jsonl", + "max_age_min": None, "when": "meta", + "repair": "self_repair cron 未注册"}, +] + + +def is_trading(now): + return now.weekday() < 5 and 9 <= now.hour <= 16 + + +def main(): + now = datetime.now() + trading = is_trading(now) + results = [] + + for L in LAYERS: + if L["when"] == "trading" and not trading: + results.append({"layer": L["layer"], "status": "skip", "reason": "非交易时段"}) + continue + if L["when"] == "meta": + # 检查 self_repair 是否注册在 cron + try: + d = json.load(open('/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json')) + jobs = d if isinstance(d, list) else d.get('jobs', []) + registered = any(j.get('script') == 'self_repair.py' and j.get('enabled', True) for j in jobs) + results.append({"layer": L["layer"], + "status": "ok" if registered else "fail", + "reason": "已注册" if registered else "未在 cron 注册"}) + except Exception as e: + results.append({"layer": L["layer"], "status": "fail", "reason": str(e)[:60]}) + continue + + f = L["file"] + if not os.path.exists(f): + results.append({"layer": L["layer"], "status": "fail", + "reason": f"输出物不存在", "repair": L["repair"]}) + continue + age_min = (now.timestamp() - os.path.getmtime(f)) / 60 + if L["max_age_min"] and age_min > L["max_age_min"]: + results.append({"layer": L["layer"], "status": "fail", + "reason": f"输出物 {age_min/60:.1f}h 未更新(阈值 {L['max_age_min']}min)", + "repair": L["repair"]}) + else: + results.append({"layer": L["layer"], "status": "ok", + "reason": f"{age_min:.0f}min 前"}) + + # XMPP 桥(报备通道):只收 POST,GET 会 501,但任何 HTTP 响应都说明进程活着 + try: + import urllib.request + urllib.request.urlopen('http://127.0.0.1:5805/', timeout=3) + results.append({"layer": "XMPP桥 :5805", "status": "ok", "reason": "可达"}) + except urllib.error.HTTPError as e: + results.append({"layer": "XMPP桥 :5805", "status": "ok", "reason": f"可达(HTTP {e.code})"}) + except Exception: + results.append({"layer": "XMPP桥 :5805", "status": "fail", + "reason": "不可达", "repair": "重启 xmpp-zhiwei"}) + + fails = [r for r in results if r["status"] == "fail"] + report = { + "generated_at": now.strftime("%Y-%m-%d %H:%M:%S"), + "status": "fail" if fails else "ok", + "layers": results, + } + os.makedirs(os.path.dirname(OUT), exist_ok=True) + with open(OUT, 'w', encoding='utf-8') as f: + json.dump(report, f, ensure_ascii=False, indent=2) + + print(f"meta_watchdog: {report['status']}") + for r in results: + icon = {"ok": "✅", "fail": "❌", "skip": "⏭"}[r["status"]] + print(f" {icon} {r['layer']}: {r['reason']}") + + if fails: + try: + import urllib.request + lines = [f"🚨 自检系统自检(L4兜底)发现 {len(fails)} 层异常:"] + for r in fails: + lines.append(f"❌ {r['layer']}: {r['reason']}") + if r.get('repair'): + lines.append(f" → 处置建议: {r['repair']}") + payload = json.dumps({'to': 'hmo@yoin.fun', 'body': '\n'.join(lines), '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(' 📨 已推 XMPP(兜底直达)') + except Exception as e: + print(f' XMPP 失败: {e}') + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/docs/cleansweep-20260820.md b/docs/cleansweep-20260820.md new file mode 100644 index 00000000..5b2ad29e --- /dev/null +++ b/docs/cleansweep-20260820.md @@ -0,0 +1,80 @@ +# 代码结构大扫除(文件级清理)2026-08-20 + +> 目标:消除散落的多套副本、死代码、多余硬链接入口,收敛为「单点权威 + 运行副本」的干净结构。 + +## 一、清理前摸底(5 个散落位置) + +| 目录 | 数量 | 性质 | +|---|---|---| +| `deploy/profile-scripts/` | 176 | **git 权威源码**(唯一修改点)| +| `.hermes/.../scripts/` | 292 | cron 运行目录 = 176 硬链接 + **116 独有** | +| `MoFin/` 根 | 40 | 19 硬链接 + 2 旧版 + 19 独有应用模块 | +| `MoFin/scripts/` | 3 | 硬链接 + prepare_report_data 重复副本 | +| `/home/hmo/scripts/` | 15 | 无引用的旧项目残留 | + +## 二、本次清理动作(已物理生效,archive 可还原) + +### 1. 归档 hermes 独有死工具 111 个 → `archive/hermes-dead-tools-20260820/` +- 特征:不在 cron 调度、不被任何活跃脚本 import 的一次性排查/测试/清理工具 + (`check_*`/`test_*`/`audit_*`/`fix_*`/`sc*`/`verify_*`/`remove_*` 等) +- 含 4 个废弃 scanner:`btd1_v3_scanner`/`market_scanner`/`market_thermometer`(cron note 已注明改为 market_regime 废弃)/`s2v2_scanner` +- **hermes 独有从 116 收敛到 5 个核心**: + `alert_logger`(被 price_monitor import)、`market_screener`/`prepare_report_data`/`self_todo_executor_v2`(cron 调度)、`xmpp_zhiwei_bot`(XMPP bot wrapper) + +### 2. 归档 MoFin 根 2 个旧版 → `archive/legacy-cleanup-20260820/mofin-root-old/` +- `mo_models.py`、`technical_analysis.py`:deploy 版本更新(8-14)且为权威,根旧版可删 + +### 3. 归档 `/home/hmo/scripts/` 整个目录(无引用旧项目)→ `archive/legacy-cleanup-20260820/home-scripts/` +- crontab 引用 0、systemd 引用 0,确认无引用 + +### 4. 收敛 `MoFin/scripts/prepare_report_data.py` 重复副本 → archive +- cron 用 hermes 那份(jobs.py 从 hermes/scripts 解析),MoFin/scripts 是内容相同的重复,归档 + +## 三、关于硬链接的结论(沉淀知识) + +**为什么需要这么多硬链接?—— 不是冗余,是 server.py 的多层 sys.path 设计所需。** + +- `server.py` 的 sys.path 依次注入:`MoFin/scripts` → `MoFin/` → `MoFin/deploy/profile-scripts` +- `mofin_db.py`/`mo_data.py` 被 MoFin 根 20+ 个模块 + deploy 40+ 个脚本共同依赖 +- **用硬链接(同 inode)** 让每个 sys.path 目录都能找到同一份代码,**共享数据块不占额外空间**,改一处处处生效 +- **结论:`mofin_db.py`/`mo_data.py` 的多硬链接入口是合理设计,保留**;真正冗余的是「内容相同但 inode 不同的独立副本」,已清理 + +## 四、收敛后的目标结构(代码结构规范) + +``` +deploy/profile-scripts/ ← 唯一 git 权威源码(修改只在这里做) + └─ *.py 176 个 +.hermes/.../scripts/ ← cron 实际运行目录(176 硬链接 + 5 核心独有) +MoFin/ ← 应用主程序(server.py 等,独立于 cron 脚本集) +MoFin/scripts/ ← 仅部署脚本 + mofin_db/mo_data 硬链接 +archive/cleansweep-2026*/ ← 归档的可还原死代码 +``` + +## 五、铁律(防止再散落) + +1. **新增/修改代码只进 `deploy/profile-scripts/`**(git 权威) +2. **不在该目录下的运行时脚本一律视为孤儿**,先查 cron/import 引用再处理 +3. **不新建内容重复的独立副本**;需要多目录访问同一代码时用**硬链接**(同 inode) +4. **一次性排查/测试工具用完即归档**,不留在运行目录 +5. 归档统一放 `archive/`,标注日期,杜绝再次散落 + +## 六、验证(全部通过 ✅) + +- deploy 权威 176 个完整;hermes 181 = 176 + 5 核心 +- cron 全部脚本引用无缺失(jobs.json 每个 script 都存在) +- 关键模块 import 正常(price_monitor/mofin_health/anomaly_monitor/market_screener) +- archive 可还原:111(hermes 死工具)+ 30(收敛产物) + +## 七、待办(受提交白名单限制) + +- MoFin 仓库启用提交白名单(2026-07-21 批准),当前自主执行 agent 无 git 写权限 +- **需 kanban 提单给莫笑笑评审,由她 commit 本清理** +- 归档目录已在磁盘物理持久化,cron 未受影响,可安全等待评审 + +## 八、复查补充(2026-08-20 收尾复查) + +### 保留说明:`fix_gateway.py`(勿删) +- 虽然它不在 cron、名字像 `fix_*` 死工具,但**它是 `fix_gateway_port.py` 的活跃依赖**(被 import)。 +- cron job「Gateway看门狗-知微」执行 `fix_gateway_port.py`(enabled),内部 import `fix_gateway`。 +- **结论:`fix_gateway.py`(hermes + deploy 各一份,与 fix_gateway_port 同源)必须保留**,不是死工具。 +- 复查确认:无漏网死工具;deploy=176 / hermes=181(176+5核心)结构正确。