Bit-Trading-Company commited on
Commit
a638dbc
·
verified ·
1 Parent(s): d6470c3

CI deploy local

Browse files
Files changed (3) hide show
  1. src/runtime.py +44 -3
  2. src/ui/chart.py +44 -7
  3. 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
- """Continue the context's own cadence forward by `horizon` bars."""
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  ts = pd.to_datetime(context["ts"], utc=True)
162
- deltas = ts.diff().dropna()
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(ts, scale, n, horizon),
 
 
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) -> list[dict]:
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  if len(ts) < 2:
358
  return []
359
- step = ts.diff().dropna().mode()
360
- step = step.iloc[0] if len(step) else pd.Timedelta("1h")
361
- last = ts.iloc[-1]
 
 
 
 
 
362
  out = []
363
  for i in range(6):
364
  idx = round((scale.slots - 1) * i / 5)
365
- when = last + step * (idx - (n - 1))
 
 
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("%d %H:%M"),
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