Files
MoFin/mo_models.py
T
hmo 6abc2e45b0 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.
2026-06-29 23:25:54 +08:00

222 lines
7.6 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.
# -*- 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 '有失败 ❌'}")