P0 from docs/async_refactor.md. The mutating routes are sync `def`, so FastAPI
runs them in a threadpool, and they reach Runtime.broadcast through
rebuild_levels — writing asyncio.Queue directly from there. That queue is not
thread-safe: it wakes a consumer by resolving a Future, which only the loop
thread may do. A dropped wakeup means a drawing made in one browser does not
reach another until the next market tick.
broadcast now posts through call_soon_threadsafe when it is off the loop, and
publishes directly when it is on it, so the stream's own path pays nothing.
Worth being straight about the tests: the race is timing-dependent and did not
reproduce in twenty attempts — a foreign-thread put_nowait usually lands in the
ready queue before the loop sleeps, and a tick every second covers the rest.
Even asyncio's debug thread-affinity check stays quiet unless a consumer is
parked on the Future at that instant. So the tests assert the contract rather
than provoke the failure: a broadcast from a worker thread must go through
call_soon_threadsafe, one from the loop must deliver synchronously, and both
must arrive.
Also adds the loop-lag probe, which reports scheduling drift as loop_lag_ms on
/api/status. It found P1 on its first run: 19,441ms worst against 1.5ms in
steady state, which is seeding blocking the loop. "The chart feels laggy" is now
a number.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Duplicate and delete from the line context menu.
- Copies shift ten bars right.
- Default names are up and down; copies become up 2, down 2, etc.
- Exact local data receipt time including seconds.
- Deployment timestamp removed.
- Test cleanup no longer deletes drawings created from your browser.
- JWT password session flow.
The trendline snap indicator was 66 pixels out, and so was every other overlay.
Lightweight Charts reports coordinates from the plot area's origin. The chart
element also contains the price scales, so enabling the left scale for the daily
labels moved the plot 66px into the element — and each overlay positioned with
left: against the element inherited that error twice over. The cursor's
element-x was read as a plot-x, resolving a bar about 66px right of the pointer;
the indicator was then drawn at that bar's plot-x interpreted as element-x,
landing 66px left of where the bar is painted. Neither near the cursor nor near
the bar, and scaling with zoom — two bars at 30m, a dozen at 1m — which is why
it read as random rather than as an offset.
Every diagnostic number agreed with itself the whole time, because dot_y,
expected_y and bar_low_y all come from the same API and shared the same wrong
origin. Instrumentation cannot see a systematic error in its own frame of
reference; what found this was comparing canvas.width to element.clientWidth and
getting 0.894.
Overlays now live in one container positioned over the plot canvas and inherit
plot coordinates untranslated — snap dot and label, comments, anchor handles,
preview line, tooltip, price tag, context menu — and eventPoint subtracts the
same offset so a pointer position and a chart coordinate mean the same thing.
The container follows the plot on resize.
Verified in page pixels rather than through the coordinate API: with the cursor
placed 30px below a known bar's low, the dot lands 30px above the cursor, on
that low, labelled 7789.00 L against a bar low of 7789.
Device pixel ratio, viewport size, resize desynchronisation and the chart
scrolling under the gesture were each measured and ruled out before this. The
e2e helper computed expected times from element-relative x, the same mistake in
the tests, and is corrected here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diagnostic mode proved the arithmetic: on a 30m chart every sample snapped to
the correct bar and to exactly that bar's low, while the user still saw the
indicator sitting off the bars. That leaves the half never measured in their
browser — whether the dot is drawn where the price says it should be.
The report now carries the cursor's y, the dot's rendered y, the y the snapped
price maps to, and the y coordinates of the snapped bar's high and low. A
correctly computed answer drawn in the wrong place and a wrong answer drawn
faithfully look identical on a screenshot; these four numbers separate them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diagnostic mode found in one hover what four local hypotheses could not.
coordinateToTime answers null over the right-hand price axis, over the
whitespace past the last bar, and anywhere outside the chart. snapPoint read
that as "the newest bar":
const fallbackT = point.t ?? this.bars[this.bars.length - 1]?.t ?? null;
so the indicator jumped to the live edge from wherever the cursor actually was —
hundreds of points away, on a bar the user was nowhere near. It matches the
screenshot exactly: the crosshair read 22:42 while the snap label read 22:58,
which was the last bar.
Two faults behind it. The tool's pointer listener is on window, so it processes
moves over the sidebar — a real sample reported x=1409 on a chart 1280 wide. And
a null time meant a default instead of no answer.
A pointer outside the plot now hides the indicator, and a null time resolves to
the bar nearest in pixels rather than the newest. An e2e test hovers a bar, the
price axis, the sidebar and back, asserting the indicator never sits at the live
edge from mid-chart and disappears once the cursor leaves.
Worth recording that device pixel ratio, viewport size, resize desynchronisation
and the chart scrolling under the gesture were each measured and ruled out
before this, none of which was the cause. With the browser on another machine,
instrumenting it should have come first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Chart geometry bugs live in the browser, and the browser is usually on a
different machine from whoever is debugging it — so "it passes in my headless
run" keeps being said about a chart that is unusable on someone's screen. This
session spent hours on that gap: a screenshot showed the crosshair reading 22:42
while the snap label read 22:58, sixteen bars apart on a 1m chart, and no
headless run reproduced it at any viewport, any device pixel ratio, before or
after a resize, or across ten scripted gestures.
Opening the chart with ?diag=1 makes every snap post what the client computed —
the cursor's time, price and x, the snapped time and price, how many bars are
held, the first and last of them, and the chart width — which the server logs as
SNAPDBG. ?diag=0 turns it off; the setting is remembered. Throttled to about one
report a second, and off by default, so it costs nothing when unused.
Kept as a permanent facility rather than scaffolding to delete: this will not be
the last geometry puzzle, and the endpoint takes whatever fields SnapReport
declares. Documented in AGENTS.md alongside the note about where things run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The snap dot said where an anchor would land but not what it was, which made
"the dot is in the wrong place" and "the dot is on the wrong bar" impossible to
tell apart from a screenshot. It now carries a label with the price, whether it
is a high or a low, and the bar's time.
AGENTS.md gains the environment fact this session kept rediscovering: the agent
runs on a remote machine over SSH while the user's browser runs on another, so
headless runs here render on different hardware at a different size and pixel
ratio, and "it passes in my headless run" is not evidence the user's problem is
fixed. Three consecutive reproductions passed server-side while the chart was
unusable on the user's screen. When a visual bug will not reproduce, match their
viewport and deviceScaleFactor explicitly — and prefer putting the numbers on
screen over asking for another console paste.
Device pixel ratio is ruled out for the current trendline complaint: the
bottom-sweep check lands 115 of 115 at 1600x1000 and at 1900x1400 with ratios of
1, 1.25, 1.5 and 2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sweeping the cursor along the bottom of the chart rarely landed on a bar's low,
and often nowhere near the bar at all. The cause was a nearest-in-2D search I
added an hour earlier to make bars easier to hit when zoomed out: whichever bar
nearby had the lowest low won on total distance, so the dot skipped off the bar
under the cursor instead of tracing each low in turn. Reverted.
The rule is now written down rather than adjusted per complaint — x picks the
bar, y picks which of its extremes — and an e2e test asserts it by sweeping a
zoomed-out 1h chart and requiring every position to land on the low of the bar
beneath it. 115 of 115.
The crosshair is the other half of why this felt broken. Lightweight Charts
defaults to Magnet, which snaps it to the bar's close, so hovering beside a low
displayed a price several ticks from the one an anchor would use. Arming a tool
already switched to Normal, but the chart is read before a tool is armed, which
is when the misleading reading was being taken. Normal everywhere now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Snapping took the bar sharing the cursor's time and then its nearer extreme,
which ignored how far away that extreme was. Pointing below a candle snapped to
that candle's low however distant, while the extreme genuinely under the cursor
was never considered. Zoomed out to some 360 bars at three pixels apart, that
made hitting the bar you meant a matter of several tries. snapPoint now scans
six bars either side and takes the extreme nearest in pixels.
Proven by probe: with the cursor sitting exactly on one bar's low but nudged two
pixels so coordinateToTime resolves to its neighbour, the snap takes the extreme
under the cursor rather than the neighbour's.
A report of the snap dot appearing "way above the bar" turned out to be the dot
landing correctly on the low while the cursor was 151 points below it: the right
price scale keeps a bottom margin of 0.1 and the volume overlay is drawn in it,
so the lower fifth of the pane sits below every candle.
bin/e2e runs tests/e2e against the dev stack inside the playwright service —
Node's own test runner, no dependency added here, since Playwright is global in
that container. Eleven cases, each one a bug that shipped: the viewport parked
ten hours in the past, hourly candles drawn as slivers, stale bar events
throwing, comments drifting across a timeframe switch, and three ways a
trendline anchor could disagree with its preview. Not one was reachable from
pytest. Tests delete any drawing they create, because the dev store is shared
with whoever is looking at the app.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two faults, one of them a regression that reached production.
Making a pending anchor always win fixed a twitch on the second click stealing
the start point, and broke the opposite case: a genuine press-drag begun after
an abandoned click was hijacked by that stale anchor, so the line started far
from where the drag did. A single threshold decides now — 12px of travel between
press and release makes a gesture a drag, wide enough to survive a twitch on a
deliberate click and unambiguous for a real one. A drag abandons any half-placed
anchor instead of adopting it.
The crosshair was also lying. Lightweight Charts defaults to CrosshairMode.Magnet,
which snaps the crosshair to the bar's close, so hovering beside a bar's low drew
it mid-bar and a correctly-placed anchor looked wrong. Measured: aiming 4px above
a bar low anchors at the low, 7773, not the close, 7773.25 — the placement was
right and only the feedback was wrong. Arming a tool switches the crosshair to
Normal, and a snap dot now marks the exact point the anchor will use, coloured by
the side that extreme implies.
Verified in a browser across all four paths: two clicks with a twitch on the
second, an abandoned click then a real drag, a plain press-drag, and hovering.
Each starts where it should and lands on a bar extreme, and the stale anchor is
no longer adopted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A comment placed on a 30m bar slid to the far left the moment the chart switched
to 15m. timeToCoordinate answers only for times that are data points on the
current series, so a 30m bucket start returns null on another timeframe — and
the render was reading null as "off the left edge", which parked every such
comment against the left of the pane.
Anchors resolve to the bar that contains them instead, found by binary search
over the current bars, which is timeframe-independent: an 09:30 note sits on the
09:30 bar at 15m and on the 09:00 bar at 1h. Times genuinely before the first
bar or after the last are reported separately, so real off-screen comments still
park on the edge they left rather than being confused with unresolved ones.
setBars re-renders comments too, since a timeframe switch replaces the bar grid
underneath every pinned one.
Verified in a browser across 30m to 15m to 1h and back: the anchor stays 08:30
throughout, resolving to the 08:30 bar at 15m and the 08:00 bar at 1h, never
edge-parked, and returning to its original coordinate. Edge-parking still fires
where it should — scrolled 400 bars away a comment parks right, and comes back
when the view returns to live.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A comment is a ManualLine with kind="comment", so it inherits persistence, the
shared drawing-number sequence, the list, filtering and deletion rather than
needing a parallel set of endpoints. The rule that must never bend is that
ManualLineStore.levels() excludes them: a comment reaching the level list would
join a confluence cluster and push a phone notification about a piece of text.
It is created with armed=False, and PATCH returns to_dict() rather than
to_level() for a comment, so nothing is ever handed a level-shaped comment.
Three tests cover the exclusion, the shared numbering and the kind derived for
drawings saved before comments existed.
Pinned comments carry anchor_t and anchor_p and travel with the chart; floating
ones carry x and y as fractions of the pane, hold their place through any zoom
and can be dragged. They render as DOM rather than canvas because they hold
arbitrary text, collapse to a numbered dot, and a floating one must ignore the
time scale entirely. A pinned comment scrolled out of view parks on the edge it
left, pointing back toward itself, so it never simply disappears.
Lines & levels becomes Drawings, filtered by type and by text. The text match
covers the label, the kind and the #number, so "comment", "cpi" and "7" all
narrow the list, and Delete acts on whatever the filter shows — which is what
makes deleting by type or by string one button.
Verified in a browser: placing a comment renders it at the click, clicking it
collapses it, scrolling away parks it on the edge, a floating one holds its
pixel position through a 300-bar scroll, and the filters cut 3 rows to 1 by type,
1 by text and 0 for a miss, with no console errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two faults, both of which made a finished line disagree with its own preview.
A placed anchor was being discarded by a twitch. finishToolGesture recomputed
`dragged` from the second click's own pointerdown/up, so a few pixels of
movement while pressing was read as a fresh press-drag-release: the anchor from
the first click was thrown away and the line began at the second click instead,
to the right of where it was meant to start. The preview had been rubber-banding
from the real anchor the whole time, which is why the result jumped on commit. A
pending anchor now wins over the current click's drag.
Snapping ignored where you pointed. snapPoint took the nearer of the bar's high
and low but only within 8px, so a cursor between the two snapped to neither and
returned a raw mid-bar price — and the side quietly fell back to the dropdown.
The gate is gone: with snapping on, an anchor always lands on the nearer extreme
of the nearest bar, and that choice *is* the side, a high being resistance and a
low support. app.js already preferred snappedSide over the dropdown, so the
inference was written and simply never fired.
snapPoint also guards against being handed an already-snapped point, which
carries no cursor y and previously compared against NaN.
Verified by driving the gesture in a browser: click, move, then a second click
with 4px of movement while pressed now yields anchor_t equal to the first
click's time and anchor_p exactly equal to the bar's high.
Also lands the groundwork for chart comments, inert until the UI is wired: a
`kind` on ManualLine with `pinned`, `x`, `y` and `collapsed`, a POST /comments
endpoint, GET /drawings, and the chart's DOM comment layer. Comments are stored
with the lines so they share numbering, filtering and deletion, and
ManualLineStore.levels() excludes them — a comment reaching the level list would
join a confluence cluster and push a notification about a piece of text. Tests
cover the exclusion, the derived kind for lines saved before comments existed,
and that numbering is shared.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Volume is parsed from both Schwab services, aggregated into every timeframe,
stored and broadcast in every bar payload — and nothing ever drew it.
An overlay histogram on its own hidden scale, confined to the bottom fifth and
tinted by each bar's direction. An overlay rather than a second pane, and
deliberately not on the price scale: volumes are five figures against
four-figure prices, so sharing a scale would flatten the candles into a line.
Verified in a browser that the price scales are untouched — the same price still
maps to the identical coordinate through both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The right scale was crowding: prior-day levels, session VWAP and five daily
moving averages competing with the live price and hand-drawn intraday levels.
Daily and session context moves left, and the right is left for intraday.
A price scale takes its range from the series on it, so moving levels across
would have drawn them against a different range and put them at the wrong
height — the failure this codebase has already paid for once. A transparent
candlestick mirror on the left scale gives it exactly the same input as the
right. Verified in a browser: the same price maps to the identical y coordinate
through both scales, a delta of zero pixels.
Prior-day levels move; hand-drawn price levels stay on the right, since those
are the intraday markers the space is being cleared for. priceScaleId is fixed
when a series is created, so it is passed at construction and left out of the
options reapplied afterwards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Lightweight Charts is timezone-agnostic: it reads epoch seconds as UTC and
labels them as UTC, so the axis disagreed with the wall clock by the viewer's
offset. tickMarkFormatter now formats axis ticks through the browser's own zone,
and localization.timeFormatter does the same for the crosshair readout, which
would otherwise contradict the axis.
Not done by shifting the bar timestamps, which is the other common recipe for
this. Every time in this codebase is epoch UTC by convention, and the chart's
times feed trendline anchors, indexAt, hit testing and the values posted back
for manual lines. An offset applied to the data would put every one of them out
by that offset — the same class of bug that once priced a trendline 147 points
from where it was drawn.
Tick placement is still computed on UTC days, so the day-change divider sits at
00:00 UTC rather than local midnight, carrying the local date. Verified under
America/Chicago: a bar at 13:03 UTC labels as 08:03 and the axis reads 05:30
through 08:00, with no console errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cannot update oldest data appeared in the console once ticks were live.
Switching timeframe races: the server answers subscribe with a fresh snapshot
from one coroutine while another is still draining bar events for the timeframe
just left, so a 1m bar can arrive after the 1h snapshot. Against the 1h series
it is older than every point in it, and Lightweight Charts throws rather than
ignoring it, which takes the app down instead of dropping one bar.
The race predates the tick feed. Level 1 made bar events about fifteen times
more frequent, which is what surfaced it.
Guarded at both ends. app.js honours the tf each event already carries and drops
anything for a timeframe no longer selected. chart.js refuses a bar older than
the series' last point whatever its origin, since a bar behind the last one has
nothing to contribute.
Verified: 36 rapid timeframe switches under a live tick feed produce zero
console errors, and calling candles.update() directly with a stale bar still
throws while the guarded updateBar() does not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The hourly chart drew 160 candles as unreadable slivers. Level points are
sampled on their own timeframe — VWAP every minute, the daily averages once a
session — and every distinct timestamp claims a slot on the chart's shared
scale. A minute-resolution VWAP on an hourly chart therefore spread 160 candles
across 908 slots, five to six times wider than the candles they belonged to:
before 1h inView 160 slots 908 ratio 5.46
after 1h inView 160 slots 161 ratio 1.01
Snapping ma and vwap points onto the candle grid fixes the density without
changing the line: points collapse onto the candle at or after them, and the
newest wins. Trendlines keep the raw path — lineData interpolates between two
anchors, and snapping those would move the geometry the user drew.
Two details worth keeping. Points past the final candle are clamped onto it
rather than passed through: on a daily chart every one of VWAP's ~760 minute
points falls after the last candle's session open, and letting them keep their
own times put all 760 straight back on the scale. And the viewport is re-applied
once after the level series have loaded, because setBars runs before them and
the chart holds the width it derived from the previous timeframe's density —
consumed rather than reapplied, so the minutely VWAP resync cannot yank the view
back from wherever it has been panned.
The method is snapPointsToBars, not snapToBars: this.snapToBars already exists
as the "Snap to highs/lows" boolean, and the assignment silently replaced the
prototype method with true.
Verified in a browser across all six timeframes — 1m, 5m, 15m, 30m, 1h and 1d
each show 160 candles at ratio 0.99–1.01 with no console errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chart sat about ten hours behind a perfectly healthy feed. The header price
updated live while the last candle stayed put, which reads as a dead stream.
Every layer checked out in isolation, because every layer was correct: /api/bars
served bars to the current minute from schwab, store.put keeps them strictly
ascending, the WebSocket snapshot delivered 1000 ascending bars ending at the
live edge, and the browser received all of it plus a bar event every minute.
Interrogating the page's own chart object is what separated "the data is
missing" from "the data is off-screen":
seriesLen 1000 seriesLast 10:48 (7786.25) data complete
visible 08-07T20:41 -> 08-10T00:35 viewport 617 bars too far left
617 is exactly the daily bar count. setBars derived a visible *logical* range
from the candle array length, then syncVisibleLevels attached the daily MA
series, whose 617 daily points pre-date the 1m window. A logical index addresses
the chart's shared time scale — the union of every series' time points — so
prepending those points renumbered every index and slid the view off the live
edge one tick after it had been set correctly. A time range names the instant
instead, and later series cannot move it.
Worth keeping: screenshots alone were actively misleading. The stale time axis
showed a Friday-to-Sunday gap that read as an ordinary session break, so the
view looked plausible while being ten hours wrong.
The plan gains a dated session log (§16) for this and for two things that cost a
detour today — the image needing a rebuild for schwab-py, which the bind mount
hides, and the ~82 second startup during which the port refuses connections and
an open tab logs a wall of ERR_CONNECTION_REFUSED. That slow seed is recorded,
not fixed: it replays every bar through on_bar and rebuilds all five MA levels
per daily bar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The trendline bug: a line continued past its second anchor at a different
slope. Two conventions were fighting, and both were wrong.
Lightweight Charts spaces bars evenly however much time separates them — a
weekend is forty-nine hours and one bar wide. The renderer extended the line by
interpolating between bar indices, which looked straight but disagreed with the
server, since price_at() advances per second. Measured on real bars that reached
147 points: the chart drew a level the alerts did not believe in. Making the
renderer match price_at() fixed the disagreement and made the visible kick worse,
because now the line really did climb an hour's worth of slope across a one-bar
maintenance break.
Neither convention is what a person means by drawing a line. A trendline advances
per bar, so both sides now evaluate in bar space: a new bar_space module the
runtime uses to position sloped levels, mirrored by indexAt() in the chart. The
line is straight on screen and the alert fires where it is drawn.
Also, from testing against the live chart:
- A plain click with the trendline tool armed did nothing and left the tool
armed, so the next click began a new line — which is how the slope change was
first noticed. Click-click and press-drag-release are both supported now, with
the rubber band following the cursor between clicks.
- Hand-placed levels are armed, fire once, then disarm themselves, and can be
re-armed from the sidebar. Verified end to end: created armed, tripped within
thirty seconds, disarmed, re-armed.
- Layers is collapsible.
- The 1h moving averages are gone; only the daily set remains.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The drawing controls lived in a strip above the chart while the lines they
created were listed in the sidebar — two places for one concern, and the strip
cost vertical space the chart wanted.
The sidebar now carries a tool per object, each with its own parameters. A tool
head arms the tool; the gesture happens on the chart:
- Price level: press anywhere, drag to fine-tune, release. A tag follows the
cursor showing the price snapped to the 0.25 tick, and that snapped value is
exactly what gets committed.
- Trendline: press at one end, drag, release at the other. One gesture where it
used to be two separate clicks and a "place first point / place second point"
prompt.
Dragging from the palette itself was the other candidate and was rejected: a
price level is a one-point object and drops cleanly, but a trendline needs two
points, so it would still have wanted a second click afterwards and the two
tools would have behaved differently for no visible reason.
Panning and scaling are suspended while a tool is armed, or the drag that draws
a line also drags the chart out from under it. Arming survives exactly one
placement, so a stray drag afterwards cannot create a second object.
Typing an exact price stays, next to the drag: "somewhere around here" and
"exactly 7800" are different intents and both are cheap to support.
Verified by driving headless Chrome over CDP rather than by reading: arming sets
the class, the live tag reads 7770.75 mid-drag, the committed level is 7770.75
with zero slope, the trendline rubber-bands and saves with a real slope, and
both tools disarm afterwards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was no way to say "tell me when ES reaches 7800". The only user-settable
alert was a drawn trendline, placed by clicking two points on a canvas — so you
could not hit an exact price, and making the line flat was fiddly.
A price alert is a manual line with zero slope. Reusing that rather than
building a parallel concept means it inherits JSON persistence, renaming,
recolouring, deletion, clustering, and the rule that a hand-placed level alerts
whatever its confluence score. The only genuinely new code is the input, an
endpoint that takes a price instead of two anchors, and the decision to render
zero-slope manual lines as price lines — which spans the chart and labels the
axis, instead of drawing a stubby two-point segment.
Zero-slope lines also skip drag handles, hit-testing and the "end line here"
menu: a price line has no endpoints to grab. They are managed from the sidebar.
Unlabelled alerts are named by their price, since "1d resistance" does not say
which alert fired.
Verified live: a level typed 25 points above price clusters as resistance at
that price and stays quiet, as it should until price arrives.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
The confluence engine had nothing to work with. Daily moving averages were the
only level source, and they sat 163 to 697 points from price, so every cluster
had exactly one member and no alert could ever fire.
Two new sources, chosen for having a real following — the engine is a bet that
many participants watch the same price, which is what makes a level hold:
- Prior day high/low/close, from the last *closed* daily bar so mid-session the
levels do not silently switch to today's own developing range. Full daily
weight rather than the 0.75 average discount: a traded high is structure, not
a derived average.
- Session VWAP, anchored to the 18:00 ET open like the daily bars. Institutional
execution is benchmarked against it, and zero-volume overnight minutes are
skipped rather than dividing by zero.
Both are stamped 1d, so they get their own colours to stay distinguishable from
the daily averages. Prior-day levels draw as price lines, which span the chart
and label the axis instead of relying on bar-index interpolation.
VWAP re-prices every minute while a daily average carries hundreds of points and
changes once a session, so broadcasting the whole level set on the VWAP cadence
would have pushed the entire history every minute. Levels now go out as a delta
that clients merge by id.
Adding the levels then exposed two defects that had been invisible while nothing
could cluster:
- Cluster identity was sha1(side + round(center / tolerance)), and tolerance
derives from ATR, so it changed every bar. The same zone was continually
issued a new id, never matched the cooldown table, and the cooldown did
nothing. Identity is now the set of converging levels.
- Alert suppression keyed on that identity, so a level drifting in or out of a
group read as a new zone. It now suppresses by proximity: two zones within an
ATR are the same zone, and the strongest is the one reported.
Over six replayed sessions at threshold 28 that is 247 alerts, then 54, then 40;
raising the cooldown to 4h — which only affects repeats of the same area, never
a genuinely new zone — gives 17 total with a worst session of 9.
calibrate_alerts.py now sweeps threshold and cooldown together in one pass,
since the threshold turns out to be quantised and nearly useless as a control.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>