-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathintelligence_engine.py
More file actions
746 lines (659 loc) Β· 31.5 KB
/
Copy pathintelligence_engine.py
File metadata and controls
746 lines (659 loc) Β· 31.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
# intelligence_engine.py β Unified Intelligence Engine
# Orchestrates LSTM, Monte Carlo, Exponential Smoothing into ensemble forecasts,
# computes composite risk profiles, and generates actionable insights.
import numpy as np
import pandas as pd
import yfinance as yf
from datetime import datetime, timedelta
# ============================================================================
# AUTO ANALYZER β Smart Model Selection
# ============================================================================
class AutoAnalyzer:
"""
Evaluates data characteristics for each ticker and recommends the
best forecasting model based on:
- Data length (more data β LSTM viable)
- Volatility regime (high vol β Monte Carlo better)
- Trend strength (strong trend β Exponential Smoothing)
- Return distribution (fat tails β Monte Carlo)
"""
@staticmethod
def evaluate_ticker(prices: np.ndarray) -> dict:
"""
Analyze price data and return characteristics + recommended model.
"""
n = len(prices)
returns = np.diff(prices) / prices[:-1]
# Data length score
data_score = min(n / 500, 1.0) # 500+ days = full score
# Volatility regime
ann_vol = float(np.std(returns) * np.sqrt(252))
vol_regime = "low" if ann_vol < 0.15 else "medium" if ann_vol < 0.35 else "high"
# Trend strength (slope of linear regression on recent 60 days)
recent = prices[-min(60, n):]
x = np.arange(len(recent))
slope = np.polyfit(x, recent, 1)[0]
trend_strength = abs(slope) / np.mean(recent) # normalized
has_strong_trend = trend_strength > 0.001
# Fat tails (excess kurtosis)
kurt = float(pd.Series(returns).kurtosis())
has_fat_tails = kurt > 3
# Stationarity proxy: ratio of recent vol to historical vol
if n > 120:
recent_vol = np.std(returns[-60:])
hist_vol = np.std(returns[:-60])
vol_ratio = recent_vol / hist_vol if hist_vol > 0 else 1.0
regime_change = abs(vol_ratio - 1.0) > 0.5
else:
vol_ratio = 1.0
regime_change = False
# --- MODEL SELECTION LOGIC ---
reasons = []
scores = {"lstm": 0.0, "monte_carlo": 0.0, "exp_smoothing": 0.0}
# LSTM: needs lots of data, works best with stable patterns
if n >= 200:
scores["lstm"] += 3.0
reasons.append("Sufficient data for LSTM training")
if vol_regime in ("low", "medium") and not regime_change:
scores["lstm"] += 2.0
reasons.append("Stable volatility regime favors LSTM")
# Monte Carlo: best for high volatility, fat tails, regime changes
if vol_regime == "high":
scores["monte_carlo"] += 3.0
reasons.append("High volatility favors stochastic simulation")
if has_fat_tails:
scores["monte_carlo"] += 2.0
reasons.append("Fat-tailed returns suit Monte Carlo")
if regime_change:
scores["monte_carlo"] += 2.0
reasons.append("Volatility regime change detected")
# Exponential Smoothing: best for clear trends, limited data
if has_strong_trend:
scores["exp_smoothing"] += 3.0
reasons.append("Strong trend detected β trend model advantages")
if n < 200:
scores["exp_smoothing"] += 2.0
reasons.append("Limited data β statistical model more reliable")
if vol_regime == "low":
scores["exp_smoothing"] += 1.5
reasons.append("Low volatility suits trend extrapolation")
# Always give Monte Carlo a base score (it's always somewhat valid)
scores["monte_carlo"] += 1.0
# Pick best model
best_model = max(scores, key=scores.get)
# If scores are close, recommend ensemble
sorted_scores = sorted(scores.values(), reverse=True)
use_ensemble = (sorted_scores[0] - sorted_scores[1]) < 1.5
return {
"data_points": n,
"ann_volatility": round(ann_vol * 100, 1),
"vol_regime": vol_regime,
"trend_strength": round(trend_strength * 10000, 2),
"has_strong_trend": has_strong_trend,
"kurtosis": round(kurt, 2),
"has_fat_tails": has_fat_tails,
"regime_change": regime_change,
"model_scores": {k: round(v, 1) for k, v in scores.items()},
"best_model": best_model,
"use_ensemble": use_ensemble,
"reasons": reasons,
}
@staticmethod
def get_model_label(model_name: str) -> str:
return {
"lstm": "π§ LSTM Neural Network",
"monte_carlo": "π² Monte Carlo GBM",
"exp_smoothing": "π Exponential Smoothing (Holt)",
}.get(model_name, model_name)
@staticmethod
def meta_predict(ticker: str) -> dict:
"""
Use the trained MetaClassifier (Gradient Boosted) if available,
otherwise fall back to heuristic evaluate_ticker().
"""
try:
from meta_classifier import MetaClassifier
import pandas as pd
mc = MetaClassifier()
if mc.is_trained:
result = mc.predict(ticker)
if result.get("success"):
config_to_model = {
"mc_only": "monte_carlo",
"ets_only": "exp_smoothing",
"lstm_only": "lstm",
"full_ensemble": "ensemble",
"arima_baseline": "exp_smoothing",
"naive_baseline": "monte_carlo",
"random_walk_baseline": "monte_carlo",
}
predicted = result["predicted_model"]
model_name = config_to_model.get(predicted, "monte_carlo")
return {
"best_model": model_name,
"confidence": result["confidence"],
"method": "meta_classifier",
"meta_result": result,
"use_ensemble": predicted == "full_ensemble",
}
except (ImportError, Exception):
pass
# Fallback to heuristic
import yfinance as yf
from datetime import datetime, timedelta
import pandas as pd
end = datetime.today()
start = end - timedelta(days=730)
try:
df = yf.download(ticker, start=start, end=end, progress=False)
if isinstance(df.columns, pd.MultiIndex):
df.columns = df.columns.get_level_values(0)
prices = df["Close"].dropna().values.astype(float)
analysis = AutoAnalyzer.evaluate_ticker(prices)
return {
"best_model": analysis["best_model"],
"confidence": 0.5,
"method": "heuristic",
"heuristic_result": analysis,
"use_ensemble": analysis["use_ensemble"],
}
except Exception:
return {
"best_model": "monte_carlo",
"confidence": 0.0,
"method": "fallback",
"use_ensemble": True,
}
# ============================================================================
# ENSEMBLE FORECASTER
# ============================================================================
class EnsembleForecaster:
"""
Runs multiple forecasting models on a ticker and produces a
confidence-weighted consensus prediction with auto-voting.
Models:
1. LSTM Neural Network (dl_forecaster)
2. Monte Carlo GBM (analytics_engine)
3. Double Exponential Smoothing / Holt's Method (analytics_engine)
Sentiment Gating (Patent Claim):
When a FinBERT sentiment score is provided, model weights are
dynamically adjusted via a gating function g(s, m_i):
g(s, LSTM) = 1 + 0.3s
g(s, Monte Carlo) = 1 - 0.4s
g(s, Exp Smoothing) = 1 + 0.5s
where s β [-1, 1]. Bearish sentiment increases MC weight (tail risk),
bullish increases trend-following weight.
"""
def __init__(self):
self.models_available = {
'lstm': False,
'monte_carlo': True,
'exp_smoothing': True,
}
# Check if LSTM is available
try:
import torch
self.models_available['lstm'] = True
except ImportError:
pass
@staticmethod
def _apply_sentiment_gating(weights: dict, sentiment_score: float = None) -> dict:
"""
Apply sentiment-based gating to ensemble model weights.
Bearish sentiment (s < 0) β increase Monte Carlo weight (captures tail risk)
Bullish sentiment (s > 0) β increase trend model weights (LSTM, Holt's)
Parameters:
weights: dict of {model_name: weight}
sentiment_score: float in [-1, 1] from FinBERT, or None to skip
Returns:
dict of adjusted weights (not yet normalized)
"""
if sentiment_score is None:
return weights
# Clamp to [-1, 1]
s = max(-1.0, min(1.0, float(sentiment_score)))
# Gating function: g(s, model_type)
gates = {
'lstm': 1.0 + 0.3 * s, # Slight boost for bullish
'monte_carlo': 1.0 - 0.4 * s, # Boost for bearish (tail risk)
'exp_smoothing': 1.0 + 0.5 * s, # Strong boost for bullish trends
}
gated = {}
for model, w in weights.items():
gate = gates.get(model, 1.0)
gated[model] = max(0.01, w * gate) # Ensure positive weights
return gated
def forecast(self, ticker: str, forecast_days: int = 30,
epochs: int = 30, run_lstm: bool = True,
sentiment_score: float = None) -> dict:
"""
Run all available models and produce ensemble forecast.
Returns:
{
'ticker': str,
'models': {model_name: result_dict, ...},
'consensus': {...},
'agreement_score': float (0-100),
'signal': str,
'success': bool,
}
"""
# Download shared historical data
end = datetime.today()
start = end - timedelta(days=730) # 2 years
try:
df = yf.download(ticker, start=start, end=end, progress=False)
if df.empty:
return {'success': False, 'error': f'No data for {ticker}', 'ticker': ticker}
if isinstance(df.columns, pd.MultiIndex):
df.columns = df.columns.get_level_values(0)
prices = df["Close"].dropna().values.astype(float)
if len(prices) < 60:
return {'success': False, 'error': f'Insufficient data ({len(prices)} days)', 'ticker': ticker}
except Exception as e:
return {'success': False, 'error': str(e), 'ticker': ticker}
current_price = float(prices[-1])
returns = pd.Series(prices).pct_change().dropna()
ann_mu = float(returns.mean() * 252)
ann_sigma = float(returns.std() * np.sqrt(252))
model_results = {}
model_forecasts = {} # end-of-horizon median price per model
model_weights = {}
# ββ Model 1: Exponential Smoothing ββ
try:
from analytics_engine import exponential_smoothing_forecast
ets_result = exponential_smoothing_forecast(prices, forecast_days)
if ets_result['success']:
model_results['exp_smoothing'] = {
'forecast': ets_result['forecast'],
'upper': ets_result['upper'],
'lower': ets_result['lower'],
'end_price': float(ets_result['forecast'][-1]),
'rmse': ets_result['rmse'],
'mape': ets_result['mape'],
'method': ets_result['method'],
}
model_forecasts['exp_smoothing'] = float(ets_result['forecast'][-1])
# Weight based on inverse MAPE (lower error = higher weight)
model_weights['exp_smoothing'] = max(0.1, 1.0 / max(ets_result['mape'], 0.5))
except Exception:
pass
# ββ Model 2: Monte Carlo GBM ββ
try:
from analytics_engine import run_monte_carlo_stock
mc_result = run_monte_carlo_stock(current_price, ann_mu, ann_sigma, forecast_days)
model_results['monte_carlo'] = {
'percentiles': {k: v.tolist() for k, v in mc_result['percentiles'].items()},
'end_price': mc_result['median_price'],
'p5': mc_result['p5'],
'p95': mc_result['p95'],
'prob_up': mc_result['prob_up'],
'method': 'Monte Carlo GBM (5000 sims)',
}
model_forecasts['monte_carlo'] = mc_result['median_price']
model_weights['monte_carlo'] = 1.0 # baseline weight
except Exception:
pass
# ββ Model 3: LSTM Neural Network ββ
if run_lstm and self.models_available['lstm']:
try:
from dl_forecaster import train_and_forecast
lstm_result = train_and_forecast(ticker, forecast_days, epochs=epochs)
if lstm_result['success']:
fc = lstm_result['forecast']
model_results['lstm'] = {
'forecast': fc['Predicted'].values.tolist(),
'upper': fc['Upper (95%)'].values.tolist(),
'lower': fc['Lower (95%)'].values.tolist(),
'end_price': float(fc['Predicted'].values[-1]),
'rmse': lstm_result['metrics']['rmse'],
'mape': lstm_result['metrics']['mape'],
'dates': fc['Date'].tolist(),
'method': 'Stacked LSTM (PyTorch)',
'historical': lstm_result['historical'],
}
model_forecasts['lstm'] = float(fc['Predicted'].values[-1])
# LSTM gets higher base weight (deep learning advantage)
model_weights['lstm'] = max(0.1, 1.5 / max(lstm_result['metrics']['mape'], 0.5))
except Exception:
pass
if not model_forecasts:
return {'success': False, 'error': 'All models failed.', 'ticker': ticker}
# ββ Bayesian Weight Update (Patent Claim: Adaptive Prior-Posterior Fusion) ββ
# Prior: MAPE-inverse (data-driven initial belief)
# Likelihood: Rolling directional accuracy over recent predictions
# Posterior: prior Γ likelihood (unnormalized), then normalize
bayesian_weights = {}
for model_name, w in model_weights.items():
prior = w # MAPE-inverse prior from above
# Compute likelihood from rolling accuracy if historical available
likelihood = 1.0
if model_name in model_results:
mr = model_results[model_name]
# Use forecast variance as inverse confidence
if 'mape' in mr and mr['mape'] > 0:
# Lower MAPE β higher likelihood
likelihood = np.exp(-mr['mape'] / 100.0)
elif 'prob_up' in mr:
# MC: use distance from 50% as confidence proxy
conf = abs(mr['prob_up'] - 50) / 50.0
likelihood = 0.5 + 0.5 * conf
# Posterior = prior Γ likelihood
bayesian_weights[model_name] = max(0.01, prior * likelihood)
model_weights = bayesian_weights
# ββ Sentiment Gating (Patent Claim: Sentiment-Weighted Ensemble Fusion) ββ
sentiment_gate_applied = sentiment_score is not None
if sentiment_gate_applied:
model_weights = self._apply_sentiment_gating(model_weights, sentiment_score)
# ββ Ensemble Consensus ββ
total_weight = sum(model_weights.values())
normalized = {k: w / total_weight for k, w in model_weights.items()}
consensus_price = sum(model_forecasts[k] * normalized[k] for k in model_forecasts)
expected_return = (consensus_price - current_price) / current_price * 100
# Agreement score: how much models agree (low std = high agreement)
if len(model_forecasts) > 1:
forecast_values = list(model_forecasts.values())
forecast_std = np.std(forecast_values)
forecast_mean = np.mean(forecast_values)
cv = forecast_std / abs(forecast_mean) if forecast_mean != 0 else 1.0
agreement_score = max(0, min(100, int((1 - min(cv, 1.0)) * 100)))
else:
agreement_score = 50 # single model = moderate confidence
# Directional consensus
directions = {k: ('up' if v > current_price else 'down') for k, v in model_forecasts.items()}
up_votes = sum(1 for d in directions.values() if d == 'up')
total_votes = len(directions)
# Signal generation
signal = _generate_signal(expected_return, agreement_score, up_votes, total_votes)
return {
'success': True,
'ticker': ticker,
'current_price': current_price,
'models': model_results,
'model_forecasts': model_forecasts,
'model_weights': normalized,
'consensus': {
'price': round(consensus_price, 2),
'return_pct': round(expected_return, 2),
'agreement_score': agreement_score,
'up_votes': up_votes,
'total_votes': total_votes,
'directions': directions,
},
'signal': signal,
'forecast_days': forecast_days,
'sentiment_gate_applied': sentiment_gate_applied,
'sentiment_score': sentiment_score,
}
def smart_forecast(self, ticker: str, forecast_days: int = 30, epochs: int = 25) -> dict:
"""
Auto-select the best model using the learned MetaClassifier (if trained)
or heuristics, then execute that model (or ensemble).
"""
# Download data
end = datetime.today()
start = end - timedelta(days=730)
try:
df = yf.download(ticker, start=start, end=end, progress=False)
if df.empty:
return {'success': False, 'error': f'No data for {ticker}', 'ticker': ticker}
if isinstance(df.columns, pd.MultiIndex):
df.columns = df.columns.get_level_values(0)
prices = df["Close"].dropna().values.astype(float)
if len(prices) < 30:
return {'success': False, 'error': f'Insufficient data ({len(prices)} days)', 'ticker': ticker}
except Exception as e:
return {'success': False, 'error': str(e), 'ticker': ticker}
# Use new Meta Predictive selection
meta_selection = AutoAnalyzer.meta_predict(ticker)
best_model = meta_selection['best_model']
use_ensemble = meta_selection['use_ensemble']
method = meta_selection['method']
# If ensemble is recommended or predicted by meta-classifier
if use_ensemble:
result = self.forecast(ticker, forecast_days, epochs, run_lstm=self.models_available['lstm'])
if result.get('success'):
result['meta_selection'] = meta_selection
result['selection_mode'] = 'ensemble_consensus'
result['selection_reason'] = (
f"Meta-Classifier ({method}) recommends Ensemble for this data profile."
if method == 'meta_classifier' else
"Heuristic scores are close β ensemble provides better coverage."
)
return result
# Run only the best model
current_price = float(prices[-1])
returns = pd.Series(prices).pct_change().dropna()
ann_mu = float(returns.mean() * 252)
ann_sigma = float(returns.std() * np.sqrt(252))
model_results = {}
model_forecasts = {}
model_weights = {}
if best_model == 'exp_smoothing':
try:
from analytics_engine import exponential_smoothing_forecast
ets = exponential_smoothing_forecast(prices, forecast_days)
if ets['success']:
model_results['exp_smoothing'] = {
'forecast': ets['forecast'], 'upper': ets['upper'], 'lower': ets['lower'],
'end_price': float(ets['forecast'][-1]), 'rmse': ets['rmse'],
'mape': ets['mape'], 'method': ets['method'],
}
model_forecasts['exp_smoothing'] = float(ets['forecast'][-1])
model_weights['exp_smoothing'] = 1.0
except Exception:
pass
elif best_model == 'monte_carlo':
try:
from analytics_engine import run_monte_carlo_stock
mc = run_monte_carlo_stock(current_price, ann_mu, ann_sigma, forecast_days)
model_results['monte_carlo'] = {
'percentiles': {k: v.tolist() for k, v in mc['percentiles'].items()},
'end_price': mc['median_price'], 'p5': mc['p5'], 'p95': mc['p95'],
'prob_up': mc['prob_up'], 'method': 'Monte Carlo GBM (5000 sims)',
}
model_forecasts['monte_carlo'] = mc['median_price']
model_weights['monte_carlo'] = 1.0
except Exception:
pass
elif best_model == 'lstm' and self.models_available['lstm']:
try:
from dl_forecaster import train_and_forecast
lstm = train_and_forecast(ticker, forecast_days, epochs=epochs)
if lstm['success']:
fc = lstm['forecast']
model_results['lstm'] = {
'forecast': fc['Predicted'].values.tolist(),
'upper': fc['Upper (95%)'].values.tolist(),
'lower': fc['Lower (95%)'].values.tolist(),
'end_price': float(fc['Predicted'].values[-1]),
'rmse': lstm['metrics']['rmse'], 'mape': lstm['metrics']['mape'],
'dates': fc['Date'].tolist(), 'method': 'Stacked LSTM (PyTorch)',
'historical': lstm['historical'],
}
model_forecasts['lstm'] = float(fc['Predicted'].values[-1])
model_weights['lstm'] = 1.0
except Exception:
pass
# If best model failed, fall back to ensemble
if not model_forecasts:
result = self.forecast(ticker, forecast_days, epochs, run_lstm=self.models_available['lstm'])
if result.get('success'):
result['meta_selection'] = meta_selection
result['selection_mode'] = 'fallback_ensemble'
result['selection_reason'] = f'{AutoAnalyzer.get_model_label(best_model)} inference failed β fell back to ensemble'
return result
# Build result
consensus_price = list(model_forecasts.values())[0]
expected_return = (consensus_price - current_price) / current_price * 100
signal = _generate_signal(expected_return, 60, 1 if expected_return > 0 else 0, 1)
total_w = sum(model_weights.values())
normalized = {k: w / total_w for k, w in model_weights.items()}
return {
'success': True,
'ticker': ticker,
'current_price': current_price,
'models': model_results,
'model_forecasts': model_forecasts,
'model_weights': normalized,
'consensus': {
'price': round(consensus_price, 2),
'return_pct': round(expected_return, 2),
'agreement_score': 60,
'up_votes': 1 if expected_return > 0 else 0,
'total_votes': 1,
'directions': {best_model: 'up' if expected_return > 0 else 'down'},
},
'signal': signal,
'forecast_days': forecast_days,
'meta_selection': meta_selection,
'selection_mode': f"auto_{method}",
'selection_reason': (
f"Predicted {AutoAnalyzer.get_model_label(best_model)} as optimal via {method}."
),
}
def auto_analyze_portfolio(self, tickers: list, forecast_days: int = 30) -> dict:
"""
Run smart_forecast on all tickers and return portfolio-level results.
"""
results = []
for ticker in tickers:
result = self.smart_forecast(ticker, forecast_days)
results.append(result)
return results
def _generate_signal(expected_return: float, agreement: int, up_votes: int, total: int) -> str:
"""Generate trading signal from ensemble metrics."""
bullish_ratio = up_votes / total if total > 0 else 0.5
if expected_return > 10 and agreement > 70 and bullish_ratio >= 0.67:
return "π’ STRONG BUY"
elif expected_return > 3 and agreement > 50 and bullish_ratio >= 0.5:
return "π’ BUY"
elif expected_return < -10 and agreement > 70 and bullish_ratio <= 0.33:
return "π΄ STRONG SELL"
elif expected_return < -3 and agreement > 50 and bullish_ratio <= 0.5:
return "π΄ SELL"
else:
return "π‘ HOLD"
# ============================================================================
# INSIGHT GENERATOR
# ============================================================================
class InsightGenerator:
"""
Takes ensemble forecast results + risk metrics + sentiment data
and produces actionable natural language insights.
"""
def generate_ticker_insight(self, ensemble_result: dict,
risk_row: dict = None,
sentiment_label: str = None) -> dict:
"""Generate insight for a single ticker."""
if not ensemble_result.get('success'):
return {'ticker': ensemble_result.get('ticker', '?'), 'insights': [], 'signal': 'N/A'}
ticker = ensemble_result['ticker']
consensus = ensemble_result['consensus']
signal = ensemble_result['signal']
models = ensemble_result['models']
current = ensemble_result['current_price']
target = consensus['price']
ret = consensus['return_pct']
agreement = consensus['agreement_score']
insights = []
# Price direction insight
direction = "upside" if ret > 0 else "downside"
insights.append(
f"**Ensemble Consensus:** {len(models)} models project "
f"**{abs(ret):.1f}% {direction}** to ${target:,.2f} "
f"over {ensemble_result['forecast_days']} days "
f"(Agreement: {agreement}%)."
)
# Model agreement insight
dirs = consensus['directions']
if consensus['up_votes'] == consensus['total_votes']:
insights.append("β
**Unanimous bullish** β all models agree on upward movement.")
elif consensus['up_votes'] == 0:
insights.append("β οΈ **Unanimous bearish** β all models project decline.")
else:
bull_models = [k for k, v in dirs.items() if v == 'up']
bear_models = [k for k, v in dirs.items() if v == 'down']
insights.append(
f"π **Mixed signals** β Bullish: {', '.join(bull_models)} | "
f"Bearish: {', '.join(bear_models)}"
)
# Model-specific highlights
if 'lstm' in models:
m = models['lstm']
insights.append(f"π§ LSTM target: ${m['end_price']:,.2f} (MAPE: {m['mape']:.1f}%)")
if 'monte_carlo' in models:
m = models['monte_carlo']
insights.append(
f"π² Monte Carlo: ${m['p5']:,.2f} β ${m['p95']:,.2f} range "
f"({m['prob_up']:.0f}% probability of gain)"
)
if 'exp_smoothing' in models:
m = models['exp_smoothing']
insights.append(f"π Trend Model: ${m['end_price']:,.2f} (MAPE: {m['mape']:.1f}%)")
# Risk insight
if risk_row:
rs = risk_row.get('Risk Score', 50)
vol = risk_row.get('Volatility (%)', 0)
max_dd = risk_row.get('Max Drawdown (%)', 0)
level = "π’ Low" if rs < 30 else "π‘ Moderate" if rs < 60 else "π΄ High"
insights.append(
f"β‘ Risk Score: **{rs}/100** ({level}) β "
f"Vol: {vol:.1f}%, Max DD: {max_dd:.1f}%"
)
# Sentiment insight
if sentiment_label:
insights.append(f"π° Market Sentiment: **{sentiment_label}**")
return {
'ticker': ticker,
'signal': signal,
'target_price': target,
'expected_return': ret,
'agreement': agreement,
'insights': insights,
}
def generate_portfolio_summary(self, ticker_insights: list) -> str:
"""Generate a portfolio-level intelligence summary."""
if not ticker_insights:
return "No intelligence data available."
strong_buys = [t for t in ticker_insights if 'STRONG BUY' in t['signal']]
buys = [t for t in ticker_insights if t['signal'] == 'π’ BUY']
holds = [t for t in ticker_insights if 'HOLD' in t['signal']]
sells = [t for t in ticker_insights if 'SELL' in t['signal']]
strong_sells = [t for t in ticker_insights if 'STRONG SELL' in t['signal']]
avg_return = np.mean([t['expected_return'] for t in ticker_insights])
avg_agreement = np.mean([t['agreement'] for t in ticker_insights])
lines = [
"## π Portfolio Intelligence Summary\n",
f"**Tickers Analyzed:** {len(ticker_insights)} | "
f"**Avg Expected Return:** {avg_return:+.1f}% | "
f"**Avg Model Agreement:** {avg_agreement:.0f}%\n",
"### Signal Distribution\n",
]
if strong_buys:
lines.append(f"- π’ **Strong Buy:** {', '.join(t['ticker'] for t in strong_buys)}")
if buys:
lines.append(f"- π’ **Buy:** {', '.join(t['ticker'] for t in buys)}")
if holds:
lines.append(f"- π‘ **Hold:** {', '.join(t['ticker'] for t in holds)}")
if sells:
lines.append(f"- π΄ **Sell:** {', '.join(t['ticker'] for t in sells)}")
if strong_sells:
lines.append(f"- π΄ **Strong Sell:** {', '.join(t['ticker'] for t in strong_sells)}")
lines.append("\n### Top Opportunities\n")
sorted_by_return = sorted(ticker_insights, key=lambda x: x['expected_return'], reverse=True)
for t in sorted_by_return[:3]:
lines.append(
f"- **{t['ticker']}**: {t['signal']} β "
f"Target ${t['target_price']:,.2f} ({t['expected_return']:+.1f}%), "
f"Agreement {t['agreement']}%"
)
if any(t['expected_return'] < -5 for t in ticker_insights):
lines.append("\n### β οΈ Risk Alerts\n")
for t in sorted_by_return:
if t['expected_return'] < -5:
lines.append(
f"- **{t['ticker']}**: {t['signal']} β "
f"Projected {t['expected_return']:+.1f}% decline"
)
return "\n".join(lines)