diff --git a/deploy/profile-scripts/market_config.py b/deploy/profile-scripts/market_config.py new file mode 100644 index 00000000..34c666fd --- /dev/null +++ b/deploy/profile-scripts/market_config.py @@ -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_stock,5位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开头 → sh;0/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): + """市场配置 dict:MARKETS[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 '有失败 ❌'}") diff --git a/deploy/profile-scripts/market_data.py b/deploy/profile-scripts/market_data.py index 34ee2f33..98e7f806 100644 --- a/deploy/profile-scripts/market_data.py +++ b/deploy/profile-scripts/market_data.py @@ -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): """腾讯前复权日K(qfq),与 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 diff --git a/deploy/profile-scripts/price_monitor.py b/deploy/profile-scripts/price_monitor.py index 398e6e26..f7b7940a 100644 --- a/deploy/profile-scripts/price_monitor.py +++ b/deploy/profile-scripts/price_monitor.py @@ -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() diff --git a/deploy/profile-scripts/stale_push_wlin.py b/deploy/profile-scripts/stale_push_wlin.py index e40361ef..599a59de 100644 --- a/deploy/profile-scripts/stale_push_wlin.py +++ b/deploy/profile-scripts/stale_push_wlin.py @@ -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 diff --git a/deploy/profile-scripts/technical_analysis.py b/deploy/profile-scripts/technical_analysis.py index 51fcb891..d6489afb 100644 --- a/deploy/profile-scripts/technical_analysis.py +++ b/deploy/profile-scripts/technical_analysis.py @@ -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): diff --git a/strategy_lab.py b/strategy_lab.py index fa08c81b..2fc67738 100644 --- a/strategy_lab.py +++ b/strategy_lab.py @@ -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_stock(5位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(近期) 补充净流入"""