Files
tick-stock-panel/backend/tests/test_emotion_score.py
T
shy3130 ecb49cbaca release: v0.3.6 — 核心+扩展架构完善版
合并 0.3.6 分支全部内容 (36 提交, 详细历史见 0.3.6 分支):

■ 数据与插件
- TdxQuant 数据源: 通达信量化接入 (实时/五档/日K/复权/分钟), tdxaidata 收进默认依赖
- 扩展表两级拉取配方: 概念/行业出厂即带完整可拉配置, 可作自定义接入参考示例
- 能力门禁日志带生效源; 标的池解析只走 instruments 维表

■ 页面扩展化
- 11 个官方页面全量迁移为单文件扩展 (custom/*/extension.tsx), 删目录即卸载
- 运行时页面包系统: 打包产物上传即挂载, 免重建宿主
- 新装用户扩展页默认隐藏, 老用户升级菜单无感

■ AI
- Responses 协议接入; 会话历史与长任务跨设备同步 (服务端注册表)
- 助手头部快捷入口 + 悬浮球拖拽 zoom 坐标系修复 + 系统设置开关

■ 界面
- 设置「关于」子菜单: 版权/更新/打赏 + 开源许可弹窗与合规声明双保险
- 侧栏两态收起、数据/AI 助手快捷行 (同步呼吸光晕)、核心/扩展 ext 标记
- 界面缩放默认 90%; 菜单设置徽标 Swiss 化; 策略池「叠加」分组
- 情绪评分口径可配置; 财务画像 5 张子表手动拉取

■ 文档与其他
- README 重构「核心+扩展」主线 + 架构图重画; 开放端点数核实为 65
- 版本号 bump 0.3.6; tdx 物化库测试夹具补 macOS dylib
2026-10-13 13:40:17 +08:00

201 lines
8.5 KiB
Python

"""情绪评分口径 — 默认口径还原 + 配置归一化契约。
「默认口径必须与历史硬编码算法逐位一致」是这里的核心约束: 情绪分是看板与
复盘快照的对外数字, 改动计算模块不能悄悄挪动它。原算法已从
market_overview_builder 移除, 故本文件内联一份等价实现做对照。
"""
from __future__ import annotations
import math
import random
from app.services.emotion_score import (
DEFAULT_CONFIG,
DIMENSION_ORDER,
compute,
dimension_value,
label_of,
merge_config,
public_config,
score_of,
)
def _legacy_score(value: float, low: float, high: float) -> int:
"""改造前 market_overview_builder._score 的逐字复制。"""
if high <= low:
return 50
if not math.isfinite(value):
return 50
return max(0, min(100, round((value - low) / (high - low) * 100)))
def _legacy_radar_and_score(m: dict) -> tuple[list[dict], int]:
"""改造前看板情绪雷达的逐字复制 (含 mainline 预计算那一版)。"""
radar = [
{"key": "index", "label": "指数", "value": _legacy_score(m["avg_index_pct"], -2.5, 2.5)},
{"key": "profit", "label": "赚钱", "value": round(
_legacy_score(m["up_pct"], 20, 80) * 0.45
+ _legacy_score(m["avg_pct"], -0.02, 0.02) * 0.25
+ _legacy_score(m["median_pct"], -0.02, 0.02) * 0.20
+ _legacy_score(m["strong_diff_pct"], -8, 8) * 0.10)},
{"key": "money", "label": "量能", "value": round(
_legacy_score(m["avg_vol_ratio"], 0.6, 1.8) * 0.70
+ _legacy_score(m["high_vol_pct"], 2, 12) * 0.30)},
{"key": "speculation", "label": "投机", "value": round(
_legacy_score(m["limit_up"], 5, 90) * 0.25
+ _legacy_score(m["seal_rate"], 30, 85) * 0.35
+ _legacy_score(m["max_boards"], 1, 8) * 0.25
+ _legacy_score(m["tier2_count"], 0, 30) * 0.15)},
{"key": "resilience", "label": "抗跌", "value": 100 - round(
_legacy_score(m["down_pct"], 20, 80) * 0.55
+ _legacy_score(m["strong_down_pct"], 1, 12) * 0.45)},
{"key": "mainline", "label": "主线", "value": m["mainline_score"]},
]
return radar, round(sum(r["value"] for r in radar) / len(radar))
def _sample(rng: random.Random) -> dict:
m = {
"avg_index_pct": rng.uniform(-6, 6), "up_pct": rng.uniform(0, 100),
"avg_pct": rng.uniform(-0.05, 0.05), "median_pct": rng.uniform(-0.05, 0.05),
"strong_diff_pct": rng.uniform(-20, 20), "avg_vol_ratio": rng.uniform(0.3, 3),
"high_vol_pct": rng.uniform(0, 40), "limit_up": rng.randint(0, 200),
"seal_rate": rng.uniform(0, 100), "max_boards": rng.randint(0, 12),
"tier2_count": rng.randint(0, 60), "down_pct": rng.uniform(0, 100),
"strong_down_pct": rng.uniform(0, 25), "mainline_avg": rng.uniform(-0.03, 0.08),
"mainline_cover_pct": rng.uniform(0, 30),
}
m["mainline_score"] = round(
_legacy_score(m["mainline_avg"], -0.005, 0.03) * 0.65
+ _legacy_score(m["mainline_cover_pct"], 1, 12) * 0.35)
return m
def test_default_matches_legacy_bit_for_bit() -> None:
"""出厂口径与历史硬编码算法逐位一致 (随机 5000 组)。
取整顺序在国内很敏感: 抗跌维度必须「先取整再取反」(100 - round(x)),
写成 round(100 - x) 会在 x 的小数部分恰为 .5 时差 1 分。
"""
rng = random.Random(7)
for _ in range(5000):
m = _sample(rng)
legacy_radar, legacy_score = _legacy_radar_and_score(m)
res = compute(m, None)
assert res["radar"] == legacy_radar, f"雷达不一致: {m}"
assert res["score"] == legacy_score, f"总分不一致: {m}"
def test_label_mapping_matches_legacy_over_full_range() -> None:
"""档位映射覆盖 0..100 全整数区间, 与历史阈值一致。"""
def legacy_label(score: int) -> str:
if score >= 70:
return "强势"
if score >= 55:
return "偏暖"
if score >= 45:
return "震荡"
if score >= 30:
return "偏冷"
return "冰点"
th = DEFAULT_CONFIG["thresholds"]
for score in range(101):
assert label_of(score, th) == legacy_label(score), f"{score} 档位不一致"
def test_score_of_handles_non_finite() -> None:
"""非有限输入取中性 50, 不得抛异常打断看板计算。"""
for bad in (float("nan"), float("inf"), None, "abc"):
assert score_of(bad, 0, 1) == 50
assert score_of(0.5, 0, 1) == 50
assert score_of(5, 0, 1) == 100 # 超出上界夹到 100
assert score_of(-5, 0, 1) == 0 # 低于下界夹到 0
def test_merge_config_fills_defaults_and_ignores_unknown() -> None:
"""部分配置 → 其余字段补默认; 未知 key 丢弃 (不因脏配置崩)。"""
merged = merge_config({
"dimensions": {
"index": {"weight": 3.0, "terms": [{"key": "avg_index_pct", "low": -5}]},
"no_such_dim": {"weight": 9},
},
"thresholds": {"strong": 80},
"unknown_top": 1,
})
assert merged["dimensions"]["index"]["weight"] == 3.0
assert merged["dimensions"]["index"]["terms"][0]["low"] == -5
assert merged["dimensions"]["index"]["terms"][0]["high"] == 2.5 # 未提及 → 默认
assert "no_such_dim" not in merged["dimensions"]
assert "unknown_top" not in merged
assert merged["thresholds"]["strong"] == 80
assert merged["thresholds"]["warm"] == DEFAULT_CONFIG["thresholds"]["warm"]
def test_thresholds_normalized_to_descending() -> None:
"""用户把阈值填乱序 → 自动重排为降序, 保证档位区间非空。"""
merged = merge_config({"thresholds": {"strong": 20, "warm": 90, "neutral": 50, "cold": 10}})
th = merged["thresholds"]
assert th["strong"] >= th["warm"] >= th["neutral"] >= th["cold"]
def test_thresholds_clamped_to_range() -> None:
"""阈值越界 (负数/超 100) 夹回 0..100。"""
merged = merge_config({"thresholds": {"strong": 999, "cold": -50}})
th = merged["thresholds"]
assert 0 <= th["cold"] <= 100
assert 0 <= th["strong"] <= 100
def test_dimension_weight_affects_score() -> None:
"""改维度权重必须改变总分 (否则配置形同虚设)。"""
m = _sample(random.Random(1))
base = compute(m, None)["score"]
heavy = merge_config({"dimensions": {"index": {"weight": 10.0}}})
assert compute(m, heavy)["score"] != base
def test_term_weight_within_dimension_is_normalized() -> None:
"""子项权重和不为 1 时按比例归一化, 不因总和变化而畸变。
把「指数」维度 (单子项) 的权重从 1 改成 7, 该维度得分应不变 —— 单子项
归一化后权重的绝对大小不影响自身得分。
"""
dim = merge_config(None)["dimensions"]["index"]
before = dimension_value(dim, {"avg_index_pct": 1.0})
scaled = merge_config({"dimensions": {"index": {"terms": [
{"key": "avg_index_pct", "weight": 7.0}]}}})["dimensions"]["index"]
assert dimension_value(scaled, {"avg_index_pct": 1.0}) == before
def test_missing_metric_takes_neutral() -> None:
"""指标缺失 → 该维度取中性 50 (与历史「无主线数据取 50」一致)。"""
dim = merge_config(None)["dimensions"]["mainline"]
assert dimension_value(dim, {}) == 50
def test_public_config_shape() -> None:
"""配置接口返回结构稳定 (前端表单完全依赖它渲染)。"""
payload = public_config(None)
assert payload["dimension_order"] == list(DIMENSION_ORDER)
assert set(payload["config"]) == {"dimensions", "thresholds"}
assert payload["defaults"] == DEFAULT_CONFIG
names = [l["name"] for l in payload["labels"]]
assert "强势" in names and "冰点" in names
# 每个维度都带 label / weight / terms, 每个 term 带完整六字段
for key in DIMENSION_ORDER:
dim = payload["config"]["dimensions"][key]
assert dim["label"] and "weight" in dim and dim["terms"]
for term in dim["terms"]:
assert set(term) == {"key", "label", "weight", "low", "high", "unit"}
def test_compute_returns_dimension_details() -> None:
"""compute 额外返回 dimensions (带权重与 invert), 供配置面板展示。"""
res = compute(_sample(random.Random(3)), None)
assert len(res["dimensions"]) == len(DIMENSION_ORDER)
resilience = next(d for d in res["dimensions"] if d["key"] == "resilience")
assert resilience["invert"] is True
assert res["label"] in ("强势", "偏暖", "震荡", "偏冷", "冰点")