Spaces:
Running on Zero
Running on Zero
CI deploy local
Browse files- src/runtime.py +44 -3
- src/ui/chart.py +44 -7
- tests/test_calendar.py +164 -0
src/runtime.py
CHANGED
|
@@ -157,14 +157,55 @@ class ForecastRun:
|
|
| 157 |
|
| 158 |
|
| 159 |
def future_timestamps(context: pd.DataFrame, horizon: int) -> pd.DatetimeIndex:
|
| 160 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
ts = pd.to_datetime(context["ts"], utc=True)
|
| 162 |
-
|
| 163 |
-
if not len(deltas):
|
| 164 |
raise AdapterError("cannot infer cadence from a single bar")
|
|
|
|
|
|
|
| 165 |
modal = deltas.mode()
|
| 166 |
step = modal.iloc[0] if len(modal) else deltas.median()
|
| 167 |
last = ts.iloc[-1]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
return pd.DatetimeIndex([last + step * (i + 1) for i in range(horizon)])
|
| 169 |
|
| 170 |
|
|
|
|
| 157 |
|
| 158 |
|
| 159 |
def future_timestamps(context: pd.DataFrame, horizon: int) -> pd.DatetimeIndex:
|
| 160 |
+
"""The next `horizon` bars this market will actually print.
|
| 161 |
+
|
| 162 |
+
Not `last + step * i`. That is clock time, and only a market that never
|
| 163 |
+
closes prints on clock time. Extrapolating it for equities put three
|
| 164 |
+
quarters of every hourly forecast on hours the exchange is shut: those
|
| 165 |
+
bars never appear, so those steps never resolve, and the equity standings
|
| 166 |
+
were quietly computed from whichever steps happened to land before the
|
| 167 |
+
close -- a shorter effective horizon than crypto was being judged on.
|
| 168 |
+
|
| 169 |
+
The trading calendar is read from the series itself rather than hardcoded
|
| 170 |
+
or imported: whichever (weekday, hour, minute) slots this asset has
|
| 171 |
+
actually printed in are the ones it will print in next. A 24/7 market
|
| 172 |
+
occupies every slot, so this collapses to plain extrapolation for crypto.
|
| 173 |
+
"""
|
| 174 |
ts = pd.to_datetime(context["ts"], utc=True)
|
| 175 |
+
if len(ts) < 2:
|
|
|
|
| 176 |
raise AdapterError("cannot infer cadence from a single bar")
|
| 177 |
+
|
| 178 |
+
deltas = ts.diff().dropna()
|
| 179 |
modal = deltas.mode()
|
| 180 |
step = modal.iloc[0] if len(modal) else deltas.median()
|
| 181 |
last = ts.iloc[-1]
|
| 182 |
+
|
| 183 |
+
# Daily bars are keyed on weekday alone. Their UTC hour shifts with
|
| 184 |
+
# daylight saving, so keying on the hour too fragments the calendar into
|
| 185 |
+
# "Wednesdays in summer" and drops most of the week.
|
| 186 |
+
daily = step >= pd.Timedelta("1D")
|
| 187 |
+
if daily:
|
| 188 |
+
slots = {t.weekday() for t in ts}
|
| 189 |
+
key = lambda t: t.weekday() # noqa: E731
|
| 190 |
+
else:
|
| 191 |
+
slots = {(t.weekday(), t.hour, t.minute) for t in ts}
|
| 192 |
+
key = lambda t: (t.weekday(), t.hour, t.minute) # noqa: E731
|
| 193 |
+
if not slots: # pragma: no cover - guarded by len check
|
| 194 |
+
return pd.DatetimeIndex([last + step * (i + 1) for i in range(horizon)])
|
| 195 |
+
|
| 196 |
+
out: list[pd.Timestamp] = []
|
| 197 |
+
cursor = last
|
| 198 |
+
# Bounded: a market open one hour a week still resolves inside this, and a
|
| 199 |
+
# pathological slot set falls through to extrapolation rather than hanging.
|
| 200 |
+
for _ in range(horizon * 64):
|
| 201 |
+
cursor = cursor + step
|
| 202 |
+
if key(cursor) in slots:
|
| 203 |
+
out.append(cursor)
|
| 204 |
+
if len(out) == horizon:
|
| 205 |
+
return pd.DatetimeIndex(out)
|
| 206 |
+
|
| 207 |
+
log.warning("could not walk %d bars forward from %s; extrapolating",
|
| 208 |
+
horizon, last)
|
| 209 |
return pd.DatetimeIndex([last + step * (i + 1) for i in range(horizon)])
|
| 210 |
|
| 211 |
|
src/ui/chart.py
CHANGED
|
@@ -165,7 +165,9 @@ def build(history: pd.DataFrame, runs: list, asset: str, horizon: int,
|
|
| 165 |
return {
|
| 166 |
"svg": "".join(parts),
|
| 167 |
"grid": grid,
|
| 168 |
-
"time_labels": _time_labels(
|
|
|
|
|
|
|
| 169 |
"now_left": f"{now_x / VIEW_W * 100:.2f}%",
|
| 170 |
# The line marks where the forecast begins. On a live forecast that is
|
| 171 |
# now; on an archived one it is when it was issued, and calling it
|
|
@@ -353,19 +355,54 @@ def _volumes(hist: pd.DataFrame, runs: list, scale: Scale, n: int,
|
|
| 353 |
return "".join(out)
|
| 354 |
|
| 355 |
|
| 356 |
-
def _time_labels(ts: pd.Series, scale: Scale, n: int, horizon: int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
if len(ts) < 2:
|
| 358 |
return []
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
out = []
|
| 363 |
for i in range(6):
|
| 364 |
idx = round((scale.slots - 1) * i / 5)
|
| 365 |
-
when =
|
|
|
|
|
|
|
| 366 |
x = scale.x(idx)
|
| 367 |
out.append({
|
| 368 |
"left": f"{min(97.0, max(3.0, x / VIEW_W * 100)):.2f}%",
|
| 369 |
-
"label": when.strftime(
|
| 370 |
})
|
| 371 |
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
return {
|
| 166 |
"svg": "".join(parts),
|
| 167 |
"grid": grid,
|
| 168 |
+
"time_labels": _time_labels(
|
| 169 |
+
ts, scale, n, horizon,
|
| 170 |
+
future=getattr(runs[0], "target_ts", None) if runs else None),
|
| 171 |
"now_left": f"{now_x / VIEW_W * 100:.2f}%",
|
| 172 |
# The line marks where the forecast begins. On a live forecast that is
|
| 173 |
# now; on an archived one it is when it was issued, and calling it
|
|
|
|
| 355 |
return "".join(out)
|
| 356 |
|
| 357 |
|
| 358 |
+
def _time_labels(ts: pd.Series, scale: Scale, n: int, horizon: int,
|
| 359 |
+
future: pd.DatetimeIndex | None = None) -> list[dict]:
|
| 360 |
+
"""Axis labels, read off the actual bars rather than extrapolated.
|
| 361 |
+
|
| 362 |
+
These used to be computed as `last + modal_step * offset`, which is only
|
| 363 |
+
right when bars are evenly spaced. Crypto trades around the clock so it
|
| 364 |
+
looked fine; equities do not. Fifty-six hourly SPY bars span about ten
|
| 365 |
+
calendar days, not fifty-six hours, so every history label was wrong --
|
| 366 |
+
one read "12 09:30" against a bar that was actually "04 17:30", eight days
|
| 367 |
+
out, with no way to tell from the chart.
|
| 368 |
+
|
| 369 |
+
So the history side is indexed straight into the timestamps being drawn,
|
| 370 |
+
and the forecast side into the forecast's own target timestamps.
|
| 371 |
+
"""
|
| 372 |
if len(ts) < 2:
|
| 373 |
return []
|
| 374 |
+
|
| 375 |
+
deltas = ts.diff().dropna()
|
| 376 |
+
modal = deltas.mode()
|
| 377 |
+
step = modal.iloc[0] if len(modal) else pd.Timedelta("1h")
|
| 378 |
+
# A daily series prints midnight on every bar, so the clock is noise and
|
| 379 |
+
# the month is what is missing.
|
| 380 |
+
fmt = "%d %b" if step >= pd.Timedelta("1D") else "%d %H:%M"
|
| 381 |
+
|
| 382 |
out = []
|
| 383 |
for i in range(6):
|
| 384 |
idx = round((scale.slots - 1) * i / 5)
|
| 385 |
+
when = _stamp_at(idx, ts, n, future, step)
|
| 386 |
+
if when is None:
|
| 387 |
+
continue
|
| 388 |
x = scale.x(idx)
|
| 389 |
out.append({
|
| 390 |
"left": f"{min(97.0, max(3.0, x / VIEW_W * 100)):.2f}%",
|
| 391 |
+
"label": when.strftime(fmt),
|
| 392 |
})
|
| 393 |
return out
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
def _stamp_at(idx: int, ts: pd.Series, n: int,
|
| 397 |
+
future: pd.DatetimeIndex | None, step) -> pd.Timestamp | None:
|
| 398 |
+
"""The timestamp actually plotted at slot `idx`."""
|
| 399 |
+
if idx < n:
|
| 400 |
+
return pd.Timestamp(ts.iloc[idx])
|
| 401 |
+
ahead = idx - n
|
| 402 |
+
if future is not None and ahead < len(future):
|
| 403 |
+
return pd.Timestamp(future[ahead])
|
| 404 |
+
# Only reached when the drawn horizon outruns the forecast's own targets,
|
| 405 |
+
# which the caller should not do; extrapolating is the honest fallback.
|
| 406 |
+
return pd.Timestamp(ts.iloc[-1]) + step * (ahead + 1)
|
| 407 |
+
|
| 408 |
+
|
tests/test_calendar.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Forecast targets and axis labels must follow the market, not the clock.
|
| 2 |
+
|
| 3 |
+
Both used to be computed as `last + modal_step * i`, which is only correct for
|
| 4 |
+
a market that never closes. Crypto trades around the clock, so it looked right
|
| 5 |
+
everywhere it was checked. Equities do not:
|
| 6 |
+
|
| 7 |
+
* **Axis labels** were wrong by days. A SPY bar at 04 17:30 was labelled
|
| 8 |
+
12 09:30 -- and nothing on the chart could reveal it.
|
| 9 |
+
* **Forecast targets** landed on hours the exchange was shut. Only 25% of
|
| 10 |
+
hourly equity steps could ever resolve, so the equity standings were
|
| 11 |
+
computed from whichever steps happened to fall before the close.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
import pandas as pd
|
| 18 |
+
import pytest
|
| 19 |
+
|
| 20 |
+
from src import runtime
|
| 21 |
+
from src.ui import chart
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _sessions(days: int = 60, hours=range(14, 21)) -> pd.DataFrame:
|
| 25 |
+
"""Hourly bars that trade weekday afternoons and are shut otherwise."""
|
| 26 |
+
stamps = []
|
| 27 |
+
day = pd.Timestamp("2026-01-05", tz="UTC") # a Monday
|
| 28 |
+
for _ in range(days):
|
| 29 |
+
if day.weekday() < 5:
|
| 30 |
+
stamps += [day + pd.Timedelta(hours=h) for h in hours]
|
| 31 |
+
day += pd.Timedelta(days=1)
|
| 32 |
+
ts = pd.DatetimeIndex(stamps)
|
| 33 |
+
close = 600 * np.exp(np.cumsum(np.random.default_rng(3).normal(0, 0.004, len(ts))))
|
| 34 |
+
return pd.DataFrame({"ts": ts, "open": close, "high": close * 1.002,
|
| 35 |
+
"low": close * 0.998, "close": close, "volume": 1000.0})
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _daily(days: int = 200) -> pd.DataFrame:
|
| 39 |
+
"""Daily bars on weekdays only."""
|
| 40 |
+
stamps = [d for d in pd.date_range("2026-01-05", periods=days, tz="UTC")
|
| 41 |
+
if d.weekday() < 5]
|
| 42 |
+
ts = pd.DatetimeIndex(stamps)
|
| 43 |
+
close = 600 * np.exp(np.cumsum(np.random.default_rng(4).normal(0, 0.01, len(ts))))
|
| 44 |
+
return pd.DataFrame({"ts": ts, "open": close, "high": close * 1.01,
|
| 45 |
+
"low": close * 0.99, "close": close, "volume": 1000.0})
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _crypto(n: int = 400) -> pd.DataFrame:
|
| 49 |
+
ts = pd.date_range("2026-01-05", periods=n, freq="1h", tz="UTC")
|
| 50 |
+
close = 60000 * np.exp(np.cumsum(np.random.default_rng(5).normal(0, 0.004, n)))
|
| 51 |
+
return pd.DataFrame({"ts": ts, "open": close, "high": close * 1.002,
|
| 52 |
+
"low": close * 0.998, "close": close, "volume": 1000.0})
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# --------------------------------------------------------------------------
|
| 56 |
+
# Forecast targets
|
| 57 |
+
# --------------------------------------------------------------------------
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_intraday_targets_stay_inside_trading_hours():
|
| 61 |
+
bars = _sessions()
|
| 62 |
+
future = runtime.future_timestamps(bars, 24)
|
| 63 |
+
|
| 64 |
+
assert len(future) == 24
|
| 65 |
+
for t in future:
|
| 66 |
+
assert t.weekday() < 5, f"{t} is a weekend"
|
| 67 |
+
assert 14 <= t.hour <= 20, f"{t} is outside the session"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_intraday_targets_never_repeat_or_go_backwards():
|
| 71 |
+
future = runtime.future_timestamps(_sessions(), 24)
|
| 72 |
+
assert list(future) == sorted(set(future)), "targets repeat or are unordered"
|
| 73 |
+
assert future[0] > pd.Timestamp("2026-01-05", tz="UTC")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_daily_targets_skip_weekends():
|
| 77 |
+
future = runtime.future_timestamps(_daily(), 30)
|
| 78 |
+
assert len(future) == 30
|
| 79 |
+
assert all(t.weekday() < 5 for t in future), "a weekend was forecast"
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_a_market_that_never_closes_is_unchanged():
|
| 83 |
+
"""Crypto occupies every slot, so this must collapse to plain extrapolation."""
|
| 84 |
+
bars = _crypto()
|
| 85 |
+
future = runtime.future_timestamps(bars, 24)
|
| 86 |
+
last = pd.to_datetime(bars["ts"], utc=True).iloc[-1]
|
| 87 |
+
expected = [last + pd.Timedelta(hours=i + 1) for i in range(24)]
|
| 88 |
+
assert list(future) == expected
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_targets_continue_from_the_last_bar_not_from_now():
|
| 92 |
+
bars = _sessions()
|
| 93 |
+
last = pd.to_datetime(bars["ts"], utc=True).iloc[-1]
|
| 94 |
+
future = runtime.future_timestamps(bars, 6)
|
| 95 |
+
assert future[0] > last
|
| 96 |
+
assert (future[0] - last) <= pd.Timedelta("4D"), "skipped more than a weekend"
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def test_a_single_bar_cannot_produce_a_horizon():
|
| 100 |
+
from src.adapters import AdapterError
|
| 101 |
+
|
| 102 |
+
one = _crypto(1)
|
| 103 |
+
with pytest.raises(AdapterError):
|
| 104 |
+
runtime.future_timestamps(one, 4)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# --------------------------------------------------------------------------
|
| 108 |
+
# Axis labels
|
| 109 |
+
# --------------------------------------------------------------------------
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
class _Run:
|
| 113 |
+
def __init__(self, context, future):
|
| 114 |
+
self.context, self.target_ts = context, future
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _labels_match(bars, horizon):
|
| 118 |
+
"""Every axis label must equal the timestamp actually plotted there."""
|
| 119 |
+
future = runtime.future_timestamps(bars, horizon)
|
| 120 |
+
hist = bars.tail(chart.HISTORY_BARS).reset_index(drop=True)
|
| 121 |
+
ts = pd.to_datetime(hist["ts"], utc=True)
|
| 122 |
+
n = len(hist)
|
| 123 |
+
scale = chart.Scale(n, horizon, 1.0, 2.0)
|
| 124 |
+
|
| 125 |
+
labels = chart._time_labels(ts, scale, n, horizon, future=future)
|
| 126 |
+
step = ts.diff().dropna().mode().iloc[0]
|
| 127 |
+
fmt = "%d %b" if step >= pd.Timedelta("1D") else "%d %H:%M"
|
| 128 |
+
|
| 129 |
+
for i, label in enumerate(labels):
|
| 130 |
+
idx = round((scale.slots - 1) * i / 5)
|
| 131 |
+
truth = (ts.iloc[idx] if idx < n else future[idx - n]).strftime(fmt)
|
| 132 |
+
assert label["label"] == truth, (
|
| 133 |
+
f"slot {idx}: axis says {label['label']}, bar is {truth}")
|
| 134 |
+
return labels
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def test_axis_labels_match_the_bars_on_a_session_market():
|
| 138 |
+
"""Regression: these were out by eight days on SPY."""
|
| 139 |
+
_labels_match(_sessions(), 24)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def test_axis_labels_match_the_bars_on_a_daily_market():
|
| 143 |
+
_labels_match(_daily(), 30)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def test_axis_labels_match_the_bars_on_a_continuous_market():
|
| 147 |
+
_labels_match(_crypto(), 24)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def test_daily_charts_label_the_month_not_the_clock():
|
| 151 |
+
"""Every daily bar prints midnight, so the clock is noise."""
|
| 152 |
+
labels = _labels_match(_daily(), 30)
|
| 153 |
+
assert all(":" not in x["label"] for x in labels), labels
|
| 154 |
+
assert any(any(m in x["label"] for m in
|
| 155 |
+
("Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
| 156 |
+
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"))
|
| 157 |
+
for x in labels)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def test_axis_labels_stay_on_the_canvas():
|
| 161 |
+
labels = _labels_match(_sessions(), 24)
|
| 162 |
+
for x in labels:
|
| 163 |
+
pct = float(x["left"].rstrip("%"))
|
| 164 |
+
assert 0.0 <= pct <= 100.0
|