Commit graph

143 commits

Author SHA1 Message Date
05e8aef40b better colors 2026-08-13 06:46:38 -05:00
0a56d56261 reposition price level lines 2026-08-13 06:44:01 -05:00
0e2d5fa2cb Expand drawing widths and quarantine live-feed test 2026-08-13 03:54:28 -05:00
22a638cf73 Label the axis past the last bar, and fix the Yahoo bars that exposed
Trendlines project into the whitespace beyond the newest candle, but the time
axis stopped there, so a converging pair could be seen without knowing when it
converges. Lightweight Charts only labels times present on its scale, so the
chart now carries whitespace points past the last bar: no value, nothing drawn,
but the axis has something to label and timeToCoordinate answers out there.

Future times repeat the most recent bar interval, which is what timeAtIndex
already does for the projections themselves. That drifts across the daily halt
and the weekend; agreeing with the projected line matters more than abstract
accuracy, and session-accurate projection needs server-side session rules the
client does not have.

Two bugs surfaced while measuring it. Padding meant to be five bars measured as
sixty-seven, because the interval came from the gap between the final two bars;
barInterval now takes a median over recent bars and ignores a ragged tail.

And that gap was two seconds on a one-minute chart because Yahoo stamps its
in-progress candle with the time of the request, while the poller emitted
anything newer than the last thing it sent. Every poll therefore appended a new
"1m" bar seconds after the previous one, interleaved with the real ones — live
in production, which is still on Yahoo. Timestamps are bucketed on parse, and
the final candle is emitted unclosed so it revises the current minute rather
than entering the aggregator and adding its volume to every higher timeframe
again on each poll. Verified live: eight consecutive bars, all aligned, all
sixty seconds apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:55:27 -05:00
07f7befc08 Keep the alert number out of the message and in the push body
Producing a real alert to check the wiring showed #47 twice in one Events row:
once as the badge the browser draws from the `number` field, and again at the
start of the message, because the number had been prefixed onto the shared
string.

ntfy carries plain text and has nowhere else to put a number or a timestamp, so
those belong in a push body built for it. The browser already receives `number`
and `at` as fields and formats its own local time, so its message stays clean.
Alert now carries both: `message` for a screen, `push` for a phone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:23:58 -05:00
32e25b84aa Number alerts, stamp them locally, and compact the zone list
Alerts get a number, assigned server-side and shown in both the push and the
Events list, so a notification on a phone can be matched to a row on a screen
when several fire together. It could not come from the browser: that counter
restarts on reload and differs between tabs. It is persisted next to the
cooldown state, because numbering restarting after a deploy would collide with a
phone's existing notification history — which changed that file from a list to
an object, with the loader still reading the old shape.

Pushes now carry a timestamp in the configured zone rather than the server's.
ALERT_TIMEZONE defaults to America/Chicago; containers run UTC, and a push
reading 02:14 to someone seeing 21:14 costs a translation every time. The
browser already formats its own times locally and is unchanged.

Confluence zones are one line each, ordered by price rather than by proximity,
so the list reads top to bottom the way the chart does and all of them fit on
screen — sixteen zones in 394px, about 25px each, where each previously took a
four-line block. Ordering is a display concern only: the server still returns
them nearest-first, which is what the alert path wants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:44:57 -05:00
e957991242 symbol improvements 2026-08-11 19:51:39 -05:00
0bca5d3bb7 color palette improved handling 2026-08-11 19:10:12 -05:00
81045e2128 trendlines should only show numbers until selected or hover 2026-08-11 18:59:12 -05:00
7590d53b13 Put diagnostic capture retrieval behind the same auth as everything else
Uploading a capture required a token; retrieving one did not. That was a
deliberate capability-URL design with a test asserting it, and the reasoning
held: it lets whoever is debugging fetch a capture without the chart password.

Changed because of what a capture contains. getDisplayMedia returns a picture of
someone's screen, and preferCurrentTab is a preference rather than a constraint,
so a mis-click shares a different window. An unguessable id stops guessing but
not leakage: capability URLs escape through proxy logs, browser history and
pasted links.

Retrieval now uses the dependency the rest of the API uses, which already
accepts the session cookie — so a logged-in browser needs nothing extra, which
was the condition for making this change at all. An agent on the server reads
the capture directory directly; one working over HTTP sends the API token.

Both handlers moved from meta.py to routes.py. meta.py is the deliberately open
router — health, version, login, logout — and a screenshot endpoint did not
belong there. The existing test now asserts 401 without credentials, and a new
one covers the browser path: log in, then retrieve with only the cookie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:24:04 -05:00
7d559f47f2 Treat the plan and the log as maintenance, not as a build
The app is past being built and into being changed continually, but the
documents still read as a project being executed: the plan opened by telling its
audience to work top-to-bottom, and §13 listed M0 through M10 as a queue when
all of them shipped days ago.

The milestones stay, marked as shipped. Their "Done when" criteria describe
correct behaviour and several have become tests, so they are worth more as a
specification of working subsystems than they would be archived. If one stops
matching reality, that is a bug in the document.

AGENTS.md now says when to update each, because both decay unless it is part of
finishing the work rather than tidying afterwards. The plan changes when a
decision changes. The log gains an entry when a fix was not obvious — the bar
being "would this have saved someone an hour", not every fix, because a log of
trivia stops being read and takes the useful entries down with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:32:43 -05:00
372617b08c Split the plan from the log of what actually happened
One file was trying to be two things: a spec written to be executed
top-to-bottom, and a dated record of everything that went wrong on the way. At
1,882 lines it did neither well, and the log was 36% of it — which is why the
plan's opening went unmaintained for days while the log grew every hour.

docs/plan.md keeps the decisions and the reasoning behind them, including the
risk register. docs/implementation.md takes the dated entries: the problems, the
wrong theories, the measurements that settled them. Git already says what
changed; that file says why it was hard, which is the part worth reading before
debugging something similar. Most entries describe something that looked like
one bug and turned out to be another.

Each points at the other, and the four referring files — AGENTS.md, README.md,
NEXT_STEPS.md and async_refactor.md — now point at whichever half they meant.
Git tracked the rename, so history follows plan.md rather than starting over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:29:47 -05:00
02ebc868fc Stop the plan's opening from describing a greenfield app
The first fifteen lines of IMPLEMENTATION_PLAN.md were the most misleading text
in the repository. They told an agent to work on branch feat/chart-engine, which
does not exist; to build M0 through M5 and stop for feedback, all of which
shipped days ago; and that the repo was a placeholder app with a toy /api/hello
endpoint to delete. It is the first thing anyone reads.

Replaced with what is true: the document is mostly history now, current work
starts from AGENTS.md, main deploys to production by design, and §16 onward is a
dated log that is the most useful part of the file for anyone debugging.

Also adds docs/archived/ with the convention written down, though nothing has
earned a place in it yet — feature_undo.md and mobile_enhance.md are designs not
yet built rather than dead ones. Archived documents stay tracked: gitignoring
them would delete them from the repository, which loses the history that makes
them worth keeping in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:26:36 -05:00
cbb26b19b9 Track multi-user as a direction, not a project
Separate people with their own drawings, alerts and notifications, behind OIDC
against a self-hosted Authentik that can federate Google. Written as phases that
each pay for themselves while the app is still single-user, so none of it is
scaffolding waiting on a decision.

The ordering conclusion worth stating plainly: do not build local accounts.
Going to OIDC means the app never stores or hashes a password, so building that
first means deleting it later. Shared password to OIDC subject, with nothing in
between.

One thing to fix regardless: the JWT signing key is sha256 of the password.
Today that is merely weak, since anyone holding a cookie can brute-force the
password offline. With several users it cannot work at all — either everyone
shares a signing key, or the key varies per user and a token cannot be verified
without already knowing who sent it. Added to the risk register.

The fork that decides the architecture is not an engineering one: whose market
data. One shared feed is redistribution, which Schwab's agreement and CME's
beneath it generally prohibit; each user bringing their own brokerage account
avoids the question entirely but means a stream, a token and a weekly re-auth
each, and the shared bar store stops being shared. That answer is only needed
before the last phase, which is why it is not a blocker on starting.

AGENTS.md points at both planning documents, because the cheapest moment to know
whether new state is shared or per-user is while it is being written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:21:25 -05:00
c653e65d5b diagnostic capture feature 2026-08-11 16:08:32 -05:00
ff1b9982d1 Seed in bulk and coalesce level rebuilds
P1 from docs/async_refactor.md. Measured on the dev stack: the port now accepts
connections 4 seconds after a restart rather than 121, and the worst loop lag
falls from 19,545ms to 526ms, with steady state between 0.2 and 0.6ms.

Seeding replayed years of history through on_bar, rebuilding every level from
scratch per bar and broadcasting each one to nobody. It now fills the store
quietly and derives price, ATR and the level set once at the end, from the
finished history. Alerts are deliberately not evaluated over replayed bars: a
level touched two years ago is not news, and firing on history is one way a
deploy re-alerts.

The seed was not all of it. Yahoo's first poll emits a whole day of minutes in a
single burst, each one taking the full live path, which was most of the
remaining twenty seconds. request_rebuild now coalesces to at most one rebuild
per 250ms and a background pass flushes anything deferred, so a burst costs a
handful of rebuilds instead of hundreds and the last bar is still never the one
dropped.

Verified unchanged after the change: bar counts across every timeframe, all five
daily moving averages with their full point sets, prior-day levels and VWAP. 125
python tests and 31 e2e tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:42:27 -05:00
919a71feb3 escape works for modals 2026-08-11 15:37:37 -05:00
a395818581 Post cross-thread events through the loop, and measure how late it runs
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>
2026-08-11 15:30:45 -05:00
273d947c0a name change 2026-08-11 15:18:25 -05:00
8ca774f821 compact drawing list 2026-08-11 15:13:48 -05:00
9fc6402f03 Plan the async work, and record where it must not regress
An audit for blocking work on the event loop, written up rather than acted on —
the chart changes in flight land first.

The headline is a correctness bug, not a performance one. Sync route handlers
run in FastAPI's threadpool and call rebuild_levels, which reaches
asyncio.Queue.put_nowait on every subscriber. asyncio.Queue is not thread-safe:
it wakes a consumer by resolving a Future, which has to happen on the loop
thread. A dropped wakeup means a drawing made in one browser does not reach
another until the next tick — invisible today only because the stream ticks
about once a second and covers it.

Below that: level rebuilding is CPU-bound on the loop and is the whole of the 82
second startup, and disarming an alert writes to disk from a coroutine.

Also states what not to do, since the obvious reading of "make it async" is
wrong here. Sync routes stay sync — FastAPI's threadpool is what keeps their
work off the loop, and converting them would drag the rebuild cost onto it.
ManualLineStore's threading lock stays, because both the loop and threadpool
threads reach that store.

Keeping it that way is three layers: a short async section in AGENTS.md, which
is the only file both agents load every session; comments on the lines someone
would actually edit, starting with the worker count in Procfile; and a loop-lag
probe on /api/status so a stall reports itself as a number rather than as "the
chart feels laggy". The risk register gains a row per finding pointing here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 14:58:04 -05:00
617d7c75fb Persist alert cooldowns across restarts so deploys stop re-firing every zone 2026-08-11 19:46:11 +00:00
e02f919b27 better default names 2026-08-11 14:36:58 -05:00
3b3c06a1f3 - Drag a selected trendline body to reposition the entire line.
- 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.
2026-08-11 05:46:09 -05:00
4488c7d4b9 Fix freshness and daily date labels 2026-08-11 04:23:19 -05:00
91677127a0 Unify chart geometry and deepen 30m history 2026-08-11 04:08:02 -05:00
dd9d24b419 Expand regression coverage and document next steps 2026-08-11 03:22:00 -05:00
01f5cd4060 Keep chart overlays aligned with plot 2026-08-11 02:22:38 -05:00
8f71beb59f Hand off the residual 10px overlay offset
The snap indicator still draws about one bar left of the cursor, with the right
height and the right bar chosen. Measured: bar spacing 6.96px, dot centre 10.5px
left of the cursor, and syncOverlayLayer reading containerLeft 56 against a true
plot offset of 66.

It runs once in a requestAnimationFrame during create(), before the left price
scale has sized itself to its label text. The offset settles at 66 once labels
render and nothing re-measures, so the container — and every overlay in it —
stays 10px left for the life of the page. Only x is affected, which is why the
height has always looked correct.

Written up at the bottom of the plan with the measurements, the three candidate
fixes, the trap that the scale's width tracks its label text, and a note that
the e2e suite cannot catch this because its assertions go through the same
coordinate API that carries the error — a page-pixel assertion is needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 02:07:17 -05:00
3dfd44aa02 Document how the live feed reaches production
Production shows yahoo with a ten minute delay because LIVE_SOURCE=schwab lives
in .env, which is gitignored and so has never been deployed. Nothing in the repo
can carry it, and that is deliberate — but it means the switch is invisible
until someone looks at the status line and wonders.

Written down: the three Coolify variables, the token file that has to land on
the persistent volume, and two things easy to get wrong. The local token needs
no new login because it was minted against the production callback and Schwab
binds tokens to the app rather than the machine. And the dev stream has to stop
first, because one refresh token means one streaming connection and the two
installs will fight over it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 00:28:15 -05:00
decd069ec8 Position chart overlays against the plot, not the element
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>
2026-08-10 23:55:51 -05:00
3f677a333c Report where the snap indicator is drawn, not just what it computed
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>
2026-08-10 23:32:39 -05:00
fa577bacb7 Stop the snap leaping to the live edge outside the plot area
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>
2026-08-10 23:27:52 -05:00
3222ce03f4 Add a diagnostic mode that reports chart geometry to the server
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>
2026-08-10 23:19:42 -05:00
7ce6d33bb5 Show the price the anchor will use, and record where things run
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>
2026-08-10 22:35:40 -05:00
ab372832db Snap by x for the bar and y for the extreme, and stop the crosshair magnetting
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>
2026-08-10 21:35:52 -05:00
501afb7792 Snap to the nearest extreme on screen, and add an e2e suite
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>
2026-08-10 20:16:09 -05:00
0e32c0bbab Rename to /ESsence and write down the house rules
The header spent a whole line on a "CME FUTURES" eyebrow that told you nothing
the chart didn't — it only ever shows /ES. Dropping it takes the header from
64px to 44px and hands the space to the chart. The title becomes /ESsence.

AGENTS.md records the rules worth keeping, chief among them that a bug should
prompt the question of whether a unit test could reasonably have caught it —
written when the answer is yes, skipped when it is a rendering or data-source
quirk, and named after the failure rather than the function.

It is AGENTS.md rather than CLAUDE.md deliberately: opencode's instruction
loader walks up looking for AGENTS.md only and never reads CLAUDE.md, and the
ask-opencode skill asks the calling agent to distil house rules by hand rather
than forwarding a file. CLAUDE.md is a symlink to it so both tools resolve to
one source of truth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:10:24 -05:00
039e91b4e4 Stop a zero-price tick and a late exchange bar corrupting the store
Two faults, both introduced by the tick feed, both visible as a huge bar that
flattened the price scale.

A LEVEL_ONE_FUTURES update arrived carrying LAST_PRICE: 0. The parser rejected
None, but 0 is not None, so a minute opened at zero — o=0.0 h=7777.25 l=0.0 —
and provisional_higher carried that low into 5m, 15m, 30m, 1h and the daily bar.
Non-positive prices are treated as absent now, so the last real price carries
forward and the update still counts as the trade it is.

Separately, store.put replaced a bar only when it matched the tail. That was
sufficient while one closed bar arrived per minute, but ticks open the next
minute before CHART_FUTURES delivers the previous one, so the exchange's own bar
stopped matching the tail and was silently dropped — leaving the tick-built
approximation, with its partial volume, in place permanently. put now searches
back a bounded number of buckets for the one it belongs to, and refuses to let a
provisional bar overwrite a settled one.

Tests cover all three invariants: a zero price parses as a trade with no price,
a late closed bar replaces its bucket and keeps the exchange's volume, and a
tick cannot overwrite a settled bar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:10:24 -05:00
0b3244b725 Make trendline placement match what the cursor shows
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>
2026-08-10 16:29:00 -05:00
9008e9cd8e Show the trendline Side control only when it can do anything
Snapping now always lands an anchor on a bar extreme, and which extreme it is
decides the side — a high is resistance, a low is support. That left the Side
dropdown unable to affect the result: it was overridden on every drawn line.

It now appears only when "Snap to highs/lows" is off, the one case where there
is no extreme to infer from. With snapping on the row reads "Side auto" instead,
so the behaviour is stated rather than implied by a control that does nothing.
An "Auto" option in the dropdown would have been the same no-op wearing a label.

Verified in a browser both ways, and that the inference itself holds with the
dropdown left on its default: drawing above the candles yields resistance
snapped to the bar high, drawing below yields support snapped to the bar low.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:18:56 -05:00
9b6cff853b Keep a pinned comment in place across a timeframe switch
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>
2026-08-10 16:10:51 -05:00
9ab68f6cff Add chart comments and turn Lines & levels into Drawings
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>
2026-08-10 15:58:21 -05:00
078bc8f42c Fix trendline anchors jumping away from where they were drawn
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>
2026-08-10 15:18:40 -05:00
0465519348 Reclaim the sidebar's vertical space
The right column ran past the viewport with nothing selected. Five changes, none
of which remove functionality.

The five daily MA periods now share one line. 10px gaps and a 22px indent had
pushed 200 onto a row by itself; nowrap, 7px gaps and 12px boxes fit all five
with room to spare. Auto trendlines becomes a parenthetical on the Manual lines
row instead of owning one — it is disabled until M8, so a full row overstated
it. The alert log becomes a collapsible section like Confluence zones, closed by
default with its count in the summary, so activity stays visible while it is
shut and the banner, sound and phone push are untouched.

Tools becomes a collapsible section as well, open by default, and each tool's
panel is now bound to armedTool: the label, colour, width and side controls
appear only for the tool actually armed. armTool already toggled and allowed one
armed tool at a time, so the panels follow it with no new state.

Layers moves above Tools and starts collapsed. The zeroed top margin moves from
a Tools-specific class to .sidebar-section:first-of-type, so reordering again
cannot reintroduce a gap above the first section.

Measured with nothing armed: 1110px of content down to 900px, inside the
viewport rather than past it. Verified in a browser — arming each tool reveals
that tool's panel and no other, disarming hides both, Tools opens by default,
Layers starts closed and still lays its periods out on one line when opened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 15:02:06 -05:00
a0b8ac1cd2 Reclaim vertical space in the layer panel
Three cuts, so the right column keeps more of itself for the chart.

The five daily MA periods now sit on one line. 10px gaps and a 22px indent had
pushed 200 onto a row by itself; nowrap, 7px gaps, a 4px label gap and 12px
boxes fit all five with room to spare, verified as five labels sharing one
offsetTop and no horizontal overflow.

Auto trendlines becomes a parenthetical on the Manual lines row instead of
owning a row. It is disabled until M8 builds it, so a full row overstated it.

The alert log becomes a collapsible section like Confluence zones, closed by
default with its count in the summary. Closed by default is the point — leaving
it open would reclaim nothing — and the count keeps activity visible while it
is shut, with the banner, sound and phone push unaffected either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:55:55 -05:00
bec445b599 Draw volume, which travelled the whole pipeline unseen
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>
2026-08-10 12:08:59 -05:00
16932f85ec Move daily context to the left price scale
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>
2026-08-10 12:07:52 -05:00
bb84b6e73f Update higher timeframes from ticks, and count volume-only trades
Two things kept the chart quieter than the feed.

Higher timeframes only moved once a minute. Tick bars are 1m and the socket
filters bar events by the subscriber's timeframe, so on the hourly chart every
tick was discarded and only a closed minute passing through the aggregator
showed up. They cannot simply be fed to the aggregator — it accumulates with
current.v += incoming.v, so the same forming minute re-sent on each tick would
add its volume to every higher timeframe again and again. provisional_higher
combines the aggregator's committed state with the live minute instead, without
mutating it; the next closed minute goes through normally and replaces the
result, because the store keys on the bucket timestamp. A test pins the
behaviour: five ticks in one minute leave the hour's volume at closed plus live,
counted exactly once.

Trades known only by their volume were skipped. Level 1 resends only changed
fields, so some trades carry a trade stamp and a moved TOTAL_VOLUME with neither
LAST_PRICE nor LAST_SIZE. Those now count, with size left at zero rather than
guessed from the volume delta — CHART_FUTURES replaces the minute's volume with
the exchange's own figure moments later, and two ways of counting the same
trades is how double counting starts. Measured: 66 to 74 updates per 90s.

The tick throttle drops to 0.25s, which no longer binds. Measured in regular
hours the gaps between updates are whole multiples of 1.005s — 2.01, 3.02,
4.03 — which is Schwab conflating LEVEL_ONE_FUTURES to one update per second
per symbol. One per second is the source's ceiling, not ours; the longer gaps
are seconds in which their feed carried no trade.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:07:52 -05:00
d1056ed486 Label the time axis in local time while keeping the data in UTC
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>
2026-08-10 08:04:34 -05:00