Add multi-factor scoring engine with 7 weighted factors
Browse files- multi_factor_engine.py +449 -0
multi_factor_engine.py
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Multi-Factor Scoring Engine v1.0 β Institutional-Grade Asset Scoring
|
| 2 |
+
Fuses 7 independent signals into a unified conviction score.
|
| 3 |
+
Based on: Gu et al. (2020) + Zuckerman (2021) Multi-Factor Equity Models
|
| 4 |
+
"""
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pandas as pd
|
| 7 |
+
from typing import Dict, Optional
|
| 8 |
+
|
| 9 |
+
# Factor weights β calibrated for institutional portfolio allocation
|
| 10 |
+
# Weights sum to 1.0. Override for different strategies (value, momentum, etc.)
|
| 11 |
+
DEFAULT_WEIGHTS = {
|
| 12 |
+
'trend': 0.20, # Price momentum, SMA alignment
|
| 13 |
+
'momentum': 0.15, # RSI, MACD, stochastics
|
| 14 |
+
'volatility': 0.15, # Vol regime, vol-of-vol, realized vs implied
|
| 15 |
+
'fundamentals': 0.15, # Valuation, growth, quality metrics
|
| 16 |
+
'news': 0.15, # Sentiment, event risk, news flow
|
| 17 |
+
'options': 0.10, # Put/call ratio, gamma, IV skew
|
| 18 |
+
'macro': 0.10, # Dollar, rates, VIX, sector beta
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
# Category thresholds
|
| 22 |
+
CONVICTION_BANDS = {
|
| 23 |
+
'avoid': (0, 35), # Short / zero position
|
| 24 |
+
'neutral': (35, 55), # Watchlist / benchmark weight
|
| 25 |
+
'small': (55, 70), # 1/3 of target size
|
| 26 |
+
'moderate': (70, 85), # 2/3 of target size
|
| 27 |
+
'aggressive': (85, 100), # Full target size
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class MultiFactorEngine:
|
| 32 |
+
"""Unified scoring from 7 orthogonal factors into a single conviction score."""
|
| 33 |
+
|
| 34 |
+
def __init__(self, weights: Optional[Dict[str, float]] = None):
|
| 35 |
+
self.weights = weights or dict(DEFAULT_WEIGHTS)
|
| 36 |
+
assert abs(sum(self.weights.values()) - 1.0) < 1e-6, "Weights must sum to 1.0"
|
| 37 |
+
|
| 38 |
+
# ββ Factor 1: Trend βββββββββββββββββββββββββββββββββββββββ
|
| 39 |
+
@staticmethod
|
| 40 |
+
def trend_factor(price: float, sma20: float, sma50: float, sma200: float = None,
|
| 41 |
+
adx: float = None, high_52w: float = None, low_52w: float = None) -> float:
|
| 42 |
+
"""Score 0-100 based on price position vs moving averages.
|
| 43 |
+
Bullish when price > SMA20 > SMA50 with ADX > 25 confirming.
|
| 44 |
+
"""
|
| 45 |
+
score = 50.0
|
| 46 |
+
if price > sma20: score += 15
|
| 47 |
+
if price > sma50: score += 15
|
| 48 |
+
if sma20 > sma50: score += 10
|
| 49 |
+
if sma200 and price > sma200: score += 10
|
| 50 |
+
if high_52w and low_52w and high_52w > low_52w:
|
| 51 |
+
score += 10 * ((price - low_52w) / (high_52w - low_52w + 1e-10))
|
| 52 |
+
if adx and adx > 25:
|
| 53 |
+
score += 10
|
| 54 |
+
elif adx and adx > 40:
|
| 55 |
+
score += 15
|
| 56 |
+
return max(0, min(100, score))
|
| 57 |
+
|
| 58 |
+
# ββ Factor 2: Momentum ββββββββββββββββββββββββββββββββββββ
|
| 59 |
+
@staticmethod
|
| 60 |
+
def momentum_factor(rsi: float, macd_hist: float, obv_slope: float = None,
|
| 61 |
+
rsi_divergence: float = None) -> float:
|
| 62 |
+
"""Score 0-100. Momentum peaking at RSI 50-70 range.
|
| 63 |
+
Bearish divergence (price up, RSI down) reduces score.
|
| 64 |
+
"""
|
| 65 |
+
score = 50.0
|
| 66 |
+
if rsi < 30: score += 15 # Oversold bounce
|
| 67 |
+
elif 30 <= rsi <= 45: score += 5
|
| 68 |
+
elif 45 < rsi <= 65: score += 10 # Healthy momentum
|
| 69 |
+
elif 65 < rsi <= 75: score += 3 # Still strong but caution
|
| 70 |
+
elif rsi > 75: score -= 15 # Overbought
|
| 71 |
+
|
| 72 |
+
if macd_hist > 0: score += 10
|
| 73 |
+
if macd_hist > 0.5: score += 10
|
| 74 |
+
if macd_hist < 0: score -= 10
|
| 75 |
+
|
| 76 |
+
if obv_slope and obv_slope > 0: score += 10
|
| 77 |
+
if obv_slope and obv_slope < 0: score -= 10
|
| 78 |
+
|
| 79 |
+
if rsi_divergence and rsi_divergence < 0: score -= 15 # Bearish div
|
| 80 |
+
if rsi_divergence and rsi_divergence > 0: score += 10 # Bullish div
|
| 81 |
+
|
| 82 |
+
return max(0, min(100, score))
|
| 83 |
+
|
| 84 |
+
# ββ Factor 3: Volatility βββββββββββββββββββββββββββββββββ
|
| 85 |
+
@staticmethod
|
| 86 |
+
def volatility_factor(hv: float, iv: float = None, vol_regime: str = 'normal',
|
| 87 |
+
skew: float = None, vix: float = None) -> float:
|
| 88 |
+
"""Score 0-100. Low vol regime = bullish. High vol with declining trend = bullish.
|
| 89 |
+
IV < HV (cheap options) = bullish. Rising vol = bearish.
|
| 90 |
+
"""
|
| 91 |
+
score = 50.0
|
| 92 |
+
if hv < 0.15: score += 15
|
| 93 |
+
elif hv < 0.25: score += 5
|
| 94 |
+
elif hv > 0.40: score -= 15
|
| 95 |
+
elif hv > 0.35: score -= 5
|
| 96 |
+
|
| 97 |
+
if iv and hv and iv < hv:
|
| 98 |
+
score += 10 # Cheap implied vol
|
| 99 |
+
if iv and hv and iv > hv * 1.5:
|
| 100 |
+
score -= 15 # Expensive options, fear premium
|
| 101 |
+
|
| 102 |
+
if vol_regime == 'low': score += 15
|
| 103 |
+
if vol_regime == 'declining': score += 10
|
| 104 |
+
if vol_regime == 'spiking': score -= 20
|
| 105 |
+
if vol_regime == 'high': score -= 10
|
| 106 |
+
|
| 107 |
+
if skew and skew < 0: score += 5 # Put skew mild
|
| 108 |
+
if skew and skew < -0.5: score -= 10 # Extreme fear
|
| 109 |
+
|
| 110 |
+
if vix and vix < 15: score += 10
|
| 111 |
+
if vix and vix > 30: score -= 15
|
| 112 |
+
|
| 113 |
+
return max(0, min(100, score))
|
| 114 |
+
|
| 115 |
+
# ββ Factor 4: Fundamentals ββββββββββββββββββββββοΏ½οΏ½βββββββββ
|
| 116 |
+
@staticmethod
|
| 117 |
+
def fundamentals_factor(pe: float = None, peg: float = None, ps: float = None,
|
| 118 |
+
pb: float = None, roe: float = None, debt_equity: float = None,
|
| 119 |
+
fcf_yield: float = None, growth_5y: float = None,
|
| 120 |
+
sector_pe: float = None) -> float:
|
| 121 |
+
"""Score 0-100 based on valuation + quality. Lower PEG, higher ROE = better.
|
| 122 |
+
FCF yield > 5% is strong signal. Relative to sector PE.
|
| 123 |
+
"""
|
| 124 |
+
score = 50.0
|
| 125 |
+
# Valuation
|
| 126 |
+
if pe:
|
| 127 |
+
if pe < 12: score += 15
|
| 128 |
+
elif pe < 18: score += 10
|
| 129 |
+
elif pe < 25: score += 3
|
| 130 |
+
elif pe > 35: score -= 10
|
| 131 |
+
elif pe > 50: score -= 15
|
| 132 |
+
if sector_pe and pe < sector_pe * 0.8: score += 10
|
| 133 |
+
|
| 134 |
+
if peg:
|
| 135 |
+
if peg < 0.8: score += 15
|
| 136 |
+
elif peg < 1.0: score += 10
|
| 137 |
+
elif peg < 1.5: score += 3
|
| 138 |
+
elif peg > 2.0: score -= 15
|
| 139 |
+
|
| 140 |
+
if ps:
|
| 141 |
+
if ps < 1: score += 10
|
| 142 |
+
elif ps > 10: score -= 10
|
| 143 |
+
|
| 144 |
+
if pb:
|
| 145 |
+
if pb < 1.5: score += 10
|
| 146 |
+
elif pb > 5: score -= 10
|
| 147 |
+
|
| 148 |
+
# Quality
|
| 149 |
+
if roe and roe > 0.15: score += 10
|
| 150 |
+
if roe and roe > 0.25: score += 5
|
| 151 |
+
if roe and roe < 0.05: score -= 10
|
| 152 |
+
|
| 153 |
+
if debt_equity:
|
| 154 |
+
if debt_equity < 0.5: score += 10
|
| 155 |
+
elif debt_equity > 2.0: score -= 10
|
| 156 |
+
|
| 157 |
+
if fcf_yield:
|
| 158 |
+
if fcf_yield > 0.06: score += 15
|
| 159 |
+
elif fcf_yield > 0.03: score += 10
|
| 160 |
+
elif fcf_yield < 0: score -= 15
|
| 161 |
+
|
| 162 |
+
if growth_5y:
|
| 163 |
+
if growth_5y > 0.20: score += 10
|
| 164 |
+
elif growth_5y > 0.15: score += 5
|
| 165 |
+
elif growth_5y < 0: score -= 15
|
| 166 |
+
|
| 167 |
+
return max(0, min(100, score))
|
| 168 |
+
|
| 169 |
+
# ββ Factor 5: News ββββββββββββββββββββββββββββββββββββββββ
|
| 170 |
+
@staticmethod
|
| 171 |
+
def news_factor(sentiment_score: float, news_volume: float = None,
|
| 172 |
+
event_risk: str = 'none', event_date: str = None) -> float:
|
| 173 |
+
"""Score 0-100. Sentiment 0.0-1.0 (bearish-bullish). Event risk overrides.
|
| 174 |
+
|
| 175 |
+
sentiment_score: 0.0=very bearish, 0.5=neutral, 1.0=very bullish
|
| 176 |
+
news_volume: articles per day, higher = more signal confidence
|
| 177 |
+
event_risk: 'none', 'earnings_today', 'earnings_week', 'fed_today',
|
| 178 |
+
'macro_today', 'lawsuit', 'merger', 'dividend'
|
| 179 |
+
"""
|
| 180 |
+
score = 50.0
|
| 181 |
+
# Base sentiment signal
|
| 182 |
+
if sentiment_score < 0.2: score -= 25
|
| 183 |
+
elif sentiment_score < 0.35: score -= 15
|
| 184 |
+
elif sentiment_score < 0.45: score -= 5
|
| 185 |
+
elif 0.45 <= sentiment_score <= 0.55: score += 0
|
| 186 |
+
elif sentiment_score > 0.7: score += 25
|
| 187 |
+
elif sentiment_score > 0.55: score += 15
|
| 188 |
+
elif sentiment_score > 0.5: score += 5
|
| 189 |
+
|
| 190 |
+
# Volume confidence boost
|
| 191 |
+
if news_volume:
|
| 192 |
+
if news_volume > 50: score += 5
|
| 193 |
+
if news_volume > 200: score += 5 # High coverage = signal confidence
|
| 194 |
+
|
| 195 |
+
# Event risk override
|
| 196 |
+
event_override = {
|
| 197 |
+
'none': 0,
|
| 198 |
+
'earnings_week': -5, # Reduce size before earnings
|
| 199 |
+
'earnings_today': -20, # Major uncertainty
|
| 200 |
+
'fed_today': -15,
|
| 201 |
+
'macro_today': -10,
|
| 202 |
+
'lawsuit': -25,
|
| 203 |
+
'merger': 10, # Merger arb or acquisition premium
|
| 204 |
+
'dividend': 3,
|
| 205 |
+
'split': 5,
|
| 206 |
+
'buyback': 8,
|
| 207 |
+
}
|
| 208 |
+
if event_risk in event_override:
|
| 209 |
+
score += event_override[event_risk]
|
| 210 |
+
|
| 211 |
+
return max(0, min(100, score))
|
| 212 |
+
|
| 213 |
+
# ββ Factor 6: Options Flow βββββββββββββββββββββββββββββββ
|
| 214 |
+
@staticmethod
|
| 215 |
+
def options_factor(pcr: float = None, unusual_volume: float = None,
|
| 216 |
+
gamma_exposure: float = None, iv_skew: float = None,
|
| 217 |
+
open_interest_change: float = None, max_pain: float = None,
|
| 218 |
+
current_price: float = None) -> float:
|
| 219 |
+
"""Score 0-100 based on options market microstructure signals.
|
| 220 |
+
|
| 221 |
+
pcr: put/call ratio < 0.5 bullish, > 1.2 bearish
|
| 222 |
+
unusual_volume: ratio vs 20d avg > 2 = significant
|
| 223 |
+
gamma_exposure: positive gamma = sticky price, negative = magnetic pin
|
| 224 |
+
iv_skew: steep put skew = fear, flat = complacent
|
| 225 |
+
open_interest_change: rising OI with rising price = conviction
|
| 226 |
+
max_pain: price tends toward max pain on expiry
|
| 227 |
+
"""
|
| 228 |
+
score = 50.0
|
| 229 |
+
if pcr:
|
| 230 |
+
if pcr < 0.5: score += 20 # Extreme call buying
|
| 231 |
+
elif pcr < 0.7: score += 10
|
| 232 |
+
elif pcr > 1.2: score -= 20 # Extreme put buying
|
| 233 |
+
elif pcr > 1.0: score -= 10
|
| 234 |
+
elif pcr > 0.85: score -= 3 # Mild put bias
|
| 235 |
+
|
| 236 |
+
if unusual_volume:
|
| 237 |
+
if unusual_volume > 5: score += 15 # Massive flow
|
| 238 |
+
elif unusual_volume > 2: score += 8 # Notable flow
|
| 239 |
+
elif unusual_volume < 0.5: score -= 5 # Dead options
|
| 240 |
+
|
| 241 |
+
if gamma_exposure:
|
| 242 |
+
if gamma_exposure > 0: score += 5 # Gamma long = volatility support
|
| 243 |
+
elif gamma_exposure < -5: score -= 15 # Gamma short = volatility risk
|
| 244 |
+
|
| 245 |
+
if iv_skew:
|
| 246 |
+
if iv_skew < -0.3: score -= 10 # Fear
|
| 247 |
+
if iv_skew < -0.5: score -= 15 # Extreme fear
|
| 248 |
+
if iv_skew > 0.1: score += 5 # Call skew (rare, bullish)
|
| 249 |
+
|
| 250 |
+
if open_interest_change and open_interest_change > 0.3:
|
| 251 |
+
score += 10 # Fresh conviction building
|
| 252 |
+
|
| 253 |
+
if max_pain and current_price:
|
| 254 |
+
if current_price < max_pain * 0.97: score += 5 # Room to max pain (up)
|
| 255 |
+
if current_price > max_pain * 1.03: score -= 5 # Above max pain (mean revert)
|
| 256 |
+
|
| 257 |
+
return max(0, min(100, score))
|
| 258 |
+
|
| 259 |
+
# ββ Factor 7: Macro βββββββββββββββββββββββββββββββββββββββ
|
| 260 |
+
@staticmethod
|
| 261 |
+
def macro_factor(vix: float = None, dxy_change: float = None,
|
| 262 |
+
yield_10y: float = None, yield_2y: float = None,
|
| 263 |
+
sector_beta: float = None, cpi_surprise: float = None,
|
| 264 |
+
fed_meeting_days: int = None) -> float:
|
| 265 |
+
"""Score 0-100. Macro tailwinds or headwinds for risk assets.
|
| 266 |
+
|
| 267 |
+
vix: volatility index
|
| 268 |
+
dxy_change: 20d % change in dollar index
|
| 269 |
+
yield_10y / yield_2y: treasury yields
|
| 270 |
+
sector_beta: stock's beta to rates (tech > 1, utilities < 0.5)
|
| 271 |
+
cpi_surprise: actual - expected, > 0 = hawkish
|
| 272 |
+
fed_meeting_days: days until next FOMC
|
| 273 |
+
"""
|
| 274 |
+
score = 50.0
|
| 275 |
+
# VIX regime
|
| 276 |
+
if vix:
|
| 277 |
+
if vix < 15: score += 15
|
| 278 |
+
elif vix < 20: score += 5
|
| 279 |
+
elif vix > 30: score -= 20
|
| 280 |
+
elif vix > 25: score -= 10
|
| 281 |
+
elif vix > 20: score -= 5
|
| 282 |
+
|
| 283 |
+
# Dollar strength β bad for exporters, good for domestic
|
| 284 |
+
if dxy_change:
|
| 285 |
+
if dxy_change > 0.05: score -= 10 # Strong dollar
|
| 286 |
+
if dxy_change < -0.03: score += 5 # Weak dollar
|
| 287 |
+
|
| 288 |
+
# Yield curve
|
| 289 |
+
if yield_10y and yield_2y:
|
| 290 |
+
spread = yield_10y - yield_2y
|
| 291 |
+
if spread < -0.5: score -= 15 # Deep inversion = recession
|
| 292 |
+
elif spread < 0: score -= 10 # Inverted
|
| 293 |
+
elif spread > 1.0: score += 10 # Steep = healthy
|
| 294 |
+
|
| 295 |
+
# Rising rates hurt rate-sensitive stocks
|
| 296 |
+
if yield_10y and sector_beta:
|
| 297 |
+
if yield_10y > 0.05 and sector_beta > 1.0:
|
| 298 |
+
score -= 15 # High rates + high beta = double hurt
|
| 299 |
+
elif yield_10y < 0.03 and sector_beta > 1.0:
|
| 300 |
+
score += 10 # Low rates + high beta = double boost
|
| 301 |
+
|
| 302 |
+
# CPI surprise
|
| 303 |
+
if cpi_surprise:
|
| 304 |
+
if cpi_surprise > 0.5: score -= 15 # Hot inflation = hawkish Fed
|
| 305 |
+
if cpi_surprise < -0.3: score += 10 # Cold inflation = dovish
|
| 306 |
+
|
| 307 |
+
# Fed meeting proximity
|
| 308 |
+
if fed_meeting_days is not None:
|
| 309 |
+
if fed_meeting_days <= 3: score -= 10 # Imminent uncertainty
|
| 310 |
+
if fed_meeting_days == 0: score -= 20 # Meeting day
|
| 311 |
+
|
| 312 |
+
return max(0, min(100, score))
|
| 313 |
+
|
| 314 |
+
# ββ Master Scoring ββββββββββββββββββββββββββββββββββββββββ
|
| 315 |
+
def score(self, factors: Dict[str, float], verbose: bool = False) -> Dict:
|
| 316 |
+
"""Compute unified conviction score from individual factor scores.
|
| 317 |
+
|
| 318 |
+
Args:
|
| 319 |
+
factors: dict with keys matching self.weights
|
| 320 |
+
e.g. {'trend': 75, 'momentum': 45, 'volatility': 60,
|
| 321 |
+
'fundamentals': 80, 'news': 65, 'options': 70, 'macro': 55}
|
| 322 |
+
verbose: return full breakdown
|
| 323 |
+
|
| 324 |
+
Returns:
|
| 325 |
+
Dict with score, band, and optionally per-factor contributions
|
| 326 |
+
"""
|
| 327 |
+
total = 0.0
|
| 328 |
+
contributions = {}
|
| 329 |
+
for key, weight in self.weights.items():
|
| 330 |
+
val = factors.get(key, 50.0) # Default neutral if missing
|
| 331 |
+
contrib = val * weight
|
| 332 |
+
contributions[key] = {
|
| 333 |
+
'score': val,
|
| 334 |
+
'weight': weight,
|
| 335 |
+
'contribution': contrib,
|
| 336 |
+
}
|
| 337 |
+
total += contrib
|
| 338 |
+
|
| 339 |
+
total = max(0, min(100, total))
|
| 340 |
+
|
| 341 |
+
# Determine band
|
| 342 |
+
band = 'neutral'
|
| 343 |
+
for name, (lo, hi) in CONVICTION_BANDS.items():
|
| 344 |
+
if lo <= total < hi:
|
| 345 |
+
band = name
|
| 346 |
+
break
|
| 347 |
+
if total >= 85:
|
| 348 |
+
band = 'aggressive'
|
| 349 |
+
|
| 350 |
+
# Position sizing based on band
|
| 351 |
+
sizing = {
|
| 352 |
+
'avoid': 0.00,
|
| 353 |
+
'neutral': 0.00,
|
| 354 |
+
'small': 0.33,
|
| 355 |
+
'moderate': 0.67,
|
| 356 |
+
'aggressive': 1.00,
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
result = {
|
| 360 |
+
'conviction_score': round(total, 2),
|
| 361 |
+
'band': band,
|
| 362 |
+
'target_exposure': sizing.get(band, 0),
|
| 363 |
+
'direction': 'long' if total > 55 else 'short' if total < 35 else 'neutral',
|
| 364 |
+
}
|
| 365 |
+
if verbose:
|
| 366 |
+
result['contributions'] = contributions
|
| 367 |
+
result['weights'] = dict(self.weights)
|
| 368 |
+
return result
|
| 369 |
+
|
| 370 |
+
def score_from_dataframe(self, df: pd.DataFrame,
|
| 371 |
+
fundamentals: Optional[Dict] = None,
|
| 372 |
+
news: Optional[Dict] = None,
|
| 373 |
+
options: Optional[Dict] = None,
|
| 374 |
+
macro: Optional[Dict] = None,
|
| 375 |
+
verbose: bool = False) -> Dict:
|
| 376 |
+
"""Compute multi-factor score from price DataFrame + optional overlays."""
|
| 377 |
+
l = df.iloc[-1]
|
| 378 |
+
p = df.iloc[-2] if len(df) > 1 else l
|
| 379 |
+
|
| 380 |
+
# Trend
|
| 381 |
+
trend = self.trend_factor(
|
| 382 |
+
price=l['Close'], sma20=l.get('SMA20', l['Close']),
|
| 383 |
+
sma50=l.get('SMA50', l['Close']),
|
| 384 |
+
high_52w=df['High'].max(), low_52w=df['Low'].min()
|
| 385 |
+
)
|
| 386 |
+
|
| 387 |
+
# Momentum
|
| 388 |
+
macd_hist = l.get('MACD', 0) - l.get('MACDS', 0)
|
| 389 |
+
momentum = self.momentum_factor(rsi=l.get('RSI', 50), macd_hist=macd_hist)
|
| 390 |
+
|
| 391 |
+
# Volatility
|
| 392 |
+
hv = df['Ret'].dropna().std() * np.sqrt(252)
|
| 393 |
+
volatility = self.volatility_factor(hv=hv)
|
| 394 |
+
|
| 395 |
+
# Fundamentals
|
| 396 |
+
fund = 50.0
|
| 397 |
+
if fundamentals:
|
| 398 |
+
fund = self.fundamentals_factor(**fundamentals)
|
| 399 |
+
|
| 400 |
+
# News
|
| 401 |
+
news_score = 50.0
|
| 402 |
+
if news:
|
| 403 |
+
news_score = self.news_factor(**news)
|
| 404 |
+
|
| 405 |
+
# Options
|
| 406 |
+
opt = 50.0
|
| 407 |
+
if options:
|
| 408 |
+
opt = self.options_factor(**options)
|
| 409 |
+
|
| 410 |
+
# Macro
|
| 411 |
+
macro_score = 50.0
|
| 412 |
+
if macro:
|
| 413 |
+
macro_score = self.macro_factor(**macro)
|
| 414 |
+
|
| 415 |
+
factors = {
|
| 416 |
+
'trend': trend,
|
| 417 |
+
'momentum': momentum,
|
| 418 |
+
'volatility': volatility,
|
| 419 |
+
'fundamentals': fund,
|
| 420 |
+
'news': news_score,
|
| 421 |
+
'options': opt,
|
| 422 |
+
'macro': macro_score,
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
return self.score(factors, verbose=verbose)
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
if __name__ == '__main__':
|
| 429 |
+
# Example: Microsoft Corporation with user's actual metrics
|
| 430 |
+
engine = MultiFactorEngine()
|
| 431 |
+
|
| 432 |
+
factors = {
|
| 433 |
+
'trend': 85.0, # Price > SMA20 > SMA50
|
| 434 |
+
'momentum': 45.0, # RSI 48.9, MACD bearish crossover
|
| 435 |
+
'volatility': 30.0, # High vol (29.4%), negative Sharpe
|
| 436 |
+
'fundamentals': 80.0, # MSFT: great fundamentals
|
| 437 |
+
'news': 50.0, # Neutral sentiment
|
| 438 |
+
'options': 55.0, # Mild activity
|
| 439 |
+
'macro': 40.0, # Rising yields, neutral VIX
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
result = engine.score(factors, verbose=True)
|
| 443 |
+
print(f"Conviction Score: {result['conviction_score']}/100")
|
| 444 |
+
print(f"Band: {result['band'].upper()}")
|
| 445 |
+
print(f"Direction: {result['direction'].upper()}")
|
| 446 |
+
print(f"Target Exposure: {result['target_exposure']*100:.0f}%")
|
| 447 |
+
print("\nPer-factor breakdown:")
|
| 448 |
+
for k, v in result['contributions'].items():
|
| 449 |
+
print(f" {k:12s}: {v['score']:5.1f} Γ {v['weight']:.2f} = {v['contribution']:.1f}")
|