A driftless-random-walk closed form was tried first and backtested wrong against 3 years of real exchange history (overstated touch probability 2-4x, confounded by discrete daily-VWAP sampling and real mean-reversion). Replaced with puga.history.empirical_touch(): a non-parametric estimator measuring first-passage directly from a ticker's own history, conditioned on current state (trailing-60d z-score) since mean reversion's effect depends on where price is now, not just move size. Queue-depth (existing resting quantity) folds in as a deterministic shift of the hitting time, not a max, ranking by the resulting hit_factor rather than a point estimate of median days (which created a false cliff at the 50%-touched threshold). Optional fixed-horizon right-censoring avoids recency bias in "never reached" samples. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
73 lines
4.0 KiB
Python
73 lines
4.0 KiB
Python
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
|