2026-07-27 17:12:09 +08:00
|
|
|
|
# -*- 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():
|
2026-07-27 17:31:34 +08:00
|
|
|
|
"""行情 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 兜回去)。
|
|
|
|
|
|
"""
|
2026-07-27 17:12:09 +08:00
|
|
|
|
global _redis
|
|
|
|
|
|
if _redis is not None:
|
|
|
|
|
|
return _redis
|
|
|
|
|
|
with _lock:
|
|
|
|
|
|
if _redis is None:
|
|
|
|
|
|
import redis
|
2026-07-27 17:31:34 +08:00
|
|
|
|
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)
|
2026-07-27 17:12:09 +08:00
|
|
|
|
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)}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 09:10:07 +08:00
|
|
|
|
_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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-27 17:12:09 +08:00
|
|
|
|
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
|