NSruman commited on
Commit
d2145b9
·
verified ·
1 Parent(s): 15acb55

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +240 -276
app.py CHANGED
@@ -1,46 +1,40 @@
1
- # app.py — DataSynthis_ML_JobTask (ARIMA + LSTM REQUIRED, simple UI)
2
 
3
- import os, json, math, pickle, traceback
4
- from datetime import datetime, timedelta
5
 
6
  import numpy as np
7
  import pandas as pd
8
  import yfinance as yf
 
9
  from pandas.tseries.offsets import BDay
10
- import matplotlib.pyplot as plt
11
  import gradio as gr
 
12
 
13
  from statsmodels.tsa.arima.model import ARIMA
14
  from sklearn.metrics import mean_squared_error, mean_absolute_error, mean_absolute_percentage_error
15
 
16
  # -------------------------
17
- # Artifact Paths
18
  # -------------------------
19
- ARIMA_ORDER_JSON = "arima_order.json" # required for ARIMA order (p,d,q)
20
- ARIMA_PICKLE = "arima_model.pkl" # optional (not used for inference)
 
 
21
 
22
- # LSTM artifacts (LSTM is REQUIRED in this build)
23
- LSTM_KERAS = "lstm_model.keras" # required
24
- LSTM_INFO_PRIMARY = "lstm_info.json" # primary metadata location
25
- LSTM_INFO_ALT = "lstm_model/info.json" # alternate metadata location
26
 
27
- REQUIRE_LSTM = True # <- make LSTM mandatory
28
-
29
- # -------------------------
30
- # Globals (lazy-load LSTM)
31
- # -------------------------
32
  _LSTM_MODEL = None
33
- _LSTM_META = None
34
 
35
- # -------------------------
36
  # Data utilities
37
- # -------------------------
38
  def fetch_data(ticker: str, start: str, end: str) -> pd.DataFrame:
39
- """
40
- Download daily data, convert to business-day frequency, forward-fill close.
41
- """
42
- df = yf.download(ticker, start=start, end=end, interval="1d",
43
- auto_adjust=True, progress=False)
44
  if df is None or df.empty:
45
  raise ValueError(f"No data returned for '{ticker}'. Check ticker or dates.")
46
  df = df[['Close']].copy()
@@ -50,42 +44,51 @@ def fetch_data(ticker: str, start: str, end: str) -> pd.DataFrame:
50
  df['Close'] = df['Close'].ffill()
51
  return df
52
 
53
- def load_arima_order(default=(2, 1, 2)):
54
  """
55
- Read (p,d,q) from arima_order.json; fallback to pickle; else default.
 
56
  """
57
- if os.path.isfile(ARIMA_ORDER_JSON):
58
- with open(ARIMA_ORDER_JSON, "r") as f:
59
- obj = json.load(f)
60
- order = tuple(int(x) for x in obj.get("order", list(default)))
61
- return order
62
-
63
- if os.path.isfile(ARIMA_PICKLE):
64
- try:
65
- with open(ARIMA_PICKLE, "rb") as f:
66
- res = pickle.load(f)
67
- if hasattr(res, "model") and hasattr(res.model, "order"):
68
- return res.model.order
69
- except Exception as e:
70
- print(f"[WARN] Failed to read order from {ARIMA_PICKLE}: {e}")
71
-
72
- return default
73
 
74
- # -------------------------
75
- # ARIMA evaluation / forecast
76
- # -------------------------
77
- def arima_roll_single_fit(train_s: pd.Series, test_s: pd.Series, order=(2, 1, 2)):
78
  """
79
- Fair evaluation: fit once on TRAIN, then roll 1-step ahead through TEST.
80
- Uses ARIMAResults.append() to update state; refits if append is unavailable.
81
  """
82
  res = ARIMA(train_s, order=order,
83
  enforce_stationarity=False, enforce_invertibility=False).fit()
84
  preds, trues = [], []
85
  for i in range(len(test_s)):
86
- preds.append(res.forecast(1)[0])
87
- y_true = test_s.iloc[i]
 
 
 
 
 
 
 
 
88
  trues.append(y_true)
 
 
89
  try:
90
  res = res.append(test_s.iloc[i:i+1])
91
  except Exception:
@@ -93,46 +96,24 @@ def arima_roll_single_fit(train_s: pd.Series, test_s: pd.Series, order=(2, 1, 2)
93
  enforce_stationarity=False, enforce_invertibility=False).fit()
94
  return np.array(trues), np.array(preds)
95
 
96
- def arima_future_forecast(all_close: pd.Series, order, steps: int) -> np.ndarray:
97
- """
98
- Fit ARIMA on all data and forecast next 'steps' business days.
99
- """
100
- fitted = ARIMA(all_close, order=order,
101
- enforce_stationarity=False, enforce_invertibility=False).fit()
102
- fc = fitted.get_forecast(steps=steps)
103
- return np.asarray(fc.predicted_mean).reshape(-1,)
104
-
105
- # -------------------------
106
- # LSTM helpers (Keras .keras + simple min-max scaling)
107
- # -------------------------
108
- def _find_lstm_info_path():
109
- if os.path.isfile(LSTM_INFO_PRIMARY):
110
- return LSTM_INFO_PRIMARY
111
- if os.path.isfile(LSTM_INFO_ALT):
112
- return LSTM_INFO_ALT
113
- return None
114
-
115
  def _maybe_load_lstm(required: bool = False) -> bool:
116
  """
117
- Load LSTM and metadata once. If `required=True`, raise a RuntimeError when missing.
 
118
  """
119
  global _LSTM_MODEL, _LSTM_META
120
-
121
  if _LSTM_MODEL is not None and _LSTM_META is not None:
122
  return True
123
 
124
- # find metadata path (either root json or inside folder)
125
- info_path = None
126
- if os.path.isfile("lstm_info.json"):
127
- info_path = "lstm_info.json"
128
- elif os.path.isfile("lstm_model/info.json"):
129
- info_path = "lstm_model/info.json"
130
-
131
  missing = []
132
- if not os.path.isfile("lstm_model.keras"):
133
- missing.append("lstm_model.keras")
134
  if info_path is None:
135
- missing.append("lstm_info.json (or lstm_model/info.json)")
136
 
137
  if missing:
138
  msg = "LSTM artifacts missing: " + ", ".join(missing)
@@ -143,22 +124,16 @@ def _maybe_load_lstm(required: bool = False) -> bool:
143
 
144
  try:
145
  import tensorflow as tf
146
-
147
- # 1) first try safe_mode=False (accept legacy objects)
148
  try:
149
  _LSTM_MODEL = tf.keras.models.load_model(
150
- "lstm_model.keras",
151
- compile=False,
152
- safe_mode=False, # <-- key change
153
- custom_objects={ # <-- helps resolve common initializers
154
- "Orthogonal": tf.keras.initializers.Orthogonal
155
- },
156
  )
157
  except TypeError:
158
- # older TF/Keras without safe_mode kw: retry without it
159
  _LSTM_MODEL = tf.keras.models.load_model(
160
- "lstm_model.keras",
161
- compile=False,
162
  custom_objects={"Orthogonal": tf.keras.initializers.Orthogonal},
163
  )
164
 
@@ -172,7 +147,6 @@ def _maybe_load_lstm(required: bool = False) -> bool:
172
  _LSTM_META["feature_range"] = [0.0, 1.0]
173
 
174
  return True
175
-
176
  except Exception as e:
177
  if required:
178
  raise RuntimeError(f"LSTM load failed: {e}")
@@ -180,118 +154,96 @@ def _maybe_load_lstm(required: bool = False) -> bool:
180
  _LSTM_MODEL, _LSTM_META = None, None
181
  return False
182
 
183
- def _scale(x, xmin, xmax):
184
- denom = (xmax - xmin) if (xmax - xmin) != 0 else 1.0
185
- return (x - xmin) / denom
 
186
 
187
- def _unscale(x, xmin, xmax):
188
- return x * (xmax - xmin) + xmin
 
189
 
190
- def _seq(data_1d: np.ndarray, L: int):
191
- X, y = [], []
192
- for i in range(L, len(data_1d)):
193
- X.append(data_1d[i-L:i])
194
- y.append(data_1d[i])
195
- X = np.asarray(X)[..., None] # (N, L, 1)
196
- y = np.asarray(y)
197
- return X, y
198
-
199
- def lstm_roll_eval_and_forecast(close_train: pd.Series, close_test: pd.Series, horizon: int):
200
  """
201
- Use the fixed pre-trained LSTM:
202
- (1) fair 1-step-ahead evaluation across TEST
203
- (2) recursive forecast for 'horizon' steps
204
  """
205
- # MUST be loaded; raise if not
206
- _maybe_load_lstm(required=True)
207
-
208
- import tensorflow as tf # assured by requirements
209
- model = _LSTM_MODEL
210
- L = int(_LSTM_META["lookback"])
211
- xmin = float(_LSTM_META["train_min"])
212
- xmax = float(_LSTM_META["train_max"])
213
-
214
- # Scale with training min/max
215
- train_vals = close_train.values.astype("float32")
216
- test_vals = close_test.values.astype("float32")
217
-
218
- if len(train_vals) < L:
219
- raise RuntimeError(
220
- f"Not enough history for LSTM lookback L={L}. "
221
- f"Increase 'How much history?' so train length ≥ {L}."
222
- )
223
-
224
- train_s = _scale(train_vals, xmin, xmax)
225
- test_s = _scale(test_vals, xmin, xmax)
226
-
227
- # Build windows over train+test to start test windows with exactly L prior points
228
- all_scaled = np.concatenate([train_s, test_s], axis=0)
229
- X_all, y_all = _seq(all_scaled, L)
230
-
231
- test_start_idx = len(train_s) - L
232
- if test_start_idx < 0:
233
- raise RuntimeError(
234
- f"Not enough history for LSTM lookback L={L}. "
235
- f"Increase 'How much history?' so train length ≥ {L}."
236
- )
237
-
238
- X_test = X_all[test_start_idx : test_start_idx + len(test_s)]
239
- y_test = y_all[test_start_idx : test_start_idx + len(test_s)]
240
-
241
- # Predict test
242
- y_pred_scaled = model.predict(X_test, verbose=0).reshape(-1)
243
- y_true = _unscale(y_test, xmin, xmax)
244
- y_pred = _unscale(y_pred_scaled, xmin, xmax)
245
-
246
- # Recursive future forecast using last L points from all history
247
- hist_vals = np.concatenate([train_vals, test_vals], axis=0)
248
- hist_scaled = _scale(hist_vals, xmin, xmax)
249
- window = hist_scaled[-L:].copy()
250
-
251
- fut = []
252
- for _ in range(horizon):
253
- x = window.reshape(1, L, 1)
254
- yhat_s = model.predict(x, verbose=0).reshape(-1)[0]
255
- fut.append(_unscale(yhat_s, xmin, xmax))
256
- window = np.concatenate([window[1:], [yhat_s]], axis=0) # roll in scaled space
257
 
258
- return y_true, y_pred, np.asarray(fut)
 
259
 
260
- # -------------------------
261
- # Metrics / formatting / plotting
262
- # -------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  def metrics_df(y_true, y_pred) -> pd.DataFrame:
264
  rmse = math.sqrt(mean_squared_error(y_true, y_pred))
265
  mae = mean_absolute_error(y_true, y_pred)
266
  mape = mean_absolute_percentage_error(y_true, y_pred) * 100
267
- return pd.DataFrame({"RMSE": [rmse], "MAE": [mae], "MAPE (%)": [mape]})
268
 
269
- def format_prices_df(df: pd.DataFrame, date_col: str, value_col: str, value_label: str):
270
  dates_str = pd.Index(df[date_col]).astype(str)
271
  vals = np.asarray(df[value_col]).reshape(-1,)
272
- return pd.DataFrame({"Date": dates_str, value_label: np.round(vals, 2)})
273
 
274
- def make_chart(hist_df: pd.DataFrame,
275
- arima_fut: pd.DataFrame | None,
276
- lstm_fut: pd.DataFrame | None):
277
  try:
278
  h = hist_df.copy()
 
279
  h['Date'] = pd.to_datetime(h['Date'])
 
280
  h_last = h.iloc[-252:] if len(h) > 252 else h
281
 
282
- fig, ax = plt.subplots(figsize=( nine := 9, four := 4))
283
  ax.plot(h_last['Date'], h_last['Close ($)'], label="Historical Close", linewidth=2)
284
-
285
- if arima_fut is not None and not arima_fut.empty:
286
- f = arima_fut.copy()
287
- f['Date'] = pd.to_datetime(f['Date'])
288
- ax.plot(f['Date'], f['Forecast ($)'], label="ARIMA Forecast", linewidth=2)
289
-
290
- if lstm_fut is not None and not lstm_fut.empty:
291
- lf = lstm_fut.copy()
292
- lf['Date'] = pd.to_datetime(lf['Date'])
293
- ax.plot(lf['Date'], lf['Forecast ($)'], label="LSTM Forecast", linewidth=2, linestyle="--")
294
-
295
  ax.set_title("Price (last ~1 year) + next forecasts")
296
  ax.set_xlabel("Date"); ax.set_ylabel("USD ($)")
297
  ax.grid(True, alpha=0.3); ax.legend()
@@ -301,132 +253,144 @@ def make_chart(hist_df: pd.DataFrame,
301
  print(f"[WARN] chart error: {e}")
302
  return None
303
 
304
- # -------------------------
305
- # Gradio callback
306
- # -------------------------
 
 
307
  def do_forecast(ticker_choice: str, years_back: int, horizon_days: int):
308
  """
309
- Returns:
310
- - Historical table
311
- - Future ARIMA forecast table
312
- - KPI markdown (ARIMA + LSTM)
313
- - Chart (ARIMA + LSTM)
314
- - Comparison table (ARIMA vs LSTM)
315
  """
316
  try:
317
- # Require LSTM to be present & loadable (will raise otherwise)
318
- _maybe_load_lstm(required=REQUIRE_LSTM)
319
-
320
- ticker = (ticker_choice or "").strip().upper()
321
  end = datetime.utcnow().date()
322
  start = end - timedelta(days=int(years_back * 365.25))
323
 
324
  df = fetch_data(ticker, start.isoformat(), end.isoformat())
325
- if len(df) < 80:
326
- raise RuntimeError("Not enough data to evaluate. Increase 'How much history?'.")
327
-
328
- order = load_arima_order(default=(2, 1, 2))
329
  split_idx = int(len(df) * 0.8)
330
 
331
- # ---------- ARIMA ----------
 
 
 
332
  train_s, test_s = df['Close'][:split_idx], df['Close'][split_idx:]
333
- y_true_a, y_pred_a = arima_roll_single_fit(train_s, test_s, order=order)
334
- ar_tbl = metrics_df(y_true_a, y_pred_a)
335
- fut_ar = arima_future_forecast(df['Close'], order, horizon_days)
336
 
337
- # Tables for UI (ARIMA)
338
- hist = pd.DataFrame({"date": pd.Index(df.index).astype(str),
339
- "close": np.asarray(df['Close']).reshape(-1,)})
340
- future_dates = pd.bdate_range(df.index[-1] + BDay(1), periods=horizon_days)
341
- fut_ar_df = pd.DataFrame({"date": pd.Index(future_dates).astype(str),
342
- "forecast": fut_ar})
343
- hist_out = format_prices_df(hist, "date", "close", "Close ($)")
344
- fut_ar_out = format_prices_df(fut_ar_df, "date", "forecast", "Forecast ($)")
345
 
346
- # ---------- LSTM (REQUIRED) ----------
 
 
 
 
 
 
 
 
347
  lstm_true, lstm_pred, fut_lstm = lstm_roll_eval_and_forecast(train_s, test_s, horizon_days)
348
  lstm_tbl = metrics_df(lstm_true, lstm_pred)
349
 
350
- fut_lstm_df = pd.DataFrame({"date": pd.Index(future_dates).astype(str),
351
- "forecast": fut_lstm.reshape(-1,)})
352
- fut_lstm_out = format_prices_df(fut_lstm_df, "date", "forecast", "Forecast ($)")
 
 
 
 
 
 
 
 
 
 
353
 
354
- # Comparison table
355
- comp_tbl = pd.DataFrame({
356
- "Model": ["ARIMA", "LSTM"],
357
- "RMSE": [ar_tbl["RMSE"][0], lstm_tbl["RMSE"][0]],
358
- "MAE": [ar_tbl["MAE"][0], lstm_tbl["MAE"][0]],
359
- "MAPE (%)": [ar_tbl["MAPE (%)"][0], lstm_tbl["MAPE (%)"][0]],
360
- }).round(3)
361
 
362
- # KPI text
 
 
363
  kpi = (
364
- f"**ARIMA {order}** | Test RMSE ≈ {ar_tbl['RMSE'][0]:.2f}, "
365
- f"MAE ≈ {ar_tbl['MAE'][0]:.2f}, MAPE ≈ {ar_tbl['MAPE (%)'][0]:.2f}% "
366
- f"| **Ticker:** {ticker} | **Forecast days:** {horizon_days}\n"
367
- f"**LSTM** | Test RMSE ≈ {lstm_tbl['RMSE'][0]:.2f}, "
368
- f"MAE ≈ {lstm_tbl['MAE'][0]:.2f}, MAPE ≈ {lstm_tbl['MAPE (%)'][0]:.2f}%"
369
  )
370
 
371
- # Chart overlay
372
- fig = make_chart(hist_out, fut_ar_out, fut_lstm_out)
 
 
373
 
374
- return hist_out, fut_ar_out, kpi, fig, comp_tbl
375
 
376
  except Exception as e:
377
  traceback.print_exc()
378
  err = f"❌ {str(e)}"
379
- empty = pd.DataFrame()
380
- # Return an error message in KPI area so users immediately see what's wrong
381
- return empty, empty, err, None, pd.DataFrame()
382
-
383
- # -------------------------
384
- # UI (simple & clear)
385
- # -------------------------
386
- POPULAR = ["AAPL", "MSFT", "GOOGL", "AMZN", "TSLA", "NVDA", "META"]
387
 
 
 
 
388
  with gr.Blocks(title="DataSynthis_ML_JobTask", theme=gr.themes.Soft()) as demo:
389
  gr.Markdown(
390
  """
391
- # DataSynthis_ML_JobTask — Stock Forecasts (ARIMA + LSTM)
392
- **What it does:** Downloads recent daily prices, evaluates ARIMA and LSTM fairly,
393
- and predicts the next trading days.
394
- **How to use:** Pick a stock, choose how many years of history and how far ahead to forecast, then press **Run Forecast**.
 
 
 
 
 
395
  """
396
  )
397
 
398
- with gr.Tab("Forecast"):
399
- with gr.Row():
400
- ticker_choice = gr.Dropdown(
401
- choices=POPULAR, value="AAPL",
402
- label="Choose a popular stock",
403
- info="Pick one from the list."
404
- )
405
-
406
- with gr.Row():
407
- years = gr.Slider(
408
- 1, 15, value=5, step=1, label="How much history?",
409
- info="We pull this many *years* of past daily prices."
410
- )
411
- horizon = gr.Slider(
412
- 1, 30, value=5, step=1, label="How many days to predict?",
413
- info="Business days only (markets closed on weekends/holidays)."
414
- )
415
-
416
- run_btn = gr.Button("Run Forecast", variant="primary")
417
 
418
- gr.Markdown("### Results")
419
- kpi = gr.Markdown(label="Model summary", value="")
420
- chart = gr.Plot(label="Chart (last year + forecast)")
421
- with gr.Accordion("Tables (click to expand)", open=False):
422
- hist_df = gr.Dataframe(label="Historical Prices", wrap=True, interactive=False)
423
- fut_df = gr.Dataframe(label="Future Forecasts (ARIMA)", wrap=True, interactive=False)
424
- comp_df = gr.Dataframe(label="Score Comparison (ARIMA vs LSTM)", wrap=True, interactive=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
425
 
426
- run_btn.click(
427
- do_forecast,
428
- inputs=[ticker_choice, years, horizon],
429
- outputs=[hist_df, fut_df, kpi, chart, comp_df]
 
 
 
 
 
 
 
 
 
 
 
 
430
  )
431
 
432
  demo.launch()
 
1
+ # app.py — DataSynthis_ML_JobTask (ARIMA + LSTM, minimal UI)
2
 
3
+ import os, math, json, pickle, traceback
4
+ from typing import Tuple, Optional
5
 
6
  import numpy as np
7
  import pandas as pd
8
  import yfinance as yf
9
+ from datetime import datetime, timedelta
10
  from pandas.tseries.offsets import BDay
11
+
12
  import gradio as gr
13
+ import matplotlib.pyplot as plt
14
 
15
  from statsmodels.tsa.arima.model import ARIMA
16
  from sklearn.metrics import mean_squared_error, mean_absolute_error, mean_absolute_percentage_error
17
 
18
  # -------------------------
19
+ # Required artifact paths
20
  # -------------------------
21
+ ARIMA_PKL = "arima_model.pkl" # used only to read (p,d,q) if possible; we don't unpickle the whole result
22
+ LSTM_KERAS = "lstm_model.keras" # Keras 3 native format (no .h5)
23
+ LSTM_INFO_1 = "lstm_info.json" # metadata (lookback, train_min, train_max, feature_range)
24
+ LSTM_INFO_2 = "lstm_model/info.json" # alternate path if you saved inside a folder
25
 
26
+ REQUIRE_LSTM = True # make LSTM mandatory
 
 
 
27
 
28
+ # globals (lazy-loaded)
 
 
 
 
29
  _LSTM_MODEL = None
30
+ _LSTM_META = None
31
 
32
+ # =========================
33
  # Data utilities
34
+ # =========================
35
  def fetch_data(ticker: str, start: str, end: str) -> pd.DataFrame:
36
+ """Download daily data, normalize to business days, forward-fill, return Close series."""
37
+ df = yf.download(ticker, start=start, end=end, interval="1d", auto_adjust=True, progress=False)
 
 
 
38
  if df is None or df.empty:
39
  raise ValueError(f"No data returned for '{ticker}'. Check ticker or dates.")
40
  df = df[['Close']].copy()
 
44
  df['Close'] = df['Close'].ffill()
45
  return df
46
 
47
+ def extract_order_from_pickle(default_order=(2,1,2)) -> Tuple[int, int, int]:
48
  """
49
+ Try to read (p,d,q) from a previously saved ARIMA pickle without importing
50
+ version-specific internals. If it fails, just return the default.
51
  """
52
+ try:
53
+ if os.path.exists(ARIMA_PKL):
54
+ with open(ARIMA_PKL, "rb") as f:
55
+ obj = pickle.load(f)
56
+ # statsmodels ResultWrapper typically exposes .model.order
57
+ if hasattr(obj, "model") and hasattr(obj.model, "order"):
58
+ return tuple(int(x) for x in obj.model.order)
59
+ # some custom dict-like saves
60
+ mo = getattr(obj, "model_orders", None)
61
+ if isinstance(mo, dict):
62
+ return (int(mo.get("ar", 0)), int(mo.get("diff", 0)), int(mo.get("ma", 0)))
63
+ except Exception as e:
64
+ print(f"[WARN] Could not read order from pickle, using default {default_order}: {e}")
65
+ return default_order
 
 
66
 
67
+ # =========================
68
+ # ARIMA helpers
69
+ # =========================
70
+ def arima_roll_single_fit(train_s: pd.Series, test_s: pd.Series, order=(2,1,2)) -> Tuple[np.ndarray, np.ndarray]:
71
  """
72
+ Fair eval: fit once on TRAIN, then predict 1-step ahead across TEST.
73
+ Update model state each step (res.append), fallback to refit if needed.
74
  """
75
  res = ARIMA(train_s, order=order,
76
  enforce_stationarity=False, enforce_invertibility=False).fit()
77
  preds, trues = [], []
78
  for i in range(len(test_s)):
79
+ fcast = res.forecast(1)
80
+ # robust scalar extraction (works whether Series or ndarray)
81
+ try:
82
+ fval = float(np.asarray(fcast).reshape(-1)[0])
83
+ except Exception:
84
+ # as a backup, try pandas iloc if available
85
+ fval = float(fcast.iloc[0]) if hasattr(fcast, "iloc") else float(fcast)
86
+ preds.append(fval)
87
+
88
+ y_true = float(test_s.iloc[i])
89
  trues.append(y_true)
90
+
91
+ # update internal state or refit on expanded history
92
  try:
93
  res = res.append(test_s.iloc[i:i+1])
94
  except Exception:
 
96
  enforce_stationarity=False, enforce_invertibility=False).fit()
97
  return np.array(trues), np.array(preds)
98
 
99
+ # =========================
100
+ # LSTM helpers
101
+ # =========================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  def _maybe_load_lstm(required: bool = False) -> bool:
103
  """
104
+ Load LSTM model + metadata (lookback, min/max, feature_range).
105
+ Uses a tolerant loader for Keras 3 + legacy initializers.
106
  """
107
  global _LSTM_MODEL, _LSTM_META
 
108
  if _LSTM_MODEL is not None and _LSTM_META is not None:
109
  return True
110
 
111
+ info_path = LSTM_INFO_1 if os.path.isfile(LSTM_INFO_1) else (LSTM_INFO_2 if os.path.isfile(LSTM_INFO_2) else None)
 
 
 
 
 
 
112
  missing = []
113
+ if not os.path.isfile(LSTM_KERAS):
114
+ missing.append(LSTM_KERAS)
115
  if info_path is None:
116
+ missing.append("lstm_info.json")
117
 
118
  if missing:
119
  msg = "LSTM artifacts missing: " + ", ".join(missing)
 
124
 
125
  try:
126
  import tensorflow as tf
127
+ # load with custom Orthogonal mapping; safe_mode=False for broader compatibility
 
128
  try:
129
  _LSTM_MODEL = tf.keras.models.load_model(
130
+ LSTM_KERAS, compile=False, safe_mode=False,
131
+ custom_objects={"Orthogonal": tf.keras.initializers.Orthogonal},
 
 
 
 
132
  )
133
  except TypeError:
134
+ # backoff for older TF wheels
135
  _LSTM_MODEL = tf.keras.models.load_model(
136
+ LSTM_KERAS, compile=False,
 
137
  custom_objects={"Orthogonal": tf.keras.initializers.Orthogonal},
138
  )
139
 
 
147
  _LSTM_META["feature_range"] = [0.0, 1.0]
148
 
149
  return True
 
150
  except Exception as e:
151
  if required:
152
  raise RuntimeError(f"LSTM load failed: {e}")
 
154
  _LSTM_MODEL, _LSTM_META = None, None
155
  return False
156
 
157
+ def _minmax_scale_1d(x: np.ndarray, vmin: float, vmax: float, fr_low: float, fr_high: float) -> np.ndarray:
158
+ x = np.asarray(x, dtype="float32").reshape(-1,)
159
+ denom = (vmax - vmin) if vmax != vmin else 1.0
160
+ return (x - vmin) / denom * (fr_high - fr_low) + fr_low
161
 
162
+ def _minmax_unscale_1d(z: np.ndarray, vmin: float, vmax: float, fr_low: float, fr_high: float) -> np.ndarray:
163
+ z = np.asarray(z, dtype="float32").reshape(-1,)
164
+ return ((z - fr_low) / (fr_high - fr_low)) * (vmax - vmin) + vmin
165
 
166
+ def _make_lstm_input(last_close_series: pd.Series, lookback: int, meta: dict) -> np.ndarray:
 
 
 
 
 
 
 
 
 
167
  """
168
+ Create a strict (1, lookback, 1) float32 input from the most recent closes.
 
 
169
  """
170
+ closes = np.asarray(last_close_series.values, dtype="float32").reshape(-1,)
171
+ if len(closes) < lookback:
172
+ raise ValueError(f"Not enough history for LSTM: need {lookback}, have {len(closes)}.")
173
+ window = closes[-lookback:]
174
+ fr_low, fr_high = map(float, meta.get("feature_range", [0.0, 1.0]))
175
+ vmin, vmax = float(meta["train_min"]), float(meta["train_max"])
176
+ win_scaled = _minmax_scale_1d(window, vmin, vmax, fr_low, fr_high).reshape(lookback, 1)
177
+ x = np.asarray(win_scaled, dtype="float32").reshape(1, lookback, 1)
178
+ return x
179
+
180
+ def lstm_roll_eval_and_forecast(train_s: pd.Series, test_s: pd.Series, horizon_days: int) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
181
+ """
182
+ Rolling 1-step evaluation on test; then multi-step forecast from all history.
183
+ Returns (y_true, y_pred, future_forecast).
184
+ """
185
+ global _LSTM_MODEL, _LSTM_META
186
+ model, meta = _LSTM_MODEL, _LSTM_META
187
+ lookback = int(meta["lookback"])
188
+ fr_low, fr_high = map(float, meta.get("feature_range", [0.0, 1.0]))
189
+ vmin, vmax = float(meta["train_min"]), float(meta["train_max"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
 
191
+ if len(train_s) < lookback:
192
+ raise ValueError(f"LSTM lookback={lookback} exceeds training length={len(train_s)}. Increase 'Years'.")
193
 
194
+ # ---- Rolling eval
195
+ full_hist = train_s.copy()
196
+ preds, trues = [], []
197
+ for i in range(len(test_s)):
198
+ x = _make_lstm_input(full_hist, lookback, meta)
199
+ yhat_scaled = model.predict(x, verbose=0).reshape(-1)[0]
200
+ yhat = _minmax_unscale_1d([yhat_scaled], vmin, vmax, fr_low, fr_high)[0]
201
+ preds.append(float(yhat))
202
+ trues.append(float(test_s.iloc[i]))
203
+ full_hist = pd.concat([full_hist, test_s.iloc[i:i+1]])
204
+
205
+ # ---- Multi-step future
206
+ hist_for_fc = pd.concat([train_s, test_s])
207
+ future_preds = []
208
+ hist_values = hist_for_fc.copy()
209
+ for _ in range(int(horizon_days)):
210
+ x = _make_lstm_input(hist_values, lookback, meta)
211
+ yhat_scaled = model.predict(x, verbose=0).reshape(-1)[0]
212
+ yhat = _minmax_unscale_1d([yhat_scaled], vmin, vmax, fr_low, fr_high)[0]
213
+ future_preds.append(float(yhat))
214
+ next_idx = hist_values.index[-1] + BDay(1)
215
+ hist_values.loc[next_idx] = yhat
216
+
217
+ return np.array(trues), np.array(preds), np.array(future_preds)
218
+
219
+ # =========================
220
+ # Metrics & formatting
221
+ # =========================
222
  def metrics_df(y_true, y_pred) -> pd.DataFrame:
223
  rmse = math.sqrt(mean_squared_error(y_true, y_pred))
224
  mae = mean_absolute_error(y_true, y_pred)
225
  mape = mean_absolute_percentage_error(y_true, y_pred) * 100
226
+ return pd.DataFrame({"RMSE":[rmse], "MAE":[mae], "MAPE (%)":[mape]})
227
 
228
+ def format_prices_df(df: pd.DataFrame, date_col: str, value_col: str, label: str) -> pd.DataFrame:
229
  dates_str = pd.Index(df[date_col]).astype(str)
230
  vals = np.asarray(df[value_col]).reshape(-1,)
231
+ return pd.DataFrame({"Date": dates_str, label: np.round(vals, 2)})
232
 
233
+ def make_chart(hist_df: pd.DataFrame, fut_df: pd.DataFrame):
 
 
234
  try:
235
  h = hist_df.copy()
236
+ f = fut_df.copy()
237
  h['Date'] = pd.to_datetime(h['Date'])
238
+ f['Date'] = pd.to_datetime(f['Date'])
239
  h_last = h.iloc[-252:] if len(h) > 252 else h
240
 
241
+ fig, ax = plt.subplots(figsize=(9, 4))
242
  ax.plot(h_last['Date'], h_last['Close ($)'], label="Historical Close", linewidth=2)
243
+ if "Forecast ($) (ARIMA)" in f.columns:
244
+ ax.plot(f['Date'], f['Forecast ($) (ARIMA)'], label="ARIMA Forecast", linewidth=2)
245
+ if "Forecast ($) (LSTM)" in f.columns:
246
+ ax.plot(f['Date'], f['Forecast ($) (LSTM)'], label="LSTM Forecast", linewidth=2)
 
 
 
 
 
 
 
247
  ax.set_title("Price (last ~1 year) + next forecasts")
248
  ax.set_xlabel("Date"); ax.set_ylabel("USD ($)")
249
  ax.grid(True, alpha=0.3); ax.legend()
 
253
  print(f"[WARN] chart error: {e}")
254
  return None
255
 
256
+ # =========================
257
+ # Main forecast callback
258
+ # =========================
259
+ POPULAR = ["AAPL","MSFT","GOOGL","AMZN","TSLA","NVDA","META"]
260
+
261
  def do_forecast(ticker_choice: str, years_back: int, horizon_days: int):
262
  """
263
+ Returns: historical table, future table, KPI markdown, chart
 
 
 
 
 
264
  """
265
  try:
266
+ ticker = ticker_choice.strip().upper()
 
 
 
267
  end = datetime.utcnow().date()
268
  start = end - timedelta(days=int(years_back * 365.25))
269
 
270
  df = fetch_data(ticker, start.isoformat(), end.isoformat())
 
 
 
 
271
  split_idx = int(len(df) * 0.8)
272
 
273
+ # ----------
274
+ # ARIMA
275
+ # ----------
276
+ order = extract_order_from_pickle(default_order=(2,1,2))
277
  train_s, test_s = df['Close'][:split_idx], df['Close'][split_idx:]
 
 
 
278
 
279
+ ar_true, ar_pred = arima_roll_single_fit(train_s, test_s, order=order)
280
+ ar_tbl = metrics_df(ar_true, ar_pred)
 
 
 
 
 
 
281
 
282
+ fitted = ARIMA(df['Close'], order=order,
283
+ enforce_stationarity=False, enforce_invertibility=False).fit()
284
+ fc = fitted.get_forecast(steps=horizon_days)
285
+ arima_future = np.asarray(fc.predicted_mean).reshape(-1,)
286
+
287
+ # ----------
288
+ # LSTM (required)
289
+ # ----------
290
+ _maybe_load_lstm(required=REQUIRE_LSTM)
291
  lstm_true, lstm_pred, fut_lstm = lstm_roll_eval_and_forecast(train_s, test_s, horizon_days)
292
  lstm_tbl = metrics_df(lstm_true, lstm_pred)
293
 
294
+ # ----------
295
+ # Tables (safe types)
296
+ # ----------
297
+ hist = pd.DataFrame({
298
+ "date": pd.Index(df.index).astype(str),
299
+ "close": np.asarray(df['Close']).reshape(-1,)
300
+ })
301
+ future_dates = pd.bdate_range(df.index[-1] + BDay(1), periods=horizon_days)
302
+ fut = pd.DataFrame({
303
+ "date": pd.Index(future_dates).astype(str),
304
+ "arima": np.asarray(arima_future).reshape(-1,),
305
+ "lstm": np.asarray(fut_lstm).reshape(-1,)
306
+ })
307
 
308
+ hist_out = format_prices_df(hist, "date", "close", "Close ($)")
309
+ fut_out = format_prices_df(fut.rename(columns={"arima":"forecast"}), "date", "forecast", "Forecast ($) (ARIMA)")
310
+ fut_out["Forecast ($) (LSTM)"] = np.round(fut["lstm"].values, 2)
 
 
 
 
311
 
312
+ # ----------
313
+ # KPI summary
314
+ # ----------
315
  kpi = (
316
+ f"**Ticker:** {ticker} | **Horizon:** {horizon_days} business days \n"
317
+ f"**ARIMA {order}** — RMSE: {ar_tbl['RMSE'][0]:.2f} | MAE: {ar_tbl['MAE'][0]:.2f} | MAPE: {ar_tbl['MAPE (%)'][0]:.2f}% \n"
318
+ f"**LSTM (lookback={_LSTM_META['lookback']})** — RMSE: {lstm_tbl['RMSE'][0]:.2f} | MAE: {lstm_tbl['MAE'][0]:.2f} | MAPE: {lstm_tbl['MAPE (%)'][0]:.2f}%"
 
 
319
  )
320
 
321
+ # ----------
322
+ # Chart
323
+ # ----------
324
+ chart = make_chart(hist_out, fut_out)
325
 
326
+ return hist_out, fut_out, kpi, chart
327
 
328
  except Exception as e:
329
  traceback.print_exc()
330
  err = f"❌ {str(e)}"
331
+ return pd.DataFrame(), pd.DataFrame(), err, None
 
 
 
 
 
 
 
332
 
333
+ # =========================
334
+ # UI
335
+ # =========================
336
  with gr.Blocks(title="DataSynthis_ML_JobTask", theme=gr.themes.Soft()) as demo:
337
  gr.Markdown(
338
  """
339
+ # DataSynthis_ML_JobTask — Stock Forecasts (Simple & Clear)
340
+ **What it does:** Downloads recent daily prices, evaluates two models fairly (ARIMA & LSTM),
341
+ and predicts the next trading days.
342
+
343
+ **How to use:**
344
+ 1) Pick a stock.
345
+ 2) Choose how many years of history to learn from.
346
+ 3) Choose how many **trading days** to forecast.
347
+ 4) Press **Run Forecast**.
348
  """
349
  )
350
 
351
+ with gr.Row():
352
+ ticker_choice = gr.Dropdown(
353
+ choices=POPULAR, value="AAPL", label="Choose a stock",
354
+ info="Use the dropdown to pick a symbol."
355
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
 
357
+ with gr.Row():
358
+ years = gr.Slider(1, 15, value=5, step=1, label="How much history (years)?",
359
+ info="We pull this many years of past daily prices.")
360
+ horizon = gr.Slider(1, 30, value=5, step=1, label="How many days to predict?",
361
+ info="Business days only (markets closed on weekends/holidays).")
362
+
363
+ run_btn = gr.Button("Run Forecast", variant="primary")
364
+
365
+ gr.Markdown("### Results")
366
+ kpi = gr.Markdown(label="Model summary (lower = better)", value="")
367
+ chart = gr.Plot(label="Chart (last year + forecasts)")
368
+ with gr.Accordion("Tables (click to expand)", open=False):
369
+ hist_df = gr.Dataframe(label="Historical Prices", wrap=True, interactive=False)
370
+ fut_df = gr.Dataframe(label="Future Forecasts", wrap=True, interactive=False)
371
+
372
+ run_btn.click(
373
+ do_forecast,
374
+ inputs=[ticker_choice, years, horizon],
375
+ outputs=[hist_df, fut_df, kpi, chart]
376
+ )
377
 
378
+ with gr.Tab("Help & Glossary"):
379
+ gr.Markdown(
380
+ """
381
+ ### Terms
382
+ - **Ticker**: Stock symbol, e.g., `AAPL` for Apple.
383
+ - **Years**: How far back we look to learn from history.
384
+ - **Forecast horizon**: How many **trading days** we predict.
385
+ - **ARIMA (p, d, q)**: Model settings that control how it uses past levels and past changes.
386
+ - **LSTM lookback**: How many past days the neural net reads each time it predicts.
387
+ - **RMSE / MAE**: Average error sizes in dollars (smaller is better).
388
+ - **MAPE**: Average percentage error (smaller is better).
389
+
390
+ ### Fair evaluation
391
+ We split the data into **train** (older) and **test** (recent).
392
+ Each model predicts **one day ahead** across the test period, always using only the information available up to that day.
393
+ """
394
  )
395
 
396
  demo.launch()