Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 51 additions & 8 deletions Dependencies/dashboard_assets/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || []);
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down
54 changes: 54 additions & 0 deletions Tests/Dependencies/test_dashboard_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
20 changes: 18 additions & 2 deletions docs/adr/0017-chart-only-cpr-on-a-truncated-prior-session.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion docs/lld/monitoring-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 |

Expand Down
Loading