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