import datetime, math from puga.history import empirical_touch, _trailing_zscores def _series(jump_idx, n=20, jump_price=220.0, base_price=100.0): base = datetime.date(2026, 1, 1) return [(base + datetime.timedelta(days=i), jump_price if i >= jump_idx else base_price) for i in range(n)] def test_hit_factor_has_no_cliff_at_the_50pct_touched_threshold(): """median_days flips from a number to None the instant touched_frac crosses 50% - that's fine as a reported diagnostic, but ranking by it (as sell.py's --tiers used to) creates a fake cliff: a tier touched 47% of the time would be valued at exactly 0, one touched 53% of the time at near-full value, even though hit_factor (the real E[e^-rho*tau]) moves smoothly between them. This is what tools/sell.py's --tiers ranks by instead.""" y = math.log(2.0) below = empirical_touch(_series(jump_idx=9), y, rate=0.05, condition_on_state=False) above = empirical_touch(_series(jump_idx=10), y, rate=0.05, condition_on_state=False) assert below["median_days"] is None and above["median_days"] is not None assert below["touched_frac"] < 0.5 <= above["touched_frac"] # hit_factor must not have fallen off a cliff just because median_days did assert below["hit_factor"] > 0.5 * above["hit_factor"] def test_empirical_touch_trivial_for_zero_move(): r = empirical_touch(_series(jump_idx=5), 0.0, rate=0.05) assert r == {"hit_factor": 1.0, "touched_frac": 1.0, "median_days": 0.0, "n": 20} def test_conditioning_on_state_uses_only_similar_regime_days(): """Mean reversion means the answer should depend on whether today looks like the ticker's readily-rising early regime or its since-flattened plateau - not blend both in unconditionally. Series: 20 flat days, then a steady climb, then 10 more flat days at the new high (today is in the plateau, not the climb).""" base = datetime.date(2026, 1, 1) prices = [100] * 20 + [100 + i * 5 for i in range(1, 11)] + [150] * 10 series = [(base + datetime.timedelta(days=i), float(p)) for i, p in enumerate(prices)] y = math.log(1.1) uncond = empirical_touch(series, y, rate=0.05, condition_on_state=False) cond = empirical_touch(series, y, rate=0.05, condition_on_state=True) assert uncond["touched_frac"] > 0.5 # blends in the climb regime, looks like it moves readily assert cond["touched_frac"] < uncond["touched_frac"] # today's plateau regime moves less def test_trailing_zscores_flat_window_is_zero_not_none(): logp = [math.log(100.0)] * 15 z = _trailing_zscores(logp, trail=60, min_window=10) assert z[:9] == [None] * 9 # not enough window yet assert z[9:] == [0.0] * 6 # flat price is exactly at its own mean - a real, defined state def test_horizon_removes_recency_bias_from_right_censoring(): """Without a fixed horizon, start days near the end of history get an unfairly short (or long) remaining window to register a touch, purely from running out of data. A flat series with a late jump looks like it "touches" readily only because the jump happens to sit right next to the end-of-history start days; a fixed horizon gives every sample the same window.""" base = datetime.date(2026, 1, 1) prices = [100.0] * 30 + [220.0] * 5 series = [(base + datetime.timedelta(days=i), p) for i, p in enumerate(prices)] y = math.log(2.0) uncapped = empirical_touch(series, y, rate=0.05, condition_on_state=False) capped = empirical_touch(series, y, rate=0.05, condition_on_state=False, horizon=5) assert capped["touched_frac"] < uncapped["touched_frac"] assert capped["n"] < uncapped["n"] # the last `horizon` days of start days are dropped def test_empirical_touch_never_reached_is_zero_not_excluded(): # a move far beyond anything the series ever does should look like "basically never" r = empirical_touch(_series(jump_idx=19, jump_price=101.0), math.log(50.0), rate=0.05) assert r["touched_frac"] == 0.0 assert r["hit_factor"] == 0.0 assert r["median_days"] is None