""" Wavelet band decomposition (CWT + synchrosqueezed variant), ported near-verbatim from c:\\DataS\\InstrumentSimulator\\backend\\app\\wavelet.py (project "Macro Causal Lab"). No project-specific dependencies — pure numpy/ssqueezepy, safe to call from any service. """ import warnings import numpy as np from ssqueezepy import cwt, icwt from ssqueezepy.utils.cwt_utils import center_frequency MIN_SCALES_PER_BAND = 4 # Fixed period (days) lower-bounds for bands 0..5, independent of the analysis # window's length. Without this, dividing [scales.min(), scales.max()] into # num_levels *equal* log-spaced buckets means "band i" covers a totally # different real-world period range depending on how much history is fed in # (a longer window resolves longer scales, which shifts every intermediate # boundary upward) - so the same band index is not comparable across window # lengths. Anchoring to absolute day-periods keeps "band i" meaning the same # oscillation whether the window is 3 months or 3 years; only the last band's # upper bound stays open, since a longer window can genuinely resolve slower # cycles a short one cannot. CANONICAL_PERIOD_EDGES_DAYS = [0.3, 1.5, 4.0, 12.0, 35.0, 90.0] def _period_to_log_scale_fn(wavelet, scales, n, num_calib_points=16): """Build an interpolation from log(period_days) to log(scale) by sampling center_frequency at a handful of scales (not every scale - it's not vectorized and can be slow over hundreds/thousands of scales). """ log_scales_full = np.log(scales) calib_log_scales = np.linspace(log_scales_full.min(), log_scales_full.max(), min(num_calib_points, len(scales))) calib_periods = np.array([1 / center_frequency(wavelet, scale=float(np.exp(s)), N=n) for s in calib_log_scales]) calib_log_periods = np.log(calib_periods) def period_to_log_scale(period_days): return float(np.interp(np.log(period_days), calib_log_periods, calib_log_scales)) return period_to_log_scale def band_decompose( values: list[float], dates: list[str], num_levels: int = 4, wavelet: str = "gmw", ) -> dict: x = np.asarray(values, dtype=float) n = len(x) if n < 32: raise ValueError("Serie trop courte pour une analyse ondelette (32 points minimum).") x_mean = float(x.mean()) xc = x - x_mean Wx, scales = cwt(xc, wavelet=wavelet) log_scales = np.log(scales) period_to_log_scale = _period_to_log_scale_fn(wavelet, scales, n) period_edges = list(CANONICAL_PERIOD_EDGES_DAYS[:num_levels]) while len(period_edges) < num_levels: period_edges.append(period_edges[-1] * 2.5) # scales[0] = smallest scale = highest frequency = shortest period (impulses/noise); # scales[-1] = largest scale = lowest frequency = longest period (slow trend). bands = [] for i in range(num_levels): lo = period_to_log_scale(period_edges[i]) hi = period_to_log_scale(period_edges[i + 1]) if i + 1 < len(period_edges) else log_scales.max() mask = (log_scales >= lo) & (log_scales <= hi if i == num_levels - 1 else log_scales < hi) scales_band = scales[mask] if len(scales_band) >= MIN_SCALES_PER_BAND: try: recon = icwt(Wx[mask, :], wavelet=wavelet, scales=scales_band, x_len=n) except Exception: # ssqueezepy's internal scale-type inference (log vs linear spacing # detection) can raise on some narrow slices depending on how many # scales land in this bucket for this specific series length. # Treat as a negligible band instead of failing the whole request. recon = np.zeros(n) scale_lo, scale_hi = float(scales_band.min()), float(scales_band.max()) else: # Too few scales in this bucket for a stable reconstruction; report a # flat zero band (its content ends up folded into the residual) so the # band list always stays num_levels long. recon = np.zeros(n) scale_lo, scale_hi = float(np.exp(lo)), float(np.exp(hi)) freq_at_scale_hi = center_frequency(wavelet, scale=scale_hi, N=n) freq_at_scale_lo = center_frequency(wavelet, scale=scale_lo, N=n) period_low_days = round(1 / freq_at_scale_lo, 1) if freq_at_scale_lo else None period_high_days = round(1 / freq_at_scale_hi, 1) if freq_at_scale_hi else None label = ( f"{period_low_days}-{period_high_days}j" if period_low_days is not None and period_high_days is not None else f"bande {i + 1}" ) bands.append( { "index": i, "label": label, "period_low_days": period_low_days, "period_high_days": period_high_days, "series": [round(float(v), 6) for v in recon], } ) reconstructed_total = x_mean + sum(np.array(band["series"]) for band in bands) residual = x - reconstructed_total return { "dates": dates, "original": [round(float(v), 6) for v in x], "mean": round(x_mean, 6), "bands": bands, "residual": [round(float(v), 6) for v in residual], "wavelet": wavelet, } def windowed_band_decompose( values: list[float], dates: list[str], window_size: int, num_levels: int = 4, wavelet: str = "gmw", ) -> dict: """Decompose a long series by running band_decompose independently on consecutive slices of `window_size` points, then concatenating the results. Each slice is analyzed on its own terms (own local mean, own CWT scale range), so precision near the reconstructed curve stays high even over a long history; the trade-off is a visible discontinuity at each slice boundary, which is acceptable for this diagnostic view. """ n = len(values) window_size = max(32, min(window_size, n)) if window_size else n if n <= window_size: result = band_decompose(values, dates, num_levels, wavelet) result["window_size"] = window_size result["chunks"] = 1 return result all_dates: list[str] = [] all_original: list[float] = [] all_residual: list[float] = [] band_series: list[list[float]] = [[] for _ in range(num_levels)] band_meta: list[dict | None] = [None] * num_levels chunk_count = 0 start = 0 while start < n: end = min(start + window_size, n) if 0 < n - end < 32: # Don't leave a too-short trailing slice: fold the remainder in. end = n chunk = band_decompose(values[start:end], dates[start:end], num_levels, wavelet) chunk_count += 1 all_dates.extend(chunk["dates"]) all_original.extend(chunk["original"]) all_residual.extend(chunk["residual"]) for band in chunk["bands"]: band_series[band["index"]].extend(band["series"]) band_meta[band["index"]] = band start = end bands = [] for i in range(num_levels): meta = band_meta[i] or {"label": f"bande {i + 1}", "period_low_days": None, "period_high_days": None} bands.append( { "index": i, "label": meta["label"], "period_low_days": meta["period_low_days"], "period_high_days": meta["period_high_days"], "series": band_series[i], } ) return { "dates": all_dates, "original": all_original, "mean": round(float(np.mean(all_original)), 6) if all_original else 0.0, "bands": bands, "residual": all_residual, "wavelet": wavelet, "window_size": window_size, "chunks": chunk_count, } def rolling_causal_bands( values: list[float], dates: list[str], start_idx: int, lookback: int, num_levels: int = 4, wavelet: str = "gmw", step: int = 1, ) -> dict: """Walk-forward band decomposition with no look-ahead: the band value reported for day `t` is computed from a CWT run only on the trailing `lookback` points ending at `t` (never anything after `t`). This is deliberately much slower than `band_decompose` (one CWT per day instead of one for the whole range) - that cost is the point: a single whole-range CWT lets every point "see" the entire future through the transform's global/symmetric support, which makes any backtest built on it meaningless (the peaks/troughs it finds were computed with hindsight). `step` > 1 re-runs the CWT every `step` days and holds the last computed value in between, trading fidelity for speed on long ranges. """ n = len(values) out_dates: list[str] = [] out_original: list[float] = [] band_series: list[list[float]] = [[] for _ in range(num_levels)] band_labels = [f"bande {i + 1}" for i in range(num_levels)] last_tip: list[float] | None = None recomputations = 0 for t in range(start_idx, n): window_start = t - lookback + 1 if window_start < 0: continue # not enough trailing history yet for a full lookback window if last_tip is None or (t - start_idx) % step == 0: window_values = values[window_start:t + 1] window_dates = dates[window_start:t + 1] result = band_decompose(window_values, window_dates, num_levels, wavelet) last_tip = [band["series"][-1] for band in result["bands"]] band_labels = [band["label"] for band in result["bands"]] recomputations += 1 out_dates.append(dates[t]) out_original.append(values[t]) for i in range(num_levels): band_series[i].append(last_tip[i]) bands = [ { "index": i, "label": band_labels[i], "period_low_days": None, "period_high_days": None, "series": band_series[i], } for i in range(num_levels) ] return { "dates": out_dates, "original": out_original, "bands": bands, "wavelet": wavelet, "lookback": lookback, "step": step, "recomputations": recomputations, } # --------------------------------------------------------------------------- # Synchrosqueezed variant (opt-in, method="ssq"). Everything above this line # is completely untouched by what follows: band_decompose, windowed_band_decompose # and rolling_causal_bands are the exact same functions used when method="cwt" # (the default), so any existing simulation re-run with its original config # hits the same code path and produces byte-identical results as before. # # Rationale: plain cwt/icwt smears a single true frequency's energy across # several neighboring scales (that's *why* band_decompose needs canonical period # edges and a minimum-scales guard - the boundaries are fuzzy). Synchrosqueezing # reassigns that smeared energy onto its estimated true instantaneous frequency # before splitting into bands, which should reduce cross-band leakage. It also # exposes two things plain cwt does not: per-band energy |Tx|^2 (how dominant # that cycle is *right now*, independent of direction) and a dominant-frequency # ridge (which cycle length is currently winning, tracked over time). # # Separate period edges from the cwt path's CANONICAL_PERIOD_EDGES_DAYS: with # fs=1.0 (one sample = one day, matching our daily data), ssq_cwt has a hard # Nyquist floor at exactly 2.0 days (max resolvable frequency = fs/2). The cwt # path's edges start at 0.3 days - below that floor, so reusing them here would # leave the fastest band permanently empty. This does mean "band 0" covers a # different absolute range under ssq than under cwt; that's an inherent # consequence of ssq's stricter frequency floor, not a rounding choice. # --------------------------------------------------------------------------- CANONICAL_PERIOD_EDGES_DAYS_SSQ = [2.0, 4.0, 10.0, 25.0, 60.0, 120.0] def band_decompose_ssq( values: list[float], dates: list[str], num_levels: int = 4, wavelet: str = "gmw", ) -> dict: from ssqueezepy import issq_cwt, ssq_cwt from ssqueezepy.ridge_extraction import extract_ridges x = np.asarray(values, dtype=float) n = len(x) if n < 32: raise ValueError("Serie trop courte pour une analyse ondelette (32 points minimum).") x_mean = float(x.mean()) xc = x - x_mean with warnings.catch_warnings(): warnings.simplefilter("ignore", RuntimeWarning) # fs=1.0: one sample = one day, so ssq_freqs comes out directly in # cycles/day and 1/ssq_freqs is a period in days with no extra # calibration step (unlike band_decompose's scale->period conversion, # which needs _period_to_log_scale_fn precisely because plain cwt's # scale axis has no absolute unit by itself). Tx, _Wx, ssq_freqs, _scales = ssq_cwt(xc, wavelet=wavelet, fs=1.0) ssq_freqs = np.asarray(ssq_freqs) periods = 1.0 / ssq_freqs period_edges = list(CANONICAL_PERIOD_EDGES_DAYS_SSQ[:num_levels]) while len(period_edges) < num_levels: period_edges.append(period_edges[-1] * 2.5) bands = [] for i in range(num_levels): lo_period = period_edges[i] hi_period = period_edges[i + 1] if i + 1 < len(period_edges) else float(periods.max()) mask = (periods >= lo_period) & (periods <= hi_period if i == num_levels - 1 else periods < hi_period) if mask.any(): try: Tx_band = np.zeros_like(Tx) Tx_band[mask, :] = Tx[mask, :] with warnings.catch_warnings(): warnings.simplefilter("ignore", RuntimeWarning) recon = np.real(issq_cwt(Tx_band, wavelet=wavelet)) except Exception: # Defensive, mirroring band_decompose: never let one band's edge # case fail the whole request. recon = np.zeros(n) energy = np.sum(np.abs(Tx[mask, :]) ** 2, axis=0) else: recon = np.zeros(n) energy = np.zeros(n) period_low_days = round(float(lo_period), 1) period_high_days = round(float(hi_period), 1) bands.append( { "index": i, "label": f"{period_low_days}-{period_high_days}j", "period_low_days": period_low_days, "period_high_days": period_high_days, "series": [round(float(v), 6) for v in recon], "energy": [round(float(v), 8) for v in energy], } ) reconstructed_total = x_mean + sum(np.array(band["series"]) for band in bands) residual = x - reconstructed_total try: with warnings.catch_warnings(): warnings.simplefilter("ignore", RuntimeWarning) ridge_idxs = extract_ridges(Tx, ssq_freqs, n_ridges=1, bw=4) ridge_periods = 1.0 / ssq_freqs[ridge_idxs.ravel()] ridge_period_days = [round(float(v), 3) for v in ridge_periods] except Exception: ridge_period_days = [None] * n return { "dates": dates, "original": [round(float(v), 6) for v in x], "mean": round(x_mean, 6), "bands": bands, "residual": [round(float(v), 6) for v in residual], "wavelet": wavelet, "ridge_period_days": ridge_period_days, } def rolling_causal_bands_ssq( values: list[float], dates: list[str], start_idx: int, lookback: int, num_levels: int = 4, wavelet: str = "gmw", step: int = 1, ) -> dict: """Synchrosqueezed counterpart to rolling_causal_bands - identical walk-forward, no-look-ahead structure (same day-by-day trailing window, same step logic), just calling band_decompose_ssq instead of band_decompose. rolling_causal_bands itself is untouched. """ n = len(values) out_dates: list[str] = [] out_original: list[float] = [] band_series: list[list[float]] = [[] for _ in range(num_levels)] energy_series: list[list[float]] = [[] for _ in range(num_levels)] ridge_series: list[float | None] = [] band_labels = [f"bande {i + 1}" for i in range(num_levels)] last_tip: list[float] | None = None last_energy_tip: list[float] | None = None last_ridge_tip: float | None = None recomputations = 0 for t in range(start_idx, n): window_start = t - lookback + 1 if window_start < 0: continue if last_tip is None or (t - start_idx) % step == 0: window_values = values[window_start:t + 1] window_dates = dates[window_start:t + 1] result = band_decompose_ssq(window_values, window_dates, num_levels, wavelet) last_tip = [band["series"][-1] for band in result["bands"]] last_energy_tip = [band["energy"][-1] for band in result["bands"]] last_ridge_tip = result["ridge_period_days"][-1] band_labels = [band["label"] for band in result["bands"]] recomputations += 1 out_dates.append(dates[t]) out_original.append(values[t]) for i in range(num_levels): band_series[i].append(last_tip[i]) energy_series[i].append(last_energy_tip[i]) ridge_series.append(last_ridge_tip) bands = [ { "index": i, "label": band_labels[i], "period_low_days": None, "period_high_days": None, "series": band_series[i], "energy": energy_series[i], } for i in range(num_levels) ] return { "dates": out_dates, "original": out_original, "bands": bands, "wavelet": wavelet, "lookback": lookback, "step": step, "recomputations": recomputations, "method": "ssq", "ridge_period_days": ridge_series, }