tradingSystem/app/core/tech_rules.py

159 lines
8.1 KiB
Python
Raw Normal View History

# -*- 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, "未归类", "指标组合未归入任何相位,保守中性")