Commit graph

41 commits

Author SHA1 Message Date
52e657fb1e Stream real-time /ES ticks so the candle moves between minute closes
CHART_FUTURES emits a bar only once its minute is over, so the chart stepped
once a minute and sat still in between, which reads as a dead feed.
LEVEL_ONE_FUTURES carries real trades on the same socket and the same login — no
extra REST call, no extra rate limit — and reports delayed: False on this
account. It was verified back in M6 and never subscribed to. It is now, building
a forming bar for the current minute that the authoritative CHART_FUTURES bar
then supersedes.

Three constraints shaped it, each a real bug avoided:

- Tick bars never reach the aggregator. It accumulates with current.v +=
  incoming.v, so re-sending the same forming minute would add its volume into
  every higher timeframe again on every update. Runtime.on_bar returns early for
  an unclosed bar: store, set price, broadcast, stop.
- Emissions are throttled, SCHWAB_TICK_SECONDS default 1.0, because /ES trades
  many times a second and each emission is a store write plus a broadcast to
  every open socket. Negative drops the Level 1 subscription entirely.
- A tick for a minute CHART_FUTURES has already closed is dropped, or a late
  trade would overwrite a settled exchange bar with a partial one.

Bid-only updates are skipped rather than carried forward: a bid is not a trade
and must not extend a candle's high or low. Alerts stay on closed bars — a level
is judged on a settled bar, not a price that may not last the minute — which
needed no change, since on_bar already gated on closed.

Verified against the live socket: 15 forming bars and 2 closed bars in 100
seconds, the closed bar superseding each forming minute. Verified in a browser:
the last candle's high and low visibly extend within the minute, no console
errors. 85 tests pass, four of them new.

The plan gains the cold-restart options asked for: make seeding non-quadratic
first, then persist cooldowns, then persist bars.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 06:21:35 -05:00
f64576372c Add Font Awesome 7.3.1 and move the tool glyphs onto it
The sidebar drew its tool icons with box-drawing characters, which render at the
mercy of whatever font the system picks. Two are converted as the first users of
the icon set: a trend arrow for Trendline, a rule for Price level.

Served from unpkg, matching Vue and Lightweight Charts, and pinned for the same
reason they are — an unpinned icon set is a silent redesign on someone else's
release. cdnjs was the first choice and does not carry 7.3.1 on that path; it
404s, which in a browser shows up only as icons that quietly fail to their
fallback font at zero width. Verified rendering: computed family is
"Font Awesome 7 Free" at 17.5px with glyph content, no failed requests.

Lucide 1.31.0 is the lighter alternative if the weight ever matters — stroke SVGs
that can be inlined, dropping the CDN dependency entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 06:10:28 -05:00
a15ea00c03 Keep level overlays on the candle grid so bars keep their width
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>
2026-08-10 06:07:44 -05:00
9d63e6482e Anchor the chart viewport by time, not by logical index
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>
2026-08-10 05:53:13 -05:00
09114f7c6a Switch the live feed to Schwab and document the rate limits
LIVE_SOURCE=schwab locally. Seeding stays on Yahoo, which is enforced in the
factory rather than left to configuration.

The portal's "Order Limit: 120" caps orders per minute and this app places none;
Schwab's separate REST limit is commonly cited at the same number. Neither binds
here, because streaming is not REST — one socket, bars pushed, essentially no
REST traffic in steady state. Worth recording as a reason the stream beats the
polling fallback beyond latency: polling quotes once a second would have sat at
half the limit permanently.

Reconnects are the exception. Each calls get_user_preferences() for the socket
URL, and the retry backoff is five seconds, so a sustained outage costs about
twelve REST calls a minute — under the limit, but a reason not to shorten that
backoff without thinking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 05:26:47 -05:00
d526001742 Add the Schwab live source: real-time /ES minute bars
Verified against a live account before and after writing it. CHART_FUTURES
delivers one true-OHLCV minute bar per symbol per minute, LEVEL_ONE_FUTURES
reports delayed: false, and consecutive bars arrived sixty seconds apart through
the production code path.

Yahoo stays. Schwab serves no futures history whatever, so seed_source resolves
to Yahoo even when SEED_SOURCE=schwab is asked for — the pairing is the intended
configuration rather than a fallback. The symbols differ, ES=F against /ES, so
Settings.live_symbol picks the live one while seeding always uses Yahoo's.

Three findings worth keeping, each of which cost a round trip:

- get_quote() singular returns the wrong instrument entirely. It puts the symbol
  in the URL path, where the leading slash is normalised away, so /ES resolves to
  Eversource Energy at $72 and returns HTTP 200 with a populated body. Only
  get_quotes() plural, which passes symbols as a query parameter, returns the
  future. A 200 is not evidence; assetMainType is.
- Streaming requires the Accounts and Trading product. StreamClient.login() reads
  /trader/v1/userPreference for its socket URL, and that path does not exist in
  Market Data Production.
- /ES resolves to the active contract on Schwab's side, so the contract roll
  handling the plan left open needs no code.

The stream drops the oldest queued message rather than stalling the socket, and
surfaces a dead pump task instead of waiting forever on a queue nothing fills.
schwab-py moves into requirements.txt, imported only when LIVE_SOURCE=schwab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 05:23:20 -05:00
bffd7faead Validate Schwab credentials before sending anyone to a browser
The first attempt failed with invalid_client and no way to tell why: the key,
the secret, the callback, or an app not yet propagated all look identical from
the browser, which shows raw JSON. It turned out to be a key clipped by one
character on paste.

Two preflight checks now say which. The authorize endpoint is asked whether it
recognises the key. The token endpoint is asked to exchange a deliberately
invalid code, which separates bad credentials from a bad grant — it
authenticates the key and secret over HTTP Basic before it looks at the code, so
invalid_client means the pair is wrong and invalid_grant means the pair is fine.

That second check matters more than it sounds. The secret is not used at all
during login, so a truncated one survives the whole browser round trip and only
surfaces at the exchange, by which point the authorisation code has been spent
and the flow has to start over.

Neither check can tell a wrong value from an app that is not live yet — an
invented key produces the identical response, verified — and both say so rather
than guessing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 05:02:04 -05:00
e3aad01baf Guard that the OAuth callback stays reachable under CHART_AUTH_TOKEN
The callback is registered with the provider and has to answer an unauthenticated
browser redirect. It is exempt by construction — a separate router without the
token dependency — but nothing held that in place, and the failure would only
appear in production, where the token is the one setting that differs from
local, at the last step of a login flow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 04:50:12 -05:00
5ccb2bfb52 Add Schwab settings, credential placeholders and an entitlement probe
config.py had no Schwab fields at all, so the keys listed in .env.example were
being silently dropped by extra="ignore". They exist now, blank, and nothing
reads them while live_source is yahoo.

The token path moves under data/, which is the Coolify persistent volume. Left
at the repository root it would vanish on every rebuild, and re-authenticating
is an interactive browser flow, not something a deploy can do for itself.

scripts/check_schwab.py answers empirically what the app is entitled to rather
than inferring it from documentation: whether the credentials authenticate,
whether /ES quotes return (futures market data is a separate entitlement from
equities), and whether the streamer bootstrap responds.

That last one is the decision. StreamClient.login() reads
/trader/v1/userPreference for its socket URL and credentials, and that path
belongs to the Accounts and Trading product — so an app registered for Market
Data Production alone cannot stream, and CHART_FUTURES is unreachable until the
app adds it. The script reports which of the two paths is open instead of
leaving it to be discovered halfway through an implementation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 04:48:29 -05:00
0bafe9de01 Add the OAuth callback endpoint at /api/qt
Schwab requires an HTTPS callback. The usual answer is https://127.0.0.1:8182
behind a self-signed certificate, which means clicking through a browser warning
on every re-authentication — and the refresh token expires weekly. There are
also reports of Schwab refusing to register apps whose callback is a loopback
address. This app already terminates real HTTPS, so it can take the redirect
itself.

Unauthenticated by necessity: the provider redirects a browser here and cannot
attach the chart token, so it sits alongside /health and /version. It is inert —
nothing is stored, and the page echoes only the query string of the request that
produced it, which the caller already has in their address bar. Retaining the
code would let a later anonymous visitor read it.

The path and the page are both deliberately unrevealing. That is not a security
control; it just avoids advertising which brokerage this host talks to. Treat
the path as fixed — changing a registered callback means editing the app, which
can send it back through approval.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 04:45:31 -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
51a9636ea7 Stop re-alerting when price crosses a level
Suppression matched on cluster side as well as position, but side is positional:
a level sitting at price is resistance when price is a tick below it and support
a tick later. Every crossing failed the side match and fired as a brand-new
zone — which is exactly when a level is least newsworthy, not most.

Found the honest way. A flat line placed at the live price produced four phone
pushes in two minutes.

Zones are now matched on position alone. Side still determines whether the
message reads BULLISH or BEARISH; it just no longer decides whether you are told
twice. Over the same six replayed sessions at threshold 28 that is 40 alerts
down to 26, and 10 at the configured four-hour cooldown with a worst session
of 5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:51:54 -05:00
dd1d6b7a38 Document ntfy topic setup and why local should use a different one
Cooldown state is in memory, so every restart begins with an empty fired-zone
table and the first closed bar re-alerts whatever zone price is sitting on.
Locally, with --reload, that is every file save — which would push a stream of
duplicates to a phone sharing the production topic.

Also records that a production deploy resets cooldowns for the same reason, and
that ntfy topics are public in both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:40:43 -05:00
9da7d43434 Make drawn trendlines able to alert at all
A hand-drawn line could never produce an alert, for two independent reasons:

- cluster_levels dropped any single-member group scoring under 8, and a manual
  line weighs 1 on 5m rising to 4 on 1h. A lone drawn line was discarded before
  it ever reached the alert engine.
- Even had it survived, the engine gates on confluence score, and the threshold
  is 28.

Both are wrong for a drawn line specifically. Weight exists to rank levels
nobody asked for; a line you drew by hand is an explicit statement that this
price matters, so it survives clustering on its own and bypasses the score
threshold. Everything else still has to earn its place.

Alerts naming a drawn line say so — "BEARISH LINE" with the line's label rather
than "BEARISH ZONE ... confluence 1", since knowing which drawing to go look at
is the actionable part. A line that happens to coincide with other levels still
reports as a zone, with the line named alongside.

Verified against the running app: a line placed at the current price now forms a
cluster, where before it was discarded outright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:56:21 -05:00
e9c22f6bbd Move alert evaluation server-side so push works without a browser open
Alerts were evaluated inside the WebSocket handler, with a separate AlertEngine
per connection. Three consequences, all of which defeated the point of phone
push:

- No browser connected meant no alert at all. The notification only existed if
  a tab was open to receive it, which is precisely when you least need it.
- Two tabs meant two notifications, since each connection evaluated
  independently.
- Cooldowns lived and died with the connection, so reloading the page cleared
  them and a zone that had just alerted alerted again at once.

The third also meant the calibration in the README described a system nobody was
running: it models a single engine, which is what this now is.

Evaluation moves into Runtime, once per closed 1m bar, over every level. Layer
preferences are deliberately not consulted — they are a display choice made in
one browser, and a push notification should not depend on which checkboxes that
browser has ticked. Sockets now only relay what the runtime produced.

ntfy dispatch is a detached task with its own error handling. It previously ran
inline in the socket loop and called raise_for_status(), where the only except
clause caught disconnects — so a transient ntfy outage dropped the client's
connection.

Delivery verified end to end against ntfy.sh: title, priority and the multi-line
body all arrive as intended. NTFY_TOPIC still has to be set for anything to send.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:48:03 -05:00
8c2ef80966 Remove the 4h timeframe
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>
2026-08-10 00:43:47 -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
1f1544bab1 Merge main into feat/chart-engine
Resolves main.py: the branch's application supersedes the placeholder, and
/api/version + /api/health now live in app/api/meta.py so bin/wait-deploy
keeps working.
2026-08-10 04:43:51 +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
e3ebe2914d Implement M4 confluence alerts 2026-08-09 20:51:32 -05:00
ff7d9c6e1c Implement M3.5 persistent layer controls 2026-08-09 20:45:30 -05:00
7f2fcc2020 Implement M3 projected daily moving averages 2026-08-09 20:44:11 -05:00
f87ca0a153 Implement M2 session-aware aggregation 2026-08-09 20:41:36 -05:00
acc59817c4 Implement M1 live one-minute chart 2026-08-09 20:38:50 -05:00
e071acd3a9 Implement M0 Yahoo market data and replay 2026-08-09 20:36:13 -05:00
8e50d5cbc2 Add implementation plan for /ES multi-timeframe confluence chart
Planning-only commit: no application code yet.

The plan specifies a realtime /ES chart that derives moving averages and
trendlines across multiple timeframes, projects them onto one chart in a
shared (time, price) plane, and alerts when levels from different
timeframes converge.

Key findings that shaped it, all verified against source rather than
assumed:

- Schwab streams realtime futures fine (CHART_FUTURES, LEVEL_ONE_FUTURES)
  but provides no futures price *history* at all. An account does not
  change this; it is an API-surface limit.
- Yahoo's chart endpoint needs no key and has exactly what Schwab lacks:
  ~730d of hourly ES=F (~750 sessions), enough to warm a 200DMA from
  startup. So it serves as both the no-keys dev source and the history
  seeder, behind one MarketDataSource protocol.
- Yahoo anchors daily bars to midnight ET while the CME session runs
  18:00-17:00 ET, so daily bars are built from hourly using our own
  session rules instead.
- Lightweight Charts v5 replaced addCandlestickSeries() with
  addSeries(CandlestickSeries, ...); most tutorials online are v4.

Build order defers judgment-heavy work: moving averages first (fully
deterministic), then confluence scoring, then hand-drawn trendlines.
Automatic trendline detection comes last, tuned against the hand-drawn
lines as ground truth.

Includes a real trimmed Yahoo response as a test fixture; it contains a
null in the OHLC arrays, which is the parsing case that needs handling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 20:31:40 -05:00
e3459aa72e Correct rebuild timings in README with measured warm/cold numbers 2026-08-10 01:05:51 +00:00
49b180e797 Add /api/version and bin/wait-deploy so deploys are observable from any machine 2026-08-10 01:04:58 +00:00
3cc682d63a test 2026-08-09 20:02:03 -05:00
19b0c195a0 Make local compose host port overridable; record Coolify UUID in README 2026-08-09 23:38:33 +00:00
60f8399f85 Scaffold FastAPI + Vue3 placeholder app with local docker compose stack 2026-08-09 23:34:01 +00:00