Files
MoFin/mo_data.py
T
知微 e185b4e4dc feat: DB-first architecture with lock-safe writes
- price_monitor: writes live prices to both JSON and mofin.db (holdings + live_prices + portfolio_summary)
- mofin_db: added execute_with_retry/commit_with_retry with exponential backoff on 'database is locked'
- mofin_db: increased timeout 5s->15s, added PRAGMA busy_timeout=15000
- price_monitor retry loop: fixed break-before-if-ok bug (was not retrying on write failure)
- DB connection: WAL mode + retry decorator for all write operations
- cash sync: preserves DB authoritative cash (JSON cash not pushed to DB)

This is the DB-first version. JSON writes remain for dashboard compatibility.
Next step: remove JSON writes entirely for full DB-only architecture.
2026-07-06 12:02:11 +08:00

222 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
mo_data.py — MoFin 统一数据层(纯 DB)
所有数据从 SQLite 读取。不做 JSON fallback。
JSON 文件已弃用,仅保留为历史备份。
用法:
from mo_data import read_portfolio, read_decisions, read_watchlist
pf = read_portfolio() # 返回和 portfolio.json 一样的 dict 结构
dec = read_decisions() # 返回和 decisions.json 一样的 dict 结构
wl = read_watchlist() # 返回和 watchlist.json 一样的 dict 结构
"""
import sqlite3, json
from datetime import datetime
DB_PATH = '/home/hmo/web-dashboard/data/mofin.db'
def _get_db():
"""获取数据库连接(WAL 模式,15秒超时防并发锁)"""
db = sqlite3.connect(DB_PATH, timeout=15)
db.row_factory = sqlite3.Row
db.execute("PRAGMA journal_mode=WAL")
db.execute("PRAGMA busy_timeout=15000")
return db
# ── portfolio ─────────────────────────────────────────────────────
def read_portfolio():
"""返回 portfolio.json 等价 dict。纯 DB。
总市值从 holdings 实时计算(shares × price,港股 × 汇率),
不信任 portfolio_summary 的存储值,因为可能未及时更新。
"""
db = _get_db()
rows = db.execute(
"SELECT code, name, shares, cost, price, market_value, "
"change_pct, currency, position_pct "
"FROM holdings WHERE is_active=1"
).fetchall()
holdings = []
for r in rows:
h = dict(r)
h['_currency'] = h.get('currency', 'CNY')
holdings.append(h)
sum_row = db.execute("SELECT * FROM portfolio_summary WHERE id=1").fetchone()
summary = dict(sum_row) if sum_row else {}
db.close()
# ── 总市值从 holdings 实时计算 ────────────────────
cash = float(summary.get('cash', 0) or 0)
frozen = float(summary.get('frozen_cash', 0) or 0)
# 获取港股汇率
import subprocess, os as _os
rate = 0.865 # 默认值
try:
hk_rate_py = _os.path.join(_os.path.dirname(_os.path.dirname(__file__)), 'hk_rate.py')
if _os.path.exists(hk_rate_py):
r = subprocess.run(
[_os.path.join(_os.path.dirname(_os.path.dirname(__file__)), 'venv', 'bin', 'python3'),
hk_rate_py, '--rate'],
capture_output=True, text=True, timeout=10
)
if r.returncode == 0 and r.stdout.strip():
rate = float(r.stdout.strip())
except Exception:
pass
total_mv = 0.0
for h in holdings:
p = float(h.get('price', 0) or 0)
s = float(h.get('shares', 0) or 0)
if h.get('currency') == 'HKD' or (len(str(h.get('code',''))) == 5 and str(h.get('code',''))[0] in ('0','1')):
total_mv += s * p * rate
else:
total_mv += s * p
total_mv = round(total_mv, 2)
total_assets = round(total_mv + cash + frozen, 2)
position_pct = round(total_mv / total_assets * 100, 2) if total_assets > 0 else 0
return {
"holdings": holdings,
"total_assets": total_assets,
"total_mv": total_mv,
"stock_value": total_mv,
"cash": cash,
"frozen_cash": frozen,
"position_pct": position_pct,
"currency": summary.get("currency", "CNY"),
"updated_at": summary.get("updated_at", ""),
}
# ── decisions ─────────────────────────────────────────────────────
def _parse_json(val, default):
if val:
try: return json.loads(val)
except: pass
return default
def read_decisions():
"""返回 decisions.json 等价 dict。纯 DB。"""
db = _get_db()
rows = db.execute(
"SELECT code, name, version, price, cost, shares, "
"stop_loss, take_profit, entry_low, entry_high, "
"currency, strategy_type, action, timing_signal, "
"rr_ratio, tech_snapshot, stock_category, sector_context, "
"status, trigger_json, changelog_json, source, reason, "
"created_at, updated_at, "
"avg_price, decision_timestamp, note, quality_check, "
"quality_checked_at, quality_issues_json, position_advice, "
"signal_factors_json, time_horizon, decision_type "
"FROM holding_strategies WHERE status IN ('active','updated') "
"ORDER BY code"
).fetchall()
decisions = []
for r in rows:
d = dict(r)
d['trigger'] = _parse_json(r['trigger_json'], {})
d['changelog'] = _parse_json(r['changelog_json'], [])
d['quality_issues'] = _parse_json(r['quality_issues_json'], {})
d['signal_factors'] = _parse_json(r['signal_factors_json'], [])
d['timestamp'] = r['decision_timestamp'] or r['created_at'] or ''
d['type'] = r['decision_type'] or r['strategy_type'] or '持仓策略'
decisions.append(d)
db.close()
return {
"decisions": decisions,
"total": len(decisions),
"regenerated_at": datetime.now().strftime('%Y-%m-%d %H:%M'),
}
# ── watchlist ─────────────────────────────────────────────────────
def read_watchlist():
"""返回 watchlist.json 等价 dict。纯 DB。"""
db = _get_db()
rows = db.execute(
"SELECT code, name, price, entry_low, entry_high, "
"stop_loss, currency, source, source_detail, notes, "
"added_by, added_at, analysis_json "
"FROM watchlist_stocks WHERE is_active=1"
).fetchall()
stocks = []
for r in rows:
s = dict(r)
s['source_detail'] = _parse_json(r['source_detail'], None)
if r['analysis_json']:
s['analysis'] = _parse_json(r['analysis_json'], {})
else:
s['analysis'] = {}
stocks.append(s)
db.close()
return {
"stocks": stocks,
"updated_at": datetime.now().strftime('%Y-%m-%d %H:%M'),
}
# ── 便捷别名 ───────────────────────────────────────────────────────
def read_portfolio_json():
return read_portfolio()
def read_decisions_json():
return read_decisions()
def read_watchlist_json():
return read_watchlist()
# ── cash_log 写入 ──────────────────────────────────────────────────
def write_cash_log(cash_before, cash_after, frozen_before, frozen_after,
source, note, verified=0):
"""记录现金变更到 cash_log 表。"""
change_amount = round(cash_after - cash_before, 2) if cash_after is not None and cash_before is not None else 0
db = sqlite3.connect(DB_PATH)
try:
cur = db.execute(
"""INSERT INTO cash_log
(timestamp, cash_before, cash_after, frozen_before, frozen_after,
change_amount, source, note, verified)
VALUES (datetime('now','localtime'), ?, ?, ?, ?, ?, ?, ?, ?)""",
(cash_before, cash_after, frozen_before, frozen_after,
change_amount, source, note, verified)
)
db.commit()
return cur.lastrowid
finally:
db.close()
# ── 自检 ───────────────────────────────────────────────────────────
if __name__ == "__main__":
pf = read_portfolio()
print(f"portfolio: {len(pf.get('holdings',[]))} holdings, total_assets={pf.get('total_assets',0)}")
dec = read_decisions()
print(f"decisions: {len(dec.get('decisions',[]))} entries")
wl = read_watchlist()
print(f"watchlist: {len(wl.get('stocks',[]))} stocks")