diff --git a/apps/paper_trader/engine.py b/apps/paper_trader/engine.py index 4f97433..2fae32a 100644 --- a/apps/paper_trader/engine.py +++ b/apps/paper_trader/engine.py @@ -1343,6 +1343,81 @@ class PaperTradingEngine: if entropy is not None and entropy > ent_thr: return "sgov" + # Low-vol overlay: upgrade base symbol (e.g. QQQM → TQQQ) when market is calm + overlay_sym = (risk.cash_parking_low_vol_overlay_symbol or "").upper() + if overlay_sym and overlay_sym != sym and self._parking_brake_cooldown_remaining <= 0: + + def _compute_hurst(lb: int) -> float | None: + if n < lb + 1: + return None + rets = [] + for i in range(n - lb, n): + if closes[i - 1] > 0: + rets.append((closes[i] - closes[i - 1]) / closes[i - 1]) + if len(rets) < 30: + return None + + def _rs(series: list[float]) -> float: + nn = len(series) + mean = sum(series) / nn + devs = [x - mean for x in series] + cumdev, s = [], 0.0 + for d in devs: + s += d + cumdev.append(s) + r = max(cumdev) - min(cumdev) + std = (sum(d ** 2 for d in devs) / nn) ** 0.5 + return r / std if std > 0 else 0.0 + + window_sizes = [w for w in [8, 12, 16, 24, 32] if w <= len(rets) // 2] + if len(window_sizes) < 2: + return None + log_n, log_rs = [], [] + for w in window_sizes: + rs_vals = [_rs(rets[s:s + w]) for s in range(0, len(rets) - w + 1, w) if len(rets[s:s + w]) == w] + if rs_vals: + avg_rs = sum(rs_vals) / len(rs_vals) + if avg_rs > 0: + log_n.append(math.log(w)) + log_rs.append(math.log(avg_rs)) + if len(log_n) < 2: + return None + nn2 = len(log_n) + xm, ym = sum(log_n) / nn2, sum(log_rs) / nn2 + num = sum((log_n[i] - xm) * (log_rs[i] - ym) for i in range(nn2)) + den = sum((log_n[i] - xm) ** 2 for i in range(nn2)) + return num / den if den > 0 else 0.5 + + overlay_ok = True + + overlay_vol_thr = risk.cash_parking_low_vol_overlay_vol_threshold + if overlay_vol_thr > 0 and (vol is None or vol >= overlay_vol_thr): + overlay_ok = False + + if overlay_ok: + temp_max = risk.cash_parking_low_vol_overlay_temperature_max + if temp_max > 0: + if vol_15 is None or vol_50 is None or vol_50 <= 0 or (vol_15 / vol_50) > temp_max: + overlay_ok = False + + if overlay_ok: + entropy_max = risk.cash_parking_low_vol_overlay_entropy_max + if entropy_max > 0: + ov_entropy = _compute_entropy(risk.cash_parking_entropy_lookback) + if ov_entropy is None or ov_entropy > entropy_max: + overlay_ok = False + + if overlay_ok: + hurst_min = risk.cash_parking_low_vol_overlay_hurst_min + if hurst_min > 0: + hurst = _compute_hurst(60) + if hurst is None or hurst < hurst_min: + overlay_ok = False + + if overlay_ok: + logger.info("parking_overlay_selected", symbol=overlay_sym) + return overlay_sym.lower() + return actual_sym def _parking_check_shock_brake(self, today: dt.date) -> bool: