# -*- 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(): global _redis if _redis is not None: return _redis with _lock: if _redis is None: import redis _redis = redis.Redis( 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) 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)} 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