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:
+221
@@ -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 '有失败 ❌'}")
|
||||
Reference in New Issue
Block a user