Remove the 4h timeframe

It was never in the enabled timeframes, so it held zero bars and produced no
levels, but it still carried weight 8 in the scoring table and forced
bucket_start to special-case a wall-clock ET anchor whose entire purpose was
surviving DST transitions. That was the most intricate logic in session.py,
maintained for a timeframe nobody used.

Daily is now the only session-anchored bucket, which is a much easier rule to
state and to keep correct. The DST parametrised tests go with it; the Sunday
open and daily boundary cases remain.

Manual-line tests move to 1h, so the weight assertions drop from 8 to 4.

The plan document keeps its 4h examples — rewriting a dozen illustrative
sentences would churn more than it clarifies — but the timeframe-roles section
now records the removal so nothing reads as a spec to build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chris Amow 2026-08-10 00:43:47 -05:00
parent 8ca624d435
commit 8c2ef80966
10 changed files with 31 additions and 57 deletions

View file

@ -15,7 +15,7 @@ MA_SETS__1H=
DAILY_ANCHOR_ET=18:00 DAILY_ANCHOR_ET=18:00
MANUAL_LINES_PATH=./data/manual_lines.json MANUAL_LINES_PATH=./data/manual_lines.json
CONFLUENCE_MIN_SCORE=28 CONFLUENCE_MIN_SCORE=28
# 4h. Suppression is per price zone, so an unrelated zone still alerts at once; # Four hours. Suppression is per price zone, so an unrelated zone still alerts;
# this governs only how often the same area repeats. See README. # this governs only how often the same area repeats. See README.
ALERT_COOLDOWN_SECONDS=14400 ALERT_COOLDOWN_SECONDS=14400

View file

@ -14,7 +14,7 @@ class Aggregator:
if source is Timeframe.M1: if source is Timeframe.M1:
return True return True
if source is Timeframe.H1: if source is Timeframe.H1:
return target in (Timeframe.H1, Timeframe.H4, Timeframe.D1) return target in (Timeframe.H1, Timeframe.D1)
return source is target return source is target
def update(self, incoming: Bar) -> list[Bar]: def update(self, incoming: Bar) -> list[Bar]:

View file

@ -10,7 +10,6 @@ class Timeframe(str, Enum):
M15 = "15m" M15 = "15m"
M30 = "30m" M30 = "30m"
H1 = "1h" H1 = "1h"
H4 = "4h"
D1 = "1d" D1 = "1d"
@property @property
@ -22,7 +21,6 @@ class Timeframe(str, Enum):
self.M15: 900, self.M15: 900,
self.M30: 1800, self.M30: 1800,
self.H1: 3600, self.H1: 3600,
self.H4: 14400,
} }
if self is self.D1: if self is self.D1:
raise ValueError("1d is session-defined, not a fixed number of seconds") raise ValueError("1d is session-defined, not a fixed number of seconds")

View file

@ -14,17 +14,11 @@ def _session_open_local(current: datetime) -> datetime:
def bucket_start(t: int, tf: Timeframe) -> int: def bucket_start(t: int, tf: Timeframe) -> int:
if tf not in (Timeframe.H4, Timeframe.D1): # Everything below a day divides the hour evenly, so UTC boundaries are
# correct and session anchoring is unnecessary. Only the daily bar needs to
# know that the CME trading day runs 18:00 to 17:00 ET.
if tf is not Timeframe.D1:
return (t // tf.seconds) * tf.seconds return (t // tf.seconds) * tf.seconds
current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN) current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN)
session_open = _session_open_local(current) return int(_session_open_local(current).timestamp())
if tf is Timeframe.D1:
return int(session_open.timestamp())
# CME's 4h anchors are wall-clock ET anchors. This intentionally makes the
# DST-transition bucket three or five elapsed hours instead of shifting it.
elapsed_wall = current.replace(tzinfo=None) - session_open.replace(tzinfo=None)
bucket_hours = int(elapsed_wall.total_seconds() // 14400) * 4
local_start = session_open.replace(tzinfo=None) + timedelta(hours=bucket_hours)
return int(local_start.replace(tzinfo=EASTERN).timestamp())

View file

@ -12,7 +12,6 @@ TIMEFRAME_WEIGHT = {
Timeframe.M15: 2, Timeframe.M15: 2,
Timeframe.M30: 3, Timeframe.M30: 3,
Timeframe.H1: 4, Timeframe.H1: 4,
Timeframe.H4: 8,
Timeframe.D1: 16, Timeframe.D1: 16,
} }
MA_WEIGHT_FACTOR = 0.75 MA_WEIGHT_FACTOR = 0.75
@ -35,7 +34,7 @@ class Settings(BaseSettings):
daily_anchor_et: str = "18:00" daily_anchor_et: str = "18:00"
manual_lines_path: Path = Path("./data/manual_lines.json") manual_lines_path: Path = Path("./data/manual_lines.json")
confluence_min_score: float = 28 confluence_min_score: float = 28
# 4h, chosen from the sweep in scripts/calibrate_alerts.py. Suppression is # Four hours, chosen from the sweep in scripts/calibrate_alerts.py. Suppression is
# per price zone, so an unrelated zone still alerts immediately; this only # per price zone, so an unrelated zone still alerts immediately; this only
# governs how often the *same* area repeats itself. # governs how often the *same* area repeats itself.
alert_cooldown_seconds: int = 14400 alert_cooldown_seconds: int = 14400

View file

@ -86,6 +86,13 @@ if your parser doesn't filter those, that fixture will catch it.
### Timeframe roles ### Timeframe roles
> **4h was removed from the product on 2026-08-10.** It was never enabled, and
> dropping it deleted the fiddliest logic in `session.py` — the wall-clock ET 4h
> anchor and its DST edge cases — for a timeframe nobody was using. Later
> sections of this document still use 4h in examples; read those as
> illustrative, not as a spec to build. Nothing below a day is session-anchored
> any more, so `bucket_start` now special-cases only `1d`.
``` ```
1m base chart + alert evaluation weight 1 ← a switchable base timeframe 1m base chart + alert evaluation weight 1 ← a switchable base timeframe
2m display only 2m display only
@ -93,7 +100,6 @@ if your parser doesn't filter those, that fixture will catch it.
15m manual lines weight 2 15m manual lines weight 2
30m base chart + manual lines weight 3 ← a switchable base timeframe 30m base chart + manual lines weight 3 ← a switchable base timeframe
1h manual lines (+ optional MAs) weight 4 1h manual lines (+ optional MAs) weight 4
4h manual lines (+ optional MAs) weight 8
1d base chart + THE DAILY MAs weight 16 ← a switchable base timeframe 1d base chart + THE DAILY MAs weight 16 ← a switchable base timeframe
``` ```
@ -156,7 +162,7 @@ disagree with every other chart you'll compare against.
**Build daily bars yourself by aggregating Yahoo's 1h bars** through the same **Build daily bars yourself by aggregating Yahoo's 1h bars** through the same
`aggregator.py` used for live data — one session definition everywhere. Yahoo's 1h bars `aggregator.py` used for live data — one session definition everywhere. Yahoo's 1h bars
are anchored to the top of the ET hour, so they compose into session-anchored 4h and 1d are anchored to the top of the ET hour, so they compose into session-anchored 1d
buckets cleanly. The ~730-day 1h window yields ~500 sessions: enough for a daily 200SMA buckets cleanly. The ~730-day 1h window yields ~500 sessions: enough for a daily 200SMA
(~200 sessions) with room to spare. (~200 sessions) with room to spare.
@ -320,7 +326,7 @@ Use dataclasses (or pydantic where it crosses the API boundary). All times are
```python ```python
class Timeframe(str, Enum): class Timeframe(str, Enum):
M1="1m"; M2="2m"; M5="5m"; M15="15m"; M30="30m"; H1="1h"; H4="4h"; D1="1d" M1="1m"; M2="2m"; M5="5m"; M15="15m"; M30="30m"; H1="1h"; D1="1d"
@property @property
def seconds(self) -> int: ... # D1 is session-defined, not 86400 — see §6 def seconds(self) -> int: ... # D1 is session-defined, not 86400 — see §6
@ -381,7 +387,7 @@ The weight table referenced throughout — define it once, in `config.py`:
```python ```python
TIMEFRAME_WEIGHT = { TIMEFRAME_WEIGHT = {
Timeframe.M1: 1, Timeframe.M2: 1, Timeframe.M5: 1, Timeframe.M15: 2, Timeframe.M1: 1, Timeframe.M2: 1, Timeframe.M5: 1, Timeframe.M15: 2,
Timeframe.M30: 3, Timeframe.H1: 4, Timeframe.H4: 8, Timeframe.D1: 16, Timeframe.M30: 3, Timeframe.H1: 4, Timeframe.D1: 16,
} }
MA_WEIGHT_FACTOR = 0.75 # §7.4 — MAs weigh slightly less than drawn structure MA_WEIGHT_FACTOR = 0.75 # §7.4 — MAs weigh slightly less than drawn structure
``` ```
@ -417,10 +423,9 @@ This is where a naive implementation silently produces wrong lines. CME ES is no
- `1m, 2m, 5m, 15m, 30m, 1h` — bucket on wall-clock UTC boundaries. These divide the - `1m, 2m, 5m, 15m, 30m, 1h` — bucket on wall-clock UTC boundaries. These divide the
hour evenly, so session anchoring is unnecessary and UTC keeps it simple. hour evenly, so session anchoring is unnecessary and UTC keeps it simple.
- `4h` — **anchor to the 18:00 ET session open**, not UTC midnight. Buckets are - `1d` — one bar per futures session as defined above. **This is the only
18:00, 22:00, 02:00, 06:00, 10:00, 14:00 ET. UTC-anchored 4h buckets straddle the session-anchored timeframe.** 4h used to be the other one and was the reason this
session boundary and produce meaningless bars. section warned about DST; with 4h gone, that whole class of edge case went with it.
- `1d` — one bar per futures session as defined above.
Implement this as `session.py::bucket_start(t: int, tf: Timeframe) -> int` and unit Implement this as `session.py::bucket_start(t: int, tf: Timeframe) -> int` and unit
test it hard, including both DST transitions and the Sunday open. **This function is test it hard, including both DST transitions and the Sunday open. **This function is
@ -607,7 +612,6 @@ displayed.**
MA_SETS = { MA_SETS = {
"1d": [("sma", 10), ("sma", 20), ("sma", 50), ("sma", 100), ("sma", 200)], "1d": [("sma", 10), ("sma", 20), ("sma", 50), ("sma", 100), ("sma", 200)],
# optional, off by default: # optional, off by default:
"4h": [("ema", 9), ("ema", 21)],
"1h": [("ema", 9), ("ema", 21)], "1h": [("ema", 9), ("ema", 21)],
} }
BASE_TIMEFRAMES = ["1m", "30m", "1d"] # the switcher; others remain available BASE_TIMEFRAMES = ["1m", "30m", "1d"] # the switcher; others remain available
@ -825,8 +829,6 @@ LAYERS
☑ Daily MAs ██ ☑ Daily MAs ██
☑ 10 ☑ 20 ☑ 50 ☑ 100 ☑ 200 ☑ 10 ☑ 20 ☑ 50 ☑ 100 ☑ 200
───────────────────────────────── ─────────────────────────────────
☐ 4h MAs ██
☐ EMA9 ☐ EMA21
☐ 1h MAs ▓▓ ☐ 1h MAs ▓▓
☐ EMA9 ☐ EMA21 ☐ EMA9 ☐ EMA21
───────────────────────────────── ─────────────────────────────────
@ -889,7 +891,6 @@ would mean waiting months for the parts of the product that matter most:
|---|---|---| |---|---|---|
| 5m, 15m | ~2–4 hours | immediate | | 5m, 15m | ~2–4 hours | immediate |
| 30m, 1h | ~1–2 sessions | immediate | | 30m, 1h | ~1–2 sessions | immediate |
| 4h | ~1–2 weeks | immediate (from 1h) |
| 1d | months | immediate (~500 sessions) | | 1d | months | immediate (~500 sessions) |
| 1d 200SMA | ~10 months | immediate | | 1d 200SMA | ~10 months | immediate |
@ -943,7 +944,7 @@ SCHWAB_ACCOUNT_ID=
SCHWAB_SYMBOL=/ES SCHWAB_SYMBOL=/ES
# --- timeframes & indicators --- # --- timeframes & indicators ---
TIMEFRAMES=1m,2m,5m,15m,30m,1h,4h,1d TIMEFRAMES=1m,2m,5m,15m,30m,1h,1d
BASE_TIMEFRAMES=1m,30m,1d # the chart switcher BASE_TIMEFRAMES=1m,30m,1d # the chart switcher
MAX_BARS_PER_TF=5000 # in-memory ring buffer bound MAX_BARS_PER_TF=5000 # in-memory ring buffer bound
@ -984,7 +985,7 @@ Everything downstream of `StreamService` is then testable, deterministically, of
Required tests: Required tests:
- `session.py` — bucket boundaries. Both DST transitions, Sunday 18:00 open, the - `session.py` — bucket boundaries. Both DST transitions, Sunday 18:00 open, the
17:00–18:00 break, Friday close, and the 4h session anchor. **Write these first.** 17:00–18:00 break, and Friday close. **Write these first.**
- `aggregator.py` — 1m→all TFs on synthetic bars; gap handling; idempotent replay. - `aggregator.py` — 1m→all TFs on synthetic bars; gap handling; idempotent replay.
- `moving_averages.py` — a known daily series produces known 10/20/50/100/200 values; - `moving_averages.py` — a known daily series produces known 10/20/50/100/200 values;
assert nothing is emitted before warm-up; assert the stepped projection onto 1m holds assert nothing is emitted before warm-up; assert the stepped projection onto 1m holds
@ -1041,7 +1042,7 @@ once per session on the intraday views, dashed while the current session is unfi
### M3.5 — Layer panel ### M3.5 — Layer panel
Checkbox tree to show/hide levels by timeframe and by individual MA (§9.4). Ships with Checkbox tree to show/hide levels by timeframe and by individual MA (§9.4). Ships with
M3 because 6 timeframes × 4 MAs = 24 lines is unreadable without it. M3 because 6 timeframes × 4 MAs = 24 lines is unreadable without it.
**Done when:** unchecking "4h" removes every 4h level from the chart and the state **Done when:** unchecking a group removes its every level from the chart and the state
survives a reload. survives a reload.
### M4 — Confluence + alerts (on moving averages alone) ⭐ first genuinely useful build ### M4 — Confluence + alerts (on moving averages alone) ⭐ first genuinely useful build

View file

@ -426,7 +426,7 @@ class ConfluenceChart {
ConfluenceChart.tfColors = { ConfluenceChart.tfColors = {
'1m':'#82909f', '2m':'#8a92df', '5m':'#65b7cf', '15m':'#45c39b', '1m':'#82909f', '2m':'#8a92df', '5m':'#65b7cf', '15m':'#45c39b',
'30m':'#a8c85d', '1h':'#efb643', '4h':'#ec7b42', '1d':'#d96073', '30m':'#a8c85d', '1h':'#efb643', '1d':'#d96073',
}; };
// VWAP and the prior-day levels are both stamped 1d, so without their own // VWAP and the prior-day levels are both stamped 1d, so without their own

View file

@ -19,6 +19,6 @@ button { border:1px solid var(--line); background:transparent; color:var(--muted
aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; } aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; }
.sidebar-section { margin-top:30px; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; } .sidebar-section { margin-top:30px; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; }
.trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }.trendline-row { display:grid; grid-template-columns:auto minmax(0,1fr) auto; gap:5px 8px; padding:7px; border:1px solid transparent; }.trendline-row.selected { border-color:var(--accent); }.trendline-row>.line-select { align-self:center; accent-color:var(--accent); }.trendline-row>input:not(.line-select) { min-width:0; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:11px; }.trendline-row span { grid-column:2; color:var(--muted); font-size:9px; text-transform:uppercase; }.trendline-row button { grid-column:3; grid-row:1; padding:3px 6px; font-size:9px; }.line-style-controls { grid-column:3; display:flex; align-items:center; gap:4px; }.line-style-controls input { width:24px; height:20px; padding:0; border:0; background:transparent; }.line-style-controls select { border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:9px; } .trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }.trendline-row { display:grid; grid-template-columns:auto minmax(0,1fr) auto; gap:5px 8px; padding:7px; border:1px solid transparent; }.trendline-row.selected { border-color:var(--accent); }.trendline-row>.line-select { align-self:center; accent-color:var(--accent); }.trendline-row>input:not(.line-select) { min-width:0; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:11px; }.trendline-row span { grid-column:2; color:var(--muted); font-size:9px; text-transform:uppercase; }.trendline-row button { grid-column:3; grid-row:1; padding:3px 6px; font-size:9px; }.line-style-controls { grid-column:3; display:flex; align-items:center; gap:4px; }.line-style-controls input { width:24px; height:20px; padding:0; border:0; background:transparent; }.line-style-controls select { border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:9px; }
.layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.periods { display:flex; flex-wrap:wrap; gap:10px; padding-left:22px; }.periods label { color:var(--muted); }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-4h { background:#ec7b42; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; }.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; } .layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.periods { display:flex; flex-wrap:wrap; gap:10px; padding-left:22px; }.periods label { color:var(--muted); }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; }.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; }
.cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; } .cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; }
@media (max-width:850px) { #app { padding:10px; }.chart-shell { min-width:0; }main { grid-template-columns:1fr; }.drawing-tools { flex-wrap:wrap; }.drawing-tools .line-name { width:110px; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:54px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } } @media (max-width:850px) { #app { padding:10px; }.chart-shell { min-width:0; }main { grid-template-columns:1fr; }.drawing-tools { flex-wrap:wrap; }.drawing-tools .line-name { width:110px; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:54px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } }

View file

@ -6,7 +6,7 @@ from app.bars.models import Timeframe
def sample_line(): def sample_line():
return ManualLine("ml_test", Timeframe.H4, Side.RESISTANCE, 100, 5000, -0.01, 200, 300, number=1) return ManualLine("ml_test", Timeframe.H1, Side.RESISTANCE, 100, 5000, -0.01, 200, 300, number=1)
def test_json_persistence_round_trip(tmp_path): def test_json_persistence_round_trip(tmp_path):
@ -23,12 +23,12 @@ def test_json_persistence_round_trip(tmp_path):
assert ManualLineStore(path).lines == {} assert ManualLineStore(path).lines == {}
def test_four_hour_line_uses_absolute_time_on_one_minute_chart(): def test_hourly_line_uses_absolute_time_on_one_minute_chart():
level = sample_line().to_level() level = sample_line().to_level()
instant = 160 instant = 160
assert level.tf is Timeframe.H4 assert level.tf is Timeframe.H1
assert level.price_at(instant) == 4999.4 assert level.price_at(instant) == 4999.4
assert level.weight == 8 assert level.weight == 4
def test_manual_line_raises_existing_ma_cluster_score(): def test_manual_line_raises_existing_ma_cluster_score():
@ -39,4 +39,4 @@ def test_manual_line_raises_existing_ma_cluster_score():
before = cluster_levels([ma], 160, 4998, 2)[0] before = cluster_levels([ma], 160, 4998, 2)[0]
after = cluster_levels([ma, sample_line().to_level()], 160, 4998, 2)[0] after = cluster_levels([ma, sample_line().to_level()], 160, 4998, 2)[0]
assert before.score == 12 assert before.score == 12
assert after.score == 20 assert after.score == 16

View file

@ -22,9 +22,6 @@ def epoch(value: str, zone=UTC) -> int:
("2026-08-14T20:59:00", Timeframe.D1, "2026-08-13T22:00:00"), ("2026-08-14T20:59:00", Timeframe.D1, "2026-08-13T22:00:00"),
("2026-08-10T21:30:00", Timeframe.D1, "2026-08-09T22:00:00"), ("2026-08-10T21:30:00", Timeframe.D1, "2026-08-09T22:00:00"),
("2026-08-10T22:00:00", Timeframe.D1, "2026-08-10T22:00:00"), ("2026-08-10T22:00:00", Timeframe.D1, "2026-08-10T22:00:00"),
("2026-08-10T01:59:00", Timeframe.H4, "2026-08-09T22:00:00"),
("2026-08-10T02:00:00", Timeframe.H4, "2026-08-10T02:00:00"),
("2026-08-10T17:59:00", Timeframe.H4, "2026-08-10T14:00:00"),
], ],
) )
def test_session_boundaries(value, tf, expected): def test_session_boundaries(value, tf, expected):
@ -37,21 +34,6 @@ def test_intraday_buckets_use_utc_boundaries():
) )
@pytest.mark.parametrize(
("value", "expected"),
[
# Spring forward: the 22:00 ET bucket ends at 02:00 EDT after three real hours.
("2026-03-08T06:59:00", "2026-03-08T03:00:00"),
("2026-03-08T07:00:00", "2026-03-08T07:00:00"),
# Fall back: the 22:00 ET bucket lasts five real hours and ends at 02:00 EST.
("2026-11-01T06:59:00", "2026-11-01T02:00:00"),
("2026-11-01T07:00:00", "2026-11-01T07:00:00"),
],
)
def test_four_hour_wall_clock_anchor_across_dst(value, expected):
assert bucket_start(epoch(value), Timeframe.H4) == epoch(expected)
@pytest.mark.parametrize( @pytest.mark.parametrize(
("local_value", "expected_local"), ("local_value", "expected_local"),
[ [