Immanuel Partogi Pardede commited on
Commit
8145e2b
Β·
1 Parent(s): 6d74d23

fix: use public predict() API in fallback path, harden ensemble for edge cases

Browse files
app/api/routes/analysis.py CHANGED
@@ -268,7 +268,7 @@ async def analyze_stock(request: AnalysisRequest):
268
  ff_data = None
269
  try:
270
  ff_result = await ff_fetcher.fetch(symbol, news_articles)
271
- if ff_result.confidence > 0.3:
272
  ff_data = {
273
  "trend": ff_result.trend,
274
  "signal_score": ff_result.signal_score,
@@ -298,17 +298,32 @@ async def analyze_stock(request: AnalysisRequest):
298
  except Exception as e:
299
  logger.error(f"Prediction error: {e}")
300
 
301
- # Fallback jika prediksi gagal
 
302
  if prediction_output is None:
303
- from app.ml.ensemble import PredictionOutput
304
- import pandas as pd_fallback
305
- dummy_prices = pd_fallback.DataFrame({
306
- "close": [100.0] * 5,
307
- "volume": [1000] * 5,
308
- })
309
- prediction_output = predictor._rule_based_predict(
310
- predictor._build_features(dummy_prices, sentiment_dict["score"])
311
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
 
313
  # ── 8. Sentiment alert ────────────────────────────────
314
  alert_detector = get_alert_detector()
 
268
  ff_data = None
269
  try:
270
  ff_result = await ff_fetcher.fetch(symbol, news_articles)
271
+ if ff_result and getattr(ff_result, "confidence", 0.0) > 0.3:
272
  ff_data = {
273
  "trend": ff_result.trend,
274
  "signal_score": ff_result.signal_score,
 
298
  except Exception as e:
299
  logger.error(f"Prediction error: {e}")
300
 
301
+ # Fallback jika prediksi gagal β€” pakai public API predict(), bukan
302
+ # method privat yang bisa berubah/hilang saat ensemble di-refactor.
303
  if prediction_output is None:
304
+ try:
305
+ import pandas as pd_fallback
306
+ dummy_prices = pd_fallback.DataFrame({
307
+ "close": [100.0 + i * 0.01 for i in range(30)],
308
+ "volume": [1000] * 30,
309
+ })
310
+ prediction_output = predictor.predict(
311
+ prices=dummy_prices,
312
+ sentiment=sentiment_dict["score"],
313
+ foreign_flow=0.0,
314
+ eps_surprise=0.0,
315
+ )
316
+ except Exception as e:
317
+ logger.error(f"Fallback prediction error: {e}")
318
+ from app.ml.ensemble import PredictionOutput
319
+ prediction_output = PredictionOutput(
320
+ direction="SIDEWAYS",
321
+ confidence=0.0,
322
+ risk_level="SEDANG",
323
+ timeframe="BULANAN",
324
+ explanation=["Data tidak cukup untuk analisis saat ini."],
325
+ signals={},
326
+ )
327
 
328
  # ── 8. Sentiment alert ────────────────────────────────
329
  alert_detector = get_alert_detector()
app/ml/ensemble.py CHANGED
@@ -112,40 +112,68 @@ class EnsemblePredictor:
112
  ) -> pd.Series:
113
  """
114
  Feature engineering β€” semua lookback pakai data masa lalu saja
115
- (tidak ada future leakage)
 
116
  """
117
  close = prices["close"]
118
- volume = prices["volume"]
119
-
120
- features = {}
121
-
122
- # Technical
123
- features["rsi_14"] = ta.momentum.RSIIndicator(
124
- close, window=14
125
- ).rsi().iloc[-1]
126
-
127
- macd = ta.trend.MACD(close)
128
- features["macd_signal"] = (
129
- macd.macd().iloc[-1] - macd.macd_signal().iloc[-1]
130
- )
131
-
132
- bb = ta.volatility.BollingerBands(close)
133
- features["bb_width"] = (
134
- bb.bollinger_hband().iloc[-1] - bb.bollinger_lband().iloc[-1]
135
- ) / close.iloc[-1]
136
-
137
- avg_vol_20 = volume.rolling(20).mean().iloc[-1]
138
- features["volume_ratio"] = (
139
- volume.iloc[-1] / avg_vol_20 if avg_vol_20 > 0 else 1.0
140
- )
141
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  features["price_momentum_5"] = (
143
- close.iloc[-1] / close.iloc[-6] - 1
144
- if len(close) >= 6 else 0.0
145
  )
146
  features["price_momentum_20"] = (
147
- close.iloc[-1] / close.iloc[-21] - 1
148
- if len(close) >= 21 else 0.0
149
  )
150
 
151
  # External
@@ -160,9 +188,16 @@ class EnsemblePredictor:
160
  prices: pd.DataFrame,
161
  confidence: float
162
  ) -> Literal["RENDAH", "SEDANG", "TINGGI"]:
163
- """Volatilitas historis + confidence β†’ risk level"""
 
164
  returns = prices["close"].pct_change().dropna()
165
- vol_20 = returns.rolling(20).std().iloc[-1] * np.sqrt(252)
 
 
 
 
 
 
166
 
167
  if vol_20 > 0.40 or confidence < 0.55:
168
  return "TINGGI"
 
112
  ) -> pd.Series:
113
  """
114
  Feature engineering β€” semua lookback pakai data masa lalu saja
115
+ (tidak ada future leakage). Robust terhadap data sangat kecil
116
+ (1-baris dsb) β€” setiap indikator punya guard + fallback netral.
117
  """
118
  close = prices["close"]
119
+ volume = prices["volume"] if "volume" in prices.columns else pd.Series([0] * len(prices))
120
+ n = len(close)
121
+
122
+ features: dict = {}
123
+
124
+ # RSI β€” butuh minimal 15 baris untuk hasil valid
125
+ try:
126
+ if n >= 15:
127
+ rsi_val = float(ta.momentum.RSIIndicator(close, window=14).rsi().iloc[-1])
128
+ features["rsi_14"] = 50.0 if np.isnan(rsi_val) else rsi_val
129
+ else:
130
+ features["rsi_14"] = 50.0
131
+ except (IndexError, ValueError):
132
+ features["rsi_14"] = 50.0
133
+
134
+ # MACD β€” butuh minimal ~26 baris
135
+ try:
136
+ if n >= 26:
137
+ macd = ta.trend.MACD(close)
138
+ val = float(macd.macd().iloc[-1] - macd.macd_signal().iloc[-1])
139
+ features["macd_signal"] = 0.0 if np.isnan(val) else val
140
+ else:
141
+ features["macd_signal"] = 0.0
142
+ except (IndexError, ValueError):
143
+ features["macd_signal"] = 0.0
144
+
145
+ # Bollinger Bands β€” butuh minimal 20 baris
146
+ try:
147
+ if n >= 20 and close.iloc[-1] != 0:
148
+ bb = ta.volatility.BollingerBands(close)
149
+ width = float(
150
+ (bb.bollinger_hband().iloc[-1] - bb.bollinger_lband().iloc[-1])
151
+ / close.iloc[-1]
152
+ )
153
+ features["bb_width"] = 0.0 if np.isnan(width) else width
154
+ else:
155
+ features["bb_width"] = 0.0
156
+ except (IndexError, ValueError, ZeroDivisionError):
157
+ features["bb_width"] = 0.0
158
+
159
+ # Volume ratio
160
+ try:
161
+ avg_vol_20 = volume.rolling(20).mean().iloc[-1] if n >= 1 else None
162
+ if avg_vol_20 is not None and not np.isnan(avg_vol_20) and avg_vol_20 > 0:
163
+ features["volume_ratio"] = float(volume.iloc[-1] / avg_vol_20)
164
+ else:
165
+ features["volume_ratio"] = 1.0
166
+ except (IndexError, ValueError):
167
+ features["volume_ratio"] = 1.0
168
+
169
+ # Momentum
170
  features["price_momentum_5"] = (
171
+ float(close.iloc[-1] / close.iloc[-6] - 1)
172
+ if n >= 6 and close.iloc[-6] != 0 else 0.0
173
  )
174
  features["price_momentum_20"] = (
175
+ float(close.iloc[-1] / close.iloc[-21] - 1)
176
+ if n >= 21 and close.iloc[-21] != 0 else 0.0
177
  )
178
 
179
  # External
 
188
  prices: pd.DataFrame,
189
  confidence: float
190
  ) -> Literal["RENDAH", "SEDANG", "TINGGI"]:
191
+ """Volatilitas historis + confidence β†’ risk level. Aman untuk data
192
+ sangat sedikit (0-1 return valid)."""
193
  returns = prices["close"].pct_change().dropna()
194
+
195
+ if len(returns) < 2:
196
+ vol_20 = 0.0
197
+ else:
198
+ window = min(20, len(returns))
199
+ vol_val = returns.rolling(window).std().iloc[-1] * np.sqrt(252)
200
+ vol_20 = 0.0 if np.isnan(vol_val) else vol_val
201
 
202
  if vol_20 > 0.40 or confidence < 0.55:
203
  return "TINGGI"
tests/test_ensemble.py CHANGED
@@ -121,54 +121,48 @@ class TestBuildFeatures:
121
  # ─────────────────────────────────────────────
122
 
123
  class TestRuleBasedPredict:
 
 
 
 
 
 
 
 
124
  def test_returns_prediction_output(self, predictor, prices_up):
125
- feat = predictor._build_features(prices_up, 0.7)
126
- result = predictor._rule_based_predict(feat)
127
  assert isinstance(result, PredictionOutput)
128
 
129
  def test_positive_sentiment_tends_naik(self, predictor, prices_up):
130
- feat = predictor._build_features(prices_up, sentiment=0.9)
131
- # Override ke kondisi bullish
132
- feat["rsi_14"] = 30 # oversold
133
- feat["macd_signal"] = 0.5 # bullish
134
- result = predictor._rule_based_predict(feat)
135
  assert result.direction in ("NAIK", "SIDEWAYS")
136
 
137
  def test_negative_sentiment_tends_turun(self, predictor, prices_down):
138
- feat = predictor._build_features(prices_down, sentiment=0.1)
139
- feat["rsi_14"] = 70 # overbought
140
- feat["macd_signal"] = -0.5
141
- result = predictor._rule_based_predict(feat)
142
  assert result.direction in ("TURUN", "SIDEWAYS")
143
 
144
  def test_confidence_within_range(self, predictor, prices_flat):
145
- feat = predictor._build_features(prices_flat, 0.5)
146
- result = predictor._rule_based_predict(feat)
147
  assert 0.0 <= result.confidence <= 1.0
148
 
149
  def test_explanation_not_empty(self, predictor, prices_up):
150
- feat = predictor._build_features(prices_up, 0.7)
151
- result = predictor._rule_based_predict(feat)
152
  assert len(result.explanation) >= 1
153
 
154
  def test_explanation_max_5_points(self, predictor, prices_up):
155
- feat = predictor._build_features(prices_up, 0.7)
156
- result = predictor._rule_based_predict(feat)
157
  assert len(result.explanation) <= 5
158
 
159
  def test_risk_level_valid_values(self, predictor, prices_up):
160
- feat = predictor._build_features(prices_up, 0.7)
161
- result = predictor._rule_based_predict(feat)
162
  assert result.risk_level in ("RENDAH", "SEDANG", "TINGGI")
163
 
164
  def test_timeframe_valid_values(self, predictor, prices_up):
165
- feat = predictor._build_features(prices_up, 0.7)
166
- result = predictor._rule_based_predict(feat)
167
  assert result.timeframe in ("INTRADAY", "MINGGUAN", "BULANAN")
168
 
169
  def test_direction_valid_values(self, predictor, prices_up):
170
- feat = predictor._build_features(prices_up, 0.7)
171
- result = predictor._rule_based_predict(feat)
172
  assert result.direction in ("NAIK", "TURUN", "SIDEWAYS")
173
 
174
 
 
121
  # ─────────────────────────────────────────────
122
 
123
  class TestRuleBasedPredict:
124
+ """
125
+ FASE 4: EnsemblePredictor tidak lagi punya method privat terpisah untuk
126
+ rule-based prediction. Saat model belum trained, predict() otomatis
127
+ fallback ke weighted blend tanpa komponen ML (sentiment + foreign_flow +
128
+ earnings + microstructure). Test berikut memverifikasi perilaku itu
129
+ lewat public API predict(), bukan lewat method privat.
130
+ """
131
+
132
  def test_returns_prediction_output(self, predictor, prices_up):
133
+ result = predictor.predict(prices_up, sentiment=0.7)
 
134
  assert isinstance(result, PredictionOutput)
135
 
136
  def test_positive_sentiment_tends_naik(self, predictor, prices_up):
137
+ result = predictor.predict(prices_up, sentiment=0.9)
 
 
 
 
138
  assert result.direction in ("NAIK", "SIDEWAYS")
139
 
140
  def test_negative_sentiment_tends_turun(self, predictor, prices_down):
141
+ result = predictor.predict(prices_down, sentiment=0.1)
 
 
 
142
  assert result.direction in ("TURUN", "SIDEWAYS")
143
 
144
  def test_confidence_within_range(self, predictor, prices_flat):
145
+ result = predictor.predict(prices_flat, sentiment=0.5)
 
146
  assert 0.0 <= result.confidence <= 1.0
147
 
148
  def test_explanation_not_empty(self, predictor, prices_up):
149
+ result = predictor.predict(prices_up, sentiment=0.7)
 
150
  assert len(result.explanation) >= 1
151
 
152
  def test_explanation_max_5_points(self, predictor, prices_up):
153
+ result = predictor.predict(prices_up, sentiment=0.7)
 
154
  assert len(result.explanation) <= 5
155
 
156
  def test_risk_level_valid_values(self, predictor, prices_up):
157
+ result = predictor.predict(prices_up, sentiment=0.7)
 
158
  assert result.risk_level in ("RENDAH", "SEDANG", "TINGGI")
159
 
160
  def test_timeframe_valid_values(self, predictor, prices_up):
161
+ result = predictor.predict(prices_up, sentiment=0.7)
 
162
  assert result.timeframe in ("INTRADAY", "MINGGUAN", "BULANAN")
163
 
164
  def test_direction_valid_values(self, predictor, prices_up):
165
+ result = predictor.predict(prices_up, sentiment=0.7)
 
166
  assert result.direction in ("NAIK", "TURUN", "SIDEWAYS")
167
 
168
 
tests/test_ensemble_ml.py CHANGED
@@ -67,12 +67,12 @@ class TestPredictFallbackPath:
67
  """predict() saat model belum di-train harus pakai rule-based fallback."""
68
 
69
  def test_untrained_predictor_uses_rule_based(self, predictor, prices_up):
70
- """Default predictor (_trained=False) β†’ fallback."""
 
71
  assert predictor._trained is False
72
  result = predictor.predict(prices_up, sentiment=0.7)
73
  assert isinstance(result, PredictionOutput)
74
- # Rule-based default timeframe = MINGGUAN
75
- assert result.timeframe == "MINGGUAN"
76
 
77
  def test_fallback_direction_valid(self, predictor, prices_up):
78
  result = predictor.predict(prices_up, sentiment=0.7)
@@ -124,18 +124,18 @@ class TestPredictMLPath:
124
  assert predictor.model.predict_proba.called
125
 
126
  def test_ml_path_direction_naik(self, predictor, prices_up):
127
- """P(NAIK) tertinggi β†’ direction NAIK."""
128
  self._setup_trained(predictor, [0.1, 0.2, 0.7])
129
- result = predictor.predict(prices_up, sentiment=0.7)
130
  assert result.direction == "NAIK"
131
- assert result.confidence == pytest.approx(0.7, abs=0.01)
132
 
133
  def test_ml_path_direction_turun(self, predictor, prices_down):
134
- """P(TURUN) tertinggi β†’ direction TURUN."""
135
  self._setup_trained(predictor, [0.7, 0.2, 0.1])
136
- result = predictor.predict(prices_down, sentiment=0.2)
137
  assert result.direction == "TURUN"
138
- assert result.confidence == pytest.approx(0.7, abs=0.01)
139
 
140
  def test_ml_path_direction_sideways(self, predictor, prices_up):
141
  """P(SIDEWAYS) tertinggi β†’ direction SIDEWAYS."""
 
67
  """predict() saat model belum di-train harus pakai rule-based fallback."""
68
 
69
  def test_untrained_predictor_uses_rule_based(self, predictor, prices_up):
70
+ """Default predictor (_trained=False) β†’ fallback ke weighted blend
71
+ tanpa komponen ML."""
72
  assert predictor._trained is False
73
  result = predictor.predict(prices_up, sentiment=0.7)
74
  assert isinstance(result, PredictionOutput)
75
+ assert result.timeframe in ("INTRADAY", "MINGGUAN", "BULANAN")
 
76
 
77
  def test_fallback_direction_valid(self, predictor, prices_up):
78
  result = predictor.predict(prices_up, sentiment=0.7)
 
124
  assert predictor.model.predict_proba.called
125
 
126
  def test_ml_path_direction_naik(self, predictor, prices_up):
127
+ """P(NAIK) tertinggi β†’ direction NAIK, confidence naik seiring margin ML."""
128
  self._setup_trained(predictor, [0.1, 0.2, 0.7])
129
+ result = predictor.predict(prices_up, sentiment=0.5)
130
  assert result.direction == "NAIK"
131
+ assert result.confidence > 0.55
132
 
133
  def test_ml_path_direction_turun(self, predictor, prices_down):
134
+ """P(TURUN) tertinggi β†’ direction TURUN, confidence naik seiring margin ML."""
135
  self._setup_trained(predictor, [0.7, 0.2, 0.1])
136
+ result = predictor.predict(prices_down, sentiment=0.5)
137
  assert result.direction == "TURUN"
138
+ assert result.confidence > 0.55
139
 
140
  def test_ml_path_direction_sideways(self, predictor, prices_up):
141
  """P(SIDEWAYS) tertinggi β†’ direction SIDEWAYS."""
tests/test_sentiment_integration.py CHANGED
@@ -316,5 +316,6 @@ class TestAnalyzeArticlesScoring:
316
  required_keys = {
317
  "sentiment", "score",
318
  "positive_ratio", "neutral_ratio", "negative_ratio",
 
319
  }
320
- assert set(result.keys()) == required_keys
 
316
  required_keys = {
317
  "sentiment", "score",
318
  "positive_ratio", "neutral_ratio", "negative_ratio",
319
+ "method",
320
  }
321
+ assert set(result.keys()) == required_keys