Spaces:
Running
Running
| """Runtime multi-factor probability overlay. | |
| The validation harness learns signed factor weights: positive factor values | |
| raise BUY probability, negative factor values raise SELL probability. This | |
| module applies the same semantics to the latest prediction row when an audited | |
| config file is explicitly activated. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from typing import Any | |
| import numpy as np | |
| import pandas as pd | |
| ROOT = Path(__file__).resolve().parent.parent | |
| DEFAULT_CONFIG_PATH = ROOT / "config" / "multi_factor_overlay.json" | |
| def _config_path() -> Path: | |
| value = os.getenv("MULTI_FACTOR_OVERLAY_CONFIG", "").strip() | |
| return Path(value) if value else DEFAULT_CONFIG_PATH | |
| def _env_enabled() -> bool: | |
| return os.getenv("ENABLE_MULTI_FACTOR_WEIGHT_OVERLAY", "0").strip().lower() in {"1", "true", "yes", "on"} | |
| def load_multi_factor_overlay_config(path: str | Path | None = None) -> dict[str, Any] | None: | |
| config_path = Path(path) if path else _config_path() | |
| try: | |
| payload = json.loads(config_path.read_text()) | |
| except FileNotFoundError: | |
| return None | |
| except Exception as exc: | |
| return {"active": False, "error": f"config_read_failed: {exc}", "config_path": str(config_path)} | |
| payload.setdefault("config_path", str(config_path)) | |
| return payload | |
| def _series(frame: pd.DataFrame, name: str, default: float = 0.0) -> pd.Series: | |
| if name in frame.columns: | |
| return pd.to_numeric(frame[name], errors="coerce").fillna(default) | |
| return pd.Series(default, index=frame.index, dtype=float) | |
| def _first_series(frame: pd.DataFrame, names: list[str], default: float = 0.0) -> pd.Series: | |
| for name in names: | |
| if name in frame.columns: | |
| return _series(frame, name, default) | |
| return pd.Series(default, index=frame.index, dtype=float) | |
| def _add_runtime_factor_inputs(features: pd.DataFrame, df: pd.DataFrame) -> pd.DataFrame: | |
| out = features.copy() | |
| market = df.reindex(out.index) | |
| open_ = _series(market, "open", np.nan) | |
| high = _series(market, "high", np.nan) | |
| low = _series(market, "low", np.nan) | |
| close = _series(market, "close", np.nan) | |
| volume = _series(market, "volume", 0.0).clip(lower=0.0) | |
| span = (high - low).abs().replace(0, np.nan) | |
| upper_shadow = high - np.maximum(open_, close) | |
| lower_shadow = np.minimum(open_, close) - low | |
| body = close - open_ | |
| body_abs = body.abs() | |
| rsi = _series(out, "rsi", 50.0) | |
| macd_hist_norm = _series(out, "macd_hist_norm", 0.0) | |
| k_line = _series(out, "k", 50.0) | |
| d_line = _series(out, "d", 50.0) | |
| ma10 = close.rolling(10, min_periods=5).mean() | |
| ma20 = close.rolling(20, min_periods=10).mean() | |
| high_120 = close.rolling(120, min_periods=30).max() | |
| low_120 = close.rolling(120, min_periods=30).min() | |
| out["factor_upper_shadow_ratio"] = (upper_shadow / span).fillna(0.0).clip(0.0, 1.0) | |
| out["factor_lower_shadow_ratio"] = (lower_shadow / span).fillna(0.0).clip(0.0, 1.0) | |
| out["factor_body_ratio"] = (body / span).fillna(0.0).clip(-1.0, 1.0) | |
| out["factor_close_location"] = ((close - low) / span).fillna(0.5).clip(0.0, 1.0) | |
| body_strength = (body_abs / span).fillna(0.0).clip(0.0, 1.0) | |
| close_vs_120d_high = ((close / high_120.replace(0, np.nan)) - 1.0).fillna(0.0) | |
| close_vs_120d_low = ((close / low_120.replace(0, np.nan)) - 1.0).fillna(0.0) | |
| ma20_ratio = ((close / ma20.replace(0, np.nan)) - 1.0).fillna(0.0) | |
| high_base_score = pd.Series(0.0, index=out.index) | |
| high_base_score += ((close_vs_120d_high >= -0.03) & (close.pct_change(20).fillna(0.0) >= 0.18)).astype(float) * 2.0 | |
| high_base_score += (close_vs_120d_low >= 0.60).astype(float) | |
| high_base_score += (ma20_ratio >= 0.12).astype(float) | |
| out["factor_close_vs_120d_high"] = close_vs_120d_high.clip(-1.0, 1.0) | |
| out["factor_close_vs_120d_low"] = close_vs_120d_low.clip(-1.0, 5.0) | |
| out["factor_high_base_score"] = high_base_score.clip(0.0, 4.0) | |
| out["factor_short_term_battle"] = ( | |
| (body_strength <= 0.18) | |
| & (out["factor_upper_shadow_ratio"] >= 0.35) | |
| & (out["factor_close_location"] <= 0.70) | |
| ).astype(float) | |
| rsi_short = rsi.rolling(10, min_periods=5).mean() | |
| rsi_long = rsi.rolling(20, min_periods=10).mean() | |
| ma_trend = pd.Series(np.where(ma10 > ma20, 1.0, np.where(ma10 < ma20, -1.0, 0.0)), index=out.index) | |
| rsi_trend = pd.Series(np.where(rsi_short > rsi_long, 1.0, np.where(rsi_short < rsi_long, -1.0, 0.0)), index=out.index) | |
| mom15 = close.pct_change(15).fillna(0.0).clip(-0.25, 0.25) * 4.0 | |
| kd_trend = pd.Series(np.where(k_line > d_line, 1.0, np.where(k_line < d_line, -1.0, 0.0)), index=out.index) | |
| macd_trend = (np.tanh(macd_hist_norm * 200.0) + 0.25 * np.sign(macd_hist_norm.diff().fillna(0.0))).clip(-1.0, 1.0) | |
| tej_trend_score = (ma_trend + rsi_trend + mom15 + kd_trend + macd_trend) / 5.0 | |
| trend_state = pd.Series( | |
| np.where((ma_trend > 0) & (rsi_trend > 0), 1.0, np.where((ma_trend < 0) & (rsi_trend < 0), -1.0, 0.0)), | |
| index=out.index, | |
| ) | |
| trend_flips = trend_state.ne(trend_state.shift()).astype(float).rolling(20, min_periods=5).sum().fillna(0.0) | |
| trend_persistence = trend_state.groupby(trend_state.ne(trend_state.shift()).cumsum()).cumcount().add(1).astype(float) | |
| close_ma20_distance = ((close / ma20.replace(0, np.nan)) - 1.0).abs().fillna(0.0) | |
| volatility = close.pct_change().rolling(20, min_periods=10).std().fillna(0.0) | |
| chop_penalty = ( | |
| 0.45 * (trend_flips / 8.0).clip(0.0, 1.0) | |
| + 0.30 * (volatility / 0.04).clip(0.0, 1.0) | |
| + 0.25 * (1.0 - (close_ma20_distance / 0.04).clip(0.0, 1.0)) | |
| ) | |
| persistent_trend = ((trend_persistence / 8.0).clip(0.0, 1.0) * trend_state).fillna(0.0) | |
| out["factor_tej_lstm_trend_score"] = tej_trend_score.fillna(0.0).clip(-1.0, 1.0) | |
| out["factor_tej_lstm_chop_score"] = (persistent_trend - chop_penalty).fillna(0.0).clip(-1.0, 1.0) | |
| lower_body_ratio = (lower_shadow / body_abs.replace(0, np.nan)).fillna(0.0) | |
| yin_gao_pao = ( | |
| (body < 0.0) | |
| & (lower_body_ratio >= 1.0) | |
| & (out["factor_lower_shadow_ratio"] >= 0.25) | |
| & (out["factor_close_location"] >= 0.30) | |
| ) | |
| recent_yin_gao_pao = yin_gao_pao.shift(1).fillna(False).rolling(3, min_periods=1).max().astype(bool) | |
| recent_yin_low = low.where(yin_gao_pao).shift(1).ffill() | |
| held_yin_low = recent_yin_gao_pao & recent_yin_low.notna() & (low >= recent_yin_low) & (close >= recent_yin_low) | |
| broke_yin_low = recent_yin_gao_pao & recent_yin_low.notna() & (low < recent_yin_low) | |
| out["factor_lower_shadow_body_ratio"] = lower_body_ratio.fillna(0.0).clip(0.0, 5.0) | |
| out["factor_yin_gao_pao_raw"] = yin_gao_pao.astype(float) | |
| out["factor_yin_gao_pao_hold"] = held_yin_low.astype(float) | |
| out["factor_yin_gao_pao_failed"] = broke_yin_low.astype(float) | |
| bullish_body = body > 0.0 | |
| bearish_body = body < 0.0 | |
| big_bull = bullish_body & (body_strength >= 0.45) & (out["factor_close_location"] >= 0.60) | |
| big_bear = bearish_body & (body_strength >= 0.45) & (out["factor_close_location"] <= 0.45) | |
| recent_big_bull = big_bull.shift(1).fillna(False).rolling(5, min_periods=1).max().astype(bool) | |
| recent_big_bear = big_bear.shift(1).fillna(False).rolling(5, min_periods=1).max().astype(bool) | |
| prior_big_bull_mid = ((open_ + close) / 2.0).where(big_bull).shift(1).ffill() | |
| prior_big_bull_low = low.where(big_bull).shift(1).ffill() | |
| prior_big_bear_high = high.where(big_bear).shift(1).ffill() | |
| prior_big_bear_mid = ((open_ + close) / 2.0).where(big_bear).shift(1).ffill() | |
| prior_big_bear_low = low.where(big_bear).shift(1).ffill() | |
| yin_ya_breakout = recent_big_bear & prior_big_bear_high.notna() & bullish_body & ( | |
| (close > prior_big_bear_high) | (close > prior_big_bear_mid) | |
| ) | |
| yin_ya_failed = recent_big_bear & prior_big_bear_low.notna() & (close < prior_big_bear_low) | |
| yi_yang_ding_breakdown = recent_big_bull & prior_big_bull_mid.notna() & ( | |
| (close < prior_big_bull_mid) | (close < prior_big_bull_low) | |
| ) | |
| out["factor_bt_yi_yin_ya_breakout"] = yin_ya_breakout.astype(float) | |
| out["factor_bt_yi_yin_ya_failed"] = yin_ya_failed.astype(float) | |
| out["factor_bt_yi_yang_ding_breakdown"] = yi_yang_ding_breakdown.astype(float) | |
| recent_upper_shadow_high = high.where(out["factor_upper_shadow_ratio"] >= 0.35).shift(1).rolling(20, min_periods=2).max() | |
| recent_lower_shadow_low = low.where(out["factor_lower_shadow_ratio"] >= 0.35).shift(1).rolling(20, min_periods=2).min() | |
| upper_cluster_break = recent_upper_shadow_high.notna() & (close > recent_upper_shadow_high) | |
| lower_cluster_bounce = ( | |
| recent_lower_shadow_low.notna() | |
| & (low <= recent_lower_shadow_low * 1.01) | |
| & (close >= recent_lower_shadow_low) | |
| & bullish_body | |
| ) | |
| lower_cluster_break = recent_lower_shadow_low.notna() & (close < recent_lower_shadow_low) | |
| out["factor_bt_upper_shadow_cluster_break"] = upper_cluster_break.astype(float) | |
| out["factor_bt_lower_shadow_cluster_bounce"] = lower_cluster_bounce.astype(float) | |
| out["factor_bt_lower_shadow_cluster_break"] = lower_cluster_break.astype(float) | |
| yang_gao_pao = ( | |
| bullish_body | |
| & (body_strength >= 0.35) | |
| & (out["factor_close_location"] >= 0.90) | |
| & (((open_ - low) / span).fillna(0.0) <= 0.35) | |
| ) | |
| out["factor_bt_yang_gao_pao"] = yang_gao_pao.astype(float) | |
| black_candle = bearish_body & (body_strength >= 0.15) & (out["factor_close_location"] <= 0.55) | |
| three_black = black_candle & black_candle.shift(1).fillna(False) & black_candle.shift(2).fillna(False) | |
| ma20_break = ma20.notna() & (close < ma20) & (close.shift(1) >= ma20.shift(1)) | |
| below_ma20 = ma20.notna() & (close < ma20) | |
| san_sheng_wu_nai = three_black & below_ma20 & (ma20_break | (close < low.shift(1))) | |
| downside_breakdown = ( | |
| bearish_body | |
| & (close < ma20) | |
| & ((close < low.shift(1)) | (close.pct_change().fillna(0.0) <= -0.015)) | |
| ) | |
| recent_breakdown_low = low.where(downside_breakdown).shift(1).ffill() | |
| recent_breakdown_close = close.where(downside_breakdown).shift(1).ffill() | |
| recent_breakdown_active = downside_breakdown.shift(1).fillna(False).rolling(5, min_periods=1).max().astype(bool) | |
| downside_follow_through = recent_breakdown_active & recent_breakdown_low.notna() & ( | |
| (close < recent_breakdown_low) | (close < recent_breakdown_close) | |
| ) | |
| rebound_from_break_low = ((close / recent_breakdown_low.replace(0, np.nan)) - 1.0).fillna(0.0) | |
| weak_rebound = ( | |
| recent_breakdown_active | |
| & recent_breakdown_low.notna() | |
| & (rebound_from_break_low >= 0.0) | |
| & (rebound_from_break_low <= 0.025) | |
| & (close <= ma20) | |
| ) | |
| out["factor_san_sheng_wu_nai_breakdown"] = san_sheng_wu_nai.astype(float) | |
| out["factor_downside_follow_through"] = downside_follow_through.astype(float) | |
| out["factor_weak_rebound_after_breakdown"] = weak_rebound.astype(float) | |
| prev2_bull = (close.shift(2) > open_.shift(2)) & (((close.shift(2) - open_.shift(2)).abs() / span.shift(2)) >= 0.20) | |
| prev2_bear = (close.shift(2) < open_.shift(2)) & (((close.shift(2) - open_.shift(2)).abs() / span.shift(2)) >= 0.15) | |
| prev1_bull = (close.shift(1) > open_.shift(1)) & (((close.shift(1) - open_.shift(1)).abs() / span.shift(1)) >= 0.15) | |
| prev1_bear = (close.shift(1) < open_.shift(1)) & (((close.shift(1) - open_.shift(1)).abs() / span.shift(1)) >= 0.15) | |
| current_bull = bullish_body & (body_strength >= 0.20) | |
| current_bear = (body < 0) & (body_strength >= 0.20) | |
| engulfs_prev_bear = (open_ <= close.shift(1)) & (close >= open_.shift(1)) | |
| near_engulfs_prev_bear = prev1_bear & current_bull & (open_ <= close.shift(1) * 1.01) & (close >= open_.shift(1) * 0.99) | |
| engulfs_prev_bull = (open_ >= close.shift(1)) & (close <= open_.shift(1)) | |
| near_engulfs_prev_bull = prev1_bull & current_bear & (open_ >= close.shift(1) * 0.99) & (close <= open_.shift(1) * 1.01) | |
| trend_context = close.shift(2).pct_change(10).fillna(0.0) > -0.10 | |
| out["factor_bt_double_bull_engulf_one_bear"] = ( | |
| prev2_bull & prev1_bear & current_bull & engulfs_prev_bear & trend_context | |
| ).astype(float) | |
| bull_engulf = prev1_bear & current_bull & engulfs_prev_bear | |
| bull_near_engulf = near_engulfs_prev_bear & ~bull_engulf | |
| bear_engulf_after_bulls = prev2_bull & prev1_bull & current_bear & engulfs_prev_bull | |
| bear_near_engulf_after_bulls = prev2_bull & prev1_bull & near_engulfs_prev_bull & ~bear_engulf_after_bulls | |
| two_bear_one_bull = prev2_bear & prev1_bear & current_bull | |
| two_bear_then_bull = two_bear_one_bull & (engulfs_prev_bear | near_engulfs_prev_bear) | |
| two_bull_one_bear = prev2_bull & prev1_bull & current_bear | |
| out["factor_bt_bull_engulf"] = pd.concat( | |
| [ | |
| bull_engulf.astype(float), | |
| bull_near_engulf.astype(float) * 0.5, | |
| two_bear_one_bull.astype(float) * 0.5, | |
| two_bear_then_bull.astype(float), | |
| ], | |
| axis=1, | |
| ).max(axis=1) | |
| out["factor_bt_bull_engulf_bear"] = pd.concat( | |
| [ | |
| bear_engulf_after_bulls.astype(float), | |
| bear_near_engulf_after_bulls.astype(float) * 0.5, | |
| two_bull_one_bear.astype(float) * 0.5, | |
| ], | |
| axis=1, | |
| ).max(axis=1) | |
| bull_signal = out["factor_bt_bull_engulf"] + out["factor_bt_double_bull_engulf_one_bear"] | |
| bear_signal = out["factor_bt_bull_engulf_bear"] | |
| out["factor_bt_bull_bear_engulf_ratio"] = ( | |
| (bull_signal - bear_signal) / (bull_signal + bear_signal).replace(0.0, np.nan) | |
| ).fillna(0.0).clip(-1.0, 1.0) | |
| log_volume = np.log1p(volume) | |
| mean = log_volume.shift(1).rolling(20, min_periods=10).mean() | |
| std = log_volume.shift(1).rolling(20, min_periods=10).std().mask(lambda item: item <= 1e-9, 1.0) | |
| volume_z = ((log_volume - mean) / std).fillna(0.0).clip(-5.0, 5.0) | |
| out["factor_volume_z20_prior"] = volume_z | |
| volume_base = volume.shift(1).rolling(20, min_periods=10).mean() | |
| volume_ratio_prior = (volume / volume_base.replace(0, np.nan)).fillna(1.0).clip(0.0, 20.0) | |
| high_volume_upper_shadow = ( | |
| (volume_ratio_prior >= 2.0).astype(float) | |
| * out["factor_upper_shadow_ratio"] | |
| * (volume_ratio_prior / 3.0).clip(0.0, 1.0) | |
| * (0.5 + 0.5 * (out["factor_high_base_score"] / 4.0).clip(0.0, 1.0)) | |
| ) | |
| out["factor_volume_ratio_prior20"] = volume_ratio_prior | |
| out["factor_high_volume_upper_shadow"] = high_volume_upper_shadow.fillna(0.0).clip(0.0, 1.0) | |
| out["factor_high_volume_downside_follow_through"] = ( | |
| (volume_ratio_prior >= 1.5).astype(float) | |
| * black_candle.astype(float) | |
| * ((close < low.shift(1)) | downside_follow_through).astype(float) | |
| * (volume_ratio_prior / 3.0).clip(0.0, 1.0) | |
| ).fillna(0.0).clip(0.0, 1.0) | |
| spike = volume_z >= 2.0 | |
| recent_prior_spike = spike.shift(1).fillna(False).rolling(5, min_periods=1).max().astype(bool) | |
| healthy_fade = recent_prior_spike & (volume_z < 0.0) & (out["factor_close_location"] >= 0.45) | |
| failed_fade = recent_prior_spike & (volume_z < 0.0) & (out["factor_close_location"] <= 0.30) | |
| out["factor_volume_contraction_after_spike"] = healthy_fade.astype(float) - failed_fade.astype(float) | |
| return out | |
| def build_runtime_signed_factors(features: pd.DataFrame, df: pd.DataFrame, factor_names: list[str]) -> pd.DataFrame: | |
| features = _add_runtime_factor_inputs(features, df).replace([np.inf, -np.inf], np.nan).fillna(0.0) | |
| zero = pd.Series(0.0, index=features.index) | |
| half = pd.Series(0.5, index=features.index) | |
| rsi = _series(features, "rsi", 50.0) | |
| bb_pct_b = _series(features, "bb_pct_b", 0.5) | |
| macd_above_zero = _series(features, "macd_above_zero", 0.5) * 2.0 - 1.0 | |
| macd_norm = _series(features, "macd_hist_norm", 0.0) | |
| ma_bull = _series(features, "ma_bull_alignment", 0.0) | |
| ma_bear = _series(features, "ma_bear_alignment", 0.0) | |
| taiex_return_5d = _series(features, "taiex_return_5d", 0.0) | |
| taiex_return_20d = _series(features, "taiex_return_20d", 0.0) | |
| taiex_ma20_ratio = _series(features, "taiex_ma20_ratio", 1.0) | |
| usdtwd_return_5d = _series(features, "usdtwd_return_5d", 0.0) | |
| sox_ret_5d = _series(features, "sox_ret_5d", 0.0) | |
| sox_ma20_ratio = _series(features, "sox_ma20_ratio", 1.0) | |
| tnx_change_5d = _series(features, "tnx_change_5d", 0.0) | |
| vix_level = _series(features, "vix_level", 20.0) | |
| vix_change_5d = _series(features, "vix_change_5d", 0.0) | |
| volume_z = _series(features, "factor_volume_z20_prior", 0.0) | |
| volume_ratio = _series(features, "volume_ratio", 0.0) | |
| volume_spike = _series(features, "oldwang_volume_spike", 0.0) | |
| def amount_strength(amount: pd.Series) -> pd.Series: | |
| amount = amount.abs().fillna(0.0) | |
| normalized_amount = amount.where(amount > 1000.0, amount * 1_000_000.0) | |
| return (normalized_amount / 5_000_000.0).fillna(0.0).clip(0.0, 1.0) | |
| big_1m_4m_buy = _first_series( | |
| features, | |
| [ | |
| "big_player_buy_1m_4m", | |
| "big_player_buy_1m_4m_twd", | |
| "big_player_1m_4m_buy", | |
| "big_player_1m_4m_buy_twd", | |
| "big_player_buy_100m_400m", | |
| "big_player_buy_100m_400m_twd", | |
| ], | |
| ).abs() | |
| big_1m_4m_sell = _first_series( | |
| features, | |
| [ | |
| "big_player_sell_1m_4m", | |
| "big_player_sell_1m_4m_twd", | |
| "big_player_1m_4m_sell", | |
| "big_player_1m_4m_sell_twd", | |
| "big_player_sell_100m_400m", | |
| "big_player_sell_100m_400m_twd", | |
| ], | |
| ).abs() | |
| big_gt_4m_buy = _first_series( | |
| features, | |
| [ | |
| "big_player_buy_gt_4m", | |
| "big_player_buy_gt_4m_twd", | |
| "big_player_gt_4m_buy", | |
| "big_player_gt_4m_buy_twd", | |
| "big_player_buy_over_4m", | |
| "big_player_buy_over_4m_twd", | |
| ], | |
| ).abs() | |
| big_gt_4m_sell = _first_series( | |
| features, | |
| [ | |
| "big_player_sell_gt_4m", | |
| "big_player_sell_gt_4m_twd", | |
| "big_player_gt_4m_sell", | |
| "big_player_gt_4m_sell_twd", | |
| "big_player_sell_over_4m", | |
| "big_player_sell_over_4m_twd", | |
| ], | |
| ).abs() | |
| big_1m_4m_total = (big_1m_4m_buy + big_1m_4m_sell).replace(0.0, np.nan) | |
| big_gt_4m_total = (big_gt_4m_buy + big_gt_4m_sell).replace(0.0, np.nan) | |
| big_player_buy = amount_strength(big_1m_4m_buy + big_gt_4m_buy) | |
| big_player_sell = -amount_strength(big_1m_4m_sell + big_gt_4m_sell) | |
| big_player_buy_ratio_1m_4m = (big_1m_4m_buy / big_1m_4m_total).fillna(0.0).clip(0.0, 1.0) | |
| big_player_sell_ratio_1m_4m = -((big_1m_4m_sell / big_1m_4m_total).fillna(0.0).clip(0.0, 1.0)) | |
| big_player_buy_ratio_gt_4m = (big_gt_4m_buy / big_gt_4m_total).fillna(0.0).clip(0.0, 1.0) | |
| big_player_sell_ratio_gt_4m = -((big_gt_4m_sell / big_gt_4m_total).fillna(0.0).clip(0.0, 1.0)) | |
| lower_shadow_raw = _series(features, "factor_lower_shadow_ratio").clip(0.0, 1.0) | |
| upper_shadow_raw = _series(features, "factor_upper_shadow_ratio").clip(0.0, 1.0) | |
| lower_shadow = ( | |
| 0.35 * lower_shadow_raw | |
| + 0.45 * _series(features, "factor_yin_gao_pao_raw") | |
| + 0.35 * _series(features, "factor_yin_gao_pao_hold") | |
| - 0.35 * _series(features, "factor_yin_gao_pao_failed") | |
| ).clip(0.0, 1.0) | |
| candle_shadow = (lower_shadow - upper_shadow_raw).clip(-1.0, 1.0) | |
| upper_shadow = -upper_shadow_raw | |
| bullish_upper_shadow = -((_series(features, "factor_body_ratio") > 0.35).astype(float) * _series(features, "factor_upper_shadow_ratio")).clip(0.0, 1.0) | |
| high_volume_upper_shadow_legacy = ((volume_z > 1.5).astype(float) * _series(features, "factor_upper_shadow_ratio") * np.maximum(-_series(features, "factor_body_ratio"), 0.25)).clip(0.0, 1.0) | |
| high_volume_upper_shadow = -np.maximum( | |
| high_volume_upper_shadow_legacy, | |
| _series(features, "factor_high_volume_upper_shadow"), | |
| ).clip(0.0, 1.0) | |
| high_base = -(_series(features, "factor_high_base_score") / 4.0).clip(0.0, 1.0) | |
| short_term_battle = -_series(features, "factor_short_term_battle").clip(0.0, 1.0) | |
| foreign_flow = np.tanh(_series(features, "foreign_net_vol_ratio") * 10.0) | |
| trust_flow = np.tanh(_series(features, "trust_net_vol_ratio") * 10.0) | |
| dealer_flow = np.tanh(_series(features, "dealer_net_vol_ratio") * 10.0) | |
| institutional_flow = ( | |
| _series(features, "foreign_trust_alignment") | |
| + np.tanh(_series(features, "institutional_streak") / 5.0) | |
| + np.tanh(_series(features, "institutional_20d_zscore") / 2.0) | |
| ) / 3.0 | |
| margin_risk = (-_series(features, "margin_5d_change_pct") - _series(features, "short_balance_5d_change") + (_series(features, "margin_buy_pressure", 0.5) - 0.5)).clip(-1.0, 1.0) | |
| short_pressure = -( | |
| np.tanh(_series(features, "short_margin_ratio") / 0.30) | |
| + np.tanh(_series(features, "sbl_balance_5d_change")) | |
| + np.tanh(_series(features, "sbl_balance_ratio") / 0.10) | |
| ) / 3.0 | |
| volatility_risk = -(np.tanh(_series(features, "volatility_20d") / 0.04) + np.tanh(_series(features, "atr_ratio") / 0.05)) / 2.0 | |
| momentum_60d = _series(features, "return_60d").clip(-0.5, 0.5) * 2.0 | |
| momentum_120d = _series(features, "return_120d").clip(-0.5, 0.5) * 2.0 | |
| tej_macro_risk_proxy = ( | |
| 0.28 * np.tanh((taiex_ma20_ratio - 1.0) / 0.03) | |
| + 0.18 * np.tanh(taiex_return_5d / 0.04) | |
| + 0.14 * np.tanh(taiex_return_20d / 0.08) | |
| + 0.16 * np.tanh(sox_ret_5d / 0.06) | |
| + 0.10 * np.tanh((sox_ma20_ratio - 1.0) / 0.04) | |
| - 0.06 * np.tanh(usdtwd_return_5d / 0.02) | |
| - 0.04 * np.tanh(tnx_change_5d / 0.30) | |
| - 0.04 * np.tanh((vix_level - 20.0) / 10.0) | |
| - 0.04 * np.tanh(vix_change_5d / 6.0) | |
| ) | |
| available = { | |
| "oldwang_trend": _series(features, "oldwang_triple_bull") - _series(features, "oldwang_triple_bear"), | |
| "oldwang_guard": ( | |
| _series(features, "oldwang_ma5_hold") | |
| + _series(features, "oldwang_trust_ma10_guard") | |
| + _series(features, "oldwang_foreign_ma20_guard") | |
| - _series(features, "oldwang_trust_ma10_broken") | |
| - _series(features, "oldwang_foreign_ma20_broken") | |
| ), | |
| "oldwang_volume": _series(features, "oldwang_volume_high_break") - _series(features, "oldwang_volume_low_break"), | |
| "san_sheng_wu_nai_breakdown": -_series(features, "factor_san_sheng_wu_nai_breakdown").clip(0.0, 1.0), | |
| "downside_follow_through": -_series(features, "factor_downside_follow_through").clip(0.0, 1.0), | |
| "weak_rebound_after_breakdown": -_series(features, "factor_weak_rebound_after_breakdown").clip(0.0, 1.0), | |
| "high_volume_downside_follow_through": -_series( | |
| features, "factor_high_volume_downside_follow_through" | |
| ).clip(0.0, 1.0), | |
| "intraday_60k_volume_low_guard": _series(features, "factor_intraday_60k_volume_low_guard").clip(0.0, 1.0), | |
| "intraday_60k_volume_low_break": -_series(features, "factor_intraday_60k_volume_low_break").clip(0.0, 1.0), | |
| "big_player_buy": big_player_buy, | |
| "big_player_sell": big_player_sell, | |
| # Legacy ratio/signed fields remain supported for old reports/seeds. | |
| "big_player_buy_ratio_1m_4m": big_player_buy_ratio_1m_4m, | |
| "big_player_sell_ratio_1m_4m": big_player_sell_ratio_1m_4m, | |
| "big_player_buy_ratio_gt_4m": big_player_buy_ratio_gt_4m, | |
| "big_player_sell_ratio_gt_4m": big_player_sell_ratio_gt_4m, | |
| "big_player_power_1m_4m": _series(features, "big_player_power_1m_4m").clip(-1.0, 1.0), | |
| "big_player_power_gt_4m": _series(features, "big_player_power_gt_4m").clip(-1.0, 1.0), | |
| "oldwang_gap": _series(features, "oldwang_gap_guard") - _series(features, "oldwang_gap_filled"), | |
| "volume_expansion": np.tanh((volume_ratio - 1.0).clip(-2.0, 4.0) / 2.0).clip(-1.0, 1.0), | |
| "volume_spike": volume_spike.clip(0.0, 1.0), | |
| "volume_surge": np.tanh(volume_z / 2.0).clip(-1.0, 1.0), | |
| "volume_contraction_after_spike": _series(features, "factor_volume_contraction_after_spike").clip(-1.0, 1.0), | |
| "candle_shadow": candle_shadow, | |
| "upper_shadow": upper_shadow, | |
| "lower_shadow": lower_shadow, | |
| "bullish_upper_shadow": bullish_upper_shadow, | |
| "high_base": high_base, | |
| "high_volume_upper_shadow": high_volume_upper_shadow, | |
| "short_term_battle": short_term_battle, | |
| "doji_battle": short_term_battle, | |
| "foreign_flow": foreign_flow.clip(-1.0, 1.0), | |
| "trust_flow": trust_flow.clip(-1.0, 1.0), | |
| "dealer_flow": dealer_flow.clip(-1.0, 1.0), | |
| "institutional_flow": institutional_flow.clip(-1.0, 1.0), | |
| "chip_score": _series(features, "chip_score"), | |
| "margin_risk": margin_risk, | |
| "short_pressure": short_pressure.clip(-1.0, 0.0), | |
| "macd_momentum": (np.tanh(macd_norm * 200.0) + 0.25 * macd_above_zero).clip(-1.0, 1.0), | |
| "rsi_trend": ((rsi - 50.0) / 50.0).clip(-1.0, 1.0), | |
| "rsi_reversal": pd.Series( | |
| np.where(rsi < 30.0, (30.0 - rsi) / 30.0, np.where(rsi > 70.0, -(rsi - 70.0) / 30.0, 0.0)), | |
| index=features.index, | |
| ).clip(-1.0, 1.0), | |
| "bb_reversion": pd.Series( | |
| np.where(bb_pct_b < 0.15, 1.0 - bb_pct_b / 0.15, np.where(bb_pct_b > 0.85, -(bb_pct_b - 0.85) / 0.15, 0.0)), | |
| index=features.index, | |
| ).clip(-1.0, 1.0), | |
| "ma_alignment": (ma_bull - ma_bear).clip(-1.0, 1.0), | |
| "momentum_20d": _series(features, "return_20d").clip(-0.25, 0.25) * 4.0, | |
| "momentum_60d": momentum_60d.clip(-1.0, 1.0), | |
| "momentum_120d": momentum_120d.clip(-1.0, 1.0), | |
| "volatility_risk": volatility_risk.clip(-1.0, 0.0), | |
| "liquidity_quality": -np.tanh(_series(features, "amihud_zscore") / 2.0), | |
| "bt_yi_yin_ya_breakout": ( | |
| _series(features, "factor_bt_yi_yin_ya_breakout") - _series(features, "factor_bt_yi_yin_ya_failed") | |
| ).clip(-1.0, 1.0), | |
| "bt_yi_yang_ding_breakdown": -_series(features, "factor_bt_yi_yang_ding_breakdown").clip(0.0, 1.0), | |
| "bt_shadow_cluster_support_resistance": ( | |
| _series(features, "factor_bt_upper_shadow_cluster_break") | |
| + _series(features, "factor_bt_lower_shadow_cluster_bounce") | |
| - _series(features, "factor_bt_lower_shadow_cluster_break") | |
| ).clip(-1.0, 1.0), | |
| "bt_yang_gao_pao": _series(features, "factor_bt_yang_gao_pao").clip(0.0, 1.0), | |
| "bt_bull_engulf": _series(features, "factor_bt_bull_engulf").clip(0.0, 1.0), | |
| "bt_bull_engulf_bear": -_series(features, "factor_bt_bull_engulf_bear").clip(0.0, 1.0), | |
| "bt_bull_bear_engulf_ratio": _series(features, "factor_bt_bull_bear_engulf_ratio").clip(-1.0, 1.0), | |
| "bt_double_bull_engulf_one_bear": _series(features, "factor_bt_double_bull_engulf_one_bear").clip(0.0, 1.0), | |
| "tej_lstm_trend_signal": _series(features, "factor_tej_lstm_trend_score").clip(-1.0, 1.0), | |
| "tej_lstm_chop_filter": _series(features, "factor_tej_lstm_chop_score").clip(-1.0, 1.0), | |
| "tej_macro_risk_proxy": tej_macro_risk_proxy.clip(-1.0, 1.0), | |
| } | |
| missing = [name for name in factor_names if name not in available] | |
| if missing: | |
| raise ValueError(f"unsupported runtime multi-factor names: {', '.join(missing)}") | |
| return pd.DataFrame({name: available[name].fillna(0.0).astype(float) for name in factor_names}) | |
| def _apply_side_policy(factors: pd.DataFrame, policy: dict[str, Any] | None) -> pd.DataFrame: | |
| if not policy: | |
| return factors | |
| keep_positive = {str(name) for name in policy.get("keep_positive", [])} | |
| keep_negative = {str(name) for name in policy.get("keep_negative", [])} | |
| if not keep_positive and not keep_negative: | |
| return factors | |
| output = factors.copy() | |
| for name in output.columns: | |
| values = pd.to_numeric(output[name], errors="coerce").fillna(0.0) | |
| if name not in keep_positive: | |
| values = values.where(values <= 0.0, 0.0) | |
| if name not in keep_negative: | |
| values = values.where(values >= 0.0, 0.0) | |
| output[name] = values | |
| return output | |
| def apply_multi_factor_overlay( | |
| *, | |
| buy_prob: float, | |
| sell_prob: float, | |
| features: pd.DataFrame, | |
| df: pd.DataFrame, | |
| config: dict[str, Any] | None = None, | |
| enabled: bool | None = None, | |
| ) -> tuple[float, float, dict[str, Any]]: | |
| payload = config if config is not None else load_multi_factor_overlay_config() | |
| if not payload: | |
| return buy_prob, sell_prob, {"applied": False, "reason": "config_missing"} | |
| if payload.get("error"): | |
| return buy_prob, sell_prob, {"applied": False, "reason": payload["error"]} | |
| should_apply = _env_enabled() if enabled is None else bool(enabled) | |
| should_apply = should_apply and bool(payload.get("active")) | |
| selected = payload.get("selected_config") or payload.get("config") or {} | |
| weights = selected.get("weights") or {} | |
| if not should_apply: | |
| return buy_prob, sell_prob, { | |
| "applied": False, | |
| "reason": "disabled", | |
| "active": bool(payload.get("active")), | |
| "policy": payload.get("policy"), | |
| "config_path": payload.get("config_path"), | |
| } | |
| if not weights: | |
| return buy_prob, sell_prob, {"applied": False, "reason": "weights_missing"} | |
| factor_names = list(weights) | |
| side_policy = selected.get("side_policy") or payload.get("side_policy") | |
| factor_frame = _apply_side_policy(build_runtime_signed_factors(features, df, factor_names), side_policy) | |
| latest = factor_frame.iloc[-1] | |
| buy_adjustment = 0.0 | |
| sell_adjustment = 0.0 | |
| contributions: dict[str, dict[str, float]] = {} | |
| for name, weight in weights.items(): | |
| value = float(latest.get(name, 0.0)) | |
| weight = float(weight) | |
| buy_delta = weight * max(value, 0.0) | |
| sell_delta = weight * max(-value, 0.0) | |
| buy_adjustment += buy_delta | |
| sell_adjustment += sell_delta | |
| contributions[name] = { | |
| "value": round(value, 4), | |
| "weight": round(weight, 4), | |
| "buy_delta": round(buy_delta, 4), | |
| "sell_delta": round(sell_delta, 4), | |
| } | |
| adjusted_buy = float(np.clip(buy_prob + buy_adjustment, 0.0, 1.0)) | |
| adjusted_sell = float(np.clip(sell_prob + sell_adjustment, 0.0, 1.0)) | |
| return adjusted_buy, adjusted_sell, { | |
| "applied": True, | |
| "policy": payload.get("policy"), | |
| "scope": payload.get("scope"), | |
| "source_report": payload.get("source_report"), | |
| "selected_name": payload.get("selected_name"), | |
| "side_policy": side_policy, | |
| "config_path": payload.get("config_path"), | |
| "generated_at": payload.get("generated_at"), | |
| "runtime_applied_at": datetime.now(timezone.utc).isoformat(), | |
| "validation": payload.get("validation"), | |
| "gate": payload.get("gate"), | |
| "factor_count": len(weights), | |
| "buy_probability_before": round(float(buy_prob), 4), | |
| "buy_probability_after": round(adjusted_buy, 4), | |
| "sell_probability_before": round(float(sell_prob), 4), | |
| "sell_probability_after": round(adjusted_sell, 4), | |
| "buy_adjustment": round(buy_adjustment, 4), | |
| "sell_adjustment": round(sell_adjustment, 4), | |
| "top_contributions": sorted( | |
| contributions.items(), | |
| key=lambda item: max(abs(item[1]["buy_delta"]), abs(item[1]["sell_delta"])), | |
| reverse=True, | |
| )[:8], | |
| } | |