refactor: phase 0-2 MoFin architecture reform — single source of truth

Phase 0 (止血):
- mo_models.py: unified calc_total_assets(), is_hk_stock(), get_hk_rate() — single source of truth
- Fixed 3 files missing frozen_cash: holdings_reconciliation, server, import_holding_xls
- Fixed stale_push_wlin: unified is_hk_stock detection, removed hardcoded 0.866
- Fixed price_monitor: consolidated 2 duplicate total_assets blocks into mo_models calls
- Fixed stock_scorer: replaced broken len()<=5 is_hk_stock heuristic
- Fixed strategy_lifecycle: replaced non-existent currency_utils import with mo_models

Phase 1 (DSA adapter):
- mo_provider.py: wraps DSA DataFetcherManager (16 fetchers, auto-fallback)
  - TDX relay as primary, DSA as backup for realtime/kline/news/fundamentals

Phase 2 (Integration):
- mo_bridge.py: injects DSA market review + news context into MoFin analysis prompts
- Graceful degradation if DSA not installed

Infrastructure:
- mo_config.py: centralized Config singleton replacing scattered hardcoded paths
- All 11 changed files pass python compile check

Impact: total_assets now computed in ONE place (mo_models).
        is_hk_stock now ONE implementation (no more false negatives).
        HK rate now ONE source (hk_rate API → cache → 0.87 fallback).
        No more hardcoded 0.866/0.8664/0.8700 divergence.
This commit is contained in:
hmo
2026-06-29 23:25:54 +08:00
parent 7d49470aeb
commit 6abc2e45b0
11 changed files with 954 additions and 69 deletions
+221
View File
@@ -0,0 +1,221 @@
# -*- coding: utf-8 -*-
"""
mo_models.py — MoFin 唯一数据模型(Single Source of Truth
⚠️ 铁律:MoFin 中所有以下操作必须走这个文件,严禁各自实现:
1. 判断港股 — 用 is_hk_stock(code)
2. 计算总资产 — 用 calc_total_assets(pf)
3. 获取港币汇率 — 用 get_hk_rate()
4. 币种转换 — 用 to_cny(price, code)
创建日期: 2026-06-29
原因: 之前 total_assets 在 6+ 文件中各自计算,公式不一致(3个漏了 frozen_cash);
is_hk_stock 有 3 种不同实现,存在误判风险;
hk_rate 在多个文件中硬编码不同值(0.866/0.87/0.8664/0.8700)。
"""
import sys
import os
# ── 港股检测 ────────────────────────────────────────────────────────
def is_hk_stock(code):
"""判断是否为港股。
规则:港股代码为5位数字,以0或1开头。
例:00700(腾讯), 01888(建滔积层板), 00981(中芯国际)
排除:
- A股6位代码如 000657(中钨高新) — len==6 不会被误判
- 美股字母代码如 AAPL
- 带前缀的代码如 hk00700 → 自动去前缀后判断
"""
code = (str(code or '')).strip().upper()
# 去常见前缀
for prefix in ('HK', 'SH', 'SZ', 'BJ'):
if code.startswith(prefix):
code = code[len(prefix):]
# 港股: 5位数字, 0或1开头
return len(code) == 5 and code.isdigit() and code[0] in ('0', '1')
def is_a_stock(code):
"""判断是否为A股(沪深京)"""
code = (str(code or '')).strip().upper()
for prefix in ('SH', 'SZ', 'BJ'):
if code.startswith(prefix):
code = code[len(prefix):]
if len(code) == 6 and code.isdigit():
if code.startswith(('0', '3', '6')):
return True
if code.startswith(('4', '8', '9')):
return True # 北交所/科创板
return False
def normalize_code(code):
"""归一化股票代码:去市场前缀,去后缀,统一大写"""
code = (str(code or '')).strip().upper()
for prefix in ('HK', 'SH', 'SZ', 'BJ'):
if code.startswith(prefix):
code = code[len(prefix):]
return code
# ── 港币汇率 ──────────────────────────────────────────────────────────
def get_hk_rate():
"""获取 HKD→CNY 汇率。优先用 hk_rate 模块(支持API+缓存),失败回退 0.87"""
try:
from hk_rate import hkd_to_cny
return hkd_to_cny()
except Exception:
pass
# 最后的兜底
return 0.87
def to_cny(price, code):
"""如果 code 是港股,把 price 从 HKD 转为 CNY;否则原样返回"""
if price is None or price == 0:
return price
if is_hk_stock(code):
return round(float(price) * get_hk_rate(), 2)
return price
# ── 总资产计算(唯一公式) ────────────────────────────────────────────
def calc_total_mv(holdings):
"""计算持仓总市值(所有价格已为 CNY"""
return round(sum(
(h.get('shares', 0) or 0) * (h.get('price', 0) or 0)
for h in (holdings or [])
), 2)
def calc_total_assets(pf):
"""
计算总资产 = 持仓市值 + 可用现金 + 冻结资金
这是 MoFin 中 total_assets 的 **唯一正确公式**。
所有文件必须调用此函数,严禁各自实现。
Args:
pf: dict,包含 holdings、cash、frozen_cash 字段
Returns:
float: 总资产(人民币)
"""
total_mv = calc_total_mv(pf.get('holdings', []))
cash = float(pf.get('cash', 0) or 0)
frozen = float(pf.get('frozen_cash', 0) or 0)
return round(total_mv + cash + frozen, 2)
def calc_position_pct(pf):
"""计算仓位百分比"""
total = calc_total_assets(pf)
if total > 0:
total_mv = calc_total_mv(pf.get('holdings', []))
return round(total_mv / total * 100, 2)
return 0
# ── 数据验证 ──────────────────────────────────────────────────────────
def validate_portfolio(pf):
"""验证 portfolio.json 数据一致性,返回 issues 列表"""
issues = []
holdings = pf.get('holdings', [])
# 1. 总资产校验
stored = pf.get('total_assets', 0)
calculated = calc_total_assets(pf)
if stored > 0 and abs(stored - calculated) / max(stored, 1) > 0.01:
issues.append(
f"total_assets 不匹配: 存储{stored:.2f} ≠ 计算{calculated:.2f}"
f" (市值{calc_total_mv(holdings):.2f}+现金{pf.get('cash',0):.2f}+冻结{pf.get('frozen_cash',0):.2f})"
)
# 2. 币种一致性
for h in holdings:
code = str(h.get('code', ''))
currency = h.get('currency', h.get('_currency', ''))
if is_hk_stock(code) and currency == 'HKD':
issues.append(
f"⚠️ 港股{code}({h.get('name','?')}) currency=HKD"
f"portfolio.json 应全部存 CNY"
)
# 3. 零股检查
for h in holdings:
if (h.get('shares', 0) or 0) <= 0 and h.get('code'):
issues.append(f"持仓{h.get('code')}({h.get('name','?')}) 股数为0或负数")
return issues
# ── 向后兼容别名 ──────────────────────────────────────────────────────
# 让旧代码中散落的 is_hk_stock 引用也能正确工作
__all__ = [
'is_hk_stock',
'is_a_stock',
'normalize_code',
'get_hk_rate',
'to_cny',
'calc_total_mv',
'calc_total_assets',
'calc_position_pct',
'validate_portfolio',
]
# 模块自检
if __name__ == '__main__':
# 测试 is_hk_stock
test_cases = [
('00700', True), # 腾讯
('01888', True), # 建滔积层板
('000657', False), # 中钨高新 A股
('600519', False), # 茅台 A股
('AAPL', False), # 苹果 美股
('hk00700', True), # 带前缀港股
('SH600519', False), # 带前缀A股
]
print("=== is_hk_stock 测试 ===")
all_ok = True
for code, expected in test_cases:
result = is_hk_stock(code)
status = '' if result == expected else ''
if result != expected:
all_ok = False
print(f" {status} is_hk_stock('{code}') = {result} (expected {expected})")
# 测试 calc_total_assets
print("\n=== calc_total_assets 测试 ===")
pf = {
'holdings': [
{'code': '00700', 'shares': 100, 'price': 365.0},
{'code': '600519', 'shares': 200, 'price': 1700.0},
],
'cash': 50000.0,
'frozen_cash': 10000.0,
}
expected_mv = 100 * 365.0 + 200 * 1700.0 # 36500 + 340000 = 376500
expected_ta = expected_mv + 50000 + 10000 # 436500
calc_ta = calc_total_assets(pf)
print(f" total_mv = {calc_total_mv(pf['holdings'])} (expected {expected_mv})")
print(f" total_assets = {calc_ta} (expected {expected_ta})")
print(f" {'' if abs(calc_ta - expected_ta) < 0.01 else ''} calc_total_assets")
# 测试 validate
print("\n=== validate_portfolio 测试 ===")
issues = validate_portfolio(pf)
if issues:
for i in issues:
print(f" ⚠️ {i}")
else:
print(" ✅ 无问题")
print(f"\n{'全部通过 ✅' if all_ok else '有失败 ❌'}")