239 lines
8.4 KiB
Python
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, "市场阶段待确认。")
|