Commit graph

56 commits

Author SHA1 Message Date
18585e42cd trendline selection issue 2026-08-23 23:25:55 -05:00
eaf118ef8b drawings from higher timeframes to lower fixes 2026-08-23 21:37:19 -05:00
913eec34ea lower time frame trendlines blocked from dma chart 2026-08-23 21:10:09 -05:00
dae60fdf67 undo 2026-08-21 03:10:31 +00:00
6e69b7337a current bar fix and ma features 2026-08-20 23:15:34 +00:00
58a64c1eeb fib levels 2026-08-19 09:09:47 +00:00
08cff3ce53 enable symbols and comments in the future whitespace 2026-08-19 08:36:31 +00:00
44cec38750 slope in tooltip 2026-08-19 05:30:16 +00:00
d9a272760b Add a Drawings layer toggle that hides lines and comments 2026-08-18 09:53:33 +00:00
fc09da7053 final pan fix 2026-08-16 23:25:36 -05:00
7f452703d3 some things only displayed optionally 2026-08-16 21:38:26 -05:00
73beae051a tweaks 2026-08-15 05:14:28 -05:00
11508e5325 tweaks 2026-08-15 04:48:49 -05:00
3adb98c1fe resizable drawing list 2026-08-15 04:33:06 -05:00
777ad4af91 version at bottom 2026-08-15 03:57:31 -05:00
aae47b6681 autoscroll optional 2026-08-14 13:58:44 -05:00
6e781ed163 options finding feature 2026-08-14 06:18:53 -05:00
a921db4610 horizontal line to start of trendline problem 2026-08-14 06:06:16 -05:00
8ab054cf67 timeframe trendline interactions 2026-08-14 04:58:08 -05:00
a09e4f1208 better select and visibility 2026-08-14 03:33:53 -05:00
087d9d9d2a changed ntfy string 2026-08-14 02:44:10 -05:00
f9e02fc3e4 fixed del key bug 2026-08-14 01:12:20 -05:00
54f4871757 key nudge for all drawing types 2026-08-13 19:02:49 -05:00
0a56d56261 reposition price level lines 2026-08-13 06:44:01 -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
c653e65d5b diagnostic capture feature 2026-08-11 16:08:32 -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
8ca774f821 compact drawing list 2026-08-11 15:13:48 -05: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
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
a13a54bc3f Drop stale bar events instead of letting the chart throw
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>
2026-08-10 06:30:56 -05:00
e69d1c190f Version static assets so a fixed bug stops reproducing in an open tab
The trendline slope fix was already correct — measured on the live chart, the
two rendered segments came out at screen slopes 0.4910 and 0.4903, collinear.
It still reproduced in the browser because the browser was not running it.

StaticFiles sends an ETag but no Cache-Control, and the asset URLs carried no
version, so nothing forced a refetch. A tab left open across an edit never
fetches at all: it keeps executing the JavaScript it loaded when the page was
first opened. Every fix since that tab was opened was invisible in it.

The page now stamps its own asset URLs with a digest of their contents and is
itself served no-store. Hashing rather than stamping mtimes, because a deploy
checks every file out fresh and would otherwise invalidate assets that never
changed.

This also removes a trap the README already half-documented for deploys: an
/api-only change leaves the HTML byte-identical, and until now a JavaScript
change could leave the served page byte-identical too.

The debug handle used to measure the geometry is kept deliberately. Chart
rendering bugs are invisible from the outside, and window.__chart.lineData()
against timeToCoordinate() is what settled this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 04:29:56 -05:00
2efcec6a76 Price trendlines across bars, add one-shot alerts, collapse layers, drop 1h MAs
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>
2026-08-10 04:15:05 -05:00
d5edb12171 Replace the drawing strip with an armed-tool sidebar and drag placement
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>
2026-08-10 02:37:22 -05:00
1a1514bc77 Add a price alert input
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>
2026-08-10 02:09:14 -05:00
8ca624d435 Add prior-day levels and session VWAP; fix alert repetition they exposed
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>
2026-08-10 00:36:21 -05:00
45802c1a2f Fix trendline deletion while typing, audio leak, prefs drift, cluster payload
Five defects found by exercising the running app rather than reading it:

- Backspace inside the sidebar rename field deleted the trendline instead of
  a character. The window keydown handler never checked what was focused, so
  correcting a typo in a line's name destroyed the line.
- playAlert() built a new AudioContext per alert and never closed it. Browsers
  cap a document at roughly six, after which alerts stop making any sound.
  One shared context now, with nodes released on end and a resume() for the
  autoplay policy.
- Stored layer preferences were used verbatim, so any key added to
  defaultPrefs later would be missing for existing visitors. A missing
  enabled.ma is a crash rather than a cosmetic gap. They are now deep-merged
  onto the defaults, and unparseable state falls back instead of throwing.
- The alert log keyed rows on a second-resolution timestamp, so two alerts in
  the same second collided.
- Clusters embedded whole Level objects, including a moving average's entire
  point history — hundreds of entries reaching back years. Because clusters
  are re-sent on every closed 1m bar, this shipped the whole levels payload
  once a minute. Members are now compact summaries and the client joins on
  id; Cluster.to_dict() also stops round-tripping through asdict(), which was
  deep-copying those arrays before discarding them.

/api/confluence drops from 61,838 to 1,245 bytes with five clusters live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:17:40 -05:00
821f0a0a8f Fix WebSocket reconnect: refused upgrades are 1006 not 1008, back off, prompt once 2026-08-10 04:53:41 +00:00
641492ae62 Enforce CHART_AUTH_TOKEN on /api and /ws; restore /api/version for wait-deploy 2026-08-10 04:38:36 +00:00
9777188a43 end trendline here possible 2026-08-09 23:28:23 -05:00
512e94237a respositionable trendlines 2026-08-09 23:18:10 -05:00
6599c3cd77 trendlines namable deleteable 2026-08-09 23:00:46 -05:00
f7d0ffce8a Improve local chart development 2026-08-09 22:18:36 -05:00
49cd06089f Implement M5 manual trendlines 2026-08-09 20:55:05 -05:00