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, "市场阶段待确认。")