tradingSystem/app/services/market.py

396 lines
16 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 -*-
"""
行情与参考位取数 (best-effort, 取不到一律返回 None 由上层降级)
==============================================================
现价: Redis db13 (SIGNAL_REDIS_DB_QUOTES), key `tushare:rt_min:1MIN:{600000.SH}`,
值为当日分钟 K 线数组 —— 与 bionic_trader 既有读法完全一致, 不另立口径。
参考位: 主口径取决策系统 strategy_daily_results 的支撑/压力; 日龄超 PMS_REF_STALE_TDAYS
转兜底自算 (设计 §13): 支撑 = max(MA20, 近20日低点×1.01), 压力 = 近60日高点,
止损 = 底仓成本 N×ATR 与支撑取高。因子分表 gp_stock_factor_pro_YYYYMM 经 153 代理
逐表单查 (跨分表 UNION 会被代理拒绝, 沿用 bionic 的踩坑结论)。
"""
from __future__ import annotations
import json
import logging
import threading
from datetime import datetime, timedelta
from config.settings import settings
from app.db.session import fetch_all
from app.repo import downstream_repo
from app.services import param_store
logger = logging.getLogger("pms.market")
_redis = None
_lock = threading.Lock()
QUOTE_KEY = "tushare:rt_min:1MIN:{code}"
def _r():
"""行情 Redis 客户端。
强制 RESP2 (protocol=2): 目标 Redis 是 6.0 以前的版本, 不认 RESP3 的 HELLO 命令,
而 redis-py 6.x 默认按 RESP3 握手, 会直接报 "unknown command `HELLO`" 全盘取不到价。
requirements.txt 已把 redis 锁在 5.x, 这里再显式声明一道 (老版本 redis-py 不认这个
参数, 用 TypeError 兜回去)。
"""
global _redis
if _redis is not None:
return _redis
with _lock:
if _redis is None:
import redis
kw = dict(host=settings.SIGNAL_REDIS_HOST, port=settings.SIGNAL_REDIS_PORT,
password=settings.SIGNAL_REDIS_PASSWORD or None,
db=settings.SIGNAL_REDIS_DB_QUOTES, decode_responses=True,
socket_timeout=settings.SIGNAL_REDIS_SOCKET_TIMEOUT)
try:
_redis = redis.Redis(protocol=2, **kw)
except TypeError:
_redis = redis.Redis(**kw)
return _redis
def _bars(code: str):
try:
raw = _r().get(QUOTE_KEY.format(code=code))
if not raw:
return None
bars = json.loads(raw)
return bars if isinstance(bars, list) and bars else None
except Exception as e:
logger.warning("实时行情读取失败 [%s]: %s", code, e)
return None
def get_price(ts_code: str):
bars = _bars(ts_code)
if not bars:
return None
try:
px = float(bars[-1].get("close") or 0)
return px if px > 0 else None
except (TypeError, ValueError):
return None
def get_prices(codes) -> dict:
"""批量取现价 (逐 key GET; 数量级为持仓数, 无需 pipeline 复杂化)。"""
out = {}
for c in codes or []:
out[c] = get_price(c)
return out
def day_snapshot(ts_code: str) -> dict:
"""当日分钟线聚合: 现价/开盘/最高/最低/均价(VWAP近似)/涨幅 —— 择时与不追高检查用。"""
bars = _bars(ts_code)
if not bars:
return {}
try:
pairs = [(float(b.get("close") or 0), float(b.get("vol") or 0)) for b in bars]
pairs = [(c, v) for c, v in pairs if c > 0] # 坏 bar 价量成对剔除, 不错位 (2026-08-28)
closes = [c for c, _ in pairs]
if not closes:
return {}
highs = [float(b.get("high") or b.get("close") or 0) for b in bars]
lows = [float(b.get("low") or b.get("close") or 0) for b in bars if
float(b.get("low") or b.get("close") or 0) > 0]
opens = float(bars[0].get("open") or closes[0])
vols = [v for _, v in pairs]
amt = sum(c * v for c, v in zip(closes, vols))
vwap = (amt / sum(vols)) if sum(vols) > 0 else sum(closes) / len(closes)
px = closes[-1]
return {"price": px, "open": opens, "high": max(highs) if highs else px,
"low": min(lows) if lows else px, "vwap": round(vwap, 3),
"day_chg_from_open": (px / opens - 1) if opens else None,
"bar_time": bars[-1].get("time"), "bars": len(bars)}
except (TypeError, ValueError) as e:
logger.warning("行情聚合失败 [%s]: %s", ts_code, e)
return {}
# ================================================================ 参考位
def _shard_tables(months: int = 4) -> list:
now = datetime.now()
out, d = [], now
for _ in range(max(1, months)):
out.append(f"gp_stock_factor_pro_{d.strftime('%Y%m')}")
d = (d.replace(day=1) - timedelta(days=1))
return out
def _factor_rows(ts_code: str, days: int = 70) -> list:
"""逐分表单查 (代理要求单表), 合并后取最近 days 条。symbol 先点式后前缀式试。
回看窗至少 15 个自然日 (2026-08-28 审查修): 原来按 days×1.6 算, days=3 时只回看
4.8 天 —— 春节国庆停市 7~9 个自然日, 长假后第一个交易日最近一根日线落在窗外,
昨收取空、候选池整日空掉 (正是 2026-07-31 那次故障在长假后的复发形态)。"""
since = (datetime.now() - timedelta(days=max(15, int(days * 2.5)))).strftime("%Y-%m-%d")
rows = []
for tbl in _shard_tables():
for sym in (ts_code, downstream_repo.to_prefix(ts_code)):
try:
part = fetch_all(
f"SELECT trade_date, close_qfq, high_qfq, low_qfq, atr_qfq FROM {tbl} "
f"WHERE symbol = :sym AND trade_date >= :since", {"sym": sym, "since": since})
except Exception:
part = []
if part:
rows.extend(part)
break
rows = [r for r in rows if r.get("close_qfq")]
rows.sort(key=lambda r: str(r.get("trade_date")))
return rows[-days:]
def self_calc_refs(ts_code: str, base_cost=None) -> dict:
"""兜底自算参考位 (设计 §13)。数据不足返回 {}"""
rows = _factor_rows(ts_code)
if len(rows) < 20:
return {}
close = [float(r["close_qfq"]) for r in rows]
low = [float(r.get("low_qfq") or r["close_qfq"]) for r in rows]
high = [float(r.get("high_qfq") or r["close_qfq"]) for r in rows]
atr = None
for r in reversed(rows):
if r.get("atr_qfq"):
atr = float(r["atr_qfq"])
break
ma20 = sum(close[-20:]) / 20
support = max(ma20, min(low[-20:]) * 1.01)
pressure = max(high[-60:]) if len(high) >= 60 else max(high)
stop = None
if base_cost and atr:
stop = max(float(base_cost) - param_store.get_float("PMS_STOP_ATR_MULT", 2.0) * atr,
support)
elif atr:
stop = max(close[-1] - param_store.get_float("PMS_STOP_ATR_MULT", 2.0) * atr, support)
return {"support": round(support, 3), "pressure": round(pressure, 3),
"stop": round(stop, 3) if stop else None, "atr": atr, "ma20": round(ma20, 3),
"source": "self_calc", "bars": len(rows)}
_ma_cache = {"day": None, "data": {}}
def get_last_close(ts_code: str):
"""上一交易日收盘 (日线 close_qfq)。**盘前定价的兜底** —— 按日缓存。
db13 里是**当日**分钟线, 盘前那张 key 根本不存在, get_price 必然返回 None。
2026-07-31 盘前实测: 上游计划前 30 只全部"无价", 候选池会整个空掉。计划价只用来
算批次数量 (真正的委托价由择时环节按实时行情现定), 用昨收完全够用。
"""
today = datetime.now().strftime("%Y%m%d")
key = f"PC:{ts_code}"
if _ma_cache["day"] != today:
_ma_cache.update({"day": today, "data": {}})
if key in _ma_cache["data"]:
return _ma_cache["data"][key]
val, failed = None, False
try:
rows = _factor_rows(ts_code, days=3)
closes = [float(r["close_qfq"]) for r in rows if r.get("close_qfq")]
if closes:
val = round(closes[-1], 3)
except Exception as e:
failed = True # 异常不落当日缓存: 盘前一次库抖不该让该票全天无昨收 (2026-08-28)
logger.warning("昨收取数失败 [%s] (本次不缓存, 下次重查): %s", ts_code, e)
if not failed:
_ma_cache["data"][key] = val
return val
def day_volume(ts_code: str) -> dict:
"""当日累计成交量与它实际覆盖的交易时段 (2026-09-09, 解锁重问的量能确认要用)。
返回 {"vol": 股, "frac": 覆盖占全日的比例, "from": "10:11", "to": "15:00", "minutes": 199}
取不到返回 {}
**为什么要带 frac 出来, 而不是让调用方按当前时刻算**: 实测 09-09 15:00,
601126.SH 的分钟线首根是 10:11 不是 09:30 —— db13 里这张 key 是滚动窗口, 不保证
从开盘存起。按当前时刻算已过时段, 分母会把没存进来的那段也算上, 量比被系统性压低,
「量能确认」这道条件于是永远过不了。所以折算的分母只能是**这份数据真正覆盖的时段**。
单位: 分钟线的 vol 是**股**, 因子表的日线 vol 是**手**。两边差 100 倍, 换算在
reask_rules.vol_ratio 里做, 那里有单测钉着。
"""
bars = _bars(ts_code)
if not bars:
return {}
try:
vol = sum(float(b.get("vol") or 0) for b in bars)
t0, t1 = str(bars[0].get("time") or ""), str(bars[-1].get("time") or "")
m0, m1 = _hhmm_min(t0), _hhmm_min(t1)
if m0 is None or m1 is None or m1 < m0:
return {}
mins = _trading_minutes_between(m0, m1)
if mins <= 0:
return {}
return {"vol": vol, "minutes": mins, "frac": round(mins / 240.0, 4),
"from": t0[11:16], "to": t1[11:16], "bars": len(bars)}
except (TypeError, ValueError) as e:
logger.warning("当日成交量聚合失败 [%s]: %s", ts_code, e)
return {}
def _hhmm_min(t: str):
""""2026-09-09 10:11:00" → 611 (当天第几分钟)。认不出回 None。"""
try:
hh, mm = t[11:13], t[14:16]
return int(hh) * 60 + int(mm)
except (ValueError, IndexError):
return None
def _trading_minutes_between(m0: int, m1: int) -> int:
"""两个时刻之间的**交易**分钟数 (跨午休不算休市那 90 分钟)。"""
AM0, AM1, PM0, PM1 = 9 * 60 + 30, 11 * 60 + 30, 13 * 60, 15 * 60
lo, hi = max(m0, AM0), min(m1, PM1)
if hi <= lo:
return 0
n = 0
for a, b in ((AM0, AM1), (PM0, PM1)):
n += max(0, min(hi, b) - max(lo, a))
return n
def get_vol_base5(ts_code: str):
"""近五个交易日的日均成交量 (单位: **手**, 因子表口径)。按日缓存, 取不到回 None。
单独一条 SQL 而不是扩 _factor_rows 的 SELECT: 那个函数在候选池装配的热路径上被几十只
票各调一次, 给它加一列会让每一次都多搬一份用不上的数据。这条只在解锁判定里按票调一次。
"""
today = datetime.now().strftime("%Y%m%d")
key = f"VB5:{ts_code}"
if _ma_cache["day"] != today:
_ma_cache.update({"day": today, "data": {}})
if key in _ma_cache["data"]:
return _ma_cache["data"][key]
val, failed = None, False
since = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")
try:
rows = []
for tbl in _shard_tables():
# 因子表的 symbol 实测是前缀式 (SH601126), 但两种写法都试 —— 与 _factor_rows 同手法,
# 上游哪天改了写法这里不至于静默取空。
for sym in (downstream_repo.to_prefix(ts_code), ts_code):
try:
part = fetch_all(
f"SELECT trade_date, vol FROM {tbl} "
f"WHERE symbol = :sym AND trade_date >= :since", {"sym": sym, "since": since})
except Exception:
part = []
if part:
rows.extend(part)
break
rows = [r for r in rows if r.get("vol")]
rows.sort(key=lambda r: str(r.get("trade_date")))
vols = [float(r["vol"]) for r in rows[-5:]]
if len(vols) >= 3: # 三根就够算日均; 少于三根宁可不给
val = round(sum(vols) / len(vols), 2)
except Exception as e:
failed = True
logger.warning("五日均量取数失败 [%s] (本次不缓存): %s", ts_code, e)
if not failed:
_ma_cache["data"][key] = val
return val
def plan_price(ts_code: str) -> dict:
"""规划用价: 实时价优先, 盘前/停更回落昨收。返回 {price, source}。
source: realtime / prev_close / none —— **一定要带出来**。拿昨收当现价去做"不追高"
这类判断会出错, 所以只给规划期定量用, 判断类的检查仍走 day_snapshot。
"""
px = get_price(ts_code)
if px:
return {"price": px, "source": "realtime"}
px = get_last_close(ts_code)
if px:
return {"price": px, "source": "prev_close"}
return {"price": None, "source": "none"}
def get_ma5(ts_code: str):
"""MA5 (规则闸「不追高」用)。按日缓存 —— 因子分表是日频数据, 盘中重复查没意义。"""
today = datetime.now().strftime("%Y%m%d")
if _ma_cache["day"] != today:
_ma_cache.update({"day": today, "data": {}})
if ts_code in _ma_cache["data"]:
return _ma_cache["data"][ts_code]
val, failed = None, False
try:
rows = _factor_rows(ts_code, days=10)
closes = [float(r["close_qfq"]) for r in rows if r.get("close_qfq")]
if len(closes) >= 5:
val = round(sum(closes[-5:]) / 5, 3)
except Exception as e:
failed = True
logger.warning("MA5 取数失败 [%s] (本次不缓存, 下次重查): %s", ts_code, e)
if not failed:
_ma_cache["data"][ts_code] = val
return val
def get_high5(ts_code: str):
"""近 5 个交易日最高价 (动作引擎「创 5 日新高」用)。同样按日缓存。"""
today = datetime.now().strftime("%Y%m%d")
key = f"H5:{ts_code}"
if _ma_cache["day"] != today:
_ma_cache.update({"day": today, "data": {}})
if key in _ma_cache["data"]:
return _ma_cache["data"][key]
val, failed = None, False
try:
rows = _factor_rows(ts_code, days=10)
highs = [float(r.get("high_qfq") or r.get("close_qfq") or 0) for r in rows]
highs = [h for h in highs if h > 0]
if len(highs) >= 5:
val = round(max(highs[-5:]), 3)
except Exception as e:
failed = True
logger.warning("5日高点取数失败 [%s] (本次不缓存, 下次重查): %s", ts_code, e)
if not failed:
_ma_cache["data"][key] = val
return val
def get_refs(ts_code: str, *, base_cost=None) -> dict:
"""参考位: 决策系统主口径 → 日龄超期/缺失时兜底自算 → 都拿不到返回 source=none。"""
stale_days = param_store.get_int("PMS_REF_STALE_TDAYS", 3)
try:
r = downstream_repo.fetch_refs(ts_code)
except Exception as e:
logger.warning("读决策系统结论失败 [%s]: %s", ts_code, e)
r = None
if r and (r.get("support") or r.get("pressure")):
age = _age_days(r.get("trade_date"))
if age is not None and age <= stale_days * 2: # 自然日宽松换算交易日
return {"support": r.get("support"), "pressure": r.get("pressure"),
"stop": r.get("support"), "source": "bionic",
"trade_date": str(r.get("trade_date")), "age_days": age}
try:
s = self_calc_refs(ts_code, base_cost=base_cost)
except Exception as e:
logger.warning("自算参考位失败 [%s]: %s", ts_code, e)
s = {}
if s:
s["note"] = "兜底口径 (决策系统结论缺失或停更)"
return s
return {"support": None, "pressure": None, "stop": None, "source": "none",
"note": "参考位不可用 —— 敞口无法计算, 相关动作按保守处理"}
def _age_days(trade_date):
if not trade_date:
return None
try:
s = str(trade_date)[:10].replace("-", "")
d = datetime.strptime(s, "%Y%m%d").date()
return (datetime.now().date() - d).days
except (ValueError, TypeError):
return None