tradingSystem/app/services/market.py

248 lines
9.5 KiB
Python
Raw Permalink Normal View History

# -*- 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:
closes = [float(b.get("close") or 0) for b in bars if float(b.get("close") or 0) > 0]
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 = [float(b.get("vol") or 0) for b in bars]
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 先点式后前缀式试。"""
since = (datetime.now() - timedelta(days=int(days * 1.6))).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_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 = None
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:
logger.warning("MA5 取数失败 [%s]: %s", ts_code, e)
_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 = None
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:
logger.warning("5日高点取数失败 [%s]: %s", ts_code, e)
_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