"""Review-aware factor scoring. This module is the bridge between daily review results and the next screening run. Strategy code should score named factors here instead of hard-coding every ``score += N`` forever. """ from __future__ import annotations from dataclasses import dataclass, field from typing import Any from app.config.config_loader import get_signal_weights from app.core.signal_taxonomy import signal_label_for_code DEFAULT_FACTOR_WEIGHTS = { "vp_fly_1h_current": 5.0, "volume_consecutive_1h": 4.0, "cex_top_gainer_24h": 2.0, "volume_divergence_1h": 1.0, "static_accum_4h": 5.0, "higher_lows_4h": 2.0, "compression_surge_4h": 2.0, "box_breakout_pullback_1h": 6.0, "box_breakout_pullback_4h": 8.0, "ignition_1h_current": 4.0, "ignition_4h_current": 3.0, "ignition_d1_current": 6.0, "dynamic_k_1h_bull": 3.0, "dynamic_k_d1_bull": 5.0, "breakout_pullback_d1": 8.0, "breakout_pullback_w1": 8.0, "breakout_15m_current": 3.0, "pullback_15m_confirm": 2.0, "strong_resonance_bypass": 3.0, "entry_quality_gate": 2.0, "top_trader_long": 1.0, "sector_rotation": 2.0, "sentiment_resonance": 2.0, "dex_volume_spike": 2.0, "liquidity_add": 1.5, "exchange_outflow": 2.0, "whale_accumulation": 2.5, "smart_money_buying": 2.5, "liquidity_remove_risk": 2.5, "exchange_inflow_risk": 2.5, "holder_concentration_risk": 2.0, "funding_extreme": 2.0, "trend_exhaustion": 3.0, "false_breakout": 5.0, "high_position_reject": 5.0, "risk_reward_bad": 2.0, # --- 新增因子 v1.8 --- "rs_strong": 3.0, "rs_weak": 2.0, "rs_independent_strength": 2.0, "oi_buildup": 3.0, "oi_healthy_trend": 1.5, "oi_divergence_risk": 2.0, "funding_negative_contrarian": 3.5, "funding_positive_risk": 3.0, "tf_alignment_full": 4.0, "tf_alignment_double": 2.0, "tf_alignment_single_penalty": 1.5, "tf_alignment_conflict_penalty": 3.0, # --- 新增因子 v1.8.1: VCP / Volume Profile / 突破质量 --- "vcp_bull_breakout": 5.0, "vcp_bull_forming": 3.0, "vcp_bear_breakdown": 5.0, "vcp_bear_forming": 3.0, "vp_path_clear": 1.5, "vp_path_blocked": 1.0, "breakout_quality_high": 3.0, "breakout_quality_low": 4.0, } FACTOR_GROUPS = { "vp_fly_1h_current": "momentum", "volume_consecutive_1h": "participation", "cex_top_gainer_24h": "momentum", "volume_divergence_1h": "risk", "static_accum_4h": "structure", "higher_lows_4h": "structure", "compression_surge_4h": "structure", "box_breakout_pullback_1h": "structure", "box_breakout_pullback_4h": "structure", "ignition_1h_current": "momentum", "ignition_4h_current": "momentum", "ignition_d1_current": "momentum", "dynamic_k_1h_bull": "momentum", "dynamic_k_d1_bull": "momentum", "breakout_pullback_d1": "structure", "breakout_pullback_w1": "structure", "breakout_15m_current": "entry_quality", "pullback_15m_confirm": "entry_quality", "strong_resonance_bypass": "structure", "entry_quality_gate": "entry_quality", "top_trader_long": "positioning", "sector_rotation": "narrative", "sentiment_resonance": "narrative", "funding_extreme": "risk", "trend_exhaustion": "risk", "false_breakout": "risk", "high_position_reject": "risk", "risk_reward_bad": "risk", # --- 新增因子 v1.8 --- "rs_strong": "relative_strength", "rs_weak": "relative_strength", "rs_independent_strength": "relative_strength", "oi_buildup": "positioning", "oi_healthy_trend": "positioning", "oi_divergence_risk": "risk", "funding_negative_contrarian": "positioning", "funding_positive_risk": "risk", "tf_alignment_full": "alignment", "tf_alignment_double": "alignment", "tf_alignment_single_penalty": "alignment", "tf_alignment_conflict_penalty": "alignment", # --- 新增因子 v1.8.1 --- "vcp_bull_breakout": "structure", "vcp_bull_forming": "structure", "vcp_bear_breakdown": "structure", "vcp_bear_forming": "structure", "vp_path_clear": "entry_quality", "vp_path_blocked": "risk", "breakout_quality_high": "entry_quality", "breakout_quality_low": "risk", } GROUP_CAPS = { "momentum": 16.0, "participation": 6.0, "structure": 16.0, "positioning": 8.0, "narrative": 5.0, "entry_quality": 7.0, "risk": 12.0, "relative_strength": 6.0, "alignment": 6.0, } WEIGHT_ALIASES = { "vp_fly_1h_current": ("量价齐飞", "1H当前量价齐飞"), "volume_consecutive_1h": ("连续3x放量", "连续3x放量(≥3根)", "1H连续放量"), "volume_divergence_1h": ("量价背离", "1H量价背离"), "static_accum_4h": ("静K蓄力", "4H静K蓄力"), "box_breakout_pullback_1h": ("1H箱体突破回踩", "1H底部箱体突破回踩"), "box_breakout_pullback_4h": ("4H箱体突破回踩", "4H底部箱体突破回踩"), "ignition_1h_current": ("静K动K转折", "静K→动K转折", "1H当前起爆点"), "ignition_4h_current": ("静K动K转折", "静K→动K转折", "4H当前起爆点"), "ignition_d1_current": ("静K动K转折", "静K→动K转折", "日线当前起爆点"), "breakout_15m_current": ("15min当前突破",), "pullback_15m_confirm": ("15min回踩确认",), "top_trader_long": ("大户偏多",), "sector_rotation": ("板块联动",), "sentiment_resonance": ("舆情共振",), "exchange_inflow_risk": ("交易所流入风险",), "holder_concentration_risk": ("持仓集中风险",), "funding_extreme": ("资金费率极端",), "trend_exhaustion": ("趋势衰减",), "false_breakout": ("假突破",), "high_position_reject": ("高位拒绝",), "risk_reward_bad": ("盈亏比不合格",), # --- 新增因子 v1.8 --- "rs_strong": ("RS强势", "RS相对强势"), "rs_weak": ("RS弱势", "RS相对弱势"), "rs_independent_strength": ("BTC回调中独立走强",), "oi_buildup": ("OI蓄力",), "oi_healthy_trend": ("OI健康增长",), "oi_divergence_risk": ("OI背离风险",), "funding_negative_contrarian": ("资金费率负值反向看多", "空头拥挤"), "funding_positive_risk": ("资金费率过高风险", "多头拥挤"), "tf_alignment_full": ("多周期三重对齐",), "tf_alignment_double": ("多周期双重确认",), "tf_alignment_single_penalty": ("仅单周期支持",), "tf_alignment_conflict_penalty": ("多周期方向矛盾",), # --- 新增因子 v1.8.1 --- "vcp_bull_breakout": ("VCP突破", "VCP多头突破"), "vcp_bull_forming": ("VCP蓄力", "VCP多头蓄力"), "vcp_bear_breakdown": ("顶部分配破位",), "vcp_bear_forming": ("顶部分配蓄力",), "vp_path_clear": ("VP路径清晰",), "vp_path_blocked": ("VP路径受阻",), "breakout_quality_high": ("突破质量高", "破位质量高"), "breakout_quality_low": ("突破质量低", "破位质量低"), } def _safe_float(value: Any, default: float = 0.0) -> float: try: if value is None or value == "": return default return float(value) except Exception: return default def _clamp(value: float, low: float, high: float) -> float: return max(low, min(high, value)) @dataclass class FactorScorer: """Apply review-adjusted factor weights and keep an audit trail.""" weights: dict[str, Any] = field(default_factory=dict) breakdown: list[dict[str, Any]] = field(default_factory=list) group_totals: dict[str, float] = field(default_factory=dict) @classmethod def from_runtime(cls) -> "FactorScorer": try: weights = get_signal_weights() or {} except Exception: weights = {} return cls(weights=weights) def _runtime_weight(self, code: str) -> float | None: keys = [code, signal_label_for_code(code), *WEIGHT_ALIASES.get(code, ())] for key in keys: if key in self.weights: value = self.weights[key] if isinstance(value, dict): value = value.get("weight") return max(0.0, _safe_float(value)) return None def _apply_group_cap(self, code: str, adjusted: float) -> tuple[float, str, float, float]: group = FACTOR_GROUPS.get(code, "other") cap = _safe_float(GROUP_CAPS.get(group), 99.0) if cap <= 0: return adjusted, group, cap, 0.0 used = abs(_safe_float(self.group_totals.get(group))) remaining = max(0.0, cap - used) capped = max(-remaining, min(remaining, adjusted)) self.group_totals[group] = round(_safe_float(self.group_totals.get(group)) + capped, 6) return capped, group, cap, remaining def delta(self, code: str, base: float, *, evidence: str = "", value: Any = None) -> float: """Return the review-aware score delta for one factor. ``base`` remains the strategy designer's default score. Once reviews have enough samples, ``signal_performance.weight`` becomes a multiplier against the factor's canonical baseline. A reviewed weight of 0 means the factor is effectively淘汰 for scoring. """ base = _safe_float(base) runtime_weight = self._runtime_weight(code) baseline = max(0.1, _safe_float(DEFAULT_FACTOR_WEIGHTS.get(code), abs(base) or 1.0)) if runtime_weight is None: adjusted = base multiplier = 1.0 source = "base" else: multiplier = _clamp(runtime_weight / baseline, 0.0, 1.8) adjusted = base * multiplier source = "review_weight" adjusted, group, group_cap, group_remaining = self._apply_group_cap(code, adjusted) adjusted = round(adjusted, 3) self.breakdown.append( { "factor_code": code, "factor_name": signal_label_for_code(code), "factor_group": group, "base_delta": base, "score_delta": adjusted, "runtime_weight": runtime_weight, "baseline_weight": baseline, "multiplier": round(multiplier, 4), "group_cap": group_cap, "group_remaining_before": round(group_remaining, 3), "source": source, "evidence": evidence, "value": value, } ) return adjusted def add_existing(self, code: str, observed_score: float, *, evidence: str = "", value: Any = None, cap: float | None = None) -> float: base = _safe_float(observed_score) if cap is not None: base = min(base, _safe_float(cap, base)) return self.delta(code, base, evidence=evidence, value=value) def summary(self) -> dict[str, Any]: total = round(sum(_safe_float(item.get("score_delta")) for item in self.breakdown), 3) groups = {} for item in self.breakdown: group = item.get("factor_group") or "other" bucket = groups.setdefault(group, {"score_delta": 0.0, "items": 0}) bucket["score_delta"] = round(bucket["score_delta"] + _safe_float(item.get("score_delta")), 3) bucket["items"] += 1 opportunity_groups = {"momentum", "participation", "structure", "positioning", "narrative"} opportunity_score = round(sum(_safe_float(v.get("score_delta")) for k, v in groups.items() if k in opportunity_groups), 3) entry_score = round(_safe_float(groups.get("entry_quality", {}).get("score_delta")), 3) risk_score = round(abs(min(0.0, _safe_float(groups.get("risk", {}).get("score_delta")))), 3) return { "total_delta": total, "opportunity_score": opportunity_score, "entry_score": entry_score, "risk_score": risk_score, "groups": groups, "group_caps": GROUP_CAPS, "items": self.breakdown, }