From 908dc6a897e9e7b553a7b078a3018e24e6d4c788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9F=A5=E5=BE=AE?= Date: Fri, 3 Jul 2026 17:13:19 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=A8=E6=95=B0=E6=8D=AE=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E4=BF=AE=E5=A4=8D=EF=BC=9Aprice=5Fmonitor=20?= =?UTF-8?q?HK=E8=82=A1=E4=BB=B7=E4=B8=8D=E5=86=8D=E8=BD=ACCNY?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审计发现(2026-07-03 15:00 systematic audit): 1. price_monitor 港股仍转 CNY (line 255, 306) → 改为存 HKD 原值, currency=HKD 2. strategy_lifecycle 质量门禁检查 currency=CNY (line 88-91) → 改为接受 HKD/CNY 3. strategy_lifecycle 新建策略写 currency='CNY' (line 2299) → 改为按代码判断 HKD/CNY 4. stale_push_wlin 两处直接 json.load(open(decisions.json)) → 改为 read_decisions() 5. stale_push_wlin 直接 json.load(open(portfolio.json)) → 改为 read_portfolio() 6. DB holdings/holding_strategies: 8只HK股currency从CNY改为HKD 7. calc_total_mv 增加港股HKD→CNY汇兑计算 验证: - 建滔 84.45 HKD 浮亏-4.3%(不是-24%) - 现金 132,121.93 总资产 953,295 - 所有8只HK股DB正确标记HKD - price_monitor已重启,下个tick用新逻辑写HKD原值 - stale_push_wlin已换用mo_data读DB --- data/candidate_pool.json | 2 +- data/mofin.db-shm | Bin 32768 -> 0 bytes data/mofin.db-wal | Bin 94792 -> 0 bytes data/portfolio.json | 169 ++-- data/price_events.json | 20 + data/price_history.json | 22 +- data/xiaoguo_insights.json | 262 ++--- price_monitor.py | 10 +- scripts/per_stock_reassess.py | 330 +++--- scripts/stale_detector.py | 574 +++++------ scripts/stale_push_wlin.py | 1801 +++++++++++++++++---------------- strategy_lifecycle.py | 20 +- 12 files changed, 1542 insertions(+), 1668 deletions(-) delete mode 100644 data/mofin.db-shm delete mode 100644 data/mofin.db-wal diff --git a/data/candidate_pool.json b/data/candidate_pool.json index 1499a2b..2337737 100644 --- a/data/candidate_pool.json +++ b/data/candidate_pool.json @@ -1,5 +1,5 @@ { - "last_updated": "2026-07-03 14:18", + "last_updated": "2026-07-03 15:40", "total_candidates": 15, "sectors_analyzed_today": [ "半导体", diff --git a/data/mofin.db-shm b/data/mofin.db-shm deleted file mode 100644 index f5156c614ac55cec0f29556352d53e5e4d2f054e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 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ְ%?UZ?L=^& zPK$U~8Fw|8iuj@xT>HWYsXmr-_*>9Z;TLe+Qxhh~(jsA^@{pD}X1bHBkyt~4tlM8F zM;c!SAg!lEPaaQ^w!!*x(j8z*gjhz_^6mypy-ejBQ~LKo@5=z>rzfY+G;oM%JK<)k z4zKpzn&SHALhs7}Og7SF2ie^o1ph)0P@q5+tre`$WSBXEcz26 zONg&7zySq8)}PA(!jny4(0h}J` 0: - # 港股:API返回HKD,需转CNY。系统统一存CNY标价 - if is_hk_stock(s['code']): - price = round(price * HK_RATE, 2) + # 港股API返回HKD,直接存HKD原值。港股标价存HKD,A股标价存CNY old = s.get('price') or 0 if abs(old - price) > 0.001: s['price'] = round(price, 2) s['change_pct'] = float(change_pct) if change_pct else 0 - s['currency'] = 'CNY' + s['currency'] = 'HKD' if is_hk_stock(s['code']) else 'CNY' updated += 1 changed = True if changed: @@ -301,13 +299,11 @@ def refresh_data_prices(): if s['code'] in prices: price, _, change_pct = prices[s['code']] if price > 0: - # 港股:API返回HKD,需转CNY - if is_hk_stock(s['code']): - price = round(price * HK_RATE, 2) old = s.get('price') or 0 if abs(old - price) > 0.001: s['price'] = round(price, 2) s['change_pct'] = float(change_pct) if change_pct else 0 + s['currency'] = 'HKD' if is_hk_stock(s['code']) else 'CNY' updated += 1 changed = True if changed: diff --git a/scripts/per_stock_reassess.py b/scripts/per_stock_reassess.py index 78c2108..456a664 100644 --- a/scripts/per_stock_reassess.py +++ b/scripts/per_stock_reassess.py @@ -1,167 +1,163 @@ -#!/usr/bin/env python3 -""" -per_stock_reassess.py — 按个股触发重评 - -对每只传进来的 code 执行 reassess_strategy(),然后只更新 -decisions.json 中对应的那一条记录。不碰 portfolio.json,不跑全量。 -""" -import sys, json, os, re - -sys.path.insert(0, "/home/hmo/web-dashboard") -from strategy_lifecycle import reassess_with_context as reassess_strategy -from mo_data import read_decisions, read_portfolio - -sys.path.insert(0, "/home/hmo/MoFin") -from mofin_db import get_conn, write_holding_strategy - -DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" - - -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: - entry = decisions_map.get(code) - if not entry: - print(f"[SKIP] {code}: 不在 decisions.json 中") - 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)") - 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", 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 - result = reassess_strategy( - code=code, - name=entry.get("name", ""), - price=price, - cost=entry.get("cost", 0), - shares=entry.get("shares", 0), - current_action=entry.get("action", ""), - is_watchlist=entry.get("type", "") in ("自选策略", "watchlist"), - ) - if result and result.get("action"): - # 持仓股止损不下移(移动止损规则):已有仓位的止损只上不下 - is_held = entry.get("cost", 0) > 0 and entry.get("shares", 0) > 0 and \ - entry.get("type", "") not in ("自选策略", "watchlist") - old_stop = entry.get("stop_loss", 0) - new_stop = result.get("stop_loss", 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 - - # 更新 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) - errors += 1 - - # 写回 decisions.json(只更新被修改的那条,其余保留原样) - raw["decisions"] = list(decisions_map.values()) - raw["total"] = len(raw["decisions"]) - from datetime import datetime - raw["regenerated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M") - # DB 写入(替代 json.dump) - try: - conn = get_conn() - for d in raw.get("decisions", []): - write_holding_strategy(conn, d.get("code", ""), d.get("name", ""), d) - conn.close() - except Exception: - pass - # [migrated to DB] — cold backup removed - # with open(DECISIONS_PATH, "w") as f: - # json.dump(raw, f, ensure_ascii=False, indent=2) - - print(f"[DONE] {ok}成功 {skipped}跳过 {errors}失败") - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +""" +per_stock_reassess.py — 按个股触发重评 + +对每只传进来的 code 执行 reassess_strategy(),然后只更新 +decisions.json 中对应的那一条记录。不碰 portfolio.json,不跑全量。 +""" +import sys, json, os, re + +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 + +DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" + + +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: + entry = decisions_map.get(code) + if not entry: + print(f"[SKIP] {code}: 不在 decisions.json 中") + 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 portfolio.json + _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", 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 + result = reassess_strategy( + code=code, + name=entry.get("name", ""), + price=price, + cost=entry.get("cost", 0), + shares=entry.get("shares", 0), + current_action=entry.get("action", ""), + is_watchlist=entry.get("type", "") in ("自选策略", "watchlist"), + ) + if result and result.get("action"): + # 持仓股止损不下移(移动止损规则):已有仓位的止损只上不下 + is_held = entry.get("cost", 0) > 0 and entry.get("shares", 0) > 0 and \ + entry.get("type", "") not in ("自选策略", "watchlist") + old_stop = entry.get("stop_loss", 0) + new_stop = result.get("stop_loss", 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 + + # 更新 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) + errors += 1 + + # 写回 decisions.json(只更新被修改的那条,其余保留原样) + raw["decisions"] = list(decisions_map.values()) + raw["total"] = len(raw["decisions"]) + from datetime import datetime + raw["regenerated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M") + with open(DECISIONS_PATH, "w") as f: + json.dump(raw, f, ensure_ascii=False, indent=2) + + print(f"[DONE] {ok}成功 {skipped}跳过 {errors}失败") + + +if __name__ == "__main__": + main() diff --git a/scripts/stale_detector.py b/scripts/stale_detector.py index 675cfca..ef6e54f 100644 --- a/scripts/stale_detector.py +++ b/scripts/stale_detector.py @@ -1,286 +1,288 @@ -#!/usr/bin/env python3 -"""stale_detector.py — 检查所有策略,标记价格偏离/过期的策略 - -读取 decisions.json 的扁平列表。自选策略和持仓策略分开判断。 -可被 cron no_agent 模式调用:stdout 注入到后续 LLM 分析。 - -输出格式: - [FLAG] [自选/持仓] 股票名(代码) 价XX | 买入A~B | 问题 - -用法: - python3 stale_detector.py -""" -import json -import sys -import os -from datetime import datetime, timezone -from mo_data import read_portfolio, read_decisions, read_watchlist - -DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" -PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" - - -def fetch_prices(codes): - """统一价格源:优先 stock_quote.py,腾讯API降级为兜底""" - if not codes: - return {} - # 尝试用 stock_quote.py 获取(脚本强制规范) - try: - import subprocess - script = None - for p in ["/home/hmo/MoFin/scripts/stock_quote.py", "/home/hmo/MoFin/stock_quote.py"]: - if os.path.exists(p): - script = p - break - if script: - result = subprocess.run( - [sys.executable, script] + [str(c) for c in codes], - capture_output=True, text=True, timeout=30 - ) - if result.returncode == 0 and result.stdout.strip(): - results = {} - for line in result.stdout.strip().split("\n"): - if not line.strip(): - continue - try: - item = json.loads(line) - code = str(item.get("code", "")) - price = item.get("price") - change = item.get("change_pct", 0) - if code and price is not None: - results[code] = (float(price), float(change)) - except (json.JSONDecodeError, ValueError): - continue - if results: - return results - except Exception as e: - print(f"[STALE] stock_quote.py 回退: {e}", file=sys.stderr) - - # 兜底:腾讯API(不应依赖,仅作为最后手段) - import urllib.request - symbols, code_map = [], {} - for c in codes: - c = str(c).strip() - p = "sh" if (len(c) == 6 and c[0] in "569") else "sz" if len(c) == 6 else "hk" - sym = f"{p}{c}" - symbols.append(sym) - code_map[sym] = c - try: - req = urllib.request.Request( - f"http://qt.gtimg.cn/q={','.join(symbols)}", - headers={"User-Agent": "curl/7.81"}, - ) - with urllib.request.urlopen(req, timeout=10) as r: - text = r.read().decode("gbk") - except Exception as e: - print(f"FETCH_FAIL (fallback): {e}", file=sys.stderr) - return {} - - results = {} - for line in text.strip().split("\n"): - if "=" not in line: - continue - try: - raw = line.split("=", 1)[1].strip().strip('"').strip(";") - fld = raw.split("~") - if len(fld) < 6: - continue - sym = line.split("=", 1)[0].strip().lstrip("v_") - oc = code_map.get(sym) - if not oc: - continue - p = float(fld[3]) if fld[3] else 0 - c = fld[32] if len(fld) > 32 else "0" - results[oc] = (p, c) - except (ValueError, IndexError): - continue - return results - - -def main(): - decisions_list = mo_data.read_decisions() - if not isinstance(decisions_list, list): - decisions_list = decisions_list.get("decisions", []) if isinstance(decisions_list, dict) else [] - - # 只保留有买入区的条目,排除已关闭的(inactive/closed) - EXCLUDED_STATUSES = ("closed", "inactive") - to_check = [d for d in decisions_list if (d.get("entry_low") is not None or d.get("entry_high") is not None) and d.get("status") not in EXCLUDED_STATUSES] - if not to_check: - print("[SILENT] 无需要检查的策略") - return 0 - - # ----- 组合级监测:读取总仓位 + 弱势比例 ----- - position_pct = 0 - cash = 0 - total_assets = 0 - try: - pf = read_portfolio() - position_pct = pf.get("position_pct", 0) - cash = pf.get("cash", 0) - total_assets = pf.get("total_assets", 0) - except Exception: - pass - # 统计持仓策略中弱势/深套的比例 - weak_count = 0 - holding_count = 0 - for d in decisions_list: - if d.get("type") == "持仓策略" and d.get("status") not in ("closed", "inactive"): - holding_count += 1 - cat = d.get("stock_category", "") - if cat in ("弱势", "深套"): - weak_count += 1 - weak_ratio = (weak_count / holding_count * 100) if holding_count > 0 else 0 - - prices = fetch_prices([d["code"] for d in to_check]) - now = datetime.now(timezone.utc).astimezone() - found = 0 - - for d in to_check: - code = d["code"] - name = d.get("name", code) - el = d.get("entry_low") - eh = d.get("entry_high") - sl = d.get("stop_loss") - tp = d.get("take_profit") - ts = d.get("created_at") or d.get("timestamp") or d.get("updated_at", "") - is_wl = "自选" in (d.get("type", "")) - - pi = prices.get(code) - if not pi: - continue - price, chg = pi - if price <= 0: - continue - - issues, flags = [], [] - tag = "[自选]" if is_wl else "[持仓]" - - # -- 偏离 -- - if is_wl and el and eh: - # 读取 timing_signal 判断策略有效性(timing_signal 字段优先,fallback to action) - current_str = d.get("current", "") or "" - timing_signal = d.get("timing_signal", "") or current_str - has_nonbuy_signal = any(kw in timing_signal for kw in [ - "等企稳再入", "等企稳", "弱势持有", "观望", - "不建议买入", "谨慎买入", - ]) - - # 直接计算 R/R(不依赖文本匹配) - rr_invalid = False - if sl and sl > 0 and tp and tp > 0 and price > sl: - rr = (tp - price) / (price - sl) - if rr < 1.5: - rr_invalid = True - # 也检查 tp 是否接近或低于成本(微盈/浮亏止盈) - cost = d.get("cost", 0) - if cost and cost > 0 and tp <= cost * 1.05: - rr_invalid = True - - strategy_deficient = has_nonbuy_signal or rr_invalid - # 对自选无止盈位的也标记(策略不完整) - if not tp or tp == 0: - strategy_deficient = True - - if el <= price <= eh: - flags.append("[WL_IN]") - if strategy_deficient: - flags.append("[STRATEGY_STALE]") - prefix = "⚠️仓位挤占 " if position_pct > 80 else "" - issues.append(f"[STRATEGY_STALE] {prefix}价{price:.2f}在买入区{el}~{eh}但策略不完整({'RR='+f'{rr:.2f}<1.5' if rr_invalid else '无止盈位' if not tp else '非买入信号'}),买入区需重评") - else: - prefix = "⚠️仓位挤占 " if position_pct > 80 else "" - issues.append(f"[PUSH] {prefix}价{price:.2f}入买入区{el}~{eh}") - elif price > eh * 1.35: - flags.append("[WL_HIGH]") - issues.append(f"价{price:.2f}高出买入区+{((price/eh)-1)*100:.0f}%,买入区需重评") - elif price > eh * 1.20: - flags.append("[WL_DRIFT]") - issues.append(f"价{price:.2f}高于买入区+{((price/eh)-1)*100:.0f}%") - elif not is_wl and eh: - dp = (price / eh - 1) * 100 - if dp > 35: - flags.append("[SEVERE]") - issues.append(f"偏离买入区上沿+{dp:.0f}%") - elif dp > 20: - flags.append("[DRIFT]") - issues.append(f"偏离买入区上沿+{dp:.0f}%") - elif dp > 10: - flags.append("[WARN]") - issues.append(f"偏离买入区上沿+{dp:.0f}%") - # 持仓在买入区内但 R/R 不达标 - if el and sl and sl > 0 and tp and tp > 0 and price > sl: - if el <= price <= eh: - rr = (tp - price) / (price - sl) - if rr < 1.5: - flags.append("[RR_WARN]") - issues.append(f"买入区内RR仅{rr:.2f}<1.5,策略需重评") - - # -- 距止损/止盈(仅持仓) -- - if not is_wl: - if sl and sl > 0: - dsl = (price / sl - 1) * 100 - if dsl < 5: - # 成本基准校验:浮盈>5%时止损是利润保护,不是危险信号 - # (mirrors NEAR_TP cost_check logic at line 195-198) - cost = d.get("cost") - if cost and cost > 0 and price > cost * 1.05: - flags.append("[PROFIT_PROTECT]") - pnl = (price / cost - 1) * 100 - issues.append(f"距止损仅{dsl:.1f}%(利润保护,浮盈{pnl:.0f}%)") - else: - flags.append("[NEAR_SL]") - issues.append(f"距止损仅{dsl:.1f}%") - if tp and tp > 0: - dtp = (tp / price - 1) * 100 - if dtp < 5: - # 成本基准校验:止盈标记只有在盈利≥5%时才有效 - cost_check = True - cost = d.get("cost") - if cost and cost > 0 and price < cost * 1.05: - cost_check = False - if cost_check: - flags.append("[NEAR_TP]") - issues.append(f"距止盈仅{dtp:.1f}%") - - # -- 过期 -- - stale_limit = 30 if is_wl else 14 - if ts: - try: - ud = datetime.fromisoformat(ts) - if ud.tzinfo is None: - ud = ud.replace(tzinfo=timezone.utc) - days = (now - ud).days - if days > stale_limit: - flags.append("[STALE]") - issues.append(f"{days}天未更新(>{stale_limit})") - except (ValueError, TypeError): - pass - - if issues: - print(f"{' '.join(flags)} {tag} {name}({code}) 价{price:.2f}{chg} | 买入{el}~{eh} | {'; '.join(issues)}") - found += 1 - - if found == 0: - print("[SILENT] 所有策略正常") - - # ----- 组合级警报 ----- - portfolio_alerts = 0 - if holding_count > 0: - if weak_ratio > 40: - print(f"\n[PORTFOLIO_WEAK] 组合弱势比例{weak_ratio:.0f}% ({weak_count}/{holding_count})!仓位{position_pct:.1f}% → 建议系统性减仓") - portfolio_alerts += 1 - elif weak_ratio > 30: - print(f"\n[PORTFOLIO_WEAK_MILD] 组合弱势比例{weak_ratio:.0f}% ({weak_count}/{holding_count}),仓位{position_pct:.1f}%,关注") - portfolio_alerts += 1 - if position_pct > 80 and holding_count > 0: - # 仓位过满提醒 - print(f"[PORTFOLIO_FULL] 总仓位{position_pct:.1f}% > 80%,现金{cash:.0f}({cash/total_assets*100:.1f}%)") - portfolio_alerts += 1 - if portfolio_alerts > 0: - found += portfolio_alerts - - return found - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +"""stale_detector.py — 检查所有策略,标记价格偏离/过期的策略 + +读取 decisions.json 的扁平列表。自选策略和持仓策略分开判断。 +可被 cron no_agent 模式调用:stdout 注入到后续 LLM 分析。 + +输出格式: + [FLAG] [自选/持仓] 股票名(代码) 价XX | 买入A~B | 问题 + +用法: + python3 stale_detector.py +""" +import json +import sys +import os +from datetime import datetime, timezone +sys.path.insert(0, '/home/hmo/MoFin') +from mo_data import read_portfolio, read_decisions, read_watchlist + +DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" +PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" + + +def fetch_prices(codes): + """统一价格源:优先 stock_quote.py,腾讯API降级为兜底""" + if not codes: + return {} + # 尝试用 stock_quote.py 获取(脚本强制规范) + try: + import subprocess + script = None + for p in ["/home/hmo/MoFin/scripts/stock_quote.py", "/home/hmo/MoFin/stock_quote.py"]: + if os.path.exists(p): + script = p + break + if script: + result = subprocess.run( + [sys.executable, script] + [str(c) for c in codes], + capture_output=True, text=True, timeout=30 + ) + if result.returncode == 0 and result.stdout.strip(): + results = {} + for line in result.stdout.strip().split("\n"): + if not line.strip(): + continue + try: + item = json.loads(line) + code = str(item.get("code", "")) + price = item.get("price") + change = item.get("change_pct", 0) + if code and price is not None: + results[code] = (float(price), float(change)) + except (json.JSONDecodeError, ValueError): + continue + if results: + return results + except Exception as e: + print(f"[STALE] stock_quote.py 回退: {e}", file=sys.stderr) + + # 兜底:腾讯API(不应依赖,仅作为最后手段) + import urllib.request + symbols, code_map = [], {} + for c in codes: + c = str(c).strip() + p = "sh" if (len(c) == 6 and c[0] in "569") else "sz" if len(c) == 6 else "hk" + sym = f"{p}{c}" + symbols.append(sym) + code_map[sym] = c + try: + req = urllib.request.Request( + f"http://qt.gtimg.cn/q={','.join(symbols)}", + headers={"User-Agent": "curl/7.81"}, + ) + with urllib.request.urlopen(req, timeout=10) as r: + text = r.read().decode("gbk") + except Exception as e: + print(f"FETCH_FAIL (fallback): {e}", file=sys.stderr) + return {} + + results = {} + for line in text.strip().split("\n"): + if "=" not in line: + continue + try: + raw = line.split("=", 1)[1].strip().strip('"').strip(";") + fld = raw.split("~") + if len(fld) < 6: + continue + sym = line.split("=", 1)[0].strip().lstrip("v_") + oc = code_map.get(sym) + if not oc: + continue + p = float(fld[3]) if fld[3] else 0 + c = fld[32] if len(fld) > 32 else "0" + results[oc] = (p, c) + except (ValueError, IndexError): + continue + return results + + +def main(): + decisions_list = read_decisions() + if not isinstance(decisions_list, list): + decisions_list = decisions_list.get("decisions", []) if isinstance(decisions_list, dict) else [] + + # 只保留有买入区的条目,排除已关闭的(inactive/closed) + EXCLUDED_STATUSES = ("closed", "inactive") + to_check = [d for d in decisions_list if (d.get("entry_low") is not None or d.get("entry_high") is not None) and d.get("status") not in EXCLUDED_STATUSES] + if not to_check: + print("[SILENT] 无需要检查的策略") + return 0 + + # ----- 组合级监测:读取总仓位 + 弱势比例 ----- + position_pct = 0 + cash = 0 + total_assets = 0 + try: + with open(PORTFOLIO_PATH) as f: + pf = json.load(f) + position_pct = pf.get("position_pct", 0) + cash = pf.get("cash", 0) + total_assets = pf.get("total_assets", 0) + except Exception: + pass + # 统计持仓策略中弱势/深套的比例 + weak_count = 0 + holding_count = 0 + for d in decisions_list: + if d.get("type") == "持仓策略" and d.get("status") not in ("closed", "inactive"): + holding_count += 1 + cat = d.get("stock_category", "") + if cat in ("弱势", "深套"): + weak_count += 1 + weak_ratio = (weak_count / holding_count * 100) if holding_count > 0 else 0 + + prices = fetch_prices([d["code"] for d in to_check]) + now = datetime.now(timezone.utc).astimezone() + found = 0 + + for d in to_check: + code = d["code"] + name = d.get("name", code) + el = d.get("entry_low") + eh = d.get("entry_high") + sl = d.get("stop_loss") + tp = d.get("take_profit") + ts = d.get("created_at") or d.get("timestamp") or d.get("updated_at", "") + is_wl = "自选" in (d.get("type", "")) + + pi = prices.get(code) + if not pi: + continue + price, chg = pi + if price <= 0: + continue + + issues, flags = [], [] + tag = "[自选]" if is_wl else "[持仓]" + + # -- 偏离 -- + if is_wl and el and eh: + # 读取 timing_signal 判断策略有效性(timing_signal 字段优先,fallback to action) + current_str = d.get("current", "") or "" + timing_signal = d.get("timing_signal", "") or current_str + has_nonbuy_signal = any(kw in timing_signal for kw in [ + "等企稳再入", "等企稳", "弱势持有", "观望", + "不建议买入", "谨慎买入", + ]) + + # 直接计算 R/R(不依赖文本匹配) + rr_invalid = False + if sl and sl > 0 and tp and tp > 0 and price > sl: + rr = (tp - price) / (price - sl) + if rr < 1.5: + rr_invalid = True + # 也检查 tp 是否接近或低于成本(微盈/浮亏止盈) + cost = d.get("cost", 0) + if cost and cost > 0 and tp <= cost * 1.05: + rr_invalid = True + + strategy_deficient = has_nonbuy_signal or rr_invalid + # 对自选无止盈位的也标记(策略不完整) + if not tp or tp == 0: + strategy_deficient = True + + if el <= price <= eh: + flags.append("[WL_IN]") + if strategy_deficient: + flags.append("[STRATEGY_STALE]") + prefix = "⚠️仓位挤占 " if position_pct > 80 else "" + issues.append(f"[STRATEGY_STALE] {prefix}价{price:.2f}在买入区{el}~{eh}但策略不完整({'RR='+f'{rr:.2f}<1.5' if rr_invalid else '无止盈位' if not tp else '非买入信号'}),买入区需重评") + else: + prefix = "⚠️仓位挤占 " if position_pct > 80 else "" + issues.append(f"[PUSH] {prefix}价{price:.2f}入买入区{el}~{eh}") + elif price > eh * 1.35: + flags.append("[WL_HIGH]") + issues.append(f"价{price:.2f}高出买入区+{((price/eh)-1)*100:.0f}%,买入区需重评") + elif price > eh * 1.20: + flags.append("[WL_DRIFT]") + issues.append(f"价{price:.2f}高于买入区+{((price/eh)-1)*100:.0f}%") + elif not is_wl and eh: + dp = (price / eh - 1) * 100 + if dp > 35: + flags.append("[SEVERE]") + issues.append(f"偏离买入区上沿+{dp:.0f}%") + elif dp > 20: + flags.append("[DRIFT]") + issues.append(f"偏离买入区上沿+{dp:.0f}%") + elif dp > 10: + flags.append("[WARN]") + issues.append(f"偏离买入区上沿+{dp:.0f}%") + # 持仓在买入区内但 R/R 不达标 + if el and sl and sl > 0 and tp and tp > 0 and price > sl: + if el <= price <= eh: + rr = (tp - price) / (price - sl) + if rr < 1.5: + flags.append("[RR_WARN]") + issues.append(f"买入区内RR仅{rr:.2f}<1.5,策略需重评") + + # -- 距止损/止盈(仅持仓) -- + if not is_wl: + if sl and sl > 0: + dsl = (price / sl - 1) * 100 + if dsl < 5: + # 成本基准校验:浮盈>5%时止损是利润保护,不是危险信号 + # (mirrors NEAR_TP cost_check logic at line 195-198) + cost = d.get("cost") + if cost and cost > 0 and price > cost * 1.05: + flags.append("[PROFIT_PROTECT]") + pnl = (price / cost - 1) * 100 + issues.append(f"距止损仅{dsl:.1f}%(利润保护,浮盈{pnl:.0f}%)") + else: + flags.append("[NEAR_SL]") + issues.append(f"距止损仅{dsl:.1f}%") + if tp and tp > 0: + dtp = (tp / price - 1) * 100 + if dtp < 5: + # 成本基准校验:止盈标记只有在盈利≥5%时才有效 + cost_check = True + cost = d.get("cost") + if cost and cost > 0 and price < cost * 1.05: + cost_check = False + if cost_check: + flags.append("[NEAR_TP]") + issues.append(f"距止盈仅{dtp:.1f}%") + + # -- 过期 -- + stale_limit = 30 if is_wl else 14 + if ts: + try: + ud = datetime.fromisoformat(ts) + if ud.tzinfo is None: + ud = ud.replace(tzinfo=timezone.utc) + days = (now - ud).days + if days > stale_limit: + flags.append("[STALE]") + issues.append(f"{days}天未更新(>{stale_limit})") + except (ValueError, TypeError): + pass + + if issues: + print(f"{' '.join(flags)} {tag} {name}({code}) 价{price:.2f}{chg} | 买入{el}~{eh} | {'; '.join(issues)}") + found += 1 + + if found == 0: + print("[SILENT] 所有策略正常") + + # ----- 组合级警报 ----- + portfolio_alerts = 0 + if holding_count > 0: + if weak_ratio > 40: + print(f"\n[PORTFOLIO_WEAK] 组合弱势比例{weak_ratio:.0f}% ({weak_count}/{holding_count})!仓位{position_pct:.1f}% → 建议系统性减仓") + portfolio_alerts += 1 + elif weak_ratio > 30: + print(f"\n[PORTFOLIO_WEAK_MILD] 组合弱势比例{weak_ratio:.0f}% ({weak_count}/{holding_count}),仓位{position_pct:.1f}%,关注") + portfolio_alerts += 1 + if position_pct > 80 and holding_count > 0: + # 仓位过满提醒 + print(f"[PORTFOLIO_FULL] 总仓位{position_pct:.1f}% > 80%,现金{cash:.0f}({cash/total_assets*100:.1f}%)") + portfolio_alerts += 1 + if portfolio_alerts > 0: + found += portfolio_alerts + + return found + + +if __name__ == "__main__": + main() diff --git a/scripts/stale_push_wlin.py b/scripts/stale_push_wlin.py index 3d8493f..8ad3324 100644 --- a/scripts/stale_push_wlin.py +++ b/scripts/stale_push_wlin.py @@ -1,900 +1,901 @@ -#!/usr/bin/env python3 -""" -stale_push_wlin.py — 按5步逻辑推送自选股买入区提醒 + 自动触发重评 - -5步逻辑: -1. 筛选 is_watchlist=true 且价在买入区 -2. RR<1.5/无止盈位/非买入signal → 标记 STRATEGY_STALE → 触发自动重评 -3. 可推的:计算每手买入金额和现金占比 -4. 发现 STRATEGY_STALE → 后台跑 per_stock_reassess.py 自动重评 - -no_agent模式:有推送→输出;无→静默 -搭配 cron: no_agent=True, 交易日每30分跑一次 -""" -import subprocess -import sys -import re -import json -import os -import threading -import time -from datetime import datetime, time -from mo_data import read_portfolio, read_decisions - -# ── 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: - 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,)).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" -PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" -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 = ["观望", "弱势持有", "深套持有"] - - -def load_macro_line(): - """加载大盘和市场的简要描述""" - parts = [] - try: - # 优先 DB - import sqlite3 - db = sqlite3.connect("/home/hmo/MoFin/data/mofin.db") - 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}") - 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}") - 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(): - """从 portfolio.json 实时读可用现金(可用 ≈ 实时买力),不硬编码""" - 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): - """从腾讯行情API获取港股实际每手股数(字段[60]),带缓存""" - if code in _HK_LOT_CACHE: - return _HK_LOT_CACHE[code] - try: - url = f"http://qt.gtimg.cn/q=hk{code}" - req = Request(url, headers={"User-Agent": "curl/7.81"}) - with urlopen(req, timeout=5) as r: - text = r.read().decode("gbk") - raw = text.split("=", 1)[1].strip().strip('"').strip(";") - fld = raw.split("~") - lot = int(fld[60]) if len(fld) > 60 and fld[60] else 1000 - _HK_LOT_CACHE[code] = lot - return lot - 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() - - # 读 decisions.json 获取完整策略数据 - code_data = {} - try: - dec = read_decisions() - for e in dec.get("decisions", []): - code_data[e["code"]] = e - except Exception: - pass - - 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 - - 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-06-25] 所有预推票先重评,再出报告 - # 不只是 stale 的重评,所有在买入区的自选都先刷新策略,确保推荐不滞后 - to_reassess = list(set(s[1] for s in stocks) | set(s[1] for s in stale_list)) - if to_reassess: - trigger_regen_sync(to_reassess) - # 重评完成,re-read decisions.json 获取最新策略 - code_data = {} - try: - dec = read_decisions() - for e in dec.get("decisions", []): - code_data[e["code"]] = e - except Exception: - pass - - # 重新过滤:重评后可能有策略变化(止盈/止损/信号变动) - # 重建 stocks 列表,用新数据判断(不再用旧 is_stale 标记,因为已全部重评) - stocks = [] - for (name, code, price, buy_low, buy_high, cur, is_stale) in all_candidates: - # 重评后重新检查 actionability(用新 timing_signal) - sig = code_data.get(code, {}).get("timing_signal", "") - if not is_actionable(cur, sig): - 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 - # 趋势检查:必须不是空头排列(价格在MA5以下且MA5= 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 - - # 标准格式:每个可操作标的 — 大盘/行业/个股三面 + 仓位 - lines.append(f"【💡 操作建议】(当前{n}只自选可操作 | 总资产{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) - - # 修正可操作数量(剔除冷却跳过后的实际数量) - actual_n = len(lines) - (1 if macro_line else 0) - 1 # 减去市场背景 + 操作建议标题 - if actual_n != n: - # 更新操作建议行 - for i, ln in enumerate(lines): - if "【💡 操作建议】" in ln: - lines[i] = f"【💡 操作建议】(当前{actual_n}只自选可操作 | 总资产{total_assets:,.0f}元 现金{available_cash:,.0f}元)" - break - - if actual_n <= 0: - 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) - - 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()) +#!/usr/bin/env python3 +""" +stale_push_wlin.py — 按5步逻辑推送自选股买入区提醒 + 自动触发重评 + +5步逻辑: +1. 筛选 is_watchlist=true 且价在买入区 +2. RR<1.5/无止盈位/非买入signal → 标记 STRATEGY_STALE → 触发自动重评 +3. 可推的:计算每手买入金额和现金占比 +4. 发现 STRATEGY_STALE → 后台跑 per_stock_reassess.py 自动重评 + +no_agent模式:有推送→输出;无→静默 +搭配 cron: no_agent=True, 交易日每30分跑一次 +""" +import subprocess +import sys +import re +import json +import os +import threading +from mo_data import read_portfolio, read_decisions +import time +from datetime import datetime, time + +# ── 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: + 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,)).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" +PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" +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 = ["观望", "弱势持有", "深套持有"] + + +def load_macro_line(): + """加载大盘和市场的简要描述""" + parts = [] + try: + # 优先 DB + import sqlite3 + db = sqlite3.connect("/home/hmo/MoFin/data/mofin.db") + 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}") + 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}") + 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(): + """从 portfolio.json 实时读可用现金(可用 ≈ 实时买力),不硬编码""" + try: + with open(PORTFOLIO_PATH) as f: + data = json.load(f) + 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): + """从腾讯行情API获取港股实际每手股数(字段[60]),带缓存""" + if code in _HK_LOT_CACHE: + return _HK_LOT_CACHE[code] + try: + url = f"http://qt.gtimg.cn/q=hk{code}" + req = Request(url, headers={"User-Agent": "curl/7.81"}) + with urlopen(req, timeout=5) as r: + text = r.read().decode("gbk") + raw = text.split("=", 1)[1].strip().strip('"').strip(";") + fld = raw.split("~") + lot = int(fld[60]) if len(fld) > 60 and fld[60] else 1000 + _HK_LOT_CACHE[code] = lot + return lot + 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() + + # 读 decisions 获取完整策略数据 + code_data = {} + try: + dec = read_decisions() + for e in dec.get("decisions", []): + code_data[e["code"]] = e + except Exception: + pass + + 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 + + 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-06-25] 所有预推票先重评,再出报告 + # 不只是 stale 的重评,所有在买入区的自选都先刷新策略,确保推荐不滞后 + to_reassess = list(set(s[1] for s in stocks) | set(s[1] for s in stale_list)) + if to_reassess: + trigger_regen_sync(to_reassess) + # 重评完成,re-read decisions 获取最新策略 + code_data = {} + try: + dec = read_decisions() + for e in dec.get("decisions", []): + code_data[e["code"]] = e + except Exception: + pass + + # 重新过滤:重评后可能有策略变化(止盈/止损/信号变动) + # 重建 stocks 列表,用新数据判断(不再用旧 is_stale 标记,因为已全部重评) + stocks = [] + for (name, code, price, buy_low, buy_high, cur, is_stale) in all_candidates: + # 重评后重新检查 actionability(用新 timing_signal) + sig = code_data.get(code, {}).get("timing_signal", "") + if not is_actionable(cur, sig): + 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 + # 趋势检查:必须不是空头排列(价格在MA5以下且MA5= 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 + + # 标准格式:每个可操作标的 — 大盘/行业/个股三面 + 仓位 + lines.append(f"【💡 操作建议】(当前{n}只自选可操作 | 总资产{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) + + # 修正可操作数量(剔除冷却跳过后的实际数量) + actual_n = len(lines) - (1 if macro_line else 0) - 1 # 减去市场背景 + 操作建议标题 + if actual_n != n: + # 更新操作建议行 + for i, ln in enumerate(lines): + if "【💡 操作建议】" in ln: + lines[i] = f"【💡 操作建议】(当前{actual_n}只自选可操作 | 总资产{total_assets:,.0f}元 现金{available_cash:,.0f}元)" + break + + if actual_n <= 0: + 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) + + 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/strategy_lifecycle.py b/strategy_lifecycle.py index c475b3f..81c839e 100644 --- a/strategy_lifecycle.py +++ b/strategy_lifecycle.py @@ -84,11 +84,11 @@ STRATEGY_QUALITY_GATES = [ "fix": "tech_snapshot 包含强撑/弱撑/弱压/强压至少3个数值" }, { - "id": "GATE_CURRENCY_SET", - "desc": "港股决策金额应为人民币标价(currency=CNY)", - "check": lambda d: not is_hk_stock(d.get("code","")) or d.get("currency") in ("CNY", None), - "severity": "MEDIUM", - "fix": "设置 d['currency']='CNY'(系统统一存CNY)" + "id": "GATE_CURRENCY", + "severity": "CRITICAL", + "desc": "港股决策金额应为港币标价(currency=HKD),A股为CNY", + "check": lambda d: d.get("currency") in ("HKD", "CNY") or not d.get("code"), + "fix": "设置 d['currency']='HKD' for HK stocks" }, # --- 第4条 CRITICAL 红线:9维交叉验证 (2026-07-02 Dad要求) --- # 策略不能只有价格数字,必须有证据经过了多维分析: @@ -2293,10 +2293,10 @@ def regenerate_all(stdout=True): 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": "CNY", # 系统统一存人民币标价(mo_models规范) + "cost": old_entry.get("cost", cost) if old_entry else cost, + "shares": shares, + "avg_price": old_entry.get("avg_price", 0), + "currency": "HKD" if is_hk_stock(code) else "CNY", "action": result["action"], "stop_loss": result.get("stop_loss"), "entry_low": result["entry_low"], @@ -2526,7 +2526,7 @@ def regenerate_all(stdout=True): write_holdings_batch(conn, existing_pf.get('holdings', [])) write_portfolio_summary(conn, existing_pf) for s in wl.get('stocks', []): - s.setdefault('currency', 'CNY') + s.setdefault('currency', 'HKD') if is_hk_stock(str(s.get('code',''))) else s.setdefault('currency', 'CNY') write_watchlist_stock(conn, s) for d in decisions: # ── 策略质量门禁 ──