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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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.