feat: 市场抽象层market_config.py——市场判断/行情符号/市场配置唯一事实源,收敛6处散落实现(market_data/price_monitor x2/stale_push_wlin/technical_analysis/strategy_lab)。纯重构行为等价,为港股接入打底
This commit is contained in:
@@ -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 '有失败 ❌'}")
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user