migration: preserve sentiment and pools slice

This commit is contained in:
leefer
2026-07-31 01:41:58 +08:00
parent a4264326bd
commit b3555d2603
19 changed files with 956 additions and 696 deletions
+4 -493
View File
@@ -1,496 +1,7 @@
from __future__ import annotations
"""Compatibility alias for the canonical sentiment engine implementation."""
from copy import deepcopy
from statistics import mean, median
from typing import Any
import sys
from backend.features.sentiment import engine as _implementation
COMPONENT_WEIGHTS = {
"breadth": 20,
"limit_ecology": 25,
"profit_effect": 30,
"ladder_structure": 15,
"liquidity": 10,
}
SENTIMENT_ENGINE_VERSION = 2
def _number(value: Any, default: float = 0.0) -> float:
try:
number = float(value)
return number if number == number else default
except (TypeError, ValueError):
return default
def _clamp(value: float, lower: float = 0.0, upper: float = 100.0) -> float:
return min(upper, max(lower, value))
def _linear(value: float, low: float, high: float) -> float:
if high <= low:
return 50.0
return _clamp((value - low) / (high - low) * 100)
def _percentile(value: float, history: list[float]) -> float:
if not history:
return 50.0
below = sum(item < value for item in history)
equal = sum(item == value for item in history)
return _clamp((below + equal * 0.5) / len(history) * 100)
def _adaptive_score(value: float, fixed: float, history: list[float]) -> float:
if len(history) < 20:
return fixed
return fixed * 0.25 + _percentile(value, history[-250:]) * 0.75
def _trade_date(payload: dict[str, Any]) -> str:
meta = payload.get("meta") or {}
return str(meta.get("trade_date") or payload.get("_snapshot_date") or "").replace("-", "")
def _deduplicate_snapshots(snapshots: list[dict[str, Any]]) -> list[dict[str, Any]]:
by_trade_date: dict[str, dict[str, Any]] = {}
for payload in snapshots:
trade_date = _trade_date(payload)
if trade_date:
by_trade_date[trade_date] = payload
return [by_trade_date[key] for key in sorted(by_trade_date)]
def _snapshot_stats(payload: dict[str, Any]) -> dict[str, Any]:
overview = payload.get("overview") or {}
meta = payload.get("meta") or {}
limits = list(payload.get("limits") or [])
broken = list(payload.get("broken") or [])
down_limits = list(payload.get("down_limits") or [])
yesterday = list(payload.get("yesterday_limits") or [])
limit_up = len(limits) if limits else int(_number(overview.get("limit_up_count")))
broken_count = len(broken) if broken else int(_number(overview.get("broken_count")))
limit_down = len(down_limits) if down_limits else int(_number(overview.get("limit_down_count")))
streaks = [max(1, int(_number(row.get("streak"), 1))) for row in limits]
first_board = sum(streak == 1 for streak in streaks)
second_board = sum(streak == 2 for streak in streaks)
three_plus = sum(streak >= 3 for streak in streaks)
max_height = max(streaks, default=0)
present_levels = set(streaks)
ladder_completeness = (
sum(level in present_levels for level in range(1, max_height + 1)) / max_height * 100
if max_height else 0.0
)
up_count = int(_number(overview.get("up_count")))
down_count = int(_number(overview.get("down_count")))
flat_count = int(_number(overview.get("flat_count")))
active_count = up_count + down_count
breadth_ratio = up_count / max(active_count, 1) * 100
seal_rate = _number(overview.get("seal_rate"))
if not seal_rate and limit_up + broken_count:
seal_rate = limit_up / (limit_up + broken_count) * 100
previous_limit_count = len(yesterday)
previous_positive_count = sum(_number(row.get("current_change")) > 0 for row in yesterday)
previous_positive_rate = previous_positive_count / max(previous_limit_count, 1) * 100
advanced_count = sum(row.get("outcome") == "晋级" for row in yesterday)
advance_rate = advanced_count / max(previous_limit_count, 1) * 100
average_previous_change = (
mean(_number(row.get("current_change")) for row in yesterday) if yesterday else 0.0
)
median_previous_change = (
median(_number(row.get("current_change")) for row in yesterday) if yesterday else 0.0
)
severe_loss_count = sum(_number(row.get("current_change")) <= -5 for row in yesterday)
severe_loss_rate = severe_loss_count / max(previous_limit_count, 1) * 100
previous_down_count = sum(row.get("outcome") == "跌停" for row in yesterday)
high_previous = [row for row in yesterday if int(_number(row.get("prior_streak"), 1)) >= 2]
high_positive_rate = (
sum(_number(row.get("current_change")) > 0 for row in high_previous)
/ max(len(high_previous), 1)
* 100
)
amount_billion = _number(overview.get("amount_billion"))
limit_amount_billion = sum(_number(row.get("amount_billion")) for row in limits)
return {
"trade_date": _trade_date(payload),
"previous_trade_date": str(meta.get("previous_trade_date") or "").replace("-", ""),
"up_count": up_count,
"down_count": down_count,
"flat_count": flat_count,
"breadth_ratio": round(breadth_ratio, 1),
"limit_up_count": limit_up,
"first_board_count": first_board,
"second_board_count": second_board,
"three_plus_count": three_plus,
"max_height": max_height,
"ladder_completeness": round(ladder_completeness, 1),
"broken_count": broken_count,
"limit_down_count": limit_down,
"seal_rate": round(seal_rate, 1),
"previous_limit_count": previous_limit_count,
"previous_positive_count": previous_positive_count,
"previous_positive_rate": round(previous_positive_rate, 1),
"advance_rate": round(advance_rate, 1),
"average_previous_change": round(average_previous_change, 2),
"median_previous_change": round(median_previous_change, 2),
"severe_loss_count": severe_loss_count,
"severe_loss_rate": round(severe_loss_rate, 1),
"previous_down_count": previous_down_count,
"high_positive_rate": round(high_positive_rate, 1),
"amount_billion": round(amount_billion, 1),
"limit_amount_billion": round(limit_amount_billion, 2),
}
def _sentiment_label(score: float) -> str:
if score >= 80:
return "情绪高涨"
if score >= 60:
return "情绪偏强"
if score >= 40:
return "情绪中性"
if score >= 20:
return "情绪偏弱"
return "情绪冰点"
def _phase_signal(score: float, momentum: float, profit_score: float) -> str:
if score < 25:
return "修复" if momentum > 3 else "冰点"
if score < 45:
return "修复" if momentum > 3 else "退潮"
if score >= 80:
return "高潮" if momentum >= -2 and profit_score >= 60 else "分化"
if score >= 65:
return "分化" if momentum < -3 or profit_score < 50 else "发酵"
if momentum < -5:
return "退潮"
return "发酵" if momentum >= 0 and profit_score >= 45 else "分化"
def _confirmed_phase(
previous: dict[str, Any] | None,
score: float,
day_change: float,
systemic_health: float,
profit_score: float,
ecology_score: float,
phase_signal: str,
extreme_ice: bool,
fermentation_signal_count: int,
) -> tuple[str, str]:
if previous is None:
return phase_signal, "首个连续交易日,采用原始阶段信号"
previous_phase = str(previous.get("phase") or phase_signal)
if extreme_ice:
return "冰点", "市场宽度与跌停数量触发极端冰点"
recovery = day_change >= 6 and score >= 25 and systemic_health >= 24
fermentation_confirmed = fermentation_signal_count >= 2
climax_ready = (
score >= 80
and profit_score >= 60
and systemic_health >= 60
and ecology_score >= 70
)
if previous_phase == "冰点":
return ("修复", "冰点后首次有效回升") if recovery else ("冰点", "冰点尚未形成有效修复")
if previous_phase == "退潮":
if score < 25:
return "冰点", "退潮继续下探至冰点区间"
return ("修复", "退潮后出现有效回升") if recovery else ("退潮", "退潮尚未形成有效修复")
if previous_phase == "修复":
if score < 25:
return "冰点", "修复失败并重新跌入冰点区间"
if day_change <= -6 and score < 45:
return "退潮", "修复失败且温度显著回落"
if fermentation_confirmed:
return "发酵", "发酵条件连续两个交易日成立"
return "修复", "修复延续,等待发酵确认"
if previous_phase == "发酵":
if score < 25:
return "冰点", "发酵阶段出现极端情绪坍塌"
if score < 45 and (day_change < 0 or systemic_health < 35):
return "退潮", "发酵阶段温度与系统健康度同步转弱"
if climax_ready:
return "高潮", "温度、赚钱效应与涨停生态共同达到高潮条件"
if phase_signal in {"分化", "退潮"} or day_change <= -6:
return "分化", "发酵阶段出现降温或赚钱效应弱化"
return "发酵", "发酵状态延续"
if previous_phase == "高潮":
if score < 25:
return "冰点", "高潮后出现极端情绪坍塌"
if climax_ready:
return "高潮", "高潮条件继续成立"
if score < 45 or systemic_health < 30:
return "退潮", "高潮后风险快速释放"
return "分化", "高潮条件消退,进入分化"
if previous_phase == "分化":
if score < 25:
return "冰点", "分化继续恶化至冰点区间"
if score < 45 or systemic_health < 30:
return "退潮", "分化后温度或系统健康度继续下降"
if fermentation_confirmed:
return "发酵", "分化转强条件连续两个交易日成立"
return "分化", "分化延续,等待方向确认"
return phase_signal, "采用原始阶段信号"
def build_sentiment_history(snapshots: list[dict[str, Any]]) -> list[dict[str, Any]]:
payloads = _deduplicate_snapshots(snapshots)
raw_rows = [_snapshot_stats(payload) for payload in payloads]
results: list[dict[str, Any]] = []
for index, stats in enumerate(raw_rows):
previous = raw_rows[:index]
limit_history = [float(row["limit_up_count"]) for row in previous]
down_limit_history = [float(row["limit_down_count"]) for row in previous]
height_history = [float(row["max_height"]) for row in previous]
three_plus_history = [float(row["three_plus_count"]) for row in previous]
amount_history = [float(row["amount_billion"]) for row in previous[-20:] if row["amount_billion"]]
breadth_score = _clamp(float(stats["breadth_ratio"]))
limit_strength = _adaptive_score(
float(stats["limit_up_count"]),
_linear(float(stats["limit_up_count"]), 10, 100),
limit_history,
)
down_relief = 100 - _adaptive_score(
float(stats["limit_down_count"]),
_linear(float(stats["limit_down_count"]), 0, 50),
down_limit_history,
)
seal_quality = _linear(float(stats["seal_rate"]), 35, 90)
systemic_health = breadth_score * 0.60 + down_relief * 0.40
systemic_gate = 1.0 if systemic_health >= 35 else 0.35 + systemic_health / 35 * 0.65
ecology_base_score = limit_strength * 0.35 + seal_quality * 0.35 + down_relief * 0.30
# Systemic risk is applied once to the final temperature. Reapplying it here
# would count market breadth and limit-down pressure twice.
limit_ecology_score = ecology_base_score
if stats["previous_limit_count"]:
positive_score = float(stats["previous_positive_rate"])
average_change_score = _clamp(50 + float(stats["average_previous_change"]) * 6)
median_change_score = _clamp(50 + float(stats["median_previous_change"]) * 7)
advance_score = _clamp(float(stats["advance_rate"]) * 2.5)
severe_loss_safety = _clamp(100 - float(stats["severe_loss_rate"]) * 3)
down_safety = _clamp(100 - float(stats["previous_down_count"]) / stats["previous_limit_count"] * 700)
tail_safety_score = severe_loss_safety * 0.70 + down_safety * 0.30
profit_effect_score = (
positive_score * 0.30
+ median_change_score * 0.25
+ average_change_score * 0.10
+ advance_score * 0.20
+ tail_safety_score * 0.15
)
else:
profit_effect_score = 50.0
max_height_score = _adaptive_score(
float(stats["max_height"]),
_linear(float(stats["max_height"]), 1, 7),
height_history,
)
continuation_rate = (
(float(stats["second_board_count"]) + float(stats["three_plus_count"]))
/ max(float(stats["limit_up_count"]), 1)
* 100
)
three_plus_density = float(stats["three_plus_count"]) / max(float(stats["limit_up_count"]), 1) * 100
three_plus_score = _adaptive_score(
float(stats["three_plus_count"]),
_clamp(three_plus_density * 5),
three_plus_history,
)
ladder_structure_score = (
max_height_score * 0.30
+ _clamp(continuation_rate * 3) * 0.25
+ three_plus_score * 0.25
+ float(stats["ladder_completeness"]) * 0.20
)
amount_baseline = mean(amount_history) if amount_history else float(stats["amount_billion"] or 1)
amount_ratio = float(stats["amount_billion"]) / max(amount_baseline, 1)
amount_score = _clamp(50 + (amount_ratio - 1) * 100)
limit_amount_share = float(stats["limit_amount_billion"]) / max(float(stats["amount_billion"]), 1) * 100
liquidity_score = amount_score * 0.70 + _clamp(limit_amount_share * 20) * 0.30
component_scores = {
"breadth": breadth_score,
"limit_ecology": limit_ecology_score,
"profit_effect": profit_effect_score,
"ladder_structure": ladder_structure_score,
"liquidity": liquidity_score,
}
raw_score = sum(component_scores[key] * weight / 100 for key, weight in COMPONENT_WEIGHTS.items())
score = round(
raw_score * systemic_gate
)
extreme_ice = float(stats["breadth_ratio"]) <= 15 and float(stats["limit_down_count"]) >= 100
if extreme_ice:
score = min(score, 15)
elif float(stats["breadth_ratio"]) <= 25 and float(stats["limit_down_count"]) >= 50:
score = min(score, 24)
previous_scores: list[float] = []
expected_date = str(stats.get("previous_trade_date") or "")
for prior_result in reversed(results):
if not expected_date or str(prior_result.get("trade_date") or "") != expected_date:
break
previous_scores.append(float(prior_result["score"]))
expected_date = str(prior_result.get("previous_trade_date") or "")
if len(previous_scores) == 3:
break
momentum = score - mean(previous_scores) if previous_scores else 0.0
direction = "升温" if momentum > 3 else "降温" if momentum < -3 else "持平"
normalization = "历史百分位" if len(previous) >= 20 else "固定锚点"
previous_result = (
results[-1]
if results and str(stats.get("previous_trade_date") or "") == str(results[-1].get("trade_date") or "")
else None
)
day_change = score - float(previous_result["score"]) if previous_result else 0.0
ema_score = round(
score if not previous_result
else score * 0.5 + float(previous_result.get("ema_score", previous_result["score"])) * 0.5,
1,
)
phase_signal = _phase_signal(score, momentum, profit_effect_score)
fermentation_ready = (
phase_signal == "发酵"
and score >= 45
and profit_effect_score >= 45
and systemic_health >= 35
and not extreme_ice
)
previous_fermentation_count = int(previous_result.get("fermentation_signal_count") or 0) if previous_result else 0
fermentation_signal_count = previous_fermentation_count + 1 if fermentation_ready else 0
phase, transition_reason = _confirmed_phase(
previous_result,
score,
day_change,
systemic_health,
profit_effect_score,
limit_ecology_score,
phase_signal,
extreme_ice,
fermentation_signal_count,
)
previous_phase = str(previous_result.get("phase") or "") if previous_result else ""
if phase not in {"修复", "分化"}:
fermentation_signal_count = 0
elif phase == "分化" and previous_phase != "分化":
fermentation_signal_count = 0
components = {
"breadth": {
"label": "市场宽度",
"score": round(breadth_score, 1),
"weight": COMPONENT_WEIGHTS["breadth"],
"summary": f"上涨占比 {stats['breadth_ratio']:.1f}%",
},
"limit_ecology": {
"label": "涨停生态",
"score": round(limit_ecology_score, 1),
"weight": COMPONENT_WEIGHTS["limit_ecology"],
"summary": (
f"涨停 {stats['limit_up_count']} · 跌停 {stats['limit_down_count']} · "
f"封板 {stats['seal_rate']:.1f}%"
),
},
"profit_effect": {
"label": "赚钱效应",
"score": round(profit_effect_score, 1),
"weight": COMPONENT_WEIGHTS["profit_effect"],
"summary": (
f"昨涨停红盘 {stats['previous_positive_rate']:.1f}% · "
f"中位 {stats['median_previous_change']:+.2f}% · "
f"重亏 {stats['severe_loss_rate']:.1f}%"
if stats["previous_limit_count"] else "缺少前一交易日样本"
),
},
"ladder_structure": {
"label": "连板结构",
"score": round(ladder_structure_score, 1),
"weight": COMPONENT_WEIGHTS["ladder_structure"],
"summary": f"最高 {stats['max_height']} 板 · 三板以上 {stats['three_plus_count']}",
},
"liquidity": {
"label": "成交活跃度",
"score": round(liquidity_score, 1),
"weight": COMPONENT_WEIGHTS["liquidity"],
"summary": f"成交 {stats['amount_billion']:.1f} 亿 · 均值比 {amount_ratio:.2f}",
},
}
results.append(
{
**stats,
"score": score,
"ema_score": ema_score,
"label": _sentiment_label(score),
"phase": phase,
"phase_signal": phase_signal,
"transition_reason": transition_reason,
"fermentation_signal_count": fermentation_signal_count,
"day_change": round(day_change, 1),
"direction": direction,
"momentum": round(momentum, 1),
"normalization": "250日历史百分位" if len(previous) >= 20 else normalization,
"history_days": len(previous) + 1,
"systemic_health": round(systemic_health, 1),
"risk_multiplier": round(systemic_gate, 3),
"components": components,
}
)
return results
def latest_contiguous_history(series: list[dict[str, Any]]) -> list[dict[str, Any]]:
if not series:
return []
contiguous = [series[-1]]
for row in reversed(series[:-1]):
expected_previous = str(contiguous[0].get("previous_trade_date") or "")
if not expected_previous or expected_previous != str(row.get("trade_date") or ""):
break
contiguous.insert(0, row)
return contiguous
def apply_sentiment_to_dashboard(
dashboard: dict[str, Any],
historical_snapshots: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
result = deepcopy(dashboard)
history = list(historical_snapshots or [])
history.append(result)
series = build_sentiment_history(history)
target_date = _trade_date(result)
sentiment = next((row for row in reversed(series) if row["trade_date"] == target_date), None)
if not sentiment:
return result
overview = dict(result.get("overview") or {})
overview.update(
{
"sentiment_score": sentiment["score"],
"sentiment_trend_score": sentiment["ema_score"],
"sentiment_label": sentiment["label"],
"sentiment_phase": sentiment["phase"],
"sentiment_direction": sentiment["direction"],
"sentiment_components": sentiment["components"],
"sentiment_engine_version": SENTIMENT_ENGINE_VERSION,
}
)
result["overview"] = overview
return result
sys.modules[__name__] = _implementation