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