diff --git a/Dependencies/dashboard_assets/dashboard.js b/Dependencies/dashboard_assets/dashboard.js index f69ea25..370a41a 100644 --- a/Dependencies/dashboard_assets/dashboard.js +++ b/Dependencies/dashboard_assets/dashboard.js @@ -668,6 +668,30 @@ return { levels: band.levels, previous: previous || "" }; } + /* The index of the first bar at or after `time`, or -1 if there is none. + * + * Every CPR point is placed on a bar time that already exists, which is what + * keeps the levels from adding COLUMNS to the shared time scale. Two things + * used to add them: a band's `to` of 15:29 is not a 5-minute bar (buckets end + * 15:25), and a band covering days the loaded candles do not have -- which + * happens whenever the history CSV is older than the live window -- sat in + * empty space entirely. */ + function firstBarAtOrAfter(bars, time) { + let low = 0; + let high = bars.length - 1; + let found = -1; + while (low <= high) { + const mid = (low + high) >> 1; + if (bars[mid].time >= time) { + found = mid; + high = mid - 1; + } else { + low = mid + 1; + } + } + return found; + } + function renderCprLines(bars) { if (!chart || !candleSeries) return; const bands = cprBandsForView(bars || []); @@ -697,11 +721,17 @@ cprSeries = []; if (!bands.length || !prefs.cpr) return; - /* A whitespace point -- a time with no value -- is what BREAKS the line - * between one day's band and the next. Without it the series would draw a - * diagonal from yesterday's pivot to today's, straight across the gap. - * One second past the band's end, so it cannot collide with a real bar. */ - const gap = 1; + /* ONE point per band, at the bar its day opens on, drawn as a STEP. + * + * There is deliberately no whitespace point here. A whitespace item cannot + * break a line in lightweight-charts 5.2.1: the data layer runs + * `rows.filter(hasValue)` before the series ever sees them, so the stroke + * is one continuous path and the break never happened -- which is what drew + * a diagonal from yesterday's level to today's. `LineType.WithSteps` below + * is what actually separates the days: the renderer moves horizontally at + * the previous value and only then vertically, so each level runs flat + * across its session and steps at the next open. */ + const lastBar = bars[bars.length - 1]; for (const level of CPR_LEVELS) { const on = level.group === "core" @@ -714,15 +744,28 @@ for (const band of bands) { const price = band.levels[level.key]; if (price === null || price === undefined) continue; - points.push({ time: band.from, value: price }); - if (band.to > band.from) points.push({ time: band.to, value: price }); - points.push({ time: band.to + gap }); + const index = firstBarAtOrAfter(bars, band.from); + /* No loaded candle inside this band -- a gap in the history the chart + * cannot draw against. Skipping it leaves the previous level running, + * which is honest: nothing here says otherwise. */ + if (index === -1 || bars[index].time > band.to) continue; + points.push({ time: bars[index].time, value: price }); } if (!points.length) continue; + /* Carry the newest level to the right edge; a step ends at its last + * point, so without this the freshest band would stop at its own open. */ + const newestValue = points[points.length - 1].value; + if (lastBar.time > points[points.length - 1].time) { + points.push({ time: lastBar.time, value: newestValue }); + } const series = chart.addSeries(LightweightCharts.LineSeries, { color: level.color, lineWidth: 1, + /* The whole fix. `case 1` of the library's line renderer is + * `lineTo(x, previousY)` then `lineTo(x, y)` -- horizontal, then + * vertical -- so a diagonal is not reachable in this branch. */ + lineType: LightweightCharts.LineType.WithSteps, priceLineVisible: false, /* The last value still gets an axis label, and `title` puts the level's * NAME beside it -- so the "(chart)" caveat still travels with a diff --git a/Tests/Dependencies/test_dashboard_server.py b/Tests/Dependencies/test_dashboard_server.py index 30c4e53..b5e42c2 100644 --- a/Tests/Dependencies/test_dashboard_server.py +++ b/Tests/Dependencies/test_dashboard_server.py @@ -465,6 +465,60 @@ def test_every_element_the_page_script_looks_up_exists_in_the_markup(): ) +def _render_cpr_lines_source() -> str: + """The body of `renderCprLines`, read out of the page script. + + Brace counting rather than a regex: the function holds object literals, + template strings and block comments, and a lazy match would stop at the + first `}` inside any of them. + """ + + script = (ASSETS_DIR / "dashboard.js").read_text(encoding="utf-8") + start = script.index("function renderCprLines(") + opening = script.index("{", start) + depth = 0 + for offset in range(opening, len(script)): + if script[offset] == "{": + depth += 1 + elif script[offset] == "}": + depth -= 1 + if depth == 0: + return script[start : offset + 1] + raise AssertionError("renderCprLines is not balanced; this test is checking nothing") + + +def test_the_cpr_levels_are_drawn_as_steps_and_never_rely_on_whitespace(): + """A whitespace point cannot break a CPR line, so the levels must step. + + Lightweight-charts 5.2.1 filters valueless rows out in its data layer -- + `rows.filter(hasValue)` -- before a line series ever sees them, so a point + with a `time` and no `value` contributes exactly one empty column and no + gap. The stroke stays continuous, and a level then draws a DIAGONAL from + one day's price to the next across the overnight gap. `LineType.WithSteps` + is what actually separates the days. + + This is a source assertion because the repository has no JS runtime. It + exists so a future edit that "restores the gap" with a whitespace point -- + the obvious-looking fix -- fails here instead of shipping the diagonal + back to the chart. + """ + + body = _render_cpr_lines_source() + + assert "LightweightCharts.LineType.WithSteps" in body, ( + "renderCprLines no longer sets LineType.WithSteps; without it the CPR " + "levels slope between days instead of stepping" + ) + + pushes = re.findall(r"points\.push\(\{(.*?)\}\)", body, re.DOTALL) + assert pushes, "no points.push() found in renderCprLines; this test is checking nothing" + valueless = [push.strip() for push in pushes if "value" not in push] + assert not valueless, ( + "renderCprLines pushes a point with no value: " + repr(valueless) + ". " + "The library drops it, so it breaks nothing and only adds a column." + ) + + # --------------------------------------------------------------------------- # History paging # --------------------------------------------------------------------------- diff --git a/docs/adr/0017-chart-only-cpr-on-a-truncated-prior-session.md b/docs/adr/0017-chart-only-cpr-on-a-truncated-prior-session.md index f95f144..91b1898 100644 --- a/docs/adr/0017-chart-only-cpr-on-a-truncated-prior-session.md +++ b/docs/adr/0017-chart-only-cpr-on-a-truncated-prior-session.md @@ -69,8 +69,24 @@ The chart originally drew ONE CPR across the whole of history, because it drew it with `createPriceLine` and a price line is full chart width by definition. That is wrong in the same way a single day's levels applied to last month would be wrong: the levels are a statement about ONE session. Each level is now a -LineSeries carrying two points per band plus a whitespace point -- a time with -no value -- to break the line before the next day. +LineSeries carrying ONE point per band, placed on the first loaded candle at or +after that band's start and drawn with `LineType.WithSteps`. + +**Correction.** This amendment first said each level carried two points per band +plus a whitespace point -- a time with no value -- "to break the line before the +next day." It does not: lightweight-charts 5.2.1 runs `rows.filter(hasValue)` in +its data layer, so a valueless row never reaches the series and the stroke stays +continuous. The whitespace contributed one empty column and no gap, and the +levels drew a shallow DIAGONAL from one day's price to the next -- longest +wherever the loaded candles span days the ladder has no band for, which is every +session between the history CSV's last day and today. Stepping is the mechanism +that actually separates the days: the renderer's step branch moves horizontally +at the previous value and only then vertically, so a slope is unreachable. A +band with no loaded candle inside it is now skipped rather than drawn into empty +space, which also keeps the CPR series from adding columns to the shared time +scale. `Tests/Dependencies/test_dashboard_server.py` asserts both properties +against the page source, because the repository has no JS runtime to assert them +in. The Daily timeframe takes a **monthly** ladder instead, each month's levels from the month before it. A daily band on a daily candle is one bar wide and diff --git a/docs/lld/monitoring-dashboard.md b/docs/lld/monitoring-dashboard.md index b945eb7..582bb45 100644 --- a/docs/lld/monitoring-dashboard.md +++ b/docs/lld/monitoring-dashboard.md @@ -342,6 +342,28 @@ scale built from the UNION of their times, so handing over five years of bands while a week of candles is loaded stretches the scale across five years of empty chart. +**A whitespace point does NOT break a line series.** Lightweight-charts 5.2.1 +runs `rows.filter(hasValue)` in its data layer, so a `{ time }` with no `value` +never reaches the series: the stroke is one continuous path and the intended +gap never existed. The CPR levels therefore drew a diagonal from one day's +price to the next, longest across days the loaded candles cover but the ladder +has no band for. `LineType.WithSteps` is the mechanism that separates the days; +bands with no loaded candle are skipped, and every point sits on a real bar +time so the levels add no columns to the shared scale. +`test_the_cpr_levels_are_drawn_as_steps_and_never_rely_on_whitespace` fails if +either property is edited away. + +**Verify a level by its SLOPE, not by column height.** The first check written +for the per-day bands sampled each CPR colour and asserted no column was more +than a pixel tall, to rule out a vertical connector. A shallow diagonal +satisfies that by construction, so it passed while the bug was on screen. Track +each coloured line left to right and sum its vertical travel instead: a level +that spans 450 columns should accumulate 0, and the broken build accumulated 43 +to 66 pixels. Match the colour against the background BLEND rather than exactly, +too -- a sloped one-pixel line is anti-aliased across two rows and an exact +match sees neither of them. And pick colours the candles do not share: `#ef5f5f` +and `#35c46b` are R1/R2 and S1/S2 *and* the down and up candles. + **The memo cannot key on one pivot.** With hundreds of bands it has to key on the set's extent and its newest pivot, or a new session, a newly loaded page and a timeframe switch all look identical to it. @@ -370,7 +392,7 @@ deliberately no host key. | Suite | Covers | |---|---| | `Tests/Dependencies/test_dashboard_snapshot.py` | Every pairing confidence, the Delta-0.2 / SL-Hunting-mirror / re-entry shapes, `EXIT_FAILED` not closing, malformed events, the honesty rules, NaN rejection | -| `Tests/Dependencies/test_dashboard_server.py` | Real loopback socket: 200/304/403/404/405, security headers, no CORS, empty stderr, root logger untouched, bind-in-use, no config read | +| `Tests/Dependencies/test_dashboard_server.py` | Real loopback socket: 200/304/403/404/405, security headers, no CORS, empty stderr, root logger untouched, bind-in-use, no config read; plus the page-source guards -- every wired element id exists, and the CPR levels step rather than lean on whitespace | | `Tests/Dependencies/test_dashboard_indicators.py` | CPR truncation and every degenerate session shape; **the equality test that pins the CPR algebra against `_add_daily_cpr`**; VWAP and stochastic equality with the strategies' helpers; the forming bucket; and every fixture rendered through `render_document_bytes` | | `Tests/test_nifty_multi_strategy_master.py` | The collector, the chart cache, the sink wiring in `publish_trade_event`, the "never reaches the broker or a mutating gate" assertion, the recompute-cadence guards, and fail-soft when an indicator raises |