271 lines
11 KiB
Python
271 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
from statistics import mean, median
|
|
from typing import Any
|
|
|
|
WEIGHTS = {
|
|
"breadth": 20,
|
|
"limit_ecology": 25,
|
|
"profit_effect": 30,
|
|
"ladder_structure": 15,
|
|
"liquidity": 10,
|
|
}
|
|
|
|
|
|
def calculate_sentiment(snapshot: dict[str, Any], history: list[dict[str, Any]]) -> dict[str, Any]:
|
|
stats = _stats(snapshot)
|
|
historical = [_stats(item) for item in history[-250:]]
|
|
breadth = _clamp(stats["breadth_ratio"])
|
|
limit_strength = _adaptive(
|
|
stats["limit_up"], _linear(stats["limit_up"], 10, 100), _series(historical, "limit_up")
|
|
)
|
|
down_pressure = _adaptive(
|
|
stats["limit_down"], _linear(stats["limit_down"], 0, 50), _series(historical, "limit_down")
|
|
)
|
|
down_relief = 100 - down_pressure
|
|
seal_quality = _linear(stats["seal_rate"], 35, 90)
|
|
ecology = limit_strength * 0.35 + seal_quality * 0.35 + down_relief * 0.30
|
|
systemic_health = breadth * 0.60 + down_relief * 0.40
|
|
gate = 1 if systemic_health >= 35 else 0.35 + systemic_health / 35 * 0.65
|
|
|
|
profit = _profit(stats)
|
|
max_height = _adaptive(
|
|
stats["max_height"],
|
|
_linear(stats["max_height"], 1, 7),
|
|
_series(historical, "max_height"),
|
|
)
|
|
continuation = (stats["second_board"] + stats["three_plus"]) / max(stats["limit_up"], 1) * 100
|
|
three_density = stats["three_plus"] / max(stats["limit_up"], 1) * 100
|
|
three_score = _adaptive(
|
|
stats["three_plus"], _clamp(three_density * 5), _series(historical, "three_plus")
|
|
)
|
|
ladder = (
|
|
max_height * 0.30
|
|
+ _clamp(continuation * 3) * 0.25
|
|
+ three_score * 0.25
|
|
+ stats["ladder_completeness"] * 0.20
|
|
)
|
|
|
|
prior_amounts = [item["amount"] for item in historical[-20:] if item["amount"] > 0]
|
|
baseline = mean(prior_amounts) if prior_amounts else stats["amount"] or 1
|
|
amount_ratio = stats["amount"] / max(baseline, 1)
|
|
amount_score = _clamp(50 + (amount_ratio - 1) * 100)
|
|
limit_share = stats["limit_amount"] / max(stats["amount"], 1) * 100
|
|
liquidity = amount_score * 0.70 + _clamp(limit_share * 20) * 0.30
|
|
|
|
components = {
|
|
"breadth": breadth,
|
|
"limit_ecology": ecology,
|
|
"profit_effect": profit,
|
|
"ladder_structure": ladder,
|
|
"liquidity": liquidity,
|
|
}
|
|
score = round(sum(components[key] * weight / 100 for key, weight in WEIGHTS.items()) * gate)
|
|
extreme = stats["breadth_ratio"] <= 15 and stats["limit_down"] >= 100
|
|
if extreme:
|
|
score = min(score, 15)
|
|
elif stats["breadth_ratio"] <= 25 and stats["limit_down"] >= 50:
|
|
score = min(score, 24)
|
|
|
|
prior_sentiments = [item.get("sentiment") or {} for item in history[-3:]]
|
|
prior_scores = [
|
|
float(item["score"]) for item in prior_sentiments if item.get("score") is not None
|
|
]
|
|
momentum = score - mean(prior_scores) if prior_scores else 0
|
|
direction = "升温" if momentum > 3 else "降温" if momentum < -3 else "持平"
|
|
previous = prior_sentiments[-1] if prior_sentiments else None
|
|
day_change = (
|
|
score - float(previous["score"]) if previous and previous.get("score") is not None else 0
|
|
)
|
|
signal = _phase_signal(score, momentum, profit)
|
|
phase, reason = _phase(
|
|
previous, score, day_change, systemic_health, profit, ecology, signal, extreme
|
|
)
|
|
fermentation_ready = signal == "发酵" and score >= 45 and profit >= 45 and systemic_health >= 35
|
|
previous_count = int(previous.get("fermentation_signal_count") or 0) if previous else 0
|
|
fermentation_count = previous_count + 1 if fermentation_ready else 0
|
|
history_days = len(history)
|
|
confidence = min(95, round(55 + min(history_days, 20) * 1.25 + (15 if phase == signal else 7)))
|
|
labels = {
|
|
"breadth": "市场宽度",
|
|
"limit_ecology": "涨停生态",
|
|
"profit_effect": "赚钱效应",
|
|
"ladder_structure": "连板结构",
|
|
"liquidity": "成交活跃度",
|
|
}
|
|
return {
|
|
"score": score,
|
|
"label": _label(score),
|
|
"direction": direction,
|
|
"momentum": round(momentum, 1),
|
|
"day_change": round(day_change, 1),
|
|
"phase": phase,
|
|
"phase_signal": signal,
|
|
"transition_reason": reason,
|
|
"fermentation_signal_count": fermentation_count,
|
|
"confidence": confidence,
|
|
"history_days": history_days,
|
|
"systemic_health": round(systemic_health, 1),
|
|
"components": [
|
|
{
|
|
"key": key,
|
|
"label": labels[key],
|
|
"score": round(value, 1),
|
|
"weight": WEIGHTS[key],
|
|
}
|
|
for key, value in components.items()
|
|
],
|
|
"stats": stats,
|
|
}
|
|
|
|
|
|
def _stats(snapshot: dict[str, Any]) -> dict[str, float]:
|
|
overview = snapshot.get("overview") or {}
|
|
limits = snapshot.get("limits") or []
|
|
yesterday = snapshot.get("yesterday_limits") or []
|
|
streaks = [max(1, int(_number(row.get("streak"), 1))) for row in limits]
|
|
levels = set(streaks)
|
|
max_height = max(streaks, default=0)
|
|
active = _number(overview.get("up_count")) + _number(overview.get("down_count"))
|
|
changes = [_number(row.get("current_change")) for row in yesterday]
|
|
yesterday_count = len(yesterday)
|
|
return {
|
|
"breadth_ratio": _number(overview.get("up_count")) / max(active, 1) * 100,
|
|
"limit_up": _number(overview.get("limit_up")),
|
|
"limit_down": _number(overview.get("limit_down")),
|
|
"broken": _number(overview.get("broken")),
|
|
"seal_rate": _number(overview.get("seal_rate")),
|
|
"amount": _number(overview.get("amount")),
|
|
"limit_amount": sum(_number(row.get("amount")) for row in limits),
|
|
"second_board": sum(streak == 2 for streak in streaks),
|
|
"three_plus": sum(streak >= 3 for streak in streaks),
|
|
"max_height": max_height,
|
|
"ladder_completeness": (
|
|
sum(level in levels for level in range(1, max_height + 1)) / max_height * 100
|
|
if max_height
|
|
else 0
|
|
),
|
|
"yesterday_count": yesterday_count,
|
|
"positive_rate": sum(change > 0 for change in changes) / max(yesterday_count, 1) * 100,
|
|
"advance_rate": sum(row.get("outcome") == "晋级" for row in yesterday)
|
|
/ max(yesterday_count, 1)
|
|
* 100,
|
|
"average_change": mean(changes) if changes else 0,
|
|
"median_change": median(changes) if changes else 0,
|
|
"severe_loss_rate": sum(change <= -5 for change in changes) / max(yesterday_count, 1) * 100,
|
|
"previous_down_rate": sum(row.get("outcome") == "跌停" for row in yesterday)
|
|
/ max(yesterday_count, 1)
|
|
* 100,
|
|
}
|
|
|
|
|
|
def _profit(stats: dict[str, float]) -> float:
|
|
if not stats["yesterday_count"]:
|
|
return 50
|
|
median_score = _clamp(50 + stats["median_change"] * 7)
|
|
average_score = _clamp(50 + stats["average_change"] * 6)
|
|
advance_score = _clamp(stats["advance_rate"] * 2.5)
|
|
loss_safety = _clamp(100 - stats["severe_loss_rate"] * 3)
|
|
down_safety = _clamp(100 - stats["previous_down_rate"] * 7)
|
|
tail = loss_safety * 0.70 + down_safety * 0.30
|
|
return (
|
|
stats["positive_rate"] * 0.30
|
|
+ median_score * 0.25
|
|
+ average_score * 0.10
|
|
+ advance_score * 0.20
|
|
+ tail * 0.15
|
|
)
|
|
|
|
|
|
def _phase_signal(score: float, momentum: float, profit: 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 >= 60 else "分化"
|
|
if score >= 65:
|
|
return "分化" if momentum < -3 or profit < 50 else "发酵"
|
|
if momentum < -5:
|
|
return "退潮"
|
|
return "发酵" if momentum >= 0 and profit >= 45 else "分化"
|
|
|
|
|
|
def _phase(previous, score, change, health, profit, ecology, signal, extreme):
|
|
if not previous:
|
|
return signal, "首个连续交易日,采用原始阶段信号"
|
|
prior = str(previous.get("phase") or signal)
|
|
if extreme:
|
|
return "冰点", "市场宽度与跌停数量触发极端冰点"
|
|
recovery = change >= 6 and score >= 25 and health >= 24
|
|
climax = score >= 80 and profit >= 60 and health >= 60 and ecology >= 70
|
|
if prior in {"冰点", "退潮"}:
|
|
if score < 25:
|
|
return "冰点", "市场仍处于冰点区间"
|
|
return ("修复", "出现有效回升") if recovery else (prior, "尚未形成有效修复")
|
|
if prior == "修复":
|
|
if score < 25:
|
|
return "冰点", "修复失败并跌入冰点"
|
|
if change <= -6 and score < 45:
|
|
return "退潮", "修复失败且显著降温"
|
|
prior_signal = int(previous.get("fermentation_signal_count") or 0)
|
|
if signal == "发酵" and prior_signal >= 1:
|
|
return "发酵", "发酵条件连续两个交易日成立"
|
|
return "修复", "修复延续,等待发酵确认"
|
|
if prior == "发酵":
|
|
if score < 45 and (change < 0 or health < 35):
|
|
return "退潮", "温度与系统健康度转弱"
|
|
if climax:
|
|
return "高潮", "温度、赚钱效应与涨停生态达到高潮条件"
|
|
return ("分化", "发酵阶段出现降温") if signal in {"分化", "退潮"} else ("发酵", "发酵延续")
|
|
if prior == "高潮":
|
|
if climax:
|
|
return "高潮", "高潮条件继续成立"
|
|
return ("退潮", "风险快速释放") if score < 45 or health < 30 else ("分化", "高潮条件消退")
|
|
if score < 25:
|
|
return "冰点", "分化继续恶化至冰点"
|
|
if score < 45 or health < 30:
|
|
return "退潮", "分化后继续转弱"
|
|
return "分化", "分化延续,等待方向确认"
|
|
|
|
|
|
def _label(score: float) -> str:
|
|
if score >= 80:
|
|
return "情绪高涨"
|
|
if score >= 60:
|
|
return "情绪偏强"
|
|
if score >= 40:
|
|
return "情绪中性"
|
|
if score >= 20:
|
|
return "情绪偏弱"
|
|
return "情绪冰点"
|
|
|
|
|
|
def _series(rows: list[dict[str, float]], key: str) -> list[float]:
|
|
return [row[key] for row in rows]
|
|
|
|
|
|
def _adaptive(value: float, fixed: float, history: list[float]) -> float:
|
|
if len(history) < 20:
|
|
return fixed
|
|
below = sum(item < value for item in history[-250:])
|
|
equal = sum(item == value for item in history[-250:])
|
|
percentile = (below + equal * 0.5) / len(history[-250:]) * 100
|
|
return fixed * 0.25 + percentile * 0.75
|
|
|
|
|
|
def _linear(value: float, low: float, high: float) -> float:
|
|
return _clamp((value - low) / (high - low) * 100) if high > low else 50
|
|
|
|
|
|
def _clamp(value: float) -> float:
|
|
return min(100, max(0, value))
|
|
|
|
|
|
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
|