From 8c2ef80966710aef14209385eda11d4413a2a040 Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Mon, 10 Aug 2026 00:43:47 -0500 Subject: [PATCH] Remove the 4h timeframe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 2 +- app/bars/aggregator.py | 2 +- app/bars/models.py | 2 -- app/bars/session.py | 16 +++++----------- app/config.py | 3 +-- docs/IMPLEMENTATION_PLAN.md | 31 ++++++++++++++++--------------- static/chart.js | 2 +- static/style.css | 2 +- tests/test_manual_lines.py | 10 +++++----- tests/test_session.py | 18 ------------------ 10 files changed, 31 insertions(+), 57 deletions(-) diff --git a/.env.example b/.env.example index 32db071..6efeef0 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,7 @@ MA_SETS__1H= DAILY_ANCHOR_ET=18:00 MANUAL_LINES_PATH=./data/manual_lines.json 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. ALERT_COOLDOWN_SECONDS=14400 diff --git a/app/bars/aggregator.py b/app/bars/aggregator.py index 048e62f..a7dfe9b 100644 --- a/app/bars/aggregator.py +++ b/app/bars/aggregator.py @@ -14,7 +14,7 @@ class Aggregator: if source is Timeframe.M1: return True 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 def update(self, incoming: Bar) -> list[Bar]: diff --git a/app/bars/models.py b/app/bars/models.py index b1d2077..c54bf94 100644 --- a/app/bars/models.py +++ b/app/bars/models.py @@ -10,7 +10,6 @@ class Timeframe(str, Enum): M15 = "15m" M30 = "30m" H1 = "1h" - H4 = "4h" D1 = "1d" @property @@ -22,7 +21,6 @@ class Timeframe(str, Enum): self.M15: 900, self.M30: 1800, self.H1: 3600, - self.H4: 14400, } if self is self.D1: raise ValueError("1d is session-defined, not a fixed number of seconds") diff --git a/app/bars/session.py b/app/bars/session.py index e8fab41..7e51333 100644 --- a/app/bars/session.py +++ b/app/bars/session.py @@ -14,17 +14,11 @@ def _session_open_local(current: datetime) -> datetime: 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 current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN) - session_open = _session_open_local(current) - 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()) + return int(_session_open_local(current).timestamp()) diff --git a/app/config.py b/app/config.py index 134b6da..237e7b4 100644 --- a/app/config.py +++ b/app/config.py @@ -12,7 +12,6 @@ TIMEFRAME_WEIGHT = { Timeframe.M15: 2, Timeframe.M30: 3, Timeframe.H1: 4, - Timeframe.H4: 8, Timeframe.D1: 16, } MA_WEIGHT_FACTOR = 0.75 @@ -35,7 +34,7 @@ class Settings(BaseSettings): daily_anchor_et: str = "18:00" manual_lines_path: Path = Path("./data/manual_lines.json") 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 # governs how often the *same* area repeats itself. alert_cooldown_seconds: int = 14400 diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index d5e493a..74cec74 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -86,6 +86,13 @@ if your parser doesn't filter those, that fixture will catch it. ### 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 2m display only @@ -93,7 +100,6 @@ if your parser doesn't filter those, that fixture will catch it. 15m manual lines weight 2 30m base chart + manual lines weight 3 ← a switchable base timeframe 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 ``` @@ -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 `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 (~200 sessions) with room to spare. @@ -320,7 +326,7 @@ Use dataclasses (or pydantic where it crosses the API boundary). All times are ```python 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 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 TIMEFRAME_WEIGHT = { 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 ``` @@ -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 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 - 18:00, 22:00, 02:00, 06:00, 10:00, 14:00 ET. UTC-anchored 4h buckets straddle the - session boundary and produce meaningless bars. -- `1d` — one bar per futures session as defined above. +- `1d` — one bar per futures session as defined above. **This is the only + session-anchored timeframe.** 4h used to be the other one and was the reason this + section warned about DST; with 4h gone, that whole class of edge case went with it. 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 @@ -607,7 +612,6 @@ displayed.** MA_SETS = { "1d": [("sma", 10), ("sma", 20), ("sma", 50), ("sma", 100), ("sma", 200)], # optional, off by default: - "4h": [("ema", 9), ("ema", 21)], "1h": [("ema", 9), ("ema", 21)], } BASE_TIMEFRAMES = ["1m", "30m", "1d"] # the switcher; others remain available @@ -825,8 +829,6 @@ LAYERS ☑ Daily MAs ██ ☑ 10 ☑ 20 ☑ 50 ☑ 100 ☑ 200 ───────────────────────────────── -☐ 4h MAs ██ - ☐ EMA9 ☐ EMA21 ☐ 1h MAs ▓▓ ☐ 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 | | 30m, 1h | ~1–2 sessions | immediate | -| 4h | ~1–2 weeks | immediate (from 1h) | | 1d | months | immediate (~500 sessions) | | 1d 200SMA | ~10 months | immediate | @@ -943,7 +944,7 @@ SCHWAB_ACCOUNT_ID= SCHWAB_SYMBOL=/ES # --- 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 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: - `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. - `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 @@ -1041,7 +1042,7 @@ once per session on the intraday views, dashed while the current session is unfi ### M3.5 — Layer panel 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. -**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. ### M4 — Confluence + alerts (on moving averages alone) ⭐ first genuinely useful build diff --git a/static/chart.js b/static/chart.js index 01d0866..ddda074 100644 --- a/static/chart.js +++ b/static/chart.js @@ -426,7 +426,7 @@ class ConfluenceChart { ConfluenceChart.tfColors = { '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 diff --git a/static/style.css b/static/style.css index ac51a98..f1b810c 100644 --- a/static/style.css +++ b/static/style.css @@ -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; } .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; } -.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; } @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; } } diff --git a/tests/test_manual_lines.py b/tests/test_manual_lines.py index 1f0a395..417e50b 100644 --- a/tests/test_manual_lines.py +++ b/tests/test_manual_lines.py @@ -6,7 +6,7 @@ from app.bars.models import Timeframe 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): @@ -23,12 +23,12 @@ def test_json_persistence_round_trip(tmp_path): 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() instant = 160 - assert level.tf is Timeframe.H4 + assert level.tf is Timeframe.H1 assert level.price_at(instant) == 4999.4 - assert level.weight == 8 + assert level.weight == 4 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] after = cluster_levels([ma, sample_line().to_level()], 160, 4998, 2)[0] assert before.score == 12 - assert after.score == 20 + assert after.score == 16 diff --git a/tests/test_session.py b/tests/test_session.py index da3ebac..f765335 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -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-10T21:30:00", Timeframe.D1, "2026-08-09T22: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): @@ -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( ("local_value", "expected_local"), [