tradingSystem/app/core/tech_rules.py

159 lines
8.1 KiB
Python
Raw Permalink 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 -*-
"""技术面相位合成 (2026-09-11 技术面接入, 纯逻辑, 零外部依赖)。
输入是一只票按 data_date 升序的近若干个交易日技术面读数 (tech_repo 落表的行, 字段见
ddl_pms_v1.sql 的 pms_tech_daily), 最后一行是最新那天。数据新鲜度 —— 有没有当天行、读数
陈不陈旧 —— 由 tech_service 在调用前保证 (它握着交易日历); 本模块只认传进来的 rows 内容。
合成三步, 与《技术面接入与三源合议方案》第三节一一对应:
一, 判有没有读数: 没有最新行 / 未除权重锚 / 数据质量非 OK / 关键读数缺失, 一律无读数。
无读数是弃权, 绝不折成看空 (设计原则二「无读数等于弃权」)。
二, 判是不是震荡市: 最近 20 个交易日 SAR 翻向达到 choppy_flips 次即震荡。震荡市里 SAR
翻向不当信号, 这是决策系统文档明说的指标特性。
三, 判相位: 按固定次序逐条比对, 第一个命中的就是结论 (方案第三节相位表)。
立场取值只有四个中文词: 看多 / 看空 / 中性 / 无读数。强弱只给看多看空用, 中性与无读数没有。
阈值默认值见 DEFAULTS, tech_service 会从参数中心读实际值覆盖; 本模块不读参数中心, 以便脱库单测。
"""
from __future__ import annotations
# 数翻向的窗口「最近 20 个交易日」(方案第三节)。窗口本身不设页面参数, 翻向次数阈值才设。
CHOPPY_WINDOW = 20
DEFAULTS = {
"squeeze_lookback": 5, # 近几个交易日内收过口算「刚收口过」(PMS_TECH_SQUEEZE_LOOKBACK)
"open_bw_growth": 0.20, # 今日带宽比收口日至少扩这么多算开口 (PMS_TECH_OPEN_BW_GROWTH)
"choppy_flips": 4, # 20 日 SAR 翻向达到这么多次算震荡 (PMS_TECH_CHOPPY_FLIPS)
"flip_fresh_days": 2, # SAR 翻向后这么多个交易日内算「刚转向」(PMS_TECH_FLIP_FRESH_DAYS)
}
# 价格「贴下轨」的派生判据 (带内位置 ≤ 此值)。转空确认的次要条件, 主条件是多空布林线空头区。
# 这不是方案里一次定死的五个阈值之一, 是实现派生量; 要调改这里即可。
_NEAR_LOWER = 0.20
_LONG = ""
_SHORT = ""
def _num(v):
try:
return None if v is None else float(v)
except (TypeError, ValueError):
return None
def _truthy(v) -> bool:
"""TINYINT / 布尔 / 字符串都收: 1 / True / "" 为真。"""
if isinstance(v, str):
return v.strip() in ("1", "true", "True", "")
return bool(v)
def _no_read(why: str, latest=None) -> dict:
return {"stance": "无读数", "strength": None, "phase": None, "confirm": None,
"sar_side": None, "sar_value": None, "sar_flip_days": None, "choppy": None,
"reason": why, "no_read_why": why,
"data_date": (latest or {}).get("data_date")}
def count_sar_flips(rows) -> int:
"""rows 升序, 数相邻两行 SAR 方向变化的次数 (只看最近 CHOPPY_WINDOW 行)。"""
seq = [str(r.get("sar_side") or "") for r in (rows or [])[-CHOPPY_WINDOW:]]
seq = [s for s in seq if s in (_LONG, _SHORT)]
return sum(1 for a, b in zip(seq, seq[1:]) if a != b)
def _recent_squeeze_bw(rows, lookback):
"""近 lookback 个交易日内 (不含今日) 最近一次收口那天的带宽; 没收口过返回 None。"""
window = (rows or [])[-(lookback + 1):-1] # 去掉今日那一行
for r in reversed(window): # 由近及远
if _truthy(r.get("boll_squeeze")):
return _num(r.get("boll_bw_pct"))
return None
def synthesize(rows, *, params=None) -> dict:
"""把一只票的近 N 日读数 (升序) 合成技术面立场。返回含 stance/strength/phase/confirm/
sar_value/choppy/reason/data_date/no_read_why 的字典。"""
p = dict(DEFAULTS)
if params:
p.update({k: params[k] for k in DEFAULTS if params.get(k) is not None})
rows = list(rows or [])
latest = rows[-1] if rows else None
# ---- 第一步: 有没有读数 (无读数是弃权) ----
if not latest:
return _no_read("没有当天的技术面读数")
if not _truthy(latest.get("reanchored")):
return _no_read("未做除权重锚,读数不可比", latest)
if str(latest.get("quality") or "").upper() != "OK":
return _no_read(f"数据质量非正常({latest.get('quality') or ''}", latest)
side = str(latest.get("sar_side") or "")
pos = _num(latest.get("boll_pos"))
if side not in (_LONG, _SHORT) or pos is None:
return _no_read("关键读数缺失SAR 方向或带内位置)", latest)
squeeze = _truthy(latest.get("boll_squeeze"))
bbi_state = str(latest.get("bbi_state") or "")
bw = _num(latest.get("boll_bw_pct"))
flip_days = latest.get("sar_flip_days")
flip_days = int(flip_days) if flip_days is not None else None
sar_value = _num(latest.get("sar_value"))
base = {"sar_side": side, "sar_value": sar_value, "sar_flip_days": flip_days,
"data_date": latest.get("data_date"), "no_read_why": None}
# ---- 第二步: 震荡市 ----
flips = count_sar_flips(rows)
choppy = flips >= int(p["choppy_flips"])
base["choppy"] = choppy
def out(stance, strength, phase, reason, confirm=None):
r = dict(base)
r.update({"stance": stance, "strength": strength, "phase": phase,
"confirm": confirm, "reason": reason})
return r
long_, short_ = side == _LONG, side == _SHORT
fresh_flip = flip_days is not None and flip_days <= int(p["flip_fresh_days"])
bbi_bull = bbi_state == "多头区"
bbi_bear = bbi_state == "空头区"
upper_half = pos >= 0.5
# ---- 第三步: 相位 (固定次序, 第一个命中为结论) ----
# 1 收口等待
if squeeze:
return out("中性", None, "收口等待", "布林带收口,等开口再定方向")
# 2 / 3 开口向上 / 开口向下 (今天已不收口; 近 lookback 日内收过口且带宽扩够)
sq_bw = _recent_squeeze_bw(rows, int(p["squeeze_lookback"]))
opened = (sq_bw is not None and sq_bw > 0 and bw is not None
and (bw / sq_bw - 1) >= p["open_bw_growth"])
if opened and upper_half and long_:
return out("看多", "", "开口向上", "刚收口后放开、价在带内上半部、SAR 多:向上变盘")
if opened and (not upper_half) and short_:
return out("看空", "", "开口向下", "刚收口后放开、价在带内下半部、SAR 空:向下变盘")
# 4 转空 (非震荡, SAR 刚翻空)
if short_ and fresh_flip and not choppy:
confirm = bbi_bear or pos <= _NEAR_LOWER
why = "SAR 刚翻空" + ("、多空布林线空头区或贴下轨(确认)" if confirm else "(待确认)")
return out("看空", "" if confirm else "", "转空", why, confirm=confirm)
# 5 转多 (非震荡, SAR 刚翻多, 多空布林线不在空头区)
if long_ and fresh_flip and not bbi_bear and not choppy:
return out("看多", "", "转多", "SAR 刚翻多、多空布林线未在空头区")
# 6 趋势多 (SAR 多且多空布林线在多头区为强, 中性区为弱)
if long_ and bbi_bull:
return out("看多", "", "趋势多", "SAR 多且多空布林线多头区:上升趋势延续")
if long_ and not bbi_bear:
return out("看多", "", "趋势多", "SAR 多、多空布林线中性区:偏多但不强")
# 7 趋势空 (SAR 空且多空布林线不在多头区)
if short_ and not bbi_bull:
return out("看空", "" if bbi_bear else "", "趋势空", "SAR 空且多空布林线未在多头区:下行")
# 8 分歧 (SAR 与多空布林线方向相反)
if (long_ and bbi_bear) or (short_ and bbi_bull):
return out("中性", None, "分歧", "SAR 与多空布林线方向相反,不表态")
# 9 震荡 (震荡市且以上都不命中)
if choppy:
return out("中性", None, "震荡", f"近 20 日 SAR 翻向 {flips} 次,震荡市不表态")
# 兜底: side 已限定在 {多, 空}, 上面各分支理应已覆盖全部组合; 到这里保守中性。
return out("中性", None, "未归类", "指标组合未归入任何相位,保守中性")