zhulinsen--daily_stock_analysis
515 行
19 KiB
Python
515 行
19 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Technical indicator alert helpers for AlertService P5 rules."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, time
|
|
from math import isfinite
|
|
from typing import Any, Dict, Optional
|
|
|
|
import pandas as pd
|
|
|
|
|
|
TECHNICAL_ALERT_TYPES = frozenset({
|
|
"ma_price_cross",
|
|
"rsi_threshold",
|
|
"macd_cross",
|
|
"kdj_cross",
|
|
"cci_threshold",
|
|
})
|
|
|
|
ABOVE_BELOW_DIRECTIONS = frozenset({"above", "below"})
|
|
CROSS_DIRECTIONS = frozenset({"bullish_cross", "bearish_cross"})
|
|
MAX_REQUESTED_DAYS = 365
|
|
|
|
|
|
@dataclass
|
|
class TechnicalIndicatorAlert:
|
|
stock_code: str
|
|
alert_type: str
|
|
indicator_params: Dict[str, Any]
|
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass
|
|
class IndicatorEvaluation:
|
|
status: str
|
|
observed_value: Optional[float]
|
|
threshold: Optional[float]
|
|
message: str
|
|
data_timestamp: Optional[datetime] = None
|
|
|
|
|
|
def normalize_indicator_parameters(alert_type: str, parameters: Dict[str, Any]) -> Dict[str, Any]:
|
|
if not isinstance(parameters, dict):
|
|
raise ValueError("parameters must be an object")
|
|
|
|
if alert_type == "ma_price_cross":
|
|
normalized = {
|
|
"direction": _direction(parameters.get("direction"), ABOVE_BELOW_DIRECTIONS, default="above"),
|
|
"window": _int_in_range(parameters.get("window"), "window", default=20),
|
|
}
|
|
return _ensure_required_bars_fetchable(alert_type, normalized)
|
|
if alert_type == "rsi_threshold":
|
|
normalized = {
|
|
"direction": _direction(parameters.get("direction"), ABOVE_BELOW_DIRECTIONS, default="above"),
|
|
"period": _int_in_range(parameters.get("period"), "period", default=12),
|
|
"threshold": _float_in_range(parameters.get("threshold"), "threshold", minimum=0.0, maximum=100.0),
|
|
}
|
|
return _ensure_required_bars_fetchable(alert_type, normalized)
|
|
if alert_type == "macd_cross":
|
|
fast_period = _int_in_range(parameters.get("fast_period"), "fast_period", default=12)
|
|
slow_period = _int_in_range(parameters.get("slow_period"), "slow_period", default=26)
|
|
if fast_period >= slow_period:
|
|
raise ValueError("fast_period must be < slow_period")
|
|
normalized = {
|
|
"direction": _direction(parameters.get("direction"), CROSS_DIRECTIONS, default="bullish_cross"),
|
|
"fast_period": fast_period,
|
|
"slow_period": slow_period,
|
|
"signal_period": _int_in_range(parameters.get("signal_period"), "signal_period", default=9),
|
|
}
|
|
return _ensure_required_bars_fetchable(alert_type, normalized)
|
|
if alert_type == "kdj_cross":
|
|
normalized = {
|
|
"direction": _direction(parameters.get("direction"), CROSS_DIRECTIONS, default="bullish_cross"),
|
|
"period": _int_in_range(parameters.get("period"), "period", default=9),
|
|
"k_period": _int_in_range(parameters.get("k_period"), "k_period", default=3),
|
|
"d_period": _int_in_range(parameters.get("d_period"), "d_period", default=3),
|
|
}
|
|
return _ensure_required_bars_fetchable(alert_type, normalized)
|
|
if alert_type == "cci_threshold":
|
|
normalized = {
|
|
"direction": _direction(parameters.get("direction"), ABOVE_BELOW_DIRECTIONS, default="above"),
|
|
"period": _int_in_range(parameters.get("period"), "period", default=14),
|
|
"threshold": _finite_float(parameters.get("threshold"), "threshold"),
|
|
}
|
|
return _ensure_required_bars_fetchable(alert_type, normalized)
|
|
raise ValueError(f"unsupported technical alert_type: {alert_type}")
|
|
|
|
|
|
def compute_required_bars(alert_type: str, params: Dict[str, Any]) -> int:
|
|
if alert_type == "ma_price_cross":
|
|
return int(params["window"]) + 1
|
|
if alert_type == "rsi_threshold":
|
|
return int(params["period"]) + 1
|
|
if alert_type == "macd_cross":
|
|
return int(params["slow_period"]) + int(params["signal_period"]) + 1
|
|
if alert_type == "kdj_cross":
|
|
return int(params["period"]) + int(params["k_period"]) + int(params["d_period"]) + 1
|
|
if alert_type == "cci_threshold":
|
|
return int(params["period"]) + 1
|
|
raise ValueError(f"unsupported technical alert_type: {alert_type}")
|
|
|
|
|
|
def compute_requested_days(alert_type: str, params: Dict[str, Any]) -> int:
|
|
required_bars = compute_required_bars(alert_type, params)
|
|
return min(max(required_bars * 3, required_bars + 30), MAX_REQUESTED_DAYS)
|
|
|
|
|
|
def threshold_for_indicator(alert_type: str, params: Dict[str, Any]) -> Optional[float]:
|
|
if alert_type in {"rsi_threshold", "cci_threshold"}:
|
|
return float(params["threshold"])
|
|
if alert_type in {"macd_cross", "kdj_cross"}:
|
|
return 0.0
|
|
return None
|
|
|
|
|
|
def evaluate_indicator_alert(
|
|
alert_type: str,
|
|
stock_code: str,
|
|
params: Dict[str, Any],
|
|
df: Any,
|
|
*,
|
|
now: Optional[datetime] = None,
|
|
) -> IndicatorEvaluation:
|
|
columns = ("close",)
|
|
if alert_type in {"kdj_cross", "cci_threshold"}:
|
|
columns = ("high", "low", "close")
|
|
|
|
try:
|
|
normalized = normalize_ohlcv(df, required_columns=columns, now=now)
|
|
except ValueError as exc:
|
|
return IndicatorEvaluation(
|
|
status="degraded",
|
|
observed_value=None,
|
|
threshold=threshold_for_indicator(alert_type, params),
|
|
message=str(exc),
|
|
)
|
|
if normalized.empty:
|
|
return IndicatorEvaluation(
|
|
status="degraded",
|
|
observed_value=None,
|
|
threshold=threshold_for_indicator(alert_type, params),
|
|
message="No closed daily data available",
|
|
)
|
|
if len(normalized) < 2:
|
|
return IndicatorEvaluation(
|
|
status="degraded",
|
|
observed_value=None,
|
|
threshold=threshold_for_indicator(alert_type, params),
|
|
message="insufficient closed bars for edge evaluation",
|
|
data_timestamp=_latest_timestamp(normalized),
|
|
)
|
|
|
|
required_bars = compute_required_bars(alert_type, params)
|
|
if len(normalized) < required_bars:
|
|
return IndicatorEvaluation(
|
|
status="degraded",
|
|
observed_value=None,
|
|
threshold=threshold_for_indicator(alert_type, params),
|
|
message=f"insufficient data: need {required_bars} bars, got {len(normalized)}",
|
|
data_timestamp=_latest_timestamp(normalized),
|
|
)
|
|
|
|
if alert_type == "ma_price_cross":
|
|
return _evaluate_ma(stock_code, params, normalized)
|
|
if alert_type == "rsi_threshold":
|
|
return _evaluate_rsi(stock_code, params, normalized)
|
|
if alert_type == "macd_cross":
|
|
return _evaluate_macd(stock_code, params, normalized)
|
|
if alert_type == "kdj_cross":
|
|
return _evaluate_kdj(stock_code, params, normalized)
|
|
if alert_type == "cci_threshold":
|
|
return _evaluate_cci(stock_code, params, normalized)
|
|
raise ValueError(f"unsupported technical alert_type: {alert_type}")
|
|
|
|
|
|
def normalize_ohlcv(
|
|
df: Any,
|
|
*,
|
|
required_columns: tuple[str, ...],
|
|
now: Optional[datetime] = None,
|
|
) -> pd.DataFrame:
|
|
if df is None or getattr(df, "empty", True):
|
|
return pd.DataFrame()
|
|
if not isinstance(df, pd.DataFrame):
|
|
return pd.DataFrame()
|
|
|
|
output = pd.DataFrame(index=df.index.copy())
|
|
output["date"] = _date_series(df)
|
|
|
|
missing = []
|
|
for canonical in required_columns:
|
|
source = _find_column(df, canonical)
|
|
if source is None:
|
|
missing.append(canonical)
|
|
continue
|
|
output[canonical] = pd.to_numeric(df[source], errors="coerce")
|
|
if missing:
|
|
missing_text = ", ".join(missing)
|
|
raise ValueError(f"daily data missing {missing_text} column")
|
|
|
|
output = output.dropna(subset=list(required_columns)).copy()
|
|
if output.empty:
|
|
return output
|
|
output = _drop_partial_today(output, now=now)
|
|
if output.empty:
|
|
return output.reset_index(drop=True)
|
|
output = output.sort_values(by="date", kind="stable", na_position="first").reset_index(drop=True)
|
|
return output
|
|
|
|
|
|
def _evaluate_ma(stock_code: str, params: Dict[str, Any], df: pd.DataFrame) -> IndicatorEvaluation:
|
|
window = int(params["window"])
|
|
direction = str(params["direction"])
|
|
series = df["close"].rolling(window=window).mean()
|
|
latest = _latest_timestamp(df)
|
|
prev_close, curr_close = float(df["close"].iloc[-2]), float(df["close"].iloc[-1])
|
|
prev_ma, curr_ma = float(series.iloc[-2]), float(series.iloc[-1])
|
|
if not all(isfinite(value) for value in (prev_ma, curr_ma)):
|
|
return _indicator_unavailable("MA", latest)
|
|
|
|
prev_delta = prev_close - prev_ma
|
|
curr_delta = curr_close - curr_ma
|
|
triggered = _crossed_zero(prev_delta, curr_delta, direction)
|
|
message = (
|
|
f"{stock_code} close {curr_close:.4f} crossed {direction} MA{window} {curr_ma:.4f}"
|
|
if triggered
|
|
else f"{stock_code} close {curr_close:.4f} did not edge-cross {direction} MA{window} {curr_ma:.4f}"
|
|
)
|
|
return IndicatorEvaluation(
|
|
status="triggered" if triggered else "not_triggered",
|
|
observed_value=curr_close,
|
|
threshold=curr_ma,
|
|
message=message,
|
|
data_timestamp=latest,
|
|
)
|
|
|
|
|
|
def _evaluate_rsi(stock_code: str, params: Dict[str, Any], df: pd.DataFrame) -> IndicatorEvaluation:
|
|
period = int(params["period"])
|
|
threshold = float(params["threshold"])
|
|
direction = str(params["direction"])
|
|
rsi = _calculate_rsi(df["close"], period)
|
|
latest = _latest_timestamp(df)
|
|
prev_value, curr_value = float(rsi.iloc[-2]), float(rsi.iloc[-1])
|
|
if not all(isfinite(value) for value in (prev_value, curr_value)):
|
|
return _indicator_unavailable("RSI", latest, threshold=threshold)
|
|
|
|
triggered = _crossed_threshold(prev_value, curr_value, threshold, direction)
|
|
message = (
|
|
f"{stock_code} RSI{period} {curr_value:.2f} crossed {direction} {threshold:.2f}"
|
|
if triggered
|
|
else f"{stock_code} RSI{period} {curr_value:.2f} did not edge-cross {direction} {threshold:.2f}"
|
|
)
|
|
return IndicatorEvaluation(
|
|
status="triggered" if triggered else "not_triggered",
|
|
observed_value=curr_value,
|
|
threshold=threshold,
|
|
message=message,
|
|
data_timestamp=latest,
|
|
)
|
|
|
|
|
|
def _evaluate_macd(stock_code: str, params: Dict[str, Any], df: pd.DataFrame) -> IndicatorEvaluation:
|
|
fast_period = int(params["fast_period"])
|
|
slow_period = int(params["slow_period"])
|
|
signal_period = int(params["signal_period"])
|
|
direction = str(params["direction"])
|
|
ema_fast = df["close"].ewm(span=fast_period, adjust=False).mean()
|
|
ema_slow = df["close"].ewm(span=slow_period, adjust=False).mean()
|
|
dif = ema_fast - ema_slow
|
|
dea = dif.ewm(span=signal_period, adjust=False).mean()
|
|
delta = dif - dea
|
|
latest = _latest_timestamp(df)
|
|
prev_delta, curr_delta = float(delta.iloc[-2]), float(delta.iloc[-1])
|
|
if not all(isfinite(value) for value in (prev_delta, curr_delta)):
|
|
return _indicator_unavailable("MACD", latest, threshold=0.0)
|
|
|
|
triggered = _crossed_cross_direction(prev_delta, curr_delta, direction)
|
|
message = (
|
|
f"{stock_code} MACD DIF/DEA {direction}: delta = {curr_delta:.4f}"
|
|
if triggered
|
|
else f"{stock_code} MACD delta {curr_delta:.4f} did not edge-cross {direction}"
|
|
)
|
|
return IndicatorEvaluation(
|
|
status="triggered" if triggered else "not_triggered",
|
|
observed_value=curr_delta,
|
|
threshold=0.0,
|
|
message=message,
|
|
data_timestamp=latest,
|
|
)
|
|
|
|
|
|
def _evaluate_kdj(stock_code: str, params: Dict[str, Any], df: pd.DataFrame) -> IndicatorEvaluation:
|
|
period = int(params["period"])
|
|
k_period = int(params["k_period"])
|
|
d_period = int(params["d_period"])
|
|
direction = str(params["direction"])
|
|
lowest_low = df["low"].rolling(window=period).min()
|
|
highest_high = df["high"].rolling(window=period).max()
|
|
denominator = highest_high - lowest_low
|
|
rsv = ((df["close"] - lowest_low) / denominator.mask(denominator == 0) * 100).fillna(50)
|
|
k_value = rsv.ewm(alpha=1 / k_period, adjust=False).mean()
|
|
d_value = k_value.ewm(alpha=1 / d_period, adjust=False).mean()
|
|
delta = k_value - d_value
|
|
latest = _latest_timestamp(df)
|
|
prev_delta, curr_delta = float(delta.iloc[-2]), float(delta.iloc[-1])
|
|
if not all(isfinite(value) for value in (prev_delta, curr_delta)):
|
|
return _indicator_unavailable("KDJ", latest, threshold=0.0)
|
|
|
|
triggered = _crossed_cross_direction(prev_delta, curr_delta, direction)
|
|
message = (
|
|
f"{stock_code} KDJ K/D {direction}: delta = {curr_delta:.4f}"
|
|
if triggered
|
|
else f"{stock_code} KDJ delta {curr_delta:.4f} did not edge-cross {direction}"
|
|
)
|
|
return IndicatorEvaluation(
|
|
status="triggered" if triggered else "not_triggered",
|
|
observed_value=curr_delta,
|
|
threshold=0.0,
|
|
message=message,
|
|
data_timestamp=latest,
|
|
)
|
|
|
|
|
|
def _evaluate_cci(stock_code: str, params: Dict[str, Any], df: pd.DataFrame) -> IndicatorEvaluation:
|
|
period = int(params["period"])
|
|
threshold = float(params["threshold"])
|
|
direction = str(params["direction"])
|
|
typical_price = (df["high"] + df["low"] + df["close"]) / 3
|
|
tp_ma = typical_price.rolling(window=period).mean()
|
|
mean_deviation = typical_price.rolling(window=period).apply(
|
|
lambda values: float(abs(values - values.mean()).mean()),
|
|
raw=False,
|
|
)
|
|
cci = (typical_price - tp_ma) / (0.015 * mean_deviation.mask(mean_deviation == 0))
|
|
latest = _latest_timestamp(df)
|
|
prev_value, curr_value = float(cci.iloc[-2]), float(cci.iloc[-1])
|
|
if not all(isfinite(value) for value in (prev_value, curr_value)):
|
|
return _indicator_unavailable("CCI", latest, threshold=threshold)
|
|
|
|
triggered = _crossed_threshold(prev_value, curr_value, threshold, direction)
|
|
message = (
|
|
f"{stock_code} CCI{period} {curr_value:.2f} crossed {direction} {threshold:.2f}"
|
|
if triggered
|
|
else f"{stock_code} CCI{period} {curr_value:.2f} did not edge-cross {direction} {threshold:.2f}"
|
|
)
|
|
return IndicatorEvaluation(
|
|
status="triggered" if triggered else "not_triggered",
|
|
observed_value=curr_value,
|
|
threshold=threshold,
|
|
message=message,
|
|
data_timestamp=latest,
|
|
)
|
|
|
|
|
|
def _ensure_required_bars_fetchable(alert_type: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
required_bars = compute_required_bars(alert_type, params)
|
|
if required_bars > MAX_REQUESTED_DAYS:
|
|
raise ValueError(
|
|
f"{alert_type} periods require {required_bars} bars, "
|
|
f"but at most {MAX_REQUESTED_DAYS} days can be requested"
|
|
)
|
|
return params
|
|
|
|
|
|
def _direction(value: Any, allowed: frozenset[str], *, default: str) -> str:
|
|
direction = str(value or default).strip().lower()
|
|
if direction not in allowed:
|
|
raise ValueError(f"invalid direction: {direction}")
|
|
return direction
|
|
|
|
|
|
def _int_in_range(value: Any, field_name: str, *, default: int, minimum: int = 2, maximum: int = 250) -> int:
|
|
raw_value = default if value is None or value == "" else value
|
|
try:
|
|
number = int(raw_value)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError(f"invalid {field_name}: {value}") from exc
|
|
if str(raw_value).strip() not in {str(number), f"{number}.0"}:
|
|
raise ValueError(f"{field_name} must be an integer")
|
|
if number < minimum or number > maximum:
|
|
raise ValueError(f"{field_name} must be between {minimum} and {maximum}")
|
|
return number
|
|
|
|
|
|
def _finite_float(value: Any, field_name: str) -> float:
|
|
try:
|
|
number = float(value)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError(f"invalid {field_name}: {value}") from exc
|
|
if not isfinite(number):
|
|
raise ValueError(f"{field_name} must be finite")
|
|
return number
|
|
|
|
|
|
def _float_in_range(
|
|
value: Any,
|
|
field_name: str,
|
|
*,
|
|
minimum: float,
|
|
maximum: float,
|
|
) -> float:
|
|
number = _finite_float(value, field_name)
|
|
if number < minimum or number > maximum:
|
|
raise ValueError(f"{field_name} must be between {minimum:g} and {maximum:g}")
|
|
return number
|
|
|
|
|
|
def _calculate_rsi(close: pd.Series, period: int) -> pd.Series:
|
|
delta = close.diff()
|
|
gain = delta.where(delta > 0, 0)
|
|
loss = -delta.where(delta < 0, 0)
|
|
# 使用 Wilder's EMA / SMMA 口径,不使用 rolling SMA。
|
|
avg_gain = gain.ewm(alpha=1 / period, adjust=False).mean()
|
|
avg_loss = loss.ewm(alpha=1 / period, adjust=False).mean()
|
|
rs = avg_gain / avg_loss
|
|
return (100 - (100 / (1 + rs))).fillna(50)
|
|
|
|
|
|
def _crossed_threshold(prev_value: float, curr_value: float, threshold: float, direction: str) -> bool:
|
|
if direction == "above":
|
|
return prev_value <= threshold < curr_value
|
|
if direction == "below":
|
|
return prev_value >= threshold > curr_value
|
|
return False
|
|
|
|
|
|
def _crossed_zero(prev_delta: float, curr_delta: float, direction: str) -> bool:
|
|
if direction == "above":
|
|
return prev_delta <= 0 < curr_delta
|
|
if direction == "below":
|
|
return prev_delta >= 0 > curr_delta
|
|
return False
|
|
|
|
|
|
def _crossed_cross_direction(prev_delta: float, curr_delta: float, direction: str) -> bool:
|
|
if direction == "bullish_cross":
|
|
return prev_delta <= 0 < curr_delta
|
|
if direction == "bearish_cross":
|
|
return prev_delta >= 0 > curr_delta
|
|
return False
|
|
|
|
|
|
def _indicator_unavailable(
|
|
indicator_name: str,
|
|
data_timestamp: Optional[datetime],
|
|
*,
|
|
threshold: Optional[float] = None,
|
|
) -> IndicatorEvaluation:
|
|
return IndicatorEvaluation(
|
|
status="degraded",
|
|
observed_value=None,
|
|
threshold=threshold,
|
|
message=f"{indicator_name} value is not available",
|
|
data_timestamp=data_timestamp,
|
|
)
|
|
|
|
|
|
def _find_column(df: pd.DataFrame, canonical: str) -> Optional[Any]:
|
|
candidates = {
|
|
"date": ("date", "trade_date", "datetime", "time", "日期", "交易日期"),
|
|
"open": ("open", "open_price", "开盘", "开盘价"),
|
|
"high": ("high", "high_price", "最高", "最高价"),
|
|
"low": ("low", "low_price", "最低", "最低价"),
|
|
"close": ("close", "close_price", "收盘", "收盘价"),
|
|
"volume": ("volume", "vol", "成交量"),
|
|
}
|
|
by_normalized = {str(column).strip().lower(): column for column in df.columns}
|
|
for candidate in candidates[canonical]:
|
|
column = by_normalized.get(candidate.lower())
|
|
if column is not None:
|
|
return column
|
|
return None
|
|
|
|
|
|
def _date_series(df: pd.DataFrame) -> pd.Series:
|
|
date_column = _find_column(df, "date")
|
|
if date_column is not None:
|
|
return pd.to_datetime(df[date_column], errors="coerce")
|
|
index = df.index
|
|
if isinstance(index, pd.DatetimeIndex):
|
|
return pd.Series(index.to_pydatetime(), index=df.index)
|
|
return pd.Series([pd.NaT] * len(df), index=df.index)
|
|
|
|
|
|
def _drop_partial_today(df: pd.DataFrame, *, now: Optional[datetime] = None) -> pd.DataFrame:
|
|
current = now or datetime.now()
|
|
if current.time() >= time(16, 0):
|
|
return df
|
|
try:
|
|
parsed = pd.to_datetime(df["date"].iloc[-1], errors="coerce")
|
|
except (KeyError, IndexError, TypeError, ValueError, OverflowError):
|
|
return df.iloc[:-1].copy()
|
|
if pd.isna(parsed):
|
|
return df.iloc[:-1].copy()
|
|
last_date = parsed.date()
|
|
if last_date == current.date():
|
|
return df.iloc[:-1].copy()
|
|
return df
|
|
|
|
|
|
def _latest_timestamp(df: pd.DataFrame) -> Optional[datetime]:
|
|
try:
|
|
raw_value = df["date"].iloc[-1]
|
|
if pd.isna(raw_value):
|
|
return None
|
|
parsed = pd.to_datetime(raw_value, errors="coerce")
|
|
if pd.isna(parsed):
|
|
return None
|
|
return parsed.to_pydatetime().replace(tzinfo=None)
|
|
except Exception:
|
|
return None
|