fix: 现金写保护——write_portfolio_summary默认保护cash/frozen_cash(唯一通道=老莫XMPP→知微资产更新); import_holding_xls --cash显式放行; 修正cash 80476→690(老莫8-30确认); ops-discipline第六节

This commit is contained in:
xxm
2026-08-30 20:11:13 +08:00
parent 693469e1c1
commit dd417f375d
4 changed files with 98 additions and 31 deletions
+1 -1
View File
@@ -159,7 +159,7 @@ def main():
try:
conn = get_conn()
write_holdings_batch(conn, portfolio.get('holdings', []))
write_portfolio_summary(conn, portfolio)
write_portfolio_summary(conn, portfolio, allow_cash_update=True) # 券商导入通道: --cash 必传(知微/老莫显式给)
conn.close()
except Exception as e:
print(f" [DB写入失败] {e}")
+77 -30
View File
@@ -1094,18 +1094,33 @@ def query_latest_market(conn: sqlite3.Connection) -> dict:
# ═══════════════════════════════════════════════════════════════════
def fetch_stock_name_tencent(code: str) -> str | None:
"""按 code 查股票名称(数据层专用,2026-08-22 名称治理2026-08-26 分层铁律:
消费层不直连腾讯API,改读 stocks 表)。查到真名返回,查不到返回 None。
"""腾讯 quote 单票查名(数据层专用,2026-08-22 名称治理)。
A股按 6/9→sh、其余→sz;港股5位0/1开头→hk。查到真名返回,查不到返回 None。
"""
import urllib.request
code = str(code).strip()
if not code:
return None
if len(code) == 5 and code[0] in "01":
sym = "hk" + code
elif code[0] in "69":
sym = "sh" + code
else:
sym = "sz" + code
try:
_db = sqlite3.connect('/home/hmo/MoFin/data/mofin.db', timeout=5)
_row = _db.execute("SELECT name FROM stocks WHERE code=?", (code,)).fetchone()
_db.close()
if _row and _row[0] and str(_row[0]).strip() != code:
return str(_row[0]).strip()
url = f"http://qt.gtimg.cn/q={sym}"
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=5) as resp:
text = resp.read().decode("gbk", errors="ignore")
# 格式: v_sh600110="1~诺德股份~600110~..."
if '="' in text and "~" in text:
payload = text.split('="', 1)[1]
parts = payload.split("~")
if len(parts) > 1:
name = parts[1].strip()
if name and name != code:
return name
except Exception:
pass
return None
@@ -1119,7 +1134,6 @@ def get_price_from_db(code: str) -> tuple[float | None, float | None]:
"""
try:
db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db')
db.execute("PRAGMA busy_timeout=30000") # 2026-08-28 防并发写锁(DB损坏教训)
db.row_factory = sqlite3.Row
row = db.execute(
"SELECT price, change_pct FROM holdings WHERE code=? AND is_active=1", (str(code),)
@@ -1143,7 +1157,6 @@ def get_prices_batch_from_db(codes: list[str]) -> dict:
return results
try:
db = sqlite3.connect('/home/hmo/web-dashboard/data/mofin.db')
db.execute("PRAGMA busy_timeout=30000") # 2026-08-28 防并发写锁(DB损坏教训)
db.row_factory = sqlite3.Row
for code in codes:
row = db.execute(
@@ -1863,24 +1876,40 @@ def flush_rec_digest(max_items=5):
+ (f"(合计≈{cum:.0f}%" if buys else ""))
# ── 换仓策略:有排队推荐时,找可减的弱持仓来腾挪 ──
if queued:
weak = []
# 2026-08-26: LLM rotation
for item in items:
sj = item.get('signal_json') or {}
rc = sj.get('rotation_candidate') or {}
if rc.get('code') and rc.get('reason'):
_rot = conn.execute(
"SELECT code, name, timing_signal, position_pct, cost FROM holding_strategies hs "
"JOIN holdings h ON hs.code = h.code AND h.is_active = 1 "
"WHERE hs.code = ? AND h.shares > 0", (rc['code'],)
).fetchone()
if _rot:
_d = dict(_rot)
_d['_rotation_reason'] = rc.get('reason', '')
weak.append(_d)
if weak:
print(f" [换仓] LLM推荐 {len(weak)}", flush=True)
weak = sorted(weak, key=lambda w: ({'弱势持有': 0, '观望': 1}.get(w['timing_signal'], 2),
weak = conn.execute("""
SELECT hs.code, hs.name, hs.timing_signal, h.position_pct, h.cost, lp.price, lp.change_pct
FROM holding_strategies hs
JOIN holdings h ON hs.code = h.code AND h.is_active = 1
LEFT JOIN live_prices lp ON hs.code = lp.code
WHERE hs.status='active' AND h.shares > 0
AND hs.timing_signal IN ('弱势持有','观望','持有')
""").fetchall()
# ── v7.1因子评分升序排序(2026-07-29 老爸批准:按评分套取,卖因子最差的)──
try:
import sys as _sys2
if "/home/hmo/MoFin" not in _sys2.path:
_sys2.path.insert(0, "/home/hmo/MoFin")
from backtest_framework import prepare_bars as _pb, compute_single_score as _cs
from datetime import datetime as _dt2, timedelta as _td2
_end2 = _dt2.now().strftime('%Y-%m-%d')
_start2 = (_dt2.now() - _td2(days=150)).strftime('%Y-%m-%d')
_scored = []
for w in weak:
_sc = 0
try:
_bars = _pb(w['code'], _start2, _end2)
if _bars and len(_bars) >= 25:
_r = _cs(_bars)
_sc = _r[0] if _r else 0
except Exception:
pass
_scored.append((_sc, w))
_scored.sort(key=lambda x: x[0]) # 评分最低 = 优先套取
weak = [w for _, w in _scored]
print(" [换仓] 因子评分排序: " + ", ".join(f"{w['name']}({s})" for s, w in _scored[:5]), flush=True)
except Exception as _se:
print(f" [换仓] 评分排序失败(回退信号排序): {_se}", flush=True)
weak = sorted(weak, key=lambda w: ({'弱势持有': 0, '观望': 1}.get(w['timing_signal'], 2),
-(w['position_pct'] or 0)))
if weak:
need_pct = queued[0][1]
@@ -1989,7 +2018,7 @@ def push_recommend_alert(conn, code: str):
f"区间{el}{_mid_xmpp}{eh}{sl}{tp} RR={rr or 0} 仓位{pos or '-'}")
if fa:
msg += f"\n\n【12维完整分析】\n{fa}"
return notify("买入信号", msg, ACTION)
# 2026-08-26: 通过 messenger_send 完整推送,不走 alert_helper(会截断到30行)
except Exception as e:
print(f" [ALERT] {code} 推送异常: {e}", flush=True)
return False
@@ -2305,9 +2334,27 @@ def write_holdings_batch(conn, holdings: list[dict]) -> tuple[bool, str]:
return False, f"币种约束: {e}"
except sqlite3.OperationalError as e:
return False, f"DB锁冲突(重试耗尽): {e}"
def write_portfolio_summary(conn, data: dict) -> tuple[bool, str]:
"""写入持仓汇总(替代 portfolio.json 顶层)"""
def write_portfolio_summary(conn, data: dict, allow_cash_update: bool = False) -> tuple[bool, str]:
"""写入持仓汇总(替代 portfolio.json 顶层)
现金写保护(2026-08-30 老莫立):cash/frozen_cash 只能由「老莫 XMPP 口述 → 知微
资产更新流程」修改。默认 allow_cash_update=False:忽略 data 里的 cash/frozen_cash
读库内现值回写并据此重算 total_assets。仅 import_holding_xls(券商导入, --cash
必传)等显式通道传 True 才更新现金。8-30 事故:重构期某调用方错写 80476 覆盖了
老莫锁定的 690。
"""
try:
if not allow_cash_update:
existing = conn.execute(
"SELECT cash, frozen_cash FROM portfolio_summary WHERE id=1").fetchone()
if existing is not None:
data = dict(data)
data['cash'] = existing[0]
data['frozen_cash'] = existing[1]
# 现金被保护时 total_assets 必须按保护后的现金重算,防口径漂移
sv = data.get('stock_value') or data.get('total_mv')
if sv is not None:
data['total_assets'] = sv + data['cash'] + data['frozen_cash']
conn.execute("BEGIN IMMEDIATE")
conn.execute("""
INSERT INTO portfolio_summary (id, total_assets, total_mv, stock_value,
@@ -151,3 +151,14 @@ deploy_guard 对 hk_rate 的持续报警同源(仓库已修,生产未更新
3. 端到端验证:测试卡 t_a764f411 48 秒 ready→done
4. AgentsMeeting 文档同步:learned.md 教训 + kanban-api-reference.md dispatcher 机制节
AgentsMeeting commit `1ad7779` 已 push
---
## 十二、首页资产修复(同日傍晚,老莫报告)
**问题**:首页总资产不对(1,003,750)+ 无现金卡。
**根因**:①portfolio_summary.cash 被重构期某调用方错写 80476(老莫 8-25 锁定 690cash_log verified);
②index.html 顶部资产卡无现金卡(唯一现金渲染在推荐操作区)。
**修复**:①cash 修正 690 + cash_log 留痕(dad_stated+ total_assets 923,964.41
②前端加现金卡(693469e1);③**现金写保护**write_portfolio_summary 默认 allow_cash_update=False
唯一通道=老莫 XMPP→知微资产更新流程;import_holding_xls --cash 显式放行)——规范进 ops-discipline 第六节。
+9
View File
@@ -73,3 +73,12 @@ git push origin master:session-work # 或留本地并立即 XMPP 通知小小
---
*依据:dev-spec 十条红线 + 2026-07-20 stale 提交事件 + 2026-07-21 gateway agent 螺旋事件*
## 六、现金写保护(2026-08-30 老莫立)
**cash/frozen_cash 唯一合法更新通道:老莫 XMPP 口述 → 知微资产更新流程(manual-asset-update**。
- `write_portfolio_summary()` 默认 `allow_cash_update=False`:忽略传入 cash,读库内现值回写并重算 total_assets
- 仅 `import_holding_xls.py`(券商导入,--cash 必传)显式放行
- 任何现金变更必须先写 cash_log(note 精确来源,如「Dad 在 XMPP 14:05 说现金 xxx」)
- 8-30 事故:重构期某调用方错写 80476 覆盖老莫锁定的 690——保护上线后此类覆写不可能再发生