refactor: establish standalone application boundary
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
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, "市场阶段待确认。")
|
||||
Reference in New Issue
Block a user