feat: 市场抽象层market_config.py——市场判断/行情符号/市场配置唯一事实源,收敛6处散落实现(market_data/price_monitor x2/stale_push_wlin/technical_analysis/strategy_lab)。纯重构行为等价,为港股接入打底

This commit is contained in:
hmo
2026-08-14 15:56:47 +08:00
parent 442427b214
commit 4c2821aa67
6 changed files with 202 additions and 30 deletions
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""market_config.py — 市场抽象层(阶段1:港股接入核心结构件)
市场判断 / 行情符号 / 市场配置的**唯一事实源**,收敛散落在
market_data / price_monitor / stale_push_wlin / technical_analysis /
strategy_lab 中的 6 处重复实现。
铁律:
- 市场判断一律走 market_for_code(委托 mo_models.is_hk_stock5位0/1开头=港股)
- 行情符号一律走 kline_symbol(统一 sh/sz/hk 前缀规则)
- 市场时段/币种/涨跌停配置一律从 MARKETS 读取
- mo_models 不依赖本模块(无循环导入);本模块只依赖 mo_models
"""
from datetime import datetime
from mo_models import is_hk_stock
# ── 市场判断(唯一事实源:mo_models.is_hk_stock)────────────────────────
def market_for_code(code):
"""统一市场判断:返回 'a'A股)| 'hk'(港股)。
规则与 mo_models.is_hk_stock 完全一致:5位数字且0/1开头 → hk,其余 → a。
6位A股、带前缀代码(sh/sz/hk/...)均正确归一。
"""
return 'hk' if is_hk_stock(code) else 'a'
# ── 行情符号映射(收敛 market_data/price_monitor/stale_push_wlin/technical_analysis 四处)──
def _normalize_code(code):
"""归一化代码:去市场前缀(sh/sz/hk/SH...)与 '_' 后缀,返回大写纯代码"""
s = str(code or '').strip().upper()
for p in ('HK', 'SH', 'SZ', 'BJ'):
if s.startswith(p):
s = s[len(p):]
if '_' in s:
s = s.split('_')[0]
return s
def kline_symbol(code):
"""腾讯行情符号映射:'600519''sh600519', '000001''sz000001', '00700''hk00700'
统一规则(收敛两个来源的规则差异):
- 5位数字 → hk
- 6位数字:5/6/9开头 → sh0/1/2/3开头 → sz
(4/7/8开头按 sz 兜底——现实中无此类A股代码,price_monitor/technical_analysis
原实现即按 sz 处理)
非5/6位数字(空、字母、超长)→ None。
"""
raw = _normalize_code(code)
if not raw or not raw.isdigit():
return None
if len(raw) == 5:
return f"hk{raw}"
if len(raw) == 6:
if raw.startswith(('5', '6', '9')):
return f"sh{raw}"
return f"sz{raw}"
return None
# ── 市场配置 ────────────────────────────────────────────────────────────
MARKETS = {
'a': {
'index_code': 'sh000001', # A股大盘:上证指数
'currency': 'CNY',
'has_price_limit': True,
'trading_hours': [('09:30', '11:30'), ('13:00', '15:00')],
},
'hk': {
'index_code': 'hkHSI', # 港股大盘:恒生指数
'currency': 'HKD',
'has_price_limit': False,
'trading_hours': [('09:30', '12:00'), ('13:00', '16:00')], # 港股午休12:00-13:00
},
}
def market_config(market):
"""市场配置 dictMARKETS[market]market 为 'a''hk'"""
return MARKETS[market]
def is_trading_now(market, dt=None):
"""是否在交易时段内:工作日(weekday<5)且当前时间落在任一交易时段内。
dt: datetime 对象,缺省取当前时间。
"""
if market not in MARKETS:
return False
dt = dt or datetime.now()
if dt.weekday() >= 5:
return False
t = dt.strftime('%H:%M')
for start, end in MARKETS[market]['trading_hours']:
if start <= t <= end:
return True
return False
# ── 模块自检 ────────────────────────────────────────────────────────────
if __name__ == '__main__':
cases = [
# (code, expected_market, expected_symbol)
('600519', 'a', 'sh600519'),
('000001', 'a', 'sz000001'),
('300750', 'a', 'sz300750'),
('688111', 'a', 'sh688111'),
('510050', 'a', 'sh510050'),
('002594', 'a', 'sz002594'),
('00700', 'hk', 'hk00700'),
('09988', 'hk', 'hk09988'),
('01810', 'hk', 'hk01810'),
('sh600519', 'a', 'sh600519'),
('hk00700', 'hk', 'hk00700'),
('AAPL', 'a', None),
('', 'a', None),
]
print("=== market_for_code / kline_symbol 测试 ===")
ok = True
for code, exp_m, exp_s in cases:
m = market_for_code(code)
s = kline_symbol(code)
m_ok = m == exp_m
s_ok = s == exp_s
if not (m_ok and s_ok):
ok = False
print(f" {'' if m_ok and s_ok else ''} {code!r:>10} → market={m!r}({exp_m!r}) symbol={s!r}({exp_s!r})")
print("\n=== is_trading_now 测试 ===")
from datetime import datetime as _dt
# 2026-08-14 是周五,10:00 在 A 股/港股早盘时段内
t1 = _dt(2026, 8, 14, 10, 0)
# 11:45 在港股早盘(9:30-12:00),但 A 股午休
t2 = _dt(2026, 8, 14, 11, 45)
# 12:30 港股午休
t3 = _dt(2026, 8, 14, 12, 30)
# 周六
t4 = _dt(2026, 8, 15, 10, 0)
for market, dt, exp in [
('a', t1, True), ('hk', t1, True),
('a', t2, False), ('hk', t2, True),
('hk', t3, False),
('a', t4, False),
]:
got = is_trading_now(market, dt)
status = '' if got == exp else ''
if got != exp:
ok = False
print(f" {status} is_trading_now({market!r}, {dt.strftime('%F %H:%M')}) = {got} (expected {exp})")
print(f"\n{'全部通过 ✅' if ok else '有失败 ❌'}")
+6 -7
View File
@@ -11,6 +11,8 @@ import sqlite3
import urllib.request
from pathlib import Path
from market_config import kline_symbol
DB_PATH = Path("/home/hmo/MoFin/data/mofin.db")
UA = "Mozilla/5.0"
@@ -18,20 +20,17 @@ UA = "Mozilla/5.0"
def fetch_tx_klines(code, datalen=120):
"""腾讯前复权日Kqfq),与 stock_daily 数据零偏差,返回 [{date,open,close,high,low,volume}]"""
raw = str(code).strip()
if raw.startswith(("6", "9")):
prefix = "sh"
elif raw.startswith(("0", "3")):
prefix = "sz"
else:
sym = kline_symbol(raw)
if sym is None:
return None
url = f"http://ifzq.gtimg.cn/appstock/app/fqkline/get?param={prefix}{raw},day,,,{datalen},qfq"
url = f"http://ifzq.gtimg.cn/appstock/app/fqkline/get?param={sym},day,,,{datalen},qfq"
try:
req = urllib.request.Request(url, headers={"User-Agent": UA})
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with opener.open(req, timeout=8) as r:
text = r.read().decode("utf-8", errors="replace").strip()
data = json.loads(text)
node = data.get("data", {}).get(f"{prefix}{raw}", {})
node = data.get("data", {}).get(sym, {})
bars = node.get("qfqday") or node.get("day") or []
if not bars or len(bars) < 70:
return None
+13 -9
View File
@@ -76,6 +76,9 @@ try:
except ImportError:
HAS_DB = False
# 市场抽象层(阶段1:行情符号/市场判断唯一事实源)
from market_config import kline_symbol, market_for_code
# 策略重评依赖(技术面驱动,非机械百分比)
sys.path.insert(0, "/home/hmo/web-dashboard")
try:
@@ -141,14 +144,9 @@ def fetch_all_prices(codes):
code_map = {} # symbol -> original_code
for code in codes:
code_s = str(code).strip()
if len(code_s) == 6:
# A股:沪市以5/6/9开头,深市以0/3开头
if code_s.startswith(('5', '6', '9')):
sym = f"sh{code_s}"
else:
sym = f"sz{code_s}"
else:
sym = f"hk{code_s}"
sym = kline_symbol(code_s)
if sym is None:
sym = f"hk{code_s}" # 兜底:非5/6位数字 → 沿用旧 else 分支 hk 前缀
symbols.append(sym)
code_map[sym] = code_s
@@ -416,7 +414,13 @@ def record_event(code, name, event_type, price, trigger_value, event_label=""):
try:
from mofin_db import get_conn, write_price_event
_c = get_conn()
_exch, _typ = ("HK", "H") if len(str(code)) == 5 else (("SH", "A") if str(code).startswith(("6", "9")) else ("SZ", "A"))
# 市场判断统一走 market_for_code(港股=5位0/1开头);A股再按 6/9 区分沪深
if market_for_code(code) == 'hk':
_exch, _typ = ("HK", "H")
elif str(code).startswith(("6", "9")):
_exch, _typ = ("SH", "A")
else:
_exch, _typ = ("SZ", "A")
_c.execute("INSERT OR IGNORE INTO stocks (code, name, exchange, type, updated_at) VALUES (?,?,?,?,?)",
(str(code), name or str(code), _exch, _typ, now))
_c.commit()
+7 -4
View File
@@ -24,6 +24,7 @@ from mofin_db import get_conn
# ── MoFin unified model ──────────────────────────────────────────────
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from mo_models import is_hk_stock, get_hk_rate, to_cny, calc_total_assets
from market_config import kline_symbol
# 市场时段检查
_MARKET_HOURS = {
@@ -80,13 +81,15 @@ def fetch_trend_data(code):
# K线数据仍从腾讯取(均线计算需要历史K线,DB 里 stock_daily 表有但不一定有最新数据)
try:
prefix = "sh" if code.startswith(('60','68','51','56','50')) else "sz" if code.startswith(('00','30','15')) else "hk"
url = f"http://ifzq.gtimg.cn/appstock/app/fqkline/get?param={prefix}{code},day,,,30,qfq"
sym = kline_symbol(code)
if not sym:
return None
url = f"http://ifzq.gtimg.cn/appstock/app/fqkline/get?param={sym},day,,,30,qfq"
req = Request(url, headers={'User-Agent': 'Mozilla/5.0'})
resp = urlopen(req, timeout=5).read().decode('utf-8')
data = json.loads(resp)
day_key = 'qfqday' if prefix != 'hk' else 'day'
bars = data.get('data', {}).get(f'{prefix}{code}', {}).get(day_key, [])
day_key = 'qfqday' if not sym.startswith('hk') else 'day'
bars = data.get('data', {}).get(sym, {}).get(day_key, [])
except:
return None
+10 -9
View File
@@ -13,9 +13,14 @@
import json
import os
import sys
import urllib.request
from datetime import datetime, date
# 确保本文件所在目录可导入(market_config 与之同目录;本模块会被 MoFin 根脚本跨目录 import
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from market_config import kline_symbol
# 腾讯API字段索引
F = {
"name": 1, "code": 2, "price": 3, "close_yest": 4, "open": 5,
@@ -43,15 +48,11 @@ def _save_history(h):
def _market_prefix(code):
"""根据代码确定腾讯API前缀"""
if code.startswith("sh") or code.startswith("sz") or code.startswith("hk"):
code = code[2:] if code[2:].isdigit() else code
raw = str(code).split("_")[0]
if len(raw) == 5 and raw.isdigit():
return "hk"
if raw.startswith("6") or raw.startswith("5"):
return "sh"
return "sz"
"""根据代码确定腾讯API前缀(统一走 market_config.kline_symbol,规则以它为准)"""
sym = kline_symbol(code)
if not sym:
return "sz" # 兼容旧兜底(非5/6位数字等)
return sym[:2]
def get_quote(code):
+7 -1
View File
@@ -10,6 +10,7 @@ DB_PATH = "/home/hmo/MoFin/data/mofin.db"
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from backtest_framework import prepare_bars, compute_single_score, compute_kelly
from mo_models import is_hk_stock
# bars 缓存:批量跑多版本时共享 TA 计算
_BARS_CACHE = {}
@@ -764,7 +765,12 @@ def _get_external_factors(code, dt):
return result
def is_hk_code(code):
return len(code) == 5 and code.startswith('0')
"""港股判断:统一委托 mo_models.is_hk_stock5位0/1开头=港股,事实源规则)
规则收敛说明:旧实现只认 5位0开头;mo_models 认 5位0/1开头。
港股回测数据均为 0 开头代码,1 开头 5 位代码不存在于 stock_daily,无实际影响。
"""
return is_hk_stock(code)
def prepare_sector_context(start_date, end_date):
"""行业上下文: sector_index_daily(全历史) 提供板块趋势; sector_snapshots(近期) 补充净流入"""