Files
xiaobaifupan/app/backend/features/screener/indicators.py
T

239 lines
8.4 KiB
Python

from __future__ import annotations
import math
import statistics
from datetime import datetime
from typing import Any
from backend.data.numbers import finite_number as _number
def _optional_number(value: Any) -> float | None:
if value in (None, ""):
return None
try:
result = float(value)
except (TypeError, ValueError):
return None
return result if math.isfinite(result) else None
def _rounded_optional(value: Any, digits: int = 2) -> float | None:
parsed = _optional_number(value)
return round(parsed, digits) if parsed is not None else None
def _limit_threshold(code: str, name: str) -> float:
if code.startswith(("4", "8")):
return 29.0
if code.startswith(("30", "68")):
return 19.0
return 9.5
def _ending_streak(flags: list[bool], end_index: int | None = None) -> int:
if not flags:
return 0
index = len(flags) - 1 if end_index is None else min(end_index, len(flags) - 1)
streak = 0
while index >= 0 and flags[index]:
streak += 1
index -= 1
return streak
def _max_streak(flags: list[bool]) -> int:
best = current = 0
for value in flags:
current = current + 1 if value else 0
best = max(best, current)
return best
def _rsi(values: list[float], period: int = 6) -> float:
if len(values) <= period:
return 50.0
changes = [values[index] - values[index - 1] for index in range(len(values) - period, len(values))]
gains = sum(max(change, 0.0) for change in changes) / period
losses = sum(max(-change, 0.0) for change in changes) / period
if losses == 0:
return 100.0 if gains > 0 else 50.0
return 100 - 100 / (1 + gains / losses)
def _ema(values: list[float], period: int) -> list[float]:
if not values:
return []
alpha = 2 / (period + 1)
result = [values[0]]
for value in values[1:]:
result.append(value * alpha + result[-1] * (1 - alpha))
return result
def _macd_series(values: list[float]) -> tuple[list[float], list[float]]:
fast = _ema(values, 12)
slow = _ema(values, 26)
dif = [left - right for left, right in zip(fast, slow)]
return dif, _ema(dif, 9)
def _macd_last(values: list[float]) -> tuple[float, float]:
dif, dea = _macd_series(values)
return (dif[-1], dea[-1]) if dif and dea else (0.0, 0.0)
def _weekly_series(rows: list[dict[str, Any]]) -> tuple[list[float], list[float]]:
weeks: dict[str, tuple[float, float]] = {}
for row in rows:
trade_date = str(row.get("trade_date") or "")
try:
key = datetime.strptime(trade_date, "%Y%m%d").strftime("%G-%V")
except ValueError:
continue
close = _number(row.get("close"))
amount = _number(row.get("amount"))
previous = weeks.get(key, (close, 0.0))
weeks[key] = (close, previous[1] + amount)
ordered = list(weeks.values())
return [item[0] for item in ordered], [item[1] for item in ordered]
def _broken_reversal_metrics(
rows: list[dict[str, Any]], flags: list[bool], code: str, name: str,
) -> dict[str, Any]:
result = {"signal": 0, "days": 0, "recovered": 0, "volume_ratio": 0.0}
if not rows or not flags[-1]:
return result
current_close = _number(rows[-1].get("close"))
current_volume = _number(rows[-1].get("vol"))
for days in range(1, 4):
index = len(rows) - 1 - days
if index <= 0 or flags[index] or _ending_streak(flags, index - 1) < 2:
continue
broken_high = _number(rows[index].get("high"))
broken_volume = _number(rows[index].get("vol"))
recovered = int(current_close >= broken_high > 0)
volume_ratio = current_volume / broken_volume if broken_volume else 0.0
return {
"signal": int(recovered and volume_ratio >= 1),
"days": days,
"recovered": recovered,
"volume_ratio": round(volume_ratio, 3),
}
return result
def _is_limit_bar(rows: list[dict[str, Any]], index: int, code: str, name: str) -> bool:
if index < 0 or index >= len(rows):
return False
return _number(rows[index].get("pct_chg")) >= _limit_threshold(code, name)
def _touched_limit_bar(rows: list[dict[str, Any]], index: int, code: str, name: str) -> bool:
if index <= 0 or index >= len(rows):
return False
previous_close = _number(rows[index - 1].get("close"))
high = _number(rows[index].get("high"))
if previous_close <= 0 or high <= 0:
return False
touched_change = (high / previous_close - 1) * 100
return touched_change >= _limit_threshold(code, name)
def _matches(actual: Any, operator: str, expected: Any) -> bool:
if actual is None:
return False
try:
if operator == "between":
return float(expected[0]) <= float(actual) <= float(expected[1])
if operator == "in":
return actual in expected
if operator == ">":
return float(actual) > float(expected)
if operator == ">=":
return float(actual) >= float(expected)
if operator == "<":
return float(actual) < float(expected)
if operator == "<=":
return float(actual) <= float(expected)
if operator == "==":
return actual == expected or float(actual) == float(expected)
if operator == "!=":
return actual != expected
except (TypeError, ValueError, IndexError):
return False
return False
def _percentile_map(rows: list[dict[str, Any]], field: str, direction: str) -> dict[str, float]:
ordered = sorted(rows, key=lambda item: _number(item.get(field)))
denominator = max(1, len(ordered) - 1)
result = {}
for index, row in enumerate(ordered):
percentile = index / denominator
result[row["ts_code"]] = 1 - percentile if direction == "asc" else percentile
return result
def _available_percentile_map(
rows: list[dict[str, Any]], field: str, direction: str,
) -> dict[str, float | None]:
available = [row for row in rows if row.get(field) is not None]
result: dict[str, float | None] = {
str(row.get("ts_code") or ""): None for row in rows
}
if not available:
return result
ordered = sorted(available, key=lambda item: _number(item.get(field)))
denominator = max(1, len(ordered) - 1)
for index, row in enumerate(ordered):
percentile = 0.5 if len(ordered) == 1 else index / denominator
result[str(row.get("ts_code") or "")] = (
1 - percentile if direction == "asc" else percentile
)
return result
def _pearson(first: list[float], second: list[float]) -> float:
if len(first) != len(second) or len(first) < 20:
return 0.0
first_mean = statistics.fmean(first)
second_mean = statistics.fmean(second)
numerator = sum(
(left - first_mean) * (right - second_mean)
for left, right in zip(first, second)
)
left_sum = sum((value - first_mean) ** 2 for value in first)
right_sum = sum((value - second_mean) ** 2 for value in second)
denominator = math.sqrt(left_sum * right_sum)
return numerator / denominator if denominator else 0.0
def _risk_flags(
row: dict[str, Any], regime: str, include_regime_risk: bool = True
) -> list[str]:
flags = []
if row.get("pct_chg", 0) >= 9.5:
flags.append("当日接近涨停,次日存在高开与无法成交风险")
if row.get("return_10d", 0) >= 25:
flags.append("短期累计涨幅较高")
if row.get("volatility_10d", 0) >= 7:
flags.append("波动率偏高")
if row.get("amount_billion", 0) < 1:
flags.append("成交承载力偏弱")
if include_regime_risk and regime == "retreat":
flags.append("市场处于退潮阶段,策略可能选择空仓")
return flags
def _regime_reason(regime: str) -> str:
return {
"ice": "情绪和赚钱效应处于低位,重点观察率先抗跌与转折信号。",
"repair": "核心指标从低位改善,适合观察率先修复且有板块共振的方向。",
"fermentation": "赚钱效应扩散,主线和梯队持续增强。",
"climax": "情绪处于高位,后排跟风与兑现风险同时上升。",
"divergence": "指数或核心仍强,但广度、封板质量开始分化。",
"retreat": "情绪指标继续走弱,应提高筛选门槛并接受无候选结果。",
}.get(regime, "市场阶段待确认。")