dfm-python 0.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dfm_python/news.py ADDED
@@ -0,0 +1,732 @@
1
+ """News decomposition for nowcasting updates.
2
+
3
+ This module implements the news decomposition framework for understanding how
4
+ new data releases affect nowcasts. The "news" is defined as the difference
5
+ between the new data release and the model's previous forecast, decomposed
6
+ into contributions from each data series.
7
+
8
+ The news decomposition provides:
9
+ - Forecast updates when new data arrives
10
+ - Attribution of forecast changes to specific data series
11
+ - Understanding of which data releases are most informative
12
+
13
+ This is essential for nowcasting applications where policymakers need to
14
+ understand the drivers of forecast revisions.
15
+ """
16
+
17
+ import numpy as np
18
+ from scipy.linalg import pinv, inv
19
+ from typing import Tuple, Optional, Dict, Union, List, Any, Callable
20
+ import pandas as pd
21
+ import logging
22
+
23
+ from .kalman import skf, fis, miss_data
24
+ from .config import DFMConfig
25
+ from .dfm import DFMResult
26
+
27
+ # Set up logger
28
+ _logger = logging.getLogger(__name__)
29
+
30
+ # ============================================================================
31
+ # Constants
32
+ # ============================================================================
33
+
34
+ # Number of months to append for forecasting horizon
35
+ # Used when extending the time index for forecast generation
36
+ FORECAST_HORIZON_MONTHS: int = 12
37
+
38
+ # Default fallback date for time index extension
39
+ # Used when the end date cannot be determined from the data
40
+ DEFAULT_FALLBACK_DATE: str = '2017-01-01'
41
+
42
+
43
+ def _check_config_consistency(saved_config: Any, current_config: DFMConfig) -> None:
44
+ """Check if saved config is consistent with current config.
45
+
46
+ Parameters
47
+ ----------
48
+ saved_config : Any
49
+ Saved configuration object (may be DFMConfig or dict-like)
50
+ current_config : DFMConfig
51
+ Current configuration object
52
+
53
+ Notes
54
+ -----
55
+ Issues a UserWarning if configs differ. Logs warnings if comparison fails.
56
+ """
57
+ try:
58
+ if hasattr(saved_config, 'SeriesID') and hasattr(current_config, 'SeriesID'):
59
+ if saved_config.SeriesID != current_config.SeriesID:
60
+ import warnings
61
+ warnings.warn(
62
+ "Config used in estimation differs from current config. "
63
+ "Results may be inconsistent. Consider re-estimating.",
64
+ UserWarning
65
+ )
66
+ except Exception as e:
67
+ _logger.warning(f"update_nowcast: Config consistency check failed: {type(e).__name__}: {str(e)}")
68
+ # If comparison fails, continue anyway
69
+
70
+
71
+ def para_const(X: np.ndarray, P: Union[DFMResult, Dict[str, Any]], lag: int) -> Dict[str, Any]:
72
+ """Implement Kalman filter for news calculation with fixed parameters.
73
+
74
+ Parameters:
75
+ -----------
76
+ X : np.ndarray
77
+ Data matrix (T x N)
78
+ P : DFMResult
79
+ DFM parameters
80
+ lag : int
81
+ Number of lags
82
+
83
+ Returns:
84
+ --------
85
+ Dict with keys: Plag, P, X_sm, F
86
+ """
87
+ # Set model parameters
88
+ Z_0 = P.Z_0
89
+ V_0 = P.V_0
90
+ A = P.A
91
+ C = P.C
92
+ Q = P.Q
93
+ R = P.R
94
+ Mx = P.Mx
95
+ Wx = P.Wx
96
+
97
+ # Prepare data
98
+ T, _ = X.shape
99
+
100
+ # Standardize
101
+ Y = ((X - Mx) / Wx).T # n x T
102
+
103
+ # Apply Kalman filter and smoother
104
+ Sf = skf(Y, A, C, Q, R, Z_0, V_0)
105
+ Ss = fis(A, Sf)
106
+
107
+ # Calculate parameter output
108
+ Vs = Ss.VmT[:, :, 1:] # Smoothed factor covariance
109
+ Vf = Sf.VmU[:, :, 1:] # Filtered factor posterior covariance
110
+ Zsmooth = Ss.ZmT
111
+ Vsmooth = Ss.VmT
112
+
113
+ Plag = [Vs]
114
+
115
+ # Calculate lagged covariance matrices
116
+ # MATLAB loops backwards: for jt = size(Plag{1},3):-1:lag+1
117
+ for jk in range(1, lag + 1):
118
+ Plag_jk = np.zeros_like(Plag[0])
119
+ # Loop backwards from last time index down to lag+1 (MATLAB: size(Plag{1},3):-1:lag+1)
120
+ # Note: Python uses 0-based indexing, so range(N-1, lag, -1) gives N-1 down to lag+1
121
+ for jt in range(Plag[0].shape[2] - 1, lag, -1): # Backward iteration to match MATLAB
122
+ As = Vf[:, :, jt - jk] @ A.T @ pinv(A @ Vf[:, :, jt - jk] @ A.T + Q)
123
+ Plag_jk[:, :, jt] = As @ Plag[jk - 1][:, :, jt]
124
+ Plag.append(Plag_jk)
125
+
126
+ # Prepare data for output
127
+ Zsmooth = Zsmooth.T # T x m
128
+ x_sm = Zsmooth[1:, :] @ C.T # T x N
129
+ X_sm = Wx * x_sm + Mx # Unstandardized
130
+
131
+ return {
132
+ 'Plag': Plag,
133
+ 'P': Vsmooth,
134
+ 'X_sm': X_sm,
135
+ 'F': Zsmooth[1:, :]
136
+ }
137
+
138
+
139
+ def news_dfm(X_old: np.ndarray, X_new: np.ndarray, Res: Union[DFMResult, Dict[str, Any]],
140
+ t_fcst: int, v_news: Union[int, np.ndarray, List[int]]) -> Tuple[
141
+ Union[float, np.ndarray], Union[float, np.ndarray], np.ndarray, np.ndarray,
142
+ np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
143
+ """Calculate changes in news from data releases.
144
+
145
+ This function implements the news decomposition algorithm for dynamic factor models.
146
+ It computes how new data releases affect forecasts of target variables by decomposing
147
+ the forecast update into contributions from each new data point (news).
148
+
149
+ The algorithm:
150
+ 1. Identifies new data points (available in X_new but not in X_old)
151
+ 2. Runs Kalman filter/smoother on both old and new datasets
152
+ 3. Computes forecast updates (y_new - y_old) for target variables
153
+ 4. Decomposes the update into individual news contributions from each new data point
154
+
155
+ Parameters:
156
+ -----------
157
+ X_old : np.ndarray
158
+ Old data matrix (T x N), where T is time periods and N is number of series.
159
+ Contains data available before the new release.
160
+ X_new : np.ndarray
161
+ New data matrix (T x N), same dimensions as X_old.
162
+ Contains data after the new release. Must have same number of series as X_old.
163
+ Res : Union[DFMResult, Dict[str, Any]]
164
+ DFM estimation results. Can be:
165
+ - DFMResult: Direct result object from dfm() function
166
+ - Dict: Dictionary containing 'Res' key with DFMResult, and optionally 'Config' or 'Spec'
167
+ Must have attributes: C, Mx, Wx, X_sm (or equivalent keys if dict)
168
+ t_fcst : int
169
+ Target time index (0-based) for the forecast period.
170
+ Must satisfy 0 <= t_fcst < T (number of time periods in X_new).
171
+ v_news : Union[int, np.ndarray, List[int]]
172
+ Target variable index(s) (0-based). Specifies which series to forecast.
173
+ Can be:
174
+ - int: Single target variable (backward compatible)
175
+ - np.ndarray or List[int]: Multiple target variables (MATLAB-compatible)
176
+ All indices must be in range [0, N) where N is number of series.
177
+
178
+ Returns:
179
+ --------
180
+ Tuple containing 9 elements:
181
+
182
+ y_old : Union[float, np.ndarray]
183
+ Forecast of target variable(s) using old data.
184
+ - If v_news is scalar: float
185
+ - If v_news is array: (n_targets,) array
186
+ y_new : Union[float, np.ndarray]
187
+ Forecast of target variable(s) using new data.
188
+ - If v_news is scalar: float
189
+ - If v_news is array: (n_targets,) array
190
+ singlenews : np.ndarray
191
+ Individual news contributions from each new data point.
192
+ - If v_news is scalar: (N,) array, where singlenews[i] is contribution from series i
193
+ - If v_news is array: (N, n_targets) array, where each column is news for one target
194
+ actual : np.ndarray
195
+ Actual values of new data points (N,). NaN for series with no new data.
196
+ forecast : np.ndarray
197
+ Forecasted values (before new data) for new data points (N,). NaN for series with no new data.
198
+ weight : np.ndarray
199
+ Weights for news contributions (N,). NaN for series with no new data.
200
+ - If v_news is scalar: (N,) array
201
+ - If v_news is array: (N, n_targets) array
202
+ t_miss : np.ndarray
203
+ Time indices where new data is available (n_news,). Integer array.
204
+ v_miss : np.ndarray
205
+ Variable indices where new data is available (n_news,). Integer array.
206
+ innov : np.ndarray
207
+ Innovation terms (n_news,). Forecast errors for new data points.
208
+
209
+ Raises:
210
+ ------
211
+ ValueError
212
+ If inputs are invalid:
213
+ - X_old or X_new are not 2D numpy arrays
214
+ - X_old and X_new have different number of series
215
+ - t_fcst is out of bounds
216
+ - v_news indices are out of range
217
+ - Res does not have required attributes (C, Mx, Wx, X_sm)
218
+
219
+ Notes:
220
+ -----
221
+ - Now supports both single and multiple target variables, matching MATLAB functionality.
222
+ - The function handles two cases:
223
+ 1. NO FORECAST CASE: Target variable(s) already observed at t_fcst in X_new
224
+ 2. FORECAST CASE: Target variable(s) not yet observed, requires forecast update
225
+ - News decomposition follows the methodology in Banbura et al. (2015).
226
+ - If matrix inversion fails during gain calculation, fallback values are used and logged.
227
+
228
+ Examples:
229
+ --------
230
+ >>> from dfm_python import dfm, news_dfm
231
+ >>> # Estimate DFM model
232
+ >>> Res = dfm(X, config)
233
+ >>> # Calculate news for single target variable
234
+ >>> y_old, y_new, singlenews, actual, forecast, weight, t_miss, v_miss, innov = \\
235
+ ... news_dfm(X_old, X_new, Res, t_fcst=100, v_news=0)
236
+ >>> # Calculate news for multiple target variables
237
+ >>> targets = [0, 1, 2]
238
+ >>> y_old, y_new, singlenews, actual, forecast, weight, t_miss, v_miss, innov = \\
239
+ ... news_dfm(X_old, X_new, Res, t_fcst=100, v_news=targets)
240
+ """
241
+ # Input validation
242
+ if not isinstance(X_old, np.ndarray) or X_old.ndim != 2:
243
+ raise ValueError(f"news_dfm: X_old must be a 2D numpy array, got {type(X_old)} with shape {getattr(X_old, 'shape', 'N/A')}")
244
+ if not isinstance(X_new, np.ndarray) or X_new.ndim != 2:
245
+ raise ValueError(f"news_dfm: X_new must be a 2D numpy array, got {type(X_new)} with shape {getattr(X_new, 'shape', 'N/A')}")
246
+ if X_old.shape[1] != X_new.shape[1]:
247
+ raise ValueError(f"news_dfm: X_old and X_new must have same number of series. Got {X_old.shape[1]} and {X_new.shape[1]}")
248
+ if not isinstance(t_fcst, (int, np.integer)) or t_fcst < 0:
249
+ raise ValueError(f"news_dfm: t_fcst must be a non-negative integer, got {t_fcst}")
250
+ if t_fcst >= X_new.shape[0]:
251
+ raise ValueError(f"news_dfm: t_fcst ({t_fcst}) must be less than number of time periods ({X_new.shape[0]})")
252
+
253
+ # Validate Res structure
254
+ if isinstance(Res, dict):
255
+ if 'Res' not in Res:
256
+ raise ValueError("news_dfm: Res dict must contain 'Res' key")
257
+ Res_actual = Res['Res']
258
+ else:
259
+ Res_actual = Res
260
+
261
+ # Check required attributes
262
+ required_attrs = ['C', 'Mx', 'Wx', 'X_sm']
263
+ for attr in required_attrs:
264
+ if not hasattr(Res_actual, attr):
265
+ raise ValueError(f"news_dfm: Res must have '{attr}' attribute. Got {type(Res_actual)}")
266
+
267
+ # Normalize v_news to array for consistent handling
268
+ v_news_arr = np.atleast_1d(v_news)
269
+ is_scalar = isinstance(v_news, (int, np.integer)) or (isinstance(v_news, np.ndarray) and v_news.ndim == 0)
270
+ n_targets = len(v_news_arr)
271
+
272
+ # Validate v_news indices
273
+ if np.any(v_news_arr < 0) or np.any(v_news_arr >= X_new.shape[1]):
274
+ raise ValueError(f"news_dfm: v_news indices must be in range [0, {X_new.shape[1]}), got {v_news_arr}")
275
+
276
+ r = Res_actual.C.shape[1]
277
+ _, N = X_new.shape
278
+
279
+ # Check if targets are already observed
280
+ targets_observed = np.array([not np.isnan(X_new[t_fcst, v]) for v in v_news_arr])
281
+
282
+ if np.all(targets_observed):
283
+ # NO FORECAST CASE: Already values for all target variables at time t_fcst
284
+ Res_old = para_const(X_old, Res_actual, 0)
285
+
286
+ # Initialize output arrays
287
+ if is_scalar:
288
+ singlenews = np.zeros(N)
289
+ singlenews[v_news_arr[0]] = X_new[t_fcst, v_news_arr[0]] - Res_old['X_sm'][t_fcst, v_news_arr[0]]
290
+ y_old = Res_old['X_sm'][t_fcst, v_news_arr[0]]
291
+ y_new = X_new[t_fcst, v_news_arr[0]]
292
+ else:
293
+ singlenews = np.zeros((N, n_targets))
294
+ for i, v in enumerate(v_news_arr):
295
+ singlenews[v, i] = X_new[t_fcst, v] - Res_old['X_sm'][t_fcst, v]
296
+ y_old = np.array([Res_old['X_sm'][t_fcst, v] for v in v_news_arr])
297
+ y_new = np.array([X_new[t_fcst, v] for v in v_news_arr])
298
+
299
+ actual = np.array([])
300
+ forecast = np.array([])
301
+ weight = np.array([])
302
+ t_miss = np.array([])
303
+ v_miss = np.array([])
304
+ innov = np.array([])
305
+ else:
306
+ # FORECAST CASE
307
+ Mx = Res_actual.Mx
308
+ Wx = Res_actual.Wx
309
+
310
+ # Calculate indicators for missing values
311
+ miss_old = np.isnan(X_old)
312
+ miss_new = np.isnan(X_new)
313
+
314
+ # Indicator for missing: 1 = new data available, -1 = old data available but not new
315
+ i_miss = miss_old.astype(int) - miss_new.astype(int)
316
+
317
+ # Time/variable indices where new data is available
318
+ t_miss, v_miss = np.where(i_miss == 1)
319
+ t_miss = t_miss.flatten()
320
+ v_miss = v_miss.flatten()
321
+
322
+ if len(v_miss) == 0:
323
+ # NO NEW INFORMATION
324
+ Res_old = para_const(X_old, Res_actual, 0)
325
+ Res_new = para_const(X_new, Res_actual, 0)
326
+
327
+ if is_scalar:
328
+ y_old = Res_old['X_sm'][t_fcst, v_news_arr[0]]
329
+ y_new = y_old
330
+ singlenews = np.array([])
331
+ else:
332
+ y_old = np.array([Res_old['X_sm'][t_fcst, v] for v in v_news_arr])
333
+ y_new = y_old
334
+ singlenews = np.array([]).reshape(0, n_targets)
335
+
336
+ actual = np.array([])
337
+ forecast = np.array([])
338
+ weight = np.array([])
339
+ t_miss = np.array([])
340
+ v_miss = np.array([])
341
+ innov = np.array([])
342
+ else:
343
+ # NEW INFORMATION
344
+ # Difference between forecast time and new data time
345
+ lag = t_fcst - t_miss
346
+
347
+ # Biggest time interval
348
+ k = max([np.max(np.abs(lag)), np.max(lag) - np.min(lag)])
349
+ k = int(k)
350
+
351
+ C = Res_actual.C
352
+ R_cov = Res_actual.R.T # Note: R in Res might need transposition
353
+
354
+ n_news = len(lag)
355
+
356
+ # Smooth old dataset
357
+ Res_old = para_const(X_old, Res_actual, k)
358
+ Plag = Res_old['Plag']
359
+
360
+ # Smooth new dataset
361
+ Res_new = para_const(X_new, Res_actual, 0)
362
+
363
+ # Get nowcasts for all target variables
364
+ if is_scalar:
365
+ y_old = Res_old['X_sm'][t_fcst, v_news_arr[0]]
366
+ y_new = Res_new['X_sm'][t_fcst, v_news_arr[0]]
367
+ else:
368
+ y_old = np.array([Res_old['X_sm'][t_fcst, v] for v in v_news_arr])
369
+ y_new = np.array([Res_new['X_sm'][t_fcst, v] for v in v_news_arr])
370
+
371
+ # Calculate projection matrices (simplified)
372
+ P1 = []
373
+ for i in range(n_news):
374
+ h = abs(t_fcst - t_miss[i])
375
+ m = max(t_miss[i], t_fcst)
376
+
377
+ if t_miss[i] > t_fcst:
378
+ Pp = Plag[h + 1][:, :, m] if h + 1 < len(Plag) else Plag[-1][:, :, m]
379
+ else:
380
+ Pp = Plag[h + 1][:, :, m].T if h + 1 < len(Plag) else Plag[-1][:, :, m].T
381
+
382
+ P1.append(Pp @ C[v_miss[i], :r].T)
383
+
384
+ P1 = np.hstack(P1) if len(P1) > 0 else np.zeros((r, 1))
385
+
386
+ # Calculate innovations
387
+ innov = np.zeros(n_news)
388
+ for i in range(n_news):
389
+ X_new_norm = (X_new[t_miss[i], v_miss[i]] - Mx[v_miss[i]]) / Wx[v_miss[i]]
390
+ X_sm_norm = (Res_old['X_sm'][t_miss[i], v_miss[i]] - Mx[v_miss[i]]) / Wx[v_miss[i]]
391
+ innov[i] = X_new_norm - X_sm_norm
392
+
393
+ # Calculate P2 (covariance of innovations) - simplified
394
+ P2 = np.zeros((n_news, n_news))
395
+ for i in range(n_news):
396
+ for j in range(n_news):
397
+ h = abs(lag[i] - lag[j])
398
+ m = max(t_miss[i], t_miss[j])
399
+
400
+ if t_miss[j] > t_miss[i]:
401
+ Pp = Plag[h + 1][:, :, m] if h + 1 < len(Plag) else Plag[-1][:, :, m]
402
+ else:
403
+ Pp = Plag[h + 1][:, :, m].T if h + 1 < len(Plag) else Plag[-1][:, :, m].T
404
+
405
+ if v_miss[i] == v_miss[j] and t_miss[i] != t_miss[j]:
406
+ WW = 0
407
+ else:
408
+ WW = R_cov[v_miss[i], v_miss[j]]
409
+
410
+ P2[i, j] = C[v_miss[i], :r] @ Pp @ C[v_miss[j], :r].T + WW
411
+
412
+ # Calculate weights and news for each target variable
413
+ if n_news > 0 and P2.size > 0:
414
+ try:
415
+ P2_inv = inv(P2)
416
+ # Calculate gain for each target variable
417
+ if is_scalar:
418
+ v_idx = v_news_arr[0]
419
+ gain = (Wx[v_idx] * C[v_idx, :r] @ P1 @ P2_inv).reshape(-1)
420
+ totnews = Wx[v_idx] * C[v_idx, :r] @ P1 @ P2_inv @ innov
421
+ else:
422
+ gain = np.zeros((n_targets, n_news))
423
+ totnews = np.zeros(n_targets)
424
+ for idx, v in enumerate(v_news_arr):
425
+ gain[idx, :] = Wx[v] * C[v, :r] @ P1 @ P2_inv
426
+ totnews[idx] = Wx[v] * C[v, :r] @ P1 @ P2_inv @ innov
427
+ except (np.linalg.LinAlgError, ValueError) as e:
428
+ # If inversion fails, use simpler approach
429
+ _logger.warning(
430
+ f"news_dfm: Matrix inversion failed for P2, using fallback values. "
431
+ f"Error: {type(e).__name__}: {str(e)}"
432
+ )
433
+ if is_scalar:
434
+ gain = np.ones(n_news) * 0.1
435
+ totnews = np.sum(innov) * 0.1
436
+ else:
437
+ gain = np.ones((n_targets, n_news)) * 0.1
438
+ totnews = np.ones(n_targets) * np.sum(innov) * 0.1
439
+ else:
440
+ if is_scalar:
441
+ gain = np.zeros(n_news)
442
+ totnews = 0
443
+ else:
444
+ gain = np.zeros((n_targets, n_news))
445
+ totnews = np.zeros(n_targets)
446
+
447
+ # Organize output
448
+ if is_scalar:
449
+ singlenews = np.full(N, np.nan)
450
+ actual = np.full(N, np.nan)
451
+ forecast = np.full(N, np.nan)
452
+ weight = np.full(N, np.nan)
453
+
454
+ for i in range(n_news):
455
+ actual[v_miss[i]] = X_new[t_miss[i], v_miss[i]]
456
+ forecast[v_miss[i]] = Res_old['X_sm'][t_miss[i], v_miss[i]]
457
+ if i < len(gain):
458
+ singlenews[v_miss[i]] = gain[i] * innov[i] / Wx[v_miss[i]] if Wx[v_miss[i]] != 0 else 0
459
+ weight[v_miss[i]] = gain[i] / Wx[v_miss[i]] if Wx[v_miss[i]] != 0 else 0
460
+ else:
461
+ # Multiple targets: singlenews is (N, n_targets)
462
+ singlenews = np.full((N, n_targets), np.nan)
463
+ actual = np.full(N, np.nan)
464
+ forecast = np.full(N, np.nan)
465
+ weight = np.full((N, n_targets), np.nan)
466
+
467
+ for i in range(n_news):
468
+ actual[v_miss[i]] = X_new[t_miss[i], v_miss[i]]
469
+ forecast[v_miss[i]] = Res_old['X_sm'][t_miss[i], v_miss[i]]
470
+ for idx in range(n_targets):
471
+ if i < len(gain[idx]):
472
+ singlenews[v_miss[i], idx] = gain[idx, i] * innov[i] / Wx[v_miss[i]] if Wx[v_miss[i]] != 0 else 0
473
+ weight[v_miss[i], idx] = gain[idx, i] / Wx[v_miss[i]] if Wx[v_miss[i]] != 0 else 0
474
+
475
+ # Remove duplicates from v_miss
476
+ v_miss = np.unique(v_miss)
477
+
478
+ return y_old, y_new, singlenews, actual, forecast, weight, t_miss, v_miss, innov
479
+
480
+
481
+ # Database saving is application-specific and should be implemented in adapters.
482
+ # This keeps the DFM module generic and database-agnostic.
483
+ def update_nowcast(X_old: np.ndarray, X_new: np.ndarray, Time: pd.DatetimeIndex,
484
+ config: DFMConfig, Res: Union[DFMResult, Dict[str, Any]],
485
+ series: str, period: str,
486
+ vintage_old: str, vintage_new: str,
487
+ model_id: Optional[int] = None,
488
+ save_callback: Optional[Callable] = None) -> None:
489
+ """Update nowcast and decompose changes into news.
490
+
491
+ Parameters:
492
+ -----------
493
+ X_old : np.ndarray
494
+ Old vintage data (T_old x N)
495
+ X_new : np.ndarray
496
+ New vintage data (T_new x N)
497
+ Time : pd.DatetimeIndex
498
+ Time index
499
+ config : DFMConfig
500
+ Model configuration
501
+ Res : DFMResult or dict
502
+ DFM estimation results. If dict, must contain 'Res' and optionally 'Spec'.
503
+ If DFMResult and has .Spec attribute, it will be checked for consistency.
504
+ series : str
505
+ Target series ID
506
+ period : str
507
+ Target period (e.g., '2016q4')
508
+ vintage_old : str
509
+ Old vintage date
510
+ vintage_new : str
511
+ New vintage date
512
+ model_id : int, optional
513
+ Model ID for saving (passed to save_callback if provided)
514
+ save_callback : Callable, optional
515
+ Optional callback function to save nowcast results.
516
+ If provided, will be called with nowcast parameters.
517
+ Example: Implement save_callback in your application adapter
518
+ save_callback=lambda **kwargs: save_nowcast_to_db(**kwargs)
519
+
520
+ Notes:
521
+ ------
522
+ If Res contains a saved Config and it differs from the current config, a warning
523
+ is issued. This matches MATLAB behavior where Res.Config is checked for consistency.
524
+
525
+ Database saving is robust with error handling - failures will log warnings
526
+ but not interrupt the nowcast computation.
527
+ """
528
+ # Input validation
529
+ if not isinstance(X_old, np.ndarray) or X_old.ndim != 2:
530
+ raise ValueError(f"update_nowcast: X_old must be a 2D numpy array, got {type(X_old)}")
531
+ if not isinstance(X_new, np.ndarray) or X_new.ndim != 2:
532
+ raise ValueError(f"update_nowcast: X_new must be a 2D numpy array, got {type(X_new)}")
533
+ if X_old.shape[1] != X_new.shape[1]:
534
+ raise ValueError(f"update_nowcast: X_old and X_new must have same number of series. Got {X_old.shape[1]} and {X_new.shape[1]}")
535
+ if not isinstance(series, str):
536
+ raise ValueError(f"update_nowcast: series must be a string, got {type(series)}")
537
+ if not isinstance(period, str):
538
+ raise ValueError(f"update_nowcast: period must be a string, got {type(period)}")
539
+ if not hasattr(config, 'SeriesID'):
540
+ raise ValueError(f"update_nowcast: config must have 'SeriesID' attribute")
541
+ if series not in config.SeriesID:
542
+ raise ValueError(f"update_nowcast: series '{series}' not found in config.SeriesID")
543
+
544
+ # Validate period format
545
+ try:
546
+ i_series = config.SeriesID.index(series)
547
+ freq = config.Frequency[i_series]
548
+ if freq == 'm':
549
+ # Format: YYYYmMM (e.g., "2016m12")
550
+ parts = period.split('m')
551
+ if len(parts) != 2:
552
+ raise ValueError(f"update_nowcast: period '{period}' must be in format 'YYYYmMM' for monthly frequency")
553
+ year, month = int(parts[0]), int(parts[1])
554
+ if not (1 <= month <= 12):
555
+ raise ValueError(f"update_nowcast: month must be between 1 and 12, got {month}")
556
+ elif freq == 'q':
557
+ # Format: YYYYqQ (e.g., "2016q4")
558
+ parts = period.split('q')
559
+ if len(parts) != 2:
560
+ raise ValueError(f"update_nowcast: period '{period}' must be in format 'YYYYqQ' for quarterly frequency")
561
+ year, q = int(parts[0]), int(parts[1])
562
+ if not (1 <= q <= 4):
563
+ raise ValueError(f"update_nowcast: quarter must be between 1 and 4, got {q}")
564
+ else:
565
+ raise ValueError(f"update_nowcast: Unsupported frequency '{freq}' for period validation")
566
+ except (ValueError, IndexError) as e:
567
+ if isinstance(e, ValueError) and "not found" not in str(e):
568
+ raise ValueError(f"update_nowcast: Invalid period format '{period}': {str(e)}")
569
+ raise
570
+
571
+ # Handle dict format (from saved results) - extract Res and check config consistency
572
+ if isinstance(Res, dict):
573
+ if 'Spec' in Res and 'Res' in Res:
574
+ # Legacy: Spec key for backward compatibility
575
+ saved_config = Res['Spec']
576
+ Res = Res['Res']
577
+ # Simple consistency check: compare SeriesID
578
+ _check_config_consistency(saved_config, config)
579
+ elif 'Config' in Res and 'Res' in Res:
580
+ # New format with Config key
581
+ saved_config = Res['Config']
582
+ Res = Res['Res']
583
+ _check_config_consistency(saved_config, config)
584
+ elif 'Res' in Res:
585
+ Res = Res['Res']
586
+
587
+ # Also check if DFMResult has a Config attribute (for future compatibility)
588
+ if hasattr(Res, 'Config') and hasattr(config, 'SeriesID'):
589
+ _check_config_consistency(Res.Config, config)
590
+
591
+ # Make sure datasets are same size
592
+ N = X_new.shape[1]
593
+ T_old = X_old.shape[0]
594
+ T_new = X_new.shape[0]
595
+
596
+ if T_new > T_old:
597
+ X_old = np.vstack([X_old, np.full((T_new - T_old, N), np.nan)])
598
+
599
+ # Append 1 year (12 months) for forecasting horizon
600
+ X_old = np.vstack([X_old, np.full((FORECAST_HORIZON_MONTHS, N), np.nan)])
601
+ X_new = np.vstack([X_new, np.full((FORECAST_HORIZON_MONTHS, N), np.nan)])
602
+
603
+ # Extend time index
604
+ last_date = Time.iloc[-1] if hasattr(Time, 'iloc') else Time[-1]
605
+ if hasattr(last_date, 'month'):
606
+ new_dates = pd.date_range(
607
+ start=last_date + pd.DateOffset(months=1),
608
+ periods=FORECAST_HORIZON_MONTHS,
609
+ freq='MS'
610
+ )
611
+ else:
612
+ # Fallback if not pandas datetime
613
+ new_dates = pd.date_range(start=DEFAULT_FALLBACK_DATE, periods=FORECAST_HORIZON_MONTHS, freq='MS')
614
+
615
+ # Handle both Index and Series types
616
+ if isinstance(Time, pd.Series):
617
+ Time = pd.concat([Time, pd.Series(new_dates)])
618
+ elif hasattr(Time, 'append'):
619
+ try:
620
+ Time = Time.append(pd.Index(new_dates))
621
+ except AttributeError:
622
+ Time = pd.concat([Time, pd.Index(new_dates)])
623
+ else:
624
+ Time = pd.concat([Time, pd.Index(new_dates)])
625
+
626
+ # Find series index
627
+ try:
628
+ i_series = config.SeriesID.index(series)
629
+ except ValueError:
630
+ raise ValueError(f"Series {series} not found in configuration")
631
+
632
+ series_name = config.SeriesName[i_series]
633
+ freq = config.Frequency[i_series]
634
+
635
+ # Parse period
636
+ if freq == 'm':
637
+ year_str, month_str = period.split('m')
638
+ year = int(year_str)
639
+ month = int(month_str)
640
+ target_date = pd.Timestamp(year, month, 1)
641
+ elif freq == 'q':
642
+ year_str, q_str = period.split('q')
643
+ year = int(year_str)
644
+ q = int(q_str)
645
+ month = 3 * q
646
+ target_date = pd.Timestamp(year, month, 1)
647
+ else:
648
+ raise ValueError(f"Unsupported frequency: {freq}")
649
+
650
+ # Find time index
651
+ t_nowcast = None
652
+ for i, t in enumerate(Time):
653
+ if hasattr(t, 'year') and hasattr(t, 'month'):
654
+ if t.year == target_date.year and t.month == target_date.month:
655
+ t_nowcast = i
656
+ break
657
+
658
+ if t_nowcast is None:
659
+ raise ValueError('Period is out of nowcasting horizon (up to one year ahead).')
660
+
661
+ # Separate revisions from new releases
662
+ X_rev = X_new.copy()
663
+ X_rev[np.isnan(X_old)] = np.nan
664
+
665
+ # Compute news
666
+ # Impact from data revisions
667
+ y_old = news_dfm(X_old, X_rev, Res, t_nowcast, i_series)[0]
668
+
669
+ # Impact from data releases
670
+ y_rev, y_new, _, actual, forecast, weight = news_dfm(X_rev, X_new, Res, t_nowcast, i_series)[:6]
671
+
672
+ # Display output
673
+ print('\n\n\n')
674
+ print(f'Nowcast Update: {pd.to_datetime(vintage_new).strftime("%B %d, %Y")}')
675
+
676
+ if freq == 'q':
677
+ period_str = pd.Timestamp(Time[t_nowcast]).strftime('%Y:Q%q')
678
+ else:
679
+ period_str = pd.Timestamp(Time[t_nowcast]).strftime('%Y-%m')
680
+
681
+ print(f'Nowcast for {series_name} ({config.UnitsTransformed[i_series]}), {period_str}')
682
+
683
+ if len(forecast) == 0 or np.all(np.isnan(forecast)):
684
+ print('\n No forecast was made.\n')
685
+ else:
686
+ impact_revisions = y_rev - y_old
687
+ news = actual - forecast
688
+ impact_releases = weight * news
689
+
690
+ # Create summary table
691
+ data_released = np.any(np.isnan(X_old) & ~np.isnan(X_new), axis=0)
692
+
693
+ print('\n Nowcast Impact Decomposition')
694
+ print(' Note: The displayed output is subject to rounding error\n')
695
+ print(f' {pd.to_datetime(vintage_old).strftime("%b %d")} nowcast: {y_old:5.2f}')
696
+ print(f' Impact from data revisions: {impact_revisions:5.2f}')
697
+ print(f' Impact from data releases: {np.nansum(impact_releases):5.2f}')
698
+ print(' +_________')
699
+ print(f' Total impact: {impact_revisions + np.nansum(impact_releases):5.2f}')
700
+ print(f' {pd.to_datetime(vintage_new).strftime("%b %d")} nowcast: {y_new:5.2f}\n')
701
+
702
+ # Display table for series with updates
703
+ print('\n Nowcast Detail Table \n')
704
+ for i in range(N):
705
+ if data_released[i] and not np.isnan(forecast[i]):
706
+ series_id = config.SeriesID[i]
707
+ print(f'{series_id:20s} Forecast: {forecast[i]:8.2f} Actual: {actual[i]:8.2f} '
708
+ f'Weight: {weight[i]:8.4f} Impact: {impact_releases[i]:8.2f}')
709
+
710
+ # Save nowcast using callback if provided (generic - not database-specific)
711
+ if save_callback is not None:
712
+ try:
713
+ save_callback(
714
+ model_id=model_id,
715
+ series=series,
716
+ forecast_date=target_date,
717
+ forecast_value=y_new,
718
+ old_forecast_value=y_old,
719
+ impact_revisions=impact_revisions,
720
+ impact_releases=np.nansum(impact_releases),
721
+ total_impact=impact_revisions + np.nansum(impact_releases),
722
+ vintage_old=vintage_old,
723
+ vintage_new=vintage_new,
724
+ Res=Res
725
+ )
726
+ except Exception as e:
727
+ import warnings
728
+ warnings.warn(
729
+ f"save_callback failed: {e}. Continuing without saving.",
730
+ UserWarning
731
+ )
732
+