Changelog
v1.28.02026-09-10
Changed
- ▸`/webhooks` is 19% narrower, for reading on a portrait monitor. The table is a daily-glance page kept open on a rotated display, where four of its twelve columns were spending width on values that never varied: every rule was ON, every rule fired Always, every rule watched Price. Fires went entirely — a count nobody reads back. Status went too, its ON/OFF switch moving into the edit dialog, with the existing dimmed-row treatment left as the at-a-glance cue that a rule is paused. Metric and Mode became icons and then a single
Typecolumn, which is what actually reclaimed the space: icon-only they were 12px of content sitting under a 56px and a 46px header, so the header word rather than the value was setting both widths, and shrinking the cells alone bought 9px. Measured on the live table, minimum width fell from 859px to 694px. Sorting keeps the metric key; Mode, having two values that are almost always the same one, no longer sorts. - ▸A rule’s on/off switch moved into the edit dialog. Removing the Status column would otherwise have removed the only way to pause a rule. It is now a pill beside the dialog heading that applies on click rather than waiting for Save, since the toggle has always had its own route separate from the update body. The symbol detail page shares that dialog and previously rendered a read-only ON/OFF badge with no way to change it, so it gains the control for free.
- ▸A rule’s note is a tag rather than a sentence. Notes had settled into a few recurring cases, so the field now offers configurable short codes —
L52,RL,OGout of the box. The stored value is the code, which is what the dense table shows as a badge with the full name on hover, so the column costs three characters instead of a sentence. Matching is case-insensitive and trimmed, so a hand-typedl52still resolves. It remains a combobox and not a closed list: the field stays free text, so an ad-hoc note is never blocked and notes written before the tag list keep rendering exactly as they did. Every tag takes one neutral colour on purpose — in this app colour carries direction or alert, and tintingRLgreen would read as a signal the note is not making. - ▸Discord spells out a note tag.
fire_webhooksinterpolated the stored note straight into the message, which since notes became codes meant a fire read— RL. It now expands through the same tag list the dashboard writes, sending— RL: Resistance Level, so editing a label in Settings changes what Discord says on the next fire with no code change. The list is read once per run rather than per rule. Free text passes through untouched, a lower-case note normalises to its canonical code, and a tag deleted after rules were tagged with it falls back to printing the bare code rather than dropping the note.
Added
- ▸Note tags are editable in Settings → Webhooks. Code and label rows with add, remove and reset-to-defaults, stored in
app_settingsunderwebhook_note_tagsalongside the Discord URL that shares the tab. Saving normalises instead of rejecting — blank codes and case-duplicates are dropped, the stored result is returned and adopted by the panel, and the count removed is reported — so a half-typed row cannot wedge the save. The three defaults are a seed for a database that has never had the key written, so a fresh install needs no migration. The list reaches the table and the dialog as a server-read prop rather than a client fetch, so it is correct on first paint and the server-rendered badges use exactly the same list; callers take it as an argument rather than importing it, so a hardcoded copy cannot drift from what is configured.
Improved
- ▸The note tag dropdown opens against the room it actually has. It sits near the bottom of a
max-h-[90vh] overflow-y-autodialog, and an absolutely positioned child extends a scroll container’s scrollable area — so a list that always dropped downwards both clipped at the dialog edge and raised a scrollbar. It now measures the space to the nearest scrolling ancestor when it opens and flips above the input when there is not room below, capping its height to what is available. Overflow upwards past a container’s top edge creates no scroll, so the scrollbar is gone in both directions.
v1.27.02026-09-10
Added
- ▸`/webhooks` now shows whether a rule can actually fire, not just what it is set to. A directed crossing rule only fires from one side of its threshold:
_is_crossingneedsprevstrictly on the far side, so a Cross Down rule whose last evaluated value has already settled below its threshold is inert until price climbs back over. Rendered as two unrelated grey numbers in adjacent columns, that was invisible — and six of the thirty live rules were sitting in exactly that state, looking perfectly healthy. The condition now carries a direction arrow, and a new Distance column reads-4.5% to firewhen armed or+4.7% to armwhen inert, the same magnitude either way with only the caption changing. Inert rows take an amber last value, row wash and left bar, reusing the tier accent from/sectorsso the bar is 2px on every row and flagging one never shifts the column. Distance sorts on a single banded key, so ascending reads as most-actionable-first: armed rules nearest their threshold, then inert ones nearest to being armed again, then rules with no baseline to measure from. The add/edit dialog checks the same constraint against the live price while you type and warns, without blocking, when the threshold you have entered puts the rule on the wrong side — staying silent when it merely equals the auto-filled price, so opening the dialog does not cry wolf every time. - ▸A symbol's alerts are managed from its own page. At thirty rules the
/webhookstable stopped being where you check whether the symbol in front of you already has an alert on it./symbols/<symbol>now carries a Webhook Alerts section directly under the price chart — price thresholds are levels you read off that chart — listing every rule for that symbol with the same armed/inert treatment, an active count, and add/edit through the same dialog. Sharing that dialog meant extracting it rather than keeping a second copy of the form logic that would drift: the pure vocabulary moved tolib/webhookRules.tsso the one-sided crossing rule the engine applies has one definition instead of one per page, and the dialog tocomponents/WebhookRuleModal.tsx, which takes alockedSymbolthat renders the ticker as a readout and spares the symbol page from shipping the entire symbol list to the browser for a control it would never use.WebhooksClientfell from about 800 lines to 291. The extraction was verified behaviour-neutral by diffing the rendered page against a pre-refactor snapshot: 146,719 bytes both ways, all thirty rows matching cell for cell. - ▸Settings gained a Data tab holding the manual engine run. It reports
run_update.py's own output, which the previous trigger fetched and threw away, states plainly that the request blocks for up to five minutes so silence does not read as a hung page, and says when a manual run is even warranted given cron covers the normal case. A run that outlives the route's 300s cap surfaces in the browser as a network error while the Python side keeps going and finishes inlogs/update.log, which is why the failure state points there.
Fixed
- ▸Two decimals was not merely hard to read on sub-dollar tickers, it was wrong. Twenty-two symbols in this universe trade under a dollar, and at two decimals DVLT's
$0.295threshold and its$0.2956last value both printed$0.30— two distinct numbers collapsed onto one, reading as a 3% gap in the wrong direction when the real one was 0.2%. Share prices now take four decimals below$1and two at or above, throughfmtPrice/priceDecimalsinlib/format.ts, promoted from the local helperAlertsBadgealready carried rather than inventing a second convention. The rounding was also corrupting data, not just display: the dialog's threshold auto-fill wrotetoFixed(2)straight into the input, so accepting the default on a$0.2956stock created a rule at$0.30, 1.5% off the intended price. Market cap, EPS, scores and z-scores keep two decimals regardless of magnitude, and/overnight's own formatter is untouched since it renders index and futures levels; the boundary is written down inDESIGN.md. - ▸A sub-dollar ticker's price chart was quantised to whole cents.
PriceChartset nopriceFormat, so lightweight-charts fell back to itsprecision: 2/minMove: 0.01default and snapped the axis and crosshair onto penny steps, labelling every candle of a$0.29stock the same$0.30. Both the candlestick and the moving-average series now derive precision from the data, falling back to two decimals on an empty candle set.
Changed
- ▸The manual Refresh button is gone from the top bar. Data arrives from cron every five minutes in market hours and every fifteen in premarket, so the manual trigger bought nothing and was easy to mis-tap, on mobile especially. It now lives behind Settings → Data, where reaching it is deliberate. The
as offreshness stamp the button sat beside stays in the top bar, but only outside the live session:LiveIndicatoralready shows ET and local time while live, and shows onlyPRE,AFTor nothing at all otherwise — so removing the stamp outright would have left no staleness cue anywhere, which matters most in local development wheredata/market.dbroutinely runs weeks behind. Three empty states and the groups hint that told you to press a control which no longer existed now name the next engine run instead. - ▸Deleting a webhook rule moved into the edit dialog. The
xin the Actions column was a coin-flip to hit at that size and sat one mis-click from Edit. It is now bottom-left of the dialog behind a two-step inline confirm rather than awindow.confirm, since a native dialog stacked on a custom one reads badly and blocks the browser automation used to verify these pages. Edit lost itstext-[10px]and inherits the table's own size like every other cell.
v1.26.02026-09-06
Changed
- ▸Dependency updates across the web app.
better-sqlite312.10.0 to 13.0.3 (the native binding rewrite from raw V8 to N-API, with prebuilt binaries now bundled directly instead of fetched viaprebuild-install) and its matching@types/better-sqlite37.6.13 to 9.6.0;next16.2.7 to 16.3.4 witheslint-config-nextbumped alongside it to 16.3.4 to match;eslint10.4.1 to 10.10.0;typescript6.0.3 to 7.0.2 (the new Go-native compiler — this repo'stsconfig.jsonalready usesmoduleResolution: "bundler"andnoEmit: true, the shape it supports);react/react-dom19.2.7 to 19.2.8;recharts3.8.1 to 3.10.1;framer-motion12.40.0 to 13.2.0 (currently unused in app source);lightweight-chartspinned to 5.2.1;tailwindcss/@tailwindcss/postcss4.3.0 to 4.3.3;@types/node25.9.1 to 26.4.1;@types/react19.2.16 to 19.2.18;@types/react-dom19.2.3 to 19.2.7. Verified with a cleannpm install,tsc --noEmit, and a nativebetter-sqlite3smoke test (create/insert/select) on Windows.
v1.25.02026-08-27
Fixed
- ▸The top-bar bell could never show anything, because the alerts table was being truncated dozens of times an hour. A bare
"DELETE FROM alerts"sat in_MIGRATIONS, the listget_connection()replays on *every* connection — sofire_webhooksinserted a row and the next job inrun_update.pywiped it seconds later, roughly 13 jobs every five minutes. The dropdown, thealertsschema and the insert had all been in place for releases; the data never survived to reach them. On the production database this measured 208 recorded fires across 21 rules against zero rows inalerts. The statement is gone, with a comment in its place explaining why that file is the wrong home for one, and retention now lives infire_webhooks.run()as a bounded 7-day prune. A secondDELETE, in the web app'sensureAlertsSchema(), ran on every page render — that function is removed entirely, which also stops the root layout opening a write-mode SQLite connection on each request for two statements the engine's migrations already cover. - ▸A Discord delivery that failed did not merely lose the message, it swallowed the alert permanently.
last_evaluated_valuewas written *before* the POST, so a failed send left the baseline already past the threshold: on the next cycleprevandcurrboth sat on the far side,_is_crossingreturned false, and that crossing could never be re-detected.fired_countnever advanced either, so the rule looked as though it had simply never fired. The baseline now moves only once delivery is settled, which makes a failure self-healing — the next run re-detects the same crossing and re-sends it, and if Discord is unreachable for an hour the alert still arrives when it recovers, exactly once. - ▸Relative timestamps in the bell were off by the browser's UTC offset.
alerts.created_atcomes from SQLite'sCURRENT_TIMESTAMP, which is UTC with no zone marker, andnew Date(ts)parses that shape as *local* time — so on a UTC+7 machine every entry computed to roughly seven hours in the future and rendered as-419m ago. Timestamp handling moved intolib/format.ts, which now normalises both shapes the engine stores (2026-08-26 17:03:47and2026-08-26T16:07:11Z) through one UTC-pinning parser.
Added
- ▸The bell is now a feed of the latest 10 webhook fires. Each entry names the symbol, what crossed which threshold, and the value at the moment it fired —
NEXR crossed down $2.16 / was $2.10— with the threshold coloured by direction and the age on the right. The symbol is a link that opens the/symbolsside panel, the same interaction as the table rows.fire_webhooksnow records the fire's facts in the previously unusedalerts.payload_json, so the UI composes its own wording instead of displaying the Discord string, whose markdown asterisks would otherwise render literally and whose two-decimal price formatting flattens sub-dollar thresholds (DVLT's 0.2956 and 0.295 both print as$0.30). - ▸`/webhooks` is sortable on every column, including the two derived ones — Metric and Condition sort on their displayed labels rather than their raw enum values. Never-fired rows group together instead of interleaving, because
useSortablemaps null to-Infinity, which compares equal to every date string; the sort key is""instead. The symbol is also a link into the side panel now, and the choice persists per browser.
Changed
- ▸Webhook delivery is retried rather than dropped. A 429 is a rate limit, not a rejection, and every rule points at a single webhook URL — so a broad market move sends a burst into one bucket.
_post_discordnow honours theretry_afterDiscord returns, backs off on 5xx and network errors, spaces sends 400ms apart to avoid earning the 429 in the first place, and treats only 4xx as final, loggingWEBHOOK DEADwith a pointer to Settings. A crossing that still failed keeps its baseline held and is retried each cycle, staying a single row markedretrying deliveryuntil it lands; after 12 held cycles it is abandoned and shown asdelivery failed, so a dead endpoint cannot freeze a rule indefinitely. A run-level breaker stops calling out after three consecutive failures: with 30 rules crossing into an outage that cut a test run from ~90 POSTs over 90 seconds to 9 posts in 9.9s, and all 24 held crossings delivered on the next cycle without duplicating a row. - ▸Alert rows are written whether or not Discord accepts the POST, since the feed is the only place a delivery failure would ever surface.
fired_count,last_triggered_atandtrigger_moderemain gated on a successful send. To keep a sustained outage from inserting an identical row every five minutes and burying the ten-slot feed, a retry updates the rule's pending row instead — which required one new column,alerts.webhook_rule_id— andcreated_atstays at the moment of the crossing, not the moment delivery finally succeeded. - ▸The Price column is gone from `/webhooks`, and Last Fired reads in the browser's timezone. Price and Last Value resolved through the identical query — latest 5m close, else latest daily close — and
fire_webhooksruns immediately after the candle jobs in the same cycle, so the two columns were the same number by construction; all 30 price rules matched to the last decimal. Dropping it also removes one/api/symbols/[symbol]/snapshotrequest per distinct symbol on every page load. Last Fired is converted after hydration rather than during render, because the server cannot know the viewer's zone and formatting it server-side would mismatch — the stored UTC is available on hover.
v1.24.02026-08-20
Changed
- ▸The 24h gauges now refresh through the whole US day instead of freezing at the pre-open snapshot.
update_overnightwrites only inside the 20:00—09:30 ET window because every number in the Asia, Europe and futures rows is measured against the *next* US open — a live value there would be meaningless, not merely stale. Gold, USD/JPY and Bitcoin have no such anchor, so freezing them alongside the rest discarded an entire session of real information: a multi-percent Bitcoin move during US hours simply never reached the page. A newrefresh_gaugespath re-prices those three rows outside the window on a 15-minute cadence, leaving Asia, Europe, the futures andovernight_compositeuntouched so the mislabelling the window gate exists to prevent cannot arise. It costs one batchedyf.downloadper refresh — roughly 30 extra calls a weekday against the job's existing ~52 — becauseprior_closeis reused from the row the pre-open run wrote rather than re-fetched: gold and USD/JPY roll their daily bar at 17:00—18:00 ET and Bitcoin at 20:00 ET, all after this path stops running, so the stored settle is still the correct baseline. A 17:00 ET guard makes that assumption fail loudly instead of silently measuring against a two-sessions-old settle should the cron window ever be extended into the evening, and a failed or empty fetch leaves the existing row alone rather than blanking it. Coverage in practice is 09:30—16:55 ET, bounded by the production crontab, which stops at 20:59 UTC. - ▸The 24h gauges moved to the top of `/overnight`. They are the only block on the page that stays live during US hours, so they now lead it rather than close it, and they carry their own freshness badge on a scale keyed to the 15-minute cadence instead of inheriting the snapshot's age.
Fixed
- ▸`/overnight` presented a deliberate freeze as an alarm. The header ran a single age off
overnight_composite.updated_atand turned it red past 90 minutes, so from about 11:00 ET onward every mid-session visit readupdated 410m agoin red even though the pipeline was healthy and the snapshot was exactly as fresh as it is designed to be — cron had in fact run every five minutes through 16:55 ET. Freshness is now per section: the session-anchored tables readovernight snapshot 09:26 ETin neutral gray while ET is inside cash hours, the red treatment is reserved for a snapshot genuinely late inside its own window, and the gauges report their own age.getOvernightBoardnow selects the per-rowupdated_atit was already storing but never reading.
v1.23.12026-08-19
Fixed
- ▸`/overnight` returned a 500 on the first deploy instead of its empty state.
openDb()opens SQLite read-only and never runs migrations, soovernight_snapshotsandovernight_compositedo not exist until the engine's next Pythonget_connection()— which on production means the nextrun_update.pycron tick. Querying a missing table throwsSQLITE_ERROR, and because the query ran during server rendering the whole route became an error page rather than degrading.getOvernightBoardnow checkssqlite_masterfor both tables first and falls through to the existing "No overnight snapshot yet" state when they are absent. This is a general hazard of adding an engine table, not something specific to this page: every deploy that introduces one opens the same window between the web release and the next engine run.
v1.23.02026-08-19
Added
- ▸New `/overnight` page: a pre-open briefing built on futures, Asia and Europe. Answers three questions on one screen before the US opens — what the gap is, why, and what regime we are in. The spine is
ES=F/NQ=F/YM=Fagainst their prior settle, plus an implied S&P open level derived from the last^GSPCclose. Below that, the Asian and European cross-sections explain the gap, and a 24h gauge strip (JPY=X,GC=F,BTC-USD) carries regime context. Newupdate_overnightandbackfill_global_indicesjobs run inside the normalrun_update.pypipeline; derived output lands in two new tables,overnight_snapshotsandovernight_composite. - ▸Overnight moves are separated into echo and residual. A raw overnight quote is substantially a re-pricing of the prior US close, so printing it alone mostly re-reads yesterday's tape. Each market's move is now split into an *echo* — the part its historical beta to the prior Nasdaq session explains — and a *residual*, which is scored as a z against that market's own residual history. Betas are refitted every run from 5 years of daily closes, using only sessions strictly older than the one being scored so there is no lookahead. An Asia composite z, a down-breadth count and a theme tag (
semis/china/japan/broad, from Korea+Taiwan vs Hong Kong+Shanghai vs Japan) summarise the region. - ▸Europe is modelled on two predictors, not one. Europe opens after Asia closes, so it re-prices both the prior US session and the Asian tape. Regressing it on the US session alone would push Asia's entire influence into Europe's residual and double-count the same event, making a European market that merely follows Asia down look like new information. Europe therefore uses a two-predictor OLS (prior US + same-day Asia composite), and the fitted coefficients justify it emphatically:
beta_uslands at 0.04—0.05 whilebeta_asiaruns 0.19—0.31, so these indices track Asia 4—6x more strongly than they track Wall Street's previous close. - ▸Sessions report their own state and progress. Each row carries
session_state(closed/open/pre) andsession_pct, computed from per-market local open and close times viazoneinfoso European DST is handled without hardcoded UTC offsets. This matters because Europe is only 12—24% through its session during the US pre-open window: its z compares a partial move against full-day dispersion and therefore understates magnitude, which the page states outright rather than burying in a number. Asian rows sampled before their local close are labelled in-progress instead of being presented as settled closes. - ▸Four glossary terms on `/overnight` — Echo, Overnight Residual, Residual Z and Implied Gap — via the existing contextual drawer.
Changed
- ▸The page is framed as context, not a trigger, on measured grounds. Across 995 US sessions (2022-08 to 2026-08) the Asia composite z forecasts the *opening gap* (r2 0.166, t +14.1) and essentially nothing after it (r2 0.006). On the 15 worst Asia nights, 11 US sessions rose from the open and the worst continuation lower was 0.56%; a combined Asia-z-plus-gap filter (n=31) gave mean intraday +0.15%. So the overnight information is already in the gap by the time it can be acted on. The page carries that finding in a footer, writes nothing to
alerts, and fires no webhooks. - ▸`update_overnight` only writes inside the overnight window.
resolve_as_ofrolls to the next US session at 09:30 ET, but the Asian session feeding that open does not begin until roughly 20:00 ET. A midday run would therefore have stored today's Asian closes tagged with tomorrow'sas_of, mislabelling which open they precede. Writes are now gated to 20:00—09:30 ET, which also drops the job from about 204 yfinance calls a day to 46. Outside the window the last good snapshot is retained and the page's freshness chip ages from amber to red. - ▸Global instruments are intentionally absent from the `symbols` table.
update_fundamentalsfetches every symbol not prefixed^, so registeringES=F,GC=F,BTC-USD,JPY=Xor000001.SSwould have pushed all of them through the fundamentals path on every run — the same route that has rate-limited the pipeline before — whileprune_delistedwould delete any of them that went stale, taking their candles along. Sincecandleshas no foreign key tosymbols, the new jobs store bars without registering and carry display names inovernight_snapshots.name. All candle writes usesource='yahoo', the only value already in the table, because thecandlesprimary key includessourceand a novel string would add a parallel row per date instead of upserting.
v1.22.12026-08-12
Fixed
- ▸A rate-limited Yahoo fetch could blank a symbol's entire fundamentals row.
_fetch_and_storeswallows a failedyfinancecall into an emptyinfodict and then writes it withINSERT OR REPLACE, which replaces the whole row — wipingnext_earnings_datealong with every other column. The flaw was long-standing, but v1.22.0's new preview refresh added the imminent-earnings symbols to each run, and the first cron tick after that release hit Yahoo's rate limit hard (117 of 123 fetch attempts returnedToo Many Requests). Because the added symbols were precisely the ones reporting soonest, the damage concentrated there: 69 of 294 rows lost their earnings date — TE, CAVA, SMCI, LUNR, GO, LAC and NNE among them — leaving/earningsshowing a near-empty week. The job now bails out before the write wheninfocarries noquoteType, which Yahoo always returns for a live symbol, so a fetch that told us nothing can no longer overwrite good data; the run logs the failure and moves on with the stored row intact. - ▸Rows already blanked now recover on their own. A missing
next_earnings_dateon a company that has reported before is anomalous, and with no date stored none of the earnings refresh branches can fire — those rows would have stayed empty until the unrelated 7-day staleness sweep. They are now re-read, bounded to once every 6 hours per symbol.
v1.22.02026-08-12
Added
- ▸Sector, theme and symbol filters on `/leaders`. The leaderboard now carries the same filter bar as
/technicaland/earnings—All SectorsandAll Themesdropdowns (mutually exclusive) plus a search box matching on ticker or company name — with all three synced to the URL as?sector=/?theme=/?search=so a filtered view is shareable and survives a reload.getLeaderRankingswasn't selecting the group type the dropdowns split on, soLeaderRowgainedgroup_typevia the same subquery already used by the technical and earnings queries. Filtering to no matches now shows "No symbols match your filter" instead of the silently blank table it used to leave behind.
Fixed
- ▸Upcoming earnings dates went stale once Yahoo firmed them up. Yahoo carries an *estimated* earnings date until the company announces its schedule, and when the real date lands it moves earlier about as often as later.
update_fundamentals's refresh gate had no branch for anext_earnings_datestill in the future, so the 7-day staleness sweep was the only thing that would ever re-read it — up to a week of drift, wide enough to straddle the report itself. T1 Energy (TE) was stored as Aug 14 from an estimate captured Jul 28, confirmed by the company as Aug 12 on Aug 6, and/earningswould have shown "Aug 14, 2d" right through the morning it actually reported. A stored date within 3 days now forces a re-read, bounded to once per 6 hours per symbol — deliberately slower than the 60-minute post-report poll, since a date that shifts shifts days ahead, not hours. Costs roughly 190 extra Yahoo calls a day at the earnings-season peak and ~40 on a typical day. - ▸RSI values on `/technical` were centred under right-aligned headers. All five timeframe columns (15m/1H/4H/1D/1W) rendered
text-centerwhile their headers used the default right alignment, leaving the numbers out of line with both their own headers and the MACD columns beside them.
Improved
- ▸`/webhooks` fills its price column progressively. The page requests one price snapshot per distinct rule symbol on load and previously waited for all of them to resolve before committing a single combined update, so the column stayed empty until the slowest response returned. Each price now renders as it arrives. Request count and parallelism are unchanged — at today's 11 rules the difference is a 200 ms flicker, but the requests scale linearly at ~20 ms each and would have meant about a second of blank column at 50 rules.
Changed
- ▸Internal only:
AGENTS.mdpicked up the agent workflow gotchas found during the v1.21.0 session.
v1.21.02026-07-30
Added
- ▸Post-earnings lookback window on `/earnings`. A new dropdown (
Hide Reported/ 1d / 2d / 3d / 5d / 10d, default 5d) keeps recently reported companies visible instead of letting them vanish behind a date three months out. Yahoo rollsnext_earnings_dateforward to the next quarter the moment a company reports, so a stock that printed last night previously sorted to the bottom of the table with no indication it had just reported — unhelpful when the days *after* a print are exactly what a swing trader wants to watch. Flagged rows show the date they reported with a negative day count (Jul 22 (-8d)) and sort above the upcoming names, making the table one continuous timeline: recent prints, then imminent reports, then everything else. The selection persists across visits and is shareable via?reported=Nin the URL. Nothing is ever hidden by the control — rows outside the window simply fall back to their normal upcoming display. - ▸Column tooltips on `/earnings`. All five data columns (Next Earnings, History, Last Surprise %, Avg Move %, Last Reaction %) now explain themselves on hover, matching the pattern already used on
/sectors. - ▸`PENDING` state for reports awaiting results. There is a gap of several hours between a company reporting and Yahoo publishing the actual EPS, during which no
earnings_historyrow exists. Rows in that gap are now detected from a scheduled date that has already passed and taggedPENDING(amber) rather thanREPORTED(cyan), so an overnight print shows up immediately. Last Surprise % and Last Reaction % are dimmed on those rows, since until the result lands they still describe the *previous* quarter and would otherwise read as the market's response to the new report.
Fixed
- ▸Tooltips were painted under the pinned Symbol column. Sticky table headers create their own stacking context, so a tooltip panel's
z-50resolved inside thethrather than against the table and lost to the pinned first column'sz-10/z-20. The panel now renders through a portal intodocument.body. Affected every table with a pinned first column —/leaders,/technical,/volumeand/earnings. - ▸Earnings results were never fetched for any company that reported on the same day it was last refreshed.
update_fundamentals's refresh gate requirednext_earnings_date > fetched_at, so a symbol fetched the morning of its report was skipped that evening when the actual EPS became available, and stayed skipped until the unrelated 7-day staleness sweep picked it up a week later. Against a production snapshot this left 28 leaders — META, MSFT, QCOM, KO, V, PG, BA, PYPL, EA, HOOD among them — sitting with an un-ingested report. The gate now keys offearnings_historyinstead: a symbol refreshes when its report date has passed and no history row exists for it, and stops as soon as one lands. Because the job runs on every cron tick, polling is bounded to 3 days past the report date and at most once per hour per symbol so an actual that never arrives can't hammer the Yahoo API.
v1.20.22026-07-28
Fixed
- ▸`/earnings` mixed BMO/AMC rows on the same date. Sorting by Next Earnings only ordered by day, so before-open and after-close reports for the same date interleaved in whatever order they came back from the query. Same-day rows now order before-open (sun) first, then unknown-timing, then after-close (moon).
v1.20.12026-07-28
Fixed
- ▸`run_update.py` could die mid-pipeline and skip every job after the failure point. Root cause: Yahoo Finance was rate-limiting (HTTP 429) the VPS's IP, and three batch-fetch call sites (
update_daily,update_premarket,update_intraday) had notry/exceptaroundyf.download()— when the rate limit was severe enough for yfinance to raise instead of returning empty data, the exception was uncaught and crashed the whole script, silently skipping rankings, webhooks, indicators, and fundamentals for that cycle. Each of the 11 pipeline steps now runs through a wrapper that catches and logs a failure (ERROR in <job>: ...+ traceback) and continues to the next job instead of aborting. - ▸Manual "Refresh" runs were never logged anywhere. Only cron-triggered updates wrote to
logs/update.log; a manual refresh's output only ever reached a transient toast in the browser.POST /api/refreshnow appends its full output (success or failure) to the same log file the/logspage already reads, under aManual refresh @ <timestamp>header. - ▸The refresh error toast hid the actual failure reason. It rendered in a CSS-truncated single line, so only Node's generic "Command failed: ..." prefix was ever visible — the real traceback was cut off. It now reads "Update failed — see Logs", linking to
/logswhere the full output is durably kept.
Changed
- ▸
logs/was untracked instead of gitignored — added to.gitignore.
v1.20.02026-07-28
Added
- ▸New `/earnings` page. A sortable table of every leader stock's earnings situation: next earnings date with countdown, a beat/miss history shown as a hoverable dot streak (last 8 quarters), last surprise %, average historical move % (post-earnings reaction magnitude, direction-agnostic), and last reaction % (actual return the session after the most recent report). Sector/theme/symbol filters match the
/technicalpage pattern. Added to the sidebar nav after Premarket. Built entirely on existing fundamentals/earnings-history data — no new data source required. - ▸BMO/AMC earnings timing. Next earnings dates now show an estimated before-open (sun) or after-close (moon) icon — same icon set already used for the premarket/aftermarket session indicator — on both the
/earningstable and the earnings countdown badge on/symbols/[symbol]. Estimated from the hour of the underlying earnings timestamp, since Yahoo doesn't always confirm an exact release slot. - ▸Engine:
fundamentals_snapshotsnow also stores the rawnext_earnings_timestamp(previously only a date-only value was kept), which powers the BMO/AMC estimate above.
v1.19.22026-07-27
Changed
- ▸Mobile UX pass on `/sectors` and price charts. Removed the ETF ticker column from
/sectors(redundant with the group name, and blank for many no-ETF themes) on both desktop and mobile. Hid the sector/theme type badge on mobile, where it was wrapping the group name onto up to three lines. Disabled single-finger touch-drag panning on price charts (used on/symbols/[symbol]and/groups/[id]) so a touch scroll over the chart scrolls the page instead of panning candles; pinch-to-zoom is unaffected.
v1.19.12026-07-27
Fixed
- ▸DJI and VIX not populating in the topbar.
^DJIand^VIXwere added touniverse.yamlbutseed_universe.pywas never re-run afterward, so neither symbol ever reached thesymbols/groupstables and both showed a blank dash.run_update.pynow runs the universe seed step first on every update cycle, so futureuniverse.yamladditions sync automatically instead of requiring a manual script run.
v1.19.02026-07-16
Added
- ▸Score trend sparkline on
/groups/[id]— plots the group's last ~20 recorded composite scores next to the Score value, so trajectory (improving vs. worsening) is visible at a glance instead of requiring two static snapshots to compare. - ▸Peer rank badges on
/groups/[id]— Rank (1D), Rank (5D), Rank (1M), and Rank (3M), each showing the group's position among all groups (e.g. "4 / 33"), colored by quartile. Anchors the composite score against its peers instead of showing it in isolation. - ▸ETF candlestick chart on
/groups/[id]— reuses the existing symbol-detail price chart (MA20/MA50, volume pane, range selector) for the group's ETF, giving price/trend context that was previously only available on individual symbol pages. - ▸Leader breadth row on
/groups/[id]member table — showspositive/totalleaders per RS timeframe (Day/5D/1M/3M/6M/1Y), colored by majority, so dispersion across the group's leaders is visible without reading every row.
Changed
- ▸Group member table now stretches full width (
w-full) to match the width of the new ETF price chart above it.
v1.18.02026-07-16
Added
- ▸Gap % fallback for ETF-less themes on
/premarket. Themes with no ETF (e.g. China Electric Vehicles, Restaurants US, Natural Gas, AI Infrastructure) now show a Gap % averaged from their leader stocks' premarket returns, tagged with an "avg" indicator and tooltip, instead of a blank dash.
v1.17.02026-07-12
Added
- ▸Verdict signal chip row on
/symbols/[symbol]— rules-based state chips (UPTREND/DOWNTREND/RANGE,RS LEADER/RS LAGGARD,RS IMPROVING/RS FADING,EXTENDED,NEAR 52W HIGH/NEW 52W HIGH,RSI STACKED) with hover explanations, consolidated into one row under the company name alongside the volume signal and earnings countdown badges. - ▸Analyst Opinion card in Fundamentals — consensus rating and buy/hold/sell breakdown, mean/high/low/median price target with upside vs. current price, and a table of recent analyst upgrades/downgrades. Sourced from yfinance and refreshed on the existing fundamentals cycle; fills the former "Analyst Opinion" placeholder.
- ▸Arrow-key navigation in the side panel. With a symbol panel open,
ArrowLeft/ArrowRightstep to the previous/next symbol in whichever table the panel was opened from (Leaders, Technical, Volume, Group Members, etc.).
Changed
- ▸Chart mouse-wheel zoom now requires Ctrl/Cmd+scroll. Plain scroll over the price chart scrolls the page instead of zooming the chart.
- ▸Price/metrics block left-aligns instead of right-aligning when the symbol detail is shown in the side panel, so it no longer overflows the narrower width.
Fixed
- ▸Signal and volume tooltips no longer render off-screen. They now flip below their trigger when there isn't room above (e.g. near the top of the viewport), and render through a portal so the side panel's slide-in animation no longer displaces them past the right edge of the window.
v1.16.02026-07-12
Added
- ▸RS trend, relative-volume, and RSI sparklines on
/symbols/[symbol]— 30-day cumulative RS vs SPY and RSI trend, plus a 20-day relative-volume trend, surfaced in the header and Technical Summary so direction (not just level) is visible at a glance. - ▸Percentile ranks on the returns grid. Each 5D/1M/3M/6M/YTD/1Y tile now shows a "Top X%" or "Bottom X%" sub-label ranking the symbol's raw return against all leader symbols at the latest
as_of. - ▸Group Context section on
/symbols/[symbol]— group name, overall group rank (e.g. "#10/33"), and a 5-row mini-leaderboard of sibling leaders (1M relative return, rel vol) with the current symbol highlighted. - ▸MA20/MA50/MA200 overlays on the price chart, each independently toggleable (MA200 defaults off). Computed from the full retained candle history so lines stay stable across zoom ranges.
- ▸Volume pane on the price chart — a colored up/down histogram in its own pane below the candles.
- ▸Earnings markers on the price chart — small "E" badges on past earnings dates, colored green (beat) or red (miss), anchored to a fixed baseline at the bottom of the volume pane.
- ▸Earnings countdown badge in the
/symbols/[symbol]header, shown when the next earnings date is within 30 days; switches to warning color under 10 days. The same badge/threshold is now also shown next to the earnings date in Fundamentals. - ▸Earnings track record and last reaction in Fundamentals — "Beat X of 8 · avg surprise Y%" and the stock's close-to-close return the session after its most recent earnings report.
Changed
- ▸Company description hidden by default on
/symbols/[symbol], behind an "About Company" toggle placed next to the company name (previously a 2-line clamp shown by default) — saves vertical space. - ▸Chart pane divider color darkened to match the existing border token (was using the charting library's default light gray).
Removed
- ▸Per-symbol Fundamentals "Refresh" button and its API route — decided not worth the added surface; fundamentals still refresh on the regular engine schedule.
- ▸"as of <date>" timestamp removed from the Fundamentals Valuation header.
v1.15.02026-07-10
Added
- ▸Daily candlestick price chart on `/symbols/[symbol]`. Uses
lightweight-charts(TradingView's own charting library) against the existingcandlestable — daily bars only for now, spanning the full ~1 year of retained history. Includes a range selector (1Y / 6M / 3M / 1M / 1W / 1D), a fixed right-side margin so the latest candle never sits flush against the price labels, and zoom/pan clamped to the actual data bounds.
v1.14.02026-07-10
Added
- ▸Symbol detail opens in a side panel. Clicking a symbol from Leaders, Technical, Volume, or Group Members now opens the symbol detail view in a right-side panel instead of navigating away, reusing the same data-fetching and content as the dedicated
/symbols/[symbol]page. Direct navigation, refresh, and new tabs still render the full page. The panel offers an "Open full page" link, and closing returns to the page you started from. - ▸Fintel and Stocktwits shortcuts added to the shortcuts bar on
/symbols/[symbol], alongside TradingView. - ▸Volume signal badge surfaced immediately in the page header (next to Price / Today / Vs SPY) on
/symbols/[symbol]when a signal (e.g. "Bullish Breakout") is active, instead of requiring a scroll to a dedicated section.
Changed
- ▸Company description clamps to 2 lines (previously 3) before "Read more" on
/symbols/[symbol].
Removed
- ▸"Group Context" and "Recent Alerts" sections removed from
/symbols/[symbol]. Group membership is still shown as a badge in the page header; alerts will be reworked in a future pass. - ▸Dedicated "Volume Signal" section removed from
/symbols/[symbol]— replaced by the header badge above.
v1.13.02026-07-10
Added
- ▸YTD return on symbol detail page. Calendar year-to-date return (vs. SPY) now shown alongside 5D/1M/3M/6M/1Y in the Performance And Volume section on
/symbols/[symbol]. Stored as a separateytdtimeframe incomputed_returnsso it does not feed group scoring or rankings.
v1.12.02026-06-17
Changed
- ▸Column and title renames. "Today %" / "Today RS" shortened to "Day %" / "Day RS" across
/technical,/sectors,/volume,/leaders, and/groups/[id]. "Sector Rotation Matrix" shortened to "Sector Rotation". "Volume Spikes" shortened to "Volume". - ▸Removed the `#` rank column from
/sectors,/volume,/leaders, and/premarket— redundant with sortable columns. - ▸Premarket column moved next to Price on
/technical(previously the last column). - ▸Glossary button is now icon-only (the "Glossary" text label was dropped) on all four pages that use it.
Improved
- ▸Frozen first column and table header on scroll for
/technical,/volume,/leaders, and/sectors— the symbol/group column and column headers now stay pinned while scrolling through long tables, both vertically and horizontally. - ▸Company name hidden on mobile next to ticker symbols on
/technical,/volume, and/leadersto reduce row width on small screens. - ▸Mobile filter layout on `/technical` no longer overflows the viewport — filters stack below the title and share width evenly.
- ▸Signal filter buttons on `/volume` use tighter padding on mobile.
Fixed
- ▸Sticky header transparency. Column headers no longer show row content "ghosting" through them while scrolling —
SortableHeadernow has a solid background. - ▸Sort arrow wrapping. The ▲/▼ sort indicator no longer wraps onto its own line under the column label.
- ▸RS trend arrow wrapping on
/sectorsno longer drops to a new line under the percentage value.
v1.11.22026-06-08
Fixed
- ▸Group drill-down Today % / Today RS blank during premarket.
getGroupDetailnow checks whether intradaycomputed_returnsexist for today before using the intraday join. If they don't (premarket, weekends, or before the intraday job runs), it falls back to the latest 1d values so the columns are never blank.
v1.11.12026-06-08
Fixed
- ▸Leaders table empty when intraday data is present.
getLeaderRankingswas derivingas_offromgroup_scores(a daily date) even when called withtimeframe="intraday", so the query found zero rows. It now derivesas_offromcomputed_returnswhen the intraday timeframe is requested. - ▸Premarket column on `/technical` not sortable. The column header was a static
<th>; replaced with<SortableHeader>.
v1.11.02026-06-08
Added
- ▸PM % column on group drill-down (`/groups/[id]`). Shows each member's premarket return vs the previous close, populated from today's premarket data.
Improved
- ▸PM Vol hidden when unavailable. The PM Vol column on
/premarketis now hidden when yfinance returns no volume data for extended-hours bars, rather than showing a column of dashes. - ▸Quieter update logs. Per-symbol OK lines removed from
update_daily. yfinance "possibly delisted" spam suppressed. Failed symbols are now collected and printed as a single summary line (e.g.missing: AAPL CRM CMG) so rate-limit drops remain visible without flooding the log. - ▸Clear button on `/logs`. Truncates the log file in place. Two-step confirm (first click → "Sure?", second click → clears) to prevent accidental deletion.
v1.10.12026-06-08
Improved
- ▸Top bar cleanup. Removed the "AS OF" date label from the middle of the bar. Last data timestamp (date + time ET) now appears next to the Refresh button, always visible and always showing hours and minutes — falling back to the latest daily candle time when no intraday data is available.
- ▸Webhooks table. Added a Current Price column after Threshold, populated on page load from the snapshot API for each symbol. Shows
-for volume metric rules.
v1.10.02026-06-08
Changed
- ▸Alerts panel reworked. The bell icon now shows webhook-fired notifications instead of the old auto-generated signal-change and volume-spike alerts. Alerts are written to the database each time a webhook rule fires, using the same message sent to Discord. Old alerts are cleared on first load via an automatic schema migration.
Added
- ▸Mark alerts as read. Each alert in the panel has a checkmark button to dismiss it individually. A "Mark all read" button clears the badge in one click. Read alerts dim to 40% opacity and remain visible for reference. Badge count shows unread alerts only.
Removed
- ▸Daily signal-change and volume-spike alert generation (
generate_alertsjob removed from the update pipeline).
v1.9.12026-06-08
Added
- ▸Logs page (`/logs`). Displays the last 300 lines of the
run_update.pycron output. Auto-refreshes every 30 seconds with a live countdown. Section headers, errors, webhook fires, and skipped rules are colour-coded for quick scanning. Accessible under Admin in the sidebar.
v1.9.02026-06-08
Added
- ▸Webhook Alerts page (`/webhooks`). Configure per-ticker alert rules that POST to a Discord channel when a market condition is met. Supports three metrics: Price (crossing up/down/either), Relative Volume (exceeds threshold), and Pre-market Relative Volume (exceeds threshold). Each rule has a trigger mode (Every Time or Once Only) and an optional note appended to the Discord message.
- ▸Discord webhook settings panel (Settings → Webhooks). Global Discord webhook URL with a test button that sends a sample message to verify the integration.
- ▸Symbol snapshot strip in the rule modal. Selecting a ticker shows a compact stat bar with the current price and 1D / 5D / 1M / 3M / 6M / 1Y return percentages, colour-coded positive/negative.
- ▸Auto-fill price threshold. The Price Threshold field is automatically populated with the latest known price whenever the symbol or metric changes; switching to a volume metric clears the field instead.
- ▸`fire_webhooks` Python job. Evaluates all enabled rules on every
run_update.pycycle. First run establishes a baseline; crossing is detected on subsequent cycles. Once-Only rules self-disable after firing.
v1.8.12026-06-08
Added
- ▸Pre-Market Radar page (`/premarket`). New dedicated pre-open scan showing all sectors and themes ranked by their ETF gap vs SPY. Columns: Gap %, vs SPY, PM Rel Vol (premarket volume vs historical premarket average), 5D RS, 1M RS, 3M RS. Gap data populates 04:00–09:30 ET; RS columns always show the latest daily values.
Changed
- ▸Premarket nav item is now a live link (previously disabled). Sidebar no longer gates it behind the display preference toggle; the premarket toggle in Settings → Display remains for the Technical page column only.
v1.8.02026-06-07
Added
- ▸Premarket data job. New
update_premarketjob fetches 5-minute candles via yfinance (prepost=True), filters to the 04:00–09:29 ET window, stores them incandleswithis_extended_hours=1, and writes premarket returns for all symbols intocomputed_returns(timeframepremarket). Runs automatically as part of the daily update pipeline. - ▸Premarket % column on Technical page. The Premarket column (Settings → Display) is now fully enabled and shows each leader's pre-market return vs the previous close, colour-coded green/red. No premium API required — powered by the new premarket job.
- ▸Market session badge in the top bar. The bar now shows context-aware session state: a sun icon + PRE (amber) during pre-market hours, the existing green pulsing dot + LIVE during regular trading, and a moon icon + AFT (blue) during after-hours. Nothing is shown when the market is closed.
v1.7.32026-06-03
Fixed
- ▸New group members deleted on first refresh. Symbols added via Settings were being pruned as "delisted" on the next global refresh because they had no candle history yet. The prune job now skips any symbol actively referenced as a leader or ETF in
group_members.
v1.7.22026-06-02
Fixed
- ▸Fundamentals chart tooltip labels. Revenue and Margins tooltips now show the series name alongside the value (e.g. "Revenue : $3.06B") instead of a bare number.
- ▸Dynamic B/M units on financial charts. Revenue and Cash Flow charts now display values in billions when ≥ 1B and in millions otherwise (e.g. "$210M" instead of "$0.21B"). Y-axis ticks use the same logic, with zero rendered as "0" rather than "0.0M".
v1.7.12026-06-02
Improved
- ▸Symbol page performance cards. The Performance and Volume section now shows raw return alongside vs-SPY relative strength for each period (5D, 1M, 3M, 6M, 1Y) using the same bordered-card style as Technical Summary. Raw % is the primary value; "vs SPY" sits smaller below it, making the data immediately readable without requiring knowledge of relative-strength conventions.
- ▸6M and 1Y RS columns. Sectors table and Group Detail member table now include 6M RS and 1Y RS alongside the existing 5D/1M/3M columns, giving a fuller picture of trend duration.
Fixed
- ▸Fundamentals re-fetch for missing earnings. Symbols that have a fundamentals snapshot but zero earnings history entries now trigger a re-fetch on the next update run, catching stocks that were first fetched before the earnings feature shipped.
- ▸Prune delisted job wired in.
prune_delistednow runs at the start of everyrun_update.pycycle, keeping the universe clean without manual intervention.
v1.7.02026-06-01
Added
- ▸Cash Flow chart tab. New "Cash Flow" tab on the Fundamentals charts panel showing quarterly Operating Cash Flow and Free Cash Flow as grouped bars. Capex is shown in the hover tooltip. Data sourced from
ticker.quarterly_cash_flowandticker.cash_flowvia yfinance (~6–7 quarters of history).
Fixed
- ▸Misclassified asset types.
update_fundamentalsnow uses Yahoo Finance's ownquoteTypefield as the source of truth instead of the localsymbols.asset_type, and writes the correction back to thesymbolstable. Symbols like IAG that were wrongly registered as ETF are auto-corrected on their next refresh. - ▸Symbol page crash on fresh DB.
getFundamentalsSnapshotandgetFundamentalsHistorynow returnnull/[]instead of throwing when the fundamentals tables don't exist yet. - ▸Stale fundamentals missing description and earnings. Symbols fetched before the
long_summaryandearnings_historyfeatures were added now automatically re-fetch on the nextrun_update.pyrun rather than being skipped by the 7-day staleness guard.
v1.6.02026-06-01
Added
- ▸Company fundamentals on symbol pages. Each
/symbols/[ticker]page now has a Fundamentals section backed by Yahoo Finance. A snapshot panel shows trailing P/E, forward P/E, P/B, P/S, EV/EBITDA, market cap, gross/operating/net margins, revenue growth, and forward EPS. Data is cached in SQLite and refreshed when stale (>7 days) or when an earnings date has passed since the last fetch. - ▸Fundamentals charts. Four chart tabs (Revenue, Earnings, Valuation, Margins) show quarterly historical data via Recharts. The Earnings chart uses non-GAAP reported EPS from
ticker.earnings_datesand includes analyst estimates and beat/miss surprise % in the tooltip. Bars are color-coded green/red. - ▸Company description.
longBusinessSummaryfrom Yahoo Finance is shown in the symbol page header below the company name, collapsed to 3 lines with a "Read more" toggle. - ▸Force-refresh button. A per-symbol refresh button on the Fundamentals panel triggers a live fetch of
ticker.infoandticker.earnings_dates, bypassing the staleness cache, and re-renders the page on completion. - ▸`update_fundamentals` job. New Python job (
services/engine/jobs/update_fundamentals.py) runs as part ofrun_update.py. Fetches fundamentals snapshot, income statement history, and earnings release history per symbol, skipping ETFs and index symbols for income statement data.
Changed
- ▸Planned Data section. The "Fundamentals" placeholder panel has been removed; the three remaining placeholders (News, Retail Sentiment, Analyst Opinion) are kept in a 3-column grid.
v1.5.02026-05-29
Added
- ▸Collapsible sidebar. A hamburger toggle button in the top bar collapses and expands the navigation sidebar, giving the full viewport width to the content area. Desktop preference is persisted in
localStorage. On mobile the sidebar always loads collapsed and closes automatically after navigating to a page.
v1.4.12026-05-28
Fixed
- ▸Mojibake in source files. The
×character was corrupted to×inGlossaryDrawer,GroupsPanel, andScoringPaneldue to a Windows UTF-8/Windows-1252 encoding mismatch. Replaced with Unicode escapes (×) which are immune to this class of corruption.
Changed
- ▸Sectors table column headers. Removed the
?badge from tooltipped column headers — tooltips still activate on hover, the icon is no longer shown.
v1.4.02026-05-28
Added
- ▸Symbol detail page (`/symbols/[ticker]`). Clicking any ticker in the Leaders, Volume, Technical, or Group Members tables now navigates to a dedicated page for that symbol. The page shows: price and today's return vs SPY; group memberships; 5D / 1M / 3M relative strength, relative volume, and z-score; the volume signal classification with its description; RSI across five timeframes (15M, 1H, 4H, 1D, 1W) and MACD on the 15M; recent alerts from the last seven days; and placeholder panels for future data layers (fundamentals, news, sentiment, analyst opinion).
- ▸TradingView shortcut. A compact shortcuts bar below the symbol header links directly to TradingView for the current ticker, opening in a new tab. Additional external services can be added to the
SHORTCUTSarray in the page file. - ▸Symbol links in all tables. Tickers in the Leaders, Volume, Technical, and Group Members tables are now hyperlinks to their symbol page.
Improved
- ▸Technical Summary card layout. RSI and MACD cards now use
sm:grid-cols-4at medium width (wassm:grid-cols-5), giving an even 4 + 4 wrap before the full 8-column row at large screens. - ▸RSI / MACD label casing. All timeframe suffixes are consistently uppercase (15M, 1H, 4H, 1D, 1W).
v1.3.02026-05-28
Added
- ▸LIVE indicator moved to global header. The pulsing "LIVE · HH:MM ET | local" badge now appears in the top bar on every page (left of the Refresh button), replacing the per-page indicator that only existed on
/sectors. - ▸"as of" timestamp. The top bar date label now appends
· HH:MM ETwhen intraday data is available, so the last update time is always visible.
Improved
- ▸Alert bell redesigned. Replaced the inline icon+count with a bordered bell button and a corner pip, matching standard notification affordance. Dropdown now uses
fixedpositioning so it is no longer clipped by the header'soverflow-hidden. - ▸Volume table: "vs SPY" column removed. The relative-return column was visually redundant with Today % on most days. The value still drives all signal logic in the background and is shown in the signal tooltip conditions.
- ▸Volume table: "No Setup" replaces dash. Rows with data but no matched signal now show a quiet "No Setup" label (no pill,
text-text-3) with a tooltip explaining why, instead of an ambiguous em-dash. - ▸Tooltip `showIndicator` prop. Tooltips can now suppress the
?circle glyph when the trigger element is self-explanatory. - ▸Sector type pill contrast. The
sector/theme/indexpill in/sectorsusestext-text-3instead oftext-text-4for legibility.
v1.2.02026-05-27
Added
- ▸Settings > Display panel. New tab in
/settingswith a toggle for the Premarket column. When off, the Premarket column is hidden from the Technical table and the Premarket nav item is hidden from the sidebar. Preference stored inapp_settings(display_preferenceskey).
Improved
- ▸Company name display. New
fmtCompanyNamehelper strips trailing legal suffixes (Inc., Corp., Ltd., LLC, plc, N.V., S.A., AG, etc.) from company names shown in the Volume, Leaders, and Technical tables, reducing visual noise for long names. - ▸Signal column is now sortable. Market Radar Signal column (HOT / STRONG / WATCH / WEAK) now supports ascending/descending sort via a numeric tier mapping.
v1.1.02026-05-27
Fixed
- ▸Volume z-scores now correct on repeat intraday runs. When the engine ran more than once during the trading session,
update_dailywas skipping the 1d candle refresh for symbols already fetched that day. Leader stocks (which have no 5m candle history) were then using a stale early-morning partial volume as their projected full-day volume, collapsing nearly all z-scores to deeply negative values. ETFs were unaffected because they derive cumulative volume from the fresh 5m candle sum. Fix:update_dailynow re-fetches today's partial candle for all symbols when the market is open, so every subsequent engine run gives leaders an up-to-date volume baseline.
v1.0.22026-05-27
Fixed
- ▸Group members table restored.
/groups/[id]was showing a Glossary instead of the leader members table —GroupMembersTable.tsxwas accidentally overwritten with Glossary content during the v1.0.0 design system rename pass. Symbol, company, price, returns, and relative volume columns now display correctly.
v1.0.12026-05-27
Fixed
- ▸Group detail page restored.
/groups/[id]was accidentally overwritten with Glossary content during the v1.0.0 design system rename pass. Page now correctly shows group name, ETF metrics, intraday data, and leader members table. - ▸Rank numbers in Market Radar now render in
text-4decorative tone (#4A525E) as intended. A stale--color-text-primaryreference in the base CSS rule was preventing the token rename from taking effect. - ▸Type pills (THEME / SECTOR) in Market Radar group column are now uppercase.
v1.0.02026-05-27
Added
- ▸Design system v2 — full token, font, and component overhaul. Replaces the original ad-hoc palette with a cohesive v2 system: OKLCH semantic colors, IBM Plex Sans + JetBrains Mono via
next/font/google, and a four-level text contrast ramp (text-114.8:1 →text-42.2:1). - ▸ScoreBar component. Score column on Market Radar now renders a numeric value above a proportional magnitude bar (max = 12), color-coded by tier (HOT amber, STRONG green, WEAK red, WATCH blue, NEUTRAL gray).
Changed
- ▸SignalBadge redesigned as soft-tinted pills with a leading pip dot and tier-matched border — replaces solid-fill badges.
- ▸Market Radar table restructured: Signal column moved to position 4; tier row accents (HOT amber wash + left border, STRONG/WEAK/WATCH left borders); group type pill (THEME purple, SECTOR muted) uppercase; rank column uses decorative
text-4tone. - ▸Sidebar active route gets a blue left accent; section group labels use
font-monocaps; disabled Premarket item usestext-4decorative tone. - ▸TopBar index chips display symbol, directional caret, and signed percent in a bordered pill; "as of" timestamp uses muted uppercase styling.
- ▸Tooltip
?badge upgraded to 11px circled icon withborder-border-strong. - ▸Volume page filter chips pick up semantic color when active (Bullish green, Bearish red, Breakouts/High Conviction amber, Watch blue); Top Active Groups cards get a 2px left accent by tier; volume table rank column uses
text-4, z-scores ≥ 2σ use amber warning color.
Improved
- ▸Text contrast across all pages:
text-3(#6B7480, 3.4:1) for readable metadata;text-4(#4A525E, 2.2:1) reserved for decorative chrome (rank numbers, em-dashes, dividers) only.
v0.6.02026-05-26
Fixed
- ▸Rel Vol and Z-Score no longer deflate during market hours. The root cause was that today's daily candle is partial mid-session, making raw volume appear artificially low. Both metrics now use a projected full-day volume instead.
Added
- ▸Volume pace (VP%) projection for Rel Vol and Z-Score. During market hours, today's running cumulative volume is divided by the median historical participation fraction at the same time of day — the same volume pace method used in institutional OMS systems (Bloomberg, Fidessa). The 20-day baseline always uses only complete historical sessions.
- ▸Automatic 5m candle history backfill. On first run, the engine fetches up to 60 days of historical 5m data for all ETFs and benchmarks to seed the participation curves. Subsequent runs skip this step in under 0.1s.
- ▸Projection fallback chain. Each symbol resolves its curve in order: own 5m history → group ETF's curve → SPY curve → linear (time-proportional) → raw candle. No symbol is left without an estimate.
Improved
- ▸Glossary — Rel Vol entry rewritten to explain the VP% projection, the 20-day completed-session baseline, and the Bloomberg/Fidessa context. Includes an intraday worked example.
- ▸Glossary — Z-Score entry updated to note it uses the same projected volume as Rel Vol for consistency throughout the session.
v0.5.02026-05-26
Added
- ▸Contextual Glossary drawer on every content page. A "? Glossary" button next to each page title opens a slide-in panel showing only the terms relevant to that page. Currently wired on Market Radar, Leaders, Technical, and Volume. Full glossary at
/glossaryis unchanged. - ▸Single source of truth for glossary content. All terms live in
lib/glossaryTerms.ts. Each term carries apagesarray — adding a term to a page drawer requires only adding the page name to that array, with no other files to change. - ▸RSI and MACD terms in Glossary. Three new entries: RSI — Relative Strength Index (with overbought/oversold zone table), RSI Timeframes (15m / 1h / 4H / 1D / 1W explained), and MACD — 15m (EMA12 − EMA26 formula, line vs signal interpretation).
- ▸"Triggered by" section in volume signal tooltips. Hovering a Volume Signal badge now shows a two-layer tooltip: the plain-English meaning followed by the exact values that caused the signal to fire (e.g. Today +19.87% ≥ +5%, Z-Score +5.9σ ≥ +3σ). Only the metrics that met their threshold are shown for OR-gated signals.
Changed
- ▸Volume signal tooltip text rewritten to be shorter and less confident. Removed overstatements such as "Institutional buying likely" in favour of observational language with explicit prompts to verify the catalyst.
- ▸Inactive filter buttons and helper buttons stepped up from
text-text-mutedtotext-text-secondaryfor legibility.text-text-muted(#474D57) was producing ~1.6:1 contrast against dark backgrounds — now reserved for decorative chrome only (row numbers, separators, placeholder dashes).
Improved
- ▸ARCHITECTURE.md, FEATURES.md, and DESIGN.md updated to document the glossary single-source-of-truth pattern, the volume feature set, volume signal badge color conventions, the contextual glossary drawer pattern, and the text contrast rules.
v0.4.12026-05-25
Added
- ▸Top Active Groups cards above the Volume Spikes table. Groups are scored using
bullish − bearish + (★ bullish × 2) + (Momentum Move × 0.5)and classified into five tiers (Strong Bullish → Strong Bearish). Each card shows group name, classification, bullish/bearish signal counts, average today % and vs SPY, and top symbols. Max 6 cards, sorted by score. Cards respect the active ETF/Stock and group type filters. - ▸Top Active Groups entry in Glossary — formula, classification table with score ranges, and a worked example.
v0.4.02026-05-25
Added
- ▸Volume Signal column in Volume Spikes table. Each row is automatically classified into one of nine signals (Bullish Breakout, ★ Strong Bullish Breakout, Momentum Move, Bearish Breakdown, ★ Strong Bearish Breakdown, Bearish Momentum, Buying Pressure, Selling Pressure, Bullish Watch) based on price return, SPY-relative return, Z-Score, relative volume, and — for Bullish Watch — trend context from MA20/MA50 and multi-period returns. Badge shows beginner-friendly label; hover reveals professional name and meaning. Column is sortable.
- ▸Volume Signals section in Glossary. Each signal is documented with its beginner description, professional name, and exact pseudo-formula used by the classifier.
Changed
- ▸ETF filter unchecked by default on the Volume Spikes page. Filter state (ETF toggle, group filter) is now persisted in sessionStorage so choices are restored when navigating back to the page.
- ▸Bullish Breakout and Bearish Breakdown use an OR gate for volume confirmation. Previously all four conditions (price, vs SPY, Z-Score, Rel Vol) were required simultaneously. Now a large price move (≥ +7% / ≤ −7%) or high relative volume (≥ 2×) alone satisfies the volume requirement, preventing strong movers from showing blank.
v0.3.42026-05-20
Added
- ▸Price column in group detail view. Latest daily close price shown as the first numeric column after the stock name, consistent with the Technical Indicators page.
Removed
- ▸Role column removed from group detail view. All listed stocks are leaders, so the column added no information.
v0.3.32026-05-20
Changed
- ▸"All" filter on Market Radar excludes index groups. Sectors and themes are shown by default; click the new "Index" filter button to view index groups separately.
Improved
- ▸Filter state persists across navigation. Active filters on Market Radar, Volume Spikes, and Technical Indicators are stored in the URL (e.g.
?filter=sector), so navigating to a group detail page and pressing "← Market Radar" restores the exact filter. Hard-refreshing a filtered URL also restores state.
v0.3.22026-05-19
Added
- ▸Company names in symbol columns. Leaders, Technical, and Volume pages now show the full company name next to each ticker in a small muted label.
- ▸Company name backfill job.
backfill_company_namesruns at the start of every update cycle, fetching names from Yahoo Finance for any symbols missing them.
v0.3.12026-05-18
Changed
- ▸Dark thin scrollbar across the entire UI. 6px track, dark background matching
--color-bg, subtle gray thumb (--color-border) that brightens on hover (--color-border-strong). Covers both Firefox (scrollbar-width/color) and Chromium (-webkit-scrollbar).
v0.3.02026-05-18
Added
- ▸Changelog page at `/changelog`. Reads
CHANGELOG.mdfrom the repo root at request time usingfs.readFileSync— no extra dependencies. Renders version headers, section tags (Fixed,Added,Changed, etc.), inline bold and backtick code spans. Accessible from the sidebar nav and by clicking the version label below the logo.
Changed
- ▸Section tags are color-coded:
Fixed/Security→ red,Added/Performance→ green,Changed/Improved→ amber,Removed/Deprecated→ gray. - ▸Bullet points use a
▸amber arrow for a terminal-style look consistent with the rest of the UI.
v0.2.22026-05-18
Fixed
- ▸Top bar ticker values showed the previous day's close instead of live prices.
getIndexPerformance()was anchored exclusively to the last completed1dgroup score date, so SPY, QQQ, IWM and DIA always displayed their prior-day return untilcompute_rankings.pyre-ran after market close. The query now prefers today's intradayreturn_pctfromcomputed_returnswhen available (populated byupdate_intraday.pythroughout the trading day), falling back to the last daily close for symbols without intraday data (^GSPC,^IXIC).
v0.2.12026-05-18
Fixed
- ▸Top bar indexes (SPY, NASDAQ, etc.) were also frozen at build time.
layout.tsxfetches index performance and theas-ofdate for the top bar, but was not marked dynamic. Addedexport const dynamic = "force-dynamic"so the top bar re-fetches on every request alongside the page data.
v0.2.02026-05-18
Fixed
- ▸Pages no longer serve stale build-time data. All data pages (
/sectors,/leaders,/volume,/technical) were missingexport const dynamic = "force-dynamic", causing Next.js to pre-render them at deploy time and cache that snapshot indefinitely. Added the directive so each page queries SQLite fresh on every request. The manual refresh button and scheduled data updates now reflect immediately in the UI. - ▸Python jobs use explicit UTC timezone.
update_daily.pyandupdate_intraday.pynow usedatetime.now(timezone.utc)consistently, matchingcompute_indicators.py. - ▸`update_daily.py` no longer re-fetches the last stored candle. Incremental fetch start is now
last_date + 1 dayinstead oflast_date, avoiding a redundant yfinance download on every run.
v0.1.0initial release
- ▸Daily and intraday market radar with sector/theme group rankings
- ▸Leader stock rankings with relative strength across timeframes
- ▸Volume spike scanner with Z-score anomaly detection
- ▸RSI (5 timeframes) and MACD 15m technical indicators for all leaders
- ▸Configurable scoring weights, RSI thresholds, and group/member management
- ▸Automated deploy pipeline via GitHub Actions to VPS