Files
MoFin/scripts/mo_models.py
T
知微 66962ae190 fix: 全面系统修复 — delivery目标修正 + 脚本同步 + refresh_mtf_cache import修复 + clean_watchlist硬编码修复
修复清单:
- cron delivery修正:10个job从deliver=origin/all改为local,消除推送错误
- refresh_mtf_cache.py:替换strategy_lifecycle.PORTFOLIO_PATH导入为mofin_db直接读DB
- clean_watchlist.py:修复mo_data导入名错误和JSON硬编码路径
- MoFin/scripts/与profile scripts互相补全,双向sync到一致
- price_monitor.py:现金源从portfolio_summary表改为cash_log(Dad确认的现金权威)
- 更新watchlist.json加入000850华茂股份
2026-07-06 14:01:54 +08:00

228 lines
7.9 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):
"""计算持仓总市值。港股 price 为 HKD,需 × 汇率转 CNY"""
total = 0
rate = get_hk_rate()
for h in (holdings or []):
p = (h.get('price', 0) or 0)
s = (h.get('shares', 0) or 0)
if h.get('currency') == 'HKD' or (str(h.get('code','')).startswith(('0','1')) and len(str(h.get('code',''))) == 5):
total += s * p * rate
else:
total += s * p
return round(total, 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 '有失败 ❌'}")