Vansh180 commited on
Commit
cb488aa
·
1 Parent(s): b71e245

Optimized Agents and Fast API integration

Browse files
Files changed (4) hide show
  1. __pycache__/app.cpython-312.pyc +0 -0
  2. app.py +559 -0
  3. package-lock.json +6 -0
  4. requirements.txt +10 -0
__pycache__/app.cpython-312.pyc ADDED
Binary file (20.2 kB). View file
 
app.py ADDED
@@ -0,0 +1,559 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import logging
4
+ import joblib
5
+ import uvicorn
6
+ import numpy as np
7
+ import pandas as pd
8
+ import yfinance as yf
9
+ import tensorflow as tf
10
+ from fastapi import FastAPI, HTTPException
11
+ from fastapi.middleware.cors import CORSMiddleware
12
+ from pydantic import BaseModel, Field
13
+ from typing import List, Dict, Any, Optional
14
+ from huggingface_hub import hf_hub_download
15
+ from datetime import datetime, timedelta
16
+
17
+ # Configure logging
18
+ logging.basicConfig(level=logging.INFO)
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # --- Configuration & Global State ---
22
+ app = FastAPI(title="Equilibrium Systemic Risk API")
23
+
24
+ # Add CORS middleware
25
+ app.add_middleware(
26
+ CORSMiddleware,
27
+ allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
28
+ allow_credentials=True,
29
+ allow_methods=["*"],
30
+ allow_headers=["*"],
31
+ )
32
+
33
+ # Resolve paths relative to this file's directory
34
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
35
+ CONFIG_PATH = os.path.join(BASE_DIR, "..", "frontend", "config.json")
36
+
37
+ MODEL_REPO = "Vansh180/Equilibrium-India-V1"
38
+ MODEL_FILENAME = "systemic_risk_model.keras"
39
+ SCALER_FILENAME = "scaler.pkl"
40
+ FEATURE_COLUMNS_FILENAME = "feature_columns.json"
41
+
42
+ # Global variables (loaded at startup)
43
+ config = {}
44
+ model = None
45
+ scaler = None
46
+ feature_columns = []
47
+
48
+ # --- Pydantic Models ---
49
+ class PredictionRequest(BaseModel):
50
+ tickers: List[str]
51
+ connectivity_scale: float = Field(default=1.0, gt=0, description="Scale factor for connectivity (must be > 0)")
52
+ liquidity_buffer_scale: float = Field(default=1.0, gt=0, description="Scale factor for liquidity buffer (must be > 0)")
53
+
54
+ class CCPFundsOutput(BaseModel):
55
+ initial_margin: float
56
+ variation_margin_flow: float
57
+ default_fund: float
58
+ ccp_capital: float
59
+ units: str = "indexed"
60
+ vm_is_flow: bool = True
61
+
62
+ class PredictionResponse(BaseModel):
63
+ predicted_next_systemic_risk: float
64
+ latest_S_t: float
65
+ latest_features: Dict[str, float]
66
+ used_tickers: List[str]
67
+ masked_tickers: List[str]
68
+ end_date: str
69
+ ccp_funds: CCPFundsOutput
70
+
71
+ # --- CCP Fund Computation ---
72
+
73
+ # Baseline constants (indexed units)
74
+ IM0 = 100
75
+ VM0 = 20
76
+ DF0 = 40
77
+ C0 = 10
78
+
79
+ # Reference values for normalization (typical observed ranges from training data)
80
+ # lambda_max typically ranges 1.5-3.5 for correlation matrices with CCP
81
+ # std_risk typically ranges 0.0-0.4 for normalized risk metrics
82
+ LAMBDA_REF = 2.5 # Reference lambda_max for normalization
83
+ STD_REF = 0.25 # Reference std_risk for normalization
84
+
85
+ def compute_ccp_funds(
86
+ systemic: float,
87
+ lambda_max: float,
88
+ std_risk: float,
89
+ connectivity_scale: float = 1.0,
90
+ liquidity_buffer_scale: float = 1.0
91
+ ) -> Dict[str, Any]:
92
+ """
93
+ Compute CCP fund requirements based on systemic risk and network metrics.
94
+
95
+ Args:
96
+ systemic: Predicted systemic risk score (0-1)
97
+ lambda_max: Maximum eigenvalue of adjacency matrix
98
+ std_risk: Standard deviation of risk across nodes
99
+ connectivity_scale: Scenario override for connectivity (default 1.0)
100
+ liquidity_buffer_scale: Scenario override for liquidity buffer (default 1.0)
101
+
102
+ Returns:
103
+ Dictionary with IM, VM, DF, CCP capital metrics
104
+ """
105
+ # Normalize lambda_max and std_risk to 0-1 range using reference values
106
+ # Values above reference map to >1, below to <1
107
+ lambda_norm = min(1.0, lambda_max / LAMBDA_REF) if LAMBDA_REF > 0 else 0.0
108
+ std_norm = min(1.0, std_risk / STD_REF) if STD_REF > 0 else 0.0
109
+
110
+ # Compute CCP stress from normalized values, then clamp to [0,1]
111
+ # This ensures stress responds to changes rather than saturating
112
+ ccp_stress = 0.4 * lambda_norm + 0.3 * std_norm + 0.3 * systemic
113
+ ccp_stress = max(0.0, min(1.0, ccp_stress))
114
+
115
+ # Compute 4 values based on stress
116
+ im = IM0 * (1 + 1.5 * ccp_stress)
117
+ vm = VM0 * (1 + 2.0 * systemic) # VM is a flow based on systemic only
118
+ df = DF0 * (1 + 2.0 * max(0, ccp_stress - 0.5))
119
+
120
+ # CCP capital scales with stress: C_t = C_0 * (1 + k_C * CCPStress), clamped >= C_0
121
+ k_C = 9.0 # Scaling factor for range 10-100
122
+ ccp_capital = max(C0, C0 * (1 + k_C * ccp_stress))
123
+
124
+ # Apply scenario overrides
125
+ im *= connectivity_scale
126
+ df *= connectivity_scale
127
+ vm *= 1.0 / max(liquidity_buffer_scale, 1e-6) # Tight liquidity => bigger VM
128
+
129
+ return {
130
+ "initial_margin": round(im, 4),
131
+ "variation_margin_flow": round(vm, 4),
132
+ "default_fund": round(df, 4),
133
+ "ccp_capital": round(ccp_capital, 4),
134
+ "units": "indexed",
135
+ "vm_is_flow": True
136
+ }
137
+
138
+ def _run_ccp_funds_self_checks():
139
+ """Run self-checks to validate compute_ccp_funds logic."""
140
+ # Check 1: If systemic increases, IM/VM should increase
141
+ low_sys = compute_ccp_funds(0.2, 0.5, 0.3)
142
+ high_sys = compute_ccp_funds(0.8, 0.5, 0.3)
143
+ assert high_sys["initial_margin"] >= low_sys["initial_margin"], "IM should increase with systemic"
144
+ assert high_sys["variation_margin_flow"] >= low_sys["variation_margin_flow"], "VM should increase with systemic"
145
+
146
+ # Check 2: DF should increase only when ccp_stress > 0.5
147
+ low_stress = compute_ccp_funds(0.1, 0.1, 0.1) # ccp_stress = 0.4*0.1 + 0.3*0.1 + 0.3*0.1 = 0.1
148
+ high_stress = compute_ccp_funds(0.8, 0.8, 0.8) # ccp_stress = 0.4*0.8 + 0.3*0.8 + 0.3*0.8 = 0.8
149
+ assert low_stress["default_fund"] == DF0, "DF should stay at baseline when ccp_stress <= 0.5"
150
+ assert high_stress["default_fund"] > DF0, "DF should increase when ccp_stress > 0.5"
151
+
152
+ # Check 3: Increasing connectivity_scale increases IM and DF
153
+ base = compute_ccp_funds(0.5, 0.5, 0.5, connectivity_scale=1.0)
154
+ scaled = compute_ccp_funds(0.5, 0.5, 0.5, connectivity_scale=1.5)
155
+ assert scaled["initial_margin"] > base["initial_margin"], "IM should increase with connectivity_scale"
156
+ assert scaled["default_fund"] > base["default_fund"], "DF should increase with connectivity_scale"
157
+
158
+ logger.info("CCP funds self-checks passed.")
159
+
160
+ # --- Helper Functions ---
161
+
162
+ def load_config():
163
+ """Load configuration from frontend/config.json."""
164
+ global config
165
+ try:
166
+ if os.path.exists(CONFIG_PATH):
167
+ with open(CONFIG_PATH, "r") as f:
168
+ config = json.load(f)
169
+ logger.info("Config loaded successfully.")
170
+ else:
171
+ # Fallback default config if file is missing (though unlikely in this setup)
172
+ logger.warning(f"Config file not found at {CONFIG_PATH}. Using defaults.")
173
+ config = {
174
+ "tickers": ["HDFCBANK.NS", "KOTAKBANK.NS", "ICICIBANK.NS", "BAJFINANCE.NS", "BSE.NS",
175
+ "TCS.NS", "INFY.NS", "RELIANCE.NS", "SBIN.NS", "ADANIENT.NS",
176
+ "MRF.NS", "HINDUNILVR.NS", "TATASTEEL.NS", "AXISBANK.NS", "BHARTIARTL.NS"],
177
+ "ccp_name": "CCP",
178
+ "start": "2022-01-01",
179
+ "ret_window": 20,
180
+ "lookback": 20,
181
+ "delta_ccp": 0.1
182
+ }
183
+ except Exception as e:
184
+ logger.error(f"Error loading config: {e}")
185
+ raise
186
+
187
+ def setup_model_artifacts():
188
+ """Download and load model, scaler, and feature columns."""
189
+ global model, scaler, feature_columns
190
+ try:
191
+ # 1. Download Model
192
+ logger.info(f"Downloading {MODEL_FILENAME} from {MODEL_REPO}...")
193
+ model_path = hf_hub_download(repo_id=MODEL_REPO, filename=MODEL_FILENAME, repo_type="model")
194
+ model = tf.keras.models.load_model(model_path) # type: ignore
195
+ logger.info("Model loaded successfully.")
196
+
197
+ # 2. Download Scaler
198
+ logger.info(f"Downloading {SCALER_FILENAME} from {MODEL_REPO}...")
199
+ scaler_path = hf_hub_download(repo_id=MODEL_REPO, filename=SCALER_FILENAME, repo_type="model")
200
+ scaler = joblib.load(scaler_path)
201
+ logger.info("Scaler loaded successfully.")
202
+
203
+ # 3. Download/Set Feature Columns
204
+ try:
205
+ logger.info(f"Attempting to download {FEATURE_COLUMNS_FILENAME}...")
206
+ cols_path = hf_hub_download(repo_id=MODEL_REPO, filename=FEATURE_COLUMNS_FILENAME, repo_type="model")
207
+ with open(cols_path, "r") as f:
208
+ feature_columns = json.load(f)
209
+ logger.info(f"Feature columns loaded: {feature_columns}")
210
+ except Exception as e:
211
+ logger.warning(f"Could not load feature_columns.json ({e}). Using default columns.")
212
+ feature_columns = ["lambda_max", "mean_risk", "max_risk", "std_risk", "S_lag1", "S_lag5"]
213
+
214
+ except Exception as e:
215
+ logger.critical(f"Failed to setup model artifacts: {e}")
216
+ raise RuntimeError("Model initialization failed.")
217
+
218
+ # --- Startup Event ---
219
+ @app.on_event("startup")
220
+ async def startup_event():
221
+ load_config()
222
+ setup_model_artifacts()
223
+ _run_ccp_funds_self_checks() # Validate CCP funds logic
224
+
225
+ # --- Core Logic ---
226
+
227
+ def compute_rolling_metrics(returns, window=20):
228
+ """Compute rolling volatility and rolling max drawdown proxy."""
229
+ # Rolling Volatility
230
+ rolling_std = returns.rolling(window=window).std()
231
+
232
+ # Rolling Drawdown Proxy (simple)
233
+ # Using rolling max of price would be better, but we have returns.
234
+ # Let's reconstruct a cumulative return series for drawdown calculation roughly or use return based proxy.
235
+ # Standard practice with just returns:
236
+ # We need prices for standard drawdown. Let's assume we can use cumulative sum of log returns as log-price.
237
+
238
+ log_prices = returns.cumsum()
239
+ rolling_max = log_prices.rolling(window=window, min_periods=1).max()
240
+ drawdown = (log_prices - rolling_max) # This is log-drawdown, roughly % drawdown
241
+ # Invert so it's a positive risk metric? Drawdown is negative.
242
+ # Risk is usually magnitude. Let's take abs of drawdown (distance from peak).
243
+ rolling_drawdown = drawdown.abs()
244
+
245
+ return rolling_std, rolling_drawdown
246
+
247
+ def compute_features_for_subset(tickers_subset: List[str]):
248
+ """
249
+ Main computational pipeline.
250
+ 1. Fetch data for ALL config tickers.
251
+ 2. Compute market-wide metrics (Adjacency, Eigenvector).
252
+ 3. Apply masking: Set risk of unselected tickers to 0.
253
+ 4. Compute Systemic Risk Payoff S_t.
254
+ 5. Construct feature lags.
255
+ """
256
+ all_tickers = config["tickers"]
257
+ start_date = config["start"]
258
+ ccp_name = config["ccp_name"]
259
+ delta_ccp = config["delta_ccp"]
260
+ lookback = config["lookback"]
261
+
262
+ # Identify indices
263
+ try:
264
+ # Create a boolean mask for selected tickers
265
+ # We need to maintain the order of 'all_tickers' for matrix operations
266
+ mask_vector = np.array([1.0 if t in tickers_subset else 0.0 for t in all_tickers])
267
+ except Exception as e:
268
+ raise HTTPException(status_code=400, detail=f"Error processing ticker subset: {e}")
269
+
270
+ # 1. Fetch Data
271
+ # We fetch data until today.
272
+ # yfinance auto_adjust=True
273
+ logger.info("Fetching market data...")
274
+ try:
275
+ raw_data = yf.download(all_tickers, start=start_date, progress=False, auto_adjust=True, threads=False)
276
+ if raw_data is None or raw_data.empty:
277
+ raise HTTPException(status_code=500, detail="No data returned from yfinance.")
278
+ data = raw_data['Close']
279
+ except HTTPException as he:
280
+ raise he
281
+ except Exception as e:
282
+ raise HTTPException(status_code=500, detail=f"Failed to fetch data from yfinance: {e}")
283
+
284
+ if data is None or data.empty:
285
+ raise HTTPException(status_code=500, detail="No Close price data returned from yfinance.")
286
+
287
+ # Handle single ticker case (though unlikely given list) causing Series instead of DataFrame
288
+ if isinstance(data, pd.Series):
289
+ data = data.to_frame()
290
+
291
+ # Reorder columns to match all_tickers list exactly (yfinance might sort them)
292
+ # Filter only columns that exist (in case some tickers failed)
293
+ existing_tickers = [t for t in all_tickers if t in data.columns]
294
+ data = data[existing_tickers]
295
+
296
+ # Recalculate mask based on existing tickers (if any were dropped by yfinance)
297
+ mask_vector = np.array([1.0 if t in tickers_subset and t in existing_tickers else 0.0 for t in existing_tickers])
298
+ N = len(existing_tickers)
299
+
300
+ # 2. Log Returns
301
+ returns = np.log(data / data.shift(1)).dropna()
302
+
303
+ # 3. Rolling Calculations
304
+ # We need to iterate over time to compute A_t, v_t, r_t, S_t
305
+ # Correlation window = 20
306
+ window = 20
307
+
308
+ # Lists to store time-series of metrics
309
+ s_t_series = []
310
+ lambda_max_series = []
311
+
312
+ # Store other risk metrics for feature creation
313
+ mean_risk_series = []
314
+ max_risk_series = []
315
+ std_risk_series = []
316
+
317
+ # Rolling Volatility & Drawdown (Vectorized)
318
+ rolling_std, rolling_dd = compute_rolling_metrics(returns, window)
319
+
320
+ # To compute min-max normalization, we need expanding window/historic min-max.
321
+ # Let's simplify and do expanding window from start of data.
322
+
323
+ # Combined Risk Metric: Volatility + Drawdown (Equal weight?)
324
+ # "rolling vol + rolling drawdown proxy" - Sum?
325
+ raw_risk = rolling_std + rolling_dd
326
+
327
+ # Normalize [0,1] expanding
328
+ # Note: expanding().min() / max() might be slow in loop, let's vectorise
329
+ expanding_min = raw_risk.expanding().min()
330
+ expanding_max = raw_risk.expanding().max()
331
+
332
+ # Avoid div by zero
333
+ denom = expanding_max - expanding_min
334
+ denom = denom.replace(0, 1.0) # Handle constant case
335
+
336
+ norm_risk = (raw_risk - expanding_min) / denom
337
+
338
+ # We can only compute calculating from `window` onwards
339
+ # And we need enough history for lags (lag 5 is max)
340
+ # We need to return exactly `lookback` (20) days of features.
341
+ # BUT, to compute S_lag5 for the *first* of those 20 days, we need S from 5 days before that.
342
+ # So we need to compute S_t for range: [end - lookback - 5, end]
343
+
344
+ required_history_len = lookback + 5
345
+
346
+ # Ensure we have enough data
347
+ if len(returns) < window + required_history_len:
348
+ raise HTTPException(status_code=400, detail=f"Insufficient data history. Need at least {window + required_history_len} days.")
349
+
350
+ # Slice the relevant period for iteration
351
+ # We usually want the "latest" prediction, so we process the tail.
352
+ # Let's process the last (required_history_len) days.
353
+
354
+ process_indices = returns.index[-required_history_len:]
355
+
356
+ # Pre-compute Rolling Correlations for efficiency?
357
+ # rolling(window).corr() returns a MultiIndex series.
358
+ # It might be heavy to do for all history. Let's do loop for just the needed days.
359
+
360
+ # History of S metrics to build lags
361
+ history_S = []
362
+
363
+ for date in process_indices:
364
+ # 1. Get correlation matrix for window ending at 'date'
365
+ # Data slice: date-window+1 to date
366
+ # returns.loc[:date].tail(window)
367
+ window_returns = returns.loc[:date].tail(window)
368
+
369
+ if len(window_returns) < window:
370
+ # Should not happen given logic above
371
+ s_t_series.append(0)
372
+ continue
373
+
374
+ # Correlation
375
+ corr_mat = window_returns.corr().values
376
+ # Fill NaNs (if constant price) with 0
377
+ corr_mat = np.nan_to_num(corr_mat)
378
+
379
+ # 2. Adjacency Matrix A
380
+ # Off-diagonal = max(0, corr)
381
+ A = np.maximum(0, corr_mat)
382
+ np.fill_diagonal(A, 0)
383
+
384
+ # 3. Add CCP
385
+ # A is N x N. New A is (N+1) x (N+1)
386
+ # Append column and row
387
+ # Column N: 0.1s
388
+ # Row N: 0.1s
389
+ # A[N,N] = 0
390
+
391
+ A_ext = np.zeros((N+1, N+1))
392
+
393
+ # Copy bank-bank block
394
+ A_ext[:N, :N] = A
395
+
396
+ # Add CCP edges
397
+ A_ext[:N, N] = delta_ccp # Bank -> CCP
398
+ A_ext[N, :N] = delta_ccp # CCP -> Bank
399
+
400
+ # 4. Compute Principal Eigenvector & Lambda Max
401
+ # Power iteration or linalg.eigh
402
+ # Since A is symmetric (corr is symmetric), eigh is good.
403
+ eigvals, eigvecs = np.linalg.eigh(A_ext)
404
+
405
+ # Max eigenvalue and vector
406
+ lambda_max = eigvals[-1]
407
+ v_t = eigvecs[:, -1]
408
+
409
+ # Ensure v_t is positive (Perron-Frobenius for non-negative matrices implies there's a non-negative eigenvector)
410
+ # Sometimes solver flips sign.
411
+ if np.sum(v_t) < 0:
412
+ v_t = -v_t
413
+
414
+ # 5. Node Risks r_t
415
+ # Get risk for this date
416
+ # norm_risk is a DataFrame, get row, convert to array
417
+ r_t_banks = norm_risk.loc[date].values
418
+
419
+ # Apply MASKING
420
+ # Set unselected tickers to 0
421
+ r_t_banks_masked = r_t_banks * mask_vector
422
+
423
+ # CCP Risk = 0
424
+ r_t_ext = np.append(r_t_banks_masked, 0.0)
425
+
426
+ # 6. Payoff S_t = r_t^T * A * v_t
427
+ # Dot product
428
+ # A * v
429
+ Av = np.dot(A_ext, v_t)
430
+ # r * Av
431
+ S_t = np.dot(r_t_ext, Av)
432
+
433
+ # 7. Statistics for Features
434
+ # Compute stats on the MASKED banks risk (excluding CCP)
435
+ # "mean_risk, max_risk, std_risk"
436
+ # Using masked values might skew mean to 0 if many are masked.
437
+ # But this reflects the "effective" system state if unselected are removed.
438
+ # Let's use the masked vectors.
439
+ # Note: If we really masked checks (set to 0), maybe we should exclude them from mean/std?
440
+ # But for fixed feature vector size, usually we just compute on the vector.
441
+
442
+ mean_r = np.mean(r_t_banks_masked)
443
+ max_r = np.max(r_t_banks_masked)
444
+ std_r = np.std(r_t_banks_masked)
445
+
446
+ # Store
447
+ history_S.append(S_t)
448
+ lambda_max_series.append(lambda_max)
449
+ mean_risk_series.append(mean_r)
450
+ max_risk_series.append(max_r)
451
+ std_risk_series.append(std_r)
452
+
453
+ # Convert history to DataFrame to build features
454
+ feature_df = pd.DataFrame({
455
+ "lambda_max": lambda_max_series,
456
+ "mean_risk": mean_risk_series,
457
+ "max_risk": max_risk_series,
458
+ "std_risk": std_risk_series,
459
+ "S_t": history_S
460
+ }, index=process_indices)
461
+
462
+ # Create Lags
463
+ feature_df["S_lag1"] = feature_df["S_t"].shift(1)
464
+ feature_df["S_lag5"] = feature_df["S_t"].shift(5)
465
+
466
+ # Drop NaNs created by shifting
467
+ # We calculated `lookback + 5` days.
468
+ # Shifting by 5 loses first 5.
469
+ feature_df = feature_df.dropna()
470
+
471
+ # Select last `lookback` (20) rows
472
+ feature_df = feature_df.tail(lookback)
473
+
474
+ if len(feature_df) < lookback:
475
+ logger.error(f"Not enough data after lag creation. Have {len(feature_df)}, need {lookback}")
476
+ raise HTTPException(status_code=400, detail="Insufficient data for feature window.")
477
+
478
+ # Select feature columns in correct order
479
+ # Ensure columns match model expectation
480
+ final_features = feature_df[feature_columns]
481
+
482
+ return final_features, existing_tickers
483
+
484
+ # --- Endpoint ---
485
+ @app.post("/predict", response_model=PredictionResponse)
486
+ async def predict(request: PredictionRequest):
487
+ if not model or not scaler:
488
+ raise HTTPException(status_code=503, detail="Model not loaded.")
489
+
490
+ # Input validation
491
+ tickers_subset = request.tickers
492
+ if not tickers_subset:
493
+ raise HTTPException(status_code=400, detail="Tickers list cannot be empty.")
494
+
495
+ valid_tickers = set(config["tickers"])
496
+ invalid = [t for t in tickers_subset if t not in valid_tickers]
497
+ if invalid:
498
+ raise HTTPException(status_code=400, detail=f"Invalid tickers: {invalid}. Must be in config.")
499
+
500
+ # Compute Features
501
+ try:
502
+ features_df, available_tickers = compute_features_for_subset(tickers_subset)
503
+ except HTTPException as he:
504
+ raise he
505
+ except Exception as e:
506
+ logger.error(f"Computation error: {e}", exc_info=True)
507
+ raise HTTPException(status_code=500, detail=str(e))
508
+
509
+ # Scale Features
510
+ X = features_df.values
511
+ try:
512
+ X_scaled = scaler.transform(X)
513
+ except Exception as e:
514
+ logger.error(f"Scaling error: {e}")
515
+ raise HTTPException(status_code=500, detail=f"Scaling failed: {e}")
516
+
517
+ # Reshape for LSTM/GRU: (1, 20, 6)
518
+ # features_df should have 20 rows
519
+ X_reshaped = X_scaled.reshape(1, config["lookback"], len(feature_columns))
520
+
521
+ # Predict
522
+ try:
523
+ prediction = model.predict(X_reshaped)
524
+ risk_score = float(prediction[0][0])
525
+ except Exception as e:
526
+ logger.error(f"Prediction error: {e}")
527
+ raise HTTPException(status_code=500, detail=f"Model prediction failed: {e}")
528
+
529
+ # Latest Data
530
+ latest_row = features_df.iloc[-1]
531
+ last_date = features_df.index[-1].strftime("%Y-%m-%d")
532
+
533
+ # Extract metrics for CCP funds computation
534
+ # lambda_max and std_risk from latest features
535
+ lambda_max_val = float(latest_row.get("lambda_max", 0.0))
536
+ std_risk_val = float(latest_row.get("std_risk", 0.0))
537
+
538
+ # Compute CCP funds
539
+ ccp_funds = compute_ccp_funds(
540
+ systemic=risk_score,
541
+ lambda_max=lambda_max_val,
542
+ std_risk=std_risk_val,
543
+ connectivity_scale=request.connectivity_scale,
544
+ liquidity_buffer_scale=request.liquidity_buffer_scale
545
+ )
546
+
547
+ return {
548
+ "predicted_next_systemic_risk": risk_score,
549
+ "latest_S_t": latest_row.get("S_t", 0.0) if "S_t" in latest_row else 0.0,
550
+ "latest_features": latest_row.to_dict(),
551
+ "used_tickers": available_tickers,
552
+ "masked_tickers": [t for t in config["tickers"] if t not in tickers_subset],
553
+ "end_date": last_date,
554
+ "ccp_funds": ccp_funds
555
+ }
556
+
557
+ @app.get("/health")
558
+ def health():
559
+ return {"status": "ok", "model_loaded": model is not None}
package-lock.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "name": "backend",
3
+ "lockfileVersion": 3,
4
+ "requires": true,
5
+ "packages": {}
6
+ }
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ yfinance
4
+ pandas
5
+ numpy
6
+ scikit-learn
7
+ tensorflow-cpu
8
+ huggingface_hub
9
+ joblib
10
+ pyzmq==26.2.0