Premchan369 commited on
Commit
f282470
Β·
verified Β·
1 Parent(s): 2fdeb16

Add multi-factor scoring engine with 7 weighted factors

Browse files
Files changed (1) hide show
  1. 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}")