Compare commits

...
Author SHA1 Message Date
Michael Shimeles 49648c2b82 docs(architecture): single-writer gateway failover with synchronous ack and epoch fence 2026-10-29 14:30:44 -04:00
Michael Shimeles a90131a858 docs(architecture): Tokyo bare-metal hosting options, rule out Freestyle, colocate gateway with node 2026-10-29 14:22:39 -04:00
Michael Shimeles 84719f50f8 docs(architecture): ingest from our own non-validator node from day one
Live data now comes only from our Tokyo hl-node outputs and local info server: own L4/L2 book builder, candles at every interval with no cap, mids/marks/oracle/funding sourcing, node ops (spec, logs, monitoring, catch-up, failover, cost). Public API is limited to a one-time backfill.
2026-10-29 14:14:57 -04:00
Michael Shimeles 285b9d765b docs(architecture): address review on bar closes, SnapTrade modes, doc-op idempotency 2026-10-29 13:55:47 -04:00
Michael Shimeles 96453ac45d docs(architecture): design the Terminal data server 2026-10-29 13:46:41 -04:00
Michael ShimelesandClaude Opus 5.5 7d999a8e19 Rename the project from terminal-rs to terminal (#27)
* chore: rename the project from terminal-rs to terminal

The crate and binary are now `terminal`, and user-facing text, docs, CI,
scripts and repo URLs drop the -rs.

Saved data follows the new name:
- The desktop data folder moves from terminal-rs to terminal on first
  launch, and a link at the old path keeps older builds working.
- The browser build copies localStorage keys from terminal-rs/ to
  terminal/ once and never overwrites newer keys.
- SnapTrade credentials move from Keychain service terminal-rs to
  terminal the first time the app reads them.

The bundle ID (dev.terminal-rs.app), the Sparkle signing key's keychain
account and the notary profile keep the old name, because released
copies and the release Mac depend on them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs: add screenshots for the rename PR (removed in the next commit)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs: drop the PR screenshots so they stay out of master

The PR body links them at the previous commit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(storage): keep fallback saves and the old-folder link working

A browser tab that couldn't copy its keys keeps the old prefix. If
another tab later finishes the copy, that tab's saves went to keys the
next load skips. Its paths under terminal-rs/ now map to terminal/ as
soon as the copy's marker exists.

The link left at the old folder repeated `new` as given, so a relative
HOME or XDG_DATA_HOME made it dangle. It now names its sibling.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-29 10:47:58 -04:00
Michael ShimelesandClaude Opus 5.5 fa1b3c8886 fix(web): cache the app's wasm for good and serve /app/ without a redirect chain (#26)
* fix(web): cache the app's wasm for good and serve /app/ without a redirect chain

scripts/build-web.sh now writes each build's JS and wasm to
static/app/immutable/<hash>/, named after a hash of those files, and
fills the paths into index.html. vercel.json caches /app/immutable/ for
a year and answers a missing file there with a 404 marked no-store.
index.html and the fonts keep Vercel's default of revalidating on every
visit, so a deploy can't leave a page with one build's JS and another
build's wasm.

/app/ used to take two 308s through the SvelteKit function to reach the
page (/app/ to ../app to /app/index.html). vercel.json now sends /app
to /app/ with one 308 and serves /app/ from index.html, so the /app
endpoint is gone. Relative URLs in the page still resolve under /app/.

Both workflows move off the Node 20 majors: actions/checkout@v7,
upload-artifact@v7, download-artifact@v8 and setup-node@v7 run on
Node 24.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(web): fall back to shasum where sha256sum is missing

Older macOS releases ship shasum but not sha256sum, so build-web.sh
stopped with exit 127 before writing index.html. Both commands give the
same digest, so the folder names don't change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-29 09:07:17 -04:00
Michael ShimelesandClaude Opus 5.5 bf1dd94fd5 ci(web): deploy the site with the browser build from GitHub Actions (#24)
Vercel's build doesn't run Rust, so a deploy from Vercel's Git integration
has no /app. The new deploy-site workflow runs scripts/build-web.sh, then
vercel build and vercel deploy --prebuilt. Pushes to master deploy to
production, and pull requests from this repository get a preview.
site/vercel.json turns off Vercel's Git deployments so each commit deploys
once.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-29 07:47:03 -04:00
Michael ShimelesandClaude Opus 5.5 728179ae66 feat(web): run the terminal in the browser with WebAssembly and WebGPU (#23)
* feat(web): build the terminal for the browser with WebAssembly

Compile the app for wasm32-unknown-unknown and run it with eframe's web
runner on WebGPU, with a separate WebGL2 build for browsers without it.
Desktop-only pieces sit behind cfg(target_arch = "wasm32") seams: a
tokio/browser task shim, a websocket wrapper, localStorage in place of
files, and stand-ins for SQLite, Congress ingest and SnapTrade.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* perf(web): WebGPU-only main build, emoji fonts after first frame

The default browser build drops wgpu's WebGL2 path (naga, wgpu-core,
wgpu-hal), and the page loads a second build with it only when there is
no WebGPU adapter. egui's emoji fonts now load after the first frame
instead of shipping inside the .wasm. Together that takes the WebGPU
bundle from 2.64 MB to 1.69 MB brotli.

Also: label the browser's mixed data source as "Crypto live", tell the
Congress panes the data needs the desktop app, autosave every 2 s in the
browser, and redirect /app to the build on the site.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(readme): state browser timings as ranges across runs

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(readme): use the Retina window run for browser timings

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs: browser build screenshots

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(readme): final-build throttled timing

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(readme): rewrap browser timings

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(web): real downloads for exports, one wasm download, keep /app flags

- Layout and theme exports download a file in the browser instead of
  writing a hidden localStorage key. Imports and "open folder" say they
  need the desktop app for now.
- The page starts the WebGPU wasm with an abortable fetch and cancels it
  when there is no WebGPU adapter, so only the WebGL2 build downloads.
- /app keeps its query string when it redirects to /app/index.html.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-29 07:47:02 -04:00
Michael ShimelesandCursor 2ae060d2d0 fix(macos): ship as Terminal.dmg with the logo as its volume icon (#22)
* fix(macos): ship as Terminal.dmg with the logo as its volume icon

Bumps to 0.1.1 so the next release replaces the v0.1.0 DMG, which predates the Terminal name and icon, and renames the remaining user-facing terminal-rs strings.

Co-authored-by: Cursor <[email protected]>

* fix(macos): detach the DMG's writable image if a release build fails while it's mounted

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
2026-10-20 22:16:52 -04:00
Michael ShimelesandCursor b81f40b040 feat(macos): tell installed copies about new releases with Sparkle (#20)
Co-authored-by: Cursor <[email protected]>
2026-10-20 10:42:36 -04:00
Michael ShimelesandCursor f69f864d78 feat(site): ask for an email before the download starts (#21)
* feat(site): ask for an email before the download starts

The download buttons now open a dialog that asks for an email. It posts the
address to `POST /download`, which adds it to an `emails` sorted set in
Upstash Redis scored by when it was first given, then starts the download
with `GET /download`. The browser remembers that it gave an email, so later
downloads skip the dialog.

A failed save still downloads, and direct `/download` links and visitors
without JavaScript download without being asked. The Redis credentials are
optional in `src/env.ts`, so the site builds and serves downloads before the
Upstash integration is connected.

Co-authored-by: Cursor <[email protected]>

* fix(site): start the download if saving the email takes over 3 seconds

The dialog waited for `POST /download` to settle before starting the
download, so a hung Redis save left the button disabled with nothing
downloading. Abort the save after 3 seconds and take the existing
download-anyway path, without remembering the email as given.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
2026-10-20 10:42:12 -04:00
Michael ShimelesandCursor 3974055127 feat(site): show the download count under the download button (#19)
The hero's meta line now ends with the total downloads of every DMG on the
repo's GitHub releases, e.g. "· 293 downloads". The visitor's browser asks
the GitHub API after the page loads, so the page stays prerendered and each
visitor uses their own rate limit. Nothing shows if GitHub doesn't answer or
there are no downloads yet.

Co-authored-by: Cursor <[email protected]>
2026-10-20 09:40:31 -04:00
Michael ShimelesandCursor 9a7d584120 fix(site): unblock the Vercel build on SvelteKit 3 (#18)
@vercel/analytics 2.0.1 declares an optional peer on @sveltejs/kit ^1 || ^2,
so `npm install` on Vercel fails with ERESOLVE against Kit 3. Scope an npm
override to that package so the peer resolves to the site's own Kit.

Its SvelteKit helper also subscribes to `$app/stores`, which Kit 3 removed
and now throws on, so the root layout crashed in the browser. Use the
framework-agnostic `inject()` instead; the site has one page, so the
script's own pushState tracking covers page views.

Co-authored-by: Cursor <[email protected]>
2026-10-20 09:24:22 -04:00
vercel[bot]andVercel d6006d65f1 Add Vercel Web Analytics integration (#17)
# Vercel Web Analytics Installation Report

## Summary
Successfully installed and configured Vercel Web Analytics for this SvelteKit project following the latest official documentation from Vercel.

## Changes Made

### 1. Package Installation
- Installed `@vercel/analytics` version 2.0.1
- Used `--legacy-peer-deps` flag due to SvelteKit 3.x compatibility (the package expects SvelteKit 1 or 2, but works with 3.x)
- Updated `package.json` to add the dependency
- Updated `package-lock.json` with new dependency tree

### 2. Analytics Integration
**Created:** `site/src/routes/+layout.ts`
- Added analytics injection code using `injectAnalytics()` from `@vercel/analytics/sveltekit`
- Configured to run in development mode during local development and production mode in production
- Used `$app/env` module (SvelteKit 3.x) instead of `$app/environment` (deprecated in SvelteKit 3.x)

## Implementation Details

The analytics integration follows Vercel's official quickstart guide for SvelteKit:
- Analytics is injected at the root layout level, ensuring it tracks all pages
- Automatically detects environment (dev vs production) using SvelteKit's `dev` flag
- No changes required to existing components or routes
- Analytics will automatically add routes scoped at `/_vercel/insights/*` after deployment

## Framework Adaptation

Note: The official documentation uses `$app/environment`, which is deprecated in SvelteKit 3.x. I adapted the implementation to use `$app/env` instead, which is the correct module in SvelteKit 3.x that exports the `dev` flag.

## Verification

All verification steps completed successfully:
✅ Dependencies installed without errors
✅ Build completes successfully (`npm run build`)
✅ Type checking passes with no errors (`npm run check`)
✅ No linting errors or warnings
✅ Lock file properly updated

## Next Steps

To start collecting analytics data:
1. Deploy this code to Vercel using `vercel deploy`
2. Enable Web Analytics in the Vercel dashboard under the Analytics section
3. Visit your deployed site to start generating analytics data
4. Monitor visitor data in the Vercel dashboard

The analytics will be automatically active once deployed to Vercel.

Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
2026-10-20 01:05:20 -04:00
Michael ShimelesandCursor ab7b9c1ec2 feat: 151 technical indicators across the chart, command line and picker (#15)
* feat(core): indicator library with 151 studies and a registry

Split indicators into moving averages, momentum, trend, volatility, volume,
statistics and pivot levels, and add a data-driven registry describing each
indicator's code, aliases, parameters, pane, range, levels and outputs.
Specs serialise as their label and still read the old layout format.
Benchmark the whole catalogue at default parameters.

Co-authored-by: Cursor <[email protected]>

* feat(chart): dot markers and compact indicator value formatting

Add a Dots line kind drawn as a square per finite point (parabolic SAR,
stops, fractals) and format_value for indicator axes and legends.

Co-authored-by: Cursor <[email protected]>

* feat(app): every registry indicator on the command line, picker, chart menu and help

Any indicator code with optional parameters toggles it (RSI, SMA 200,
BB 20 2.5). IND opens the palette scoped to indicators, marking those on
the chart and staying open to toggle several. The chart menu lists each
category, sub-panes take their range and reference levels from the
registry (up to six panes), and help lists every code. The command line
no longer takes focus back from a palette a command just opened.

Co-authored-by: Cursor <[email protected]>

* docs: document the indicator library

Co-authored-by: Cursor <[email protected]>

* fix(chart): dot markers grow in behind the reveal sweep

Dots ignored the load-in sweep and appeared ahead of the bars. They now
grow in behind the front like histogram columns and fade with the pane.

Co-authored-by: Cursor <[email protected]>

* fix(indicators): stable decycler, unbroken pivots, blank volatility warm-up, zoomed-out markers

- DECYCLER: cutoffs under 3 bars put the high-pass pole outside the unit
  circle; the registry minimum is now 3 and decycler() returns NaN below it.
  A registry test keeps every price overlay near the price scale at its
  minimum, default and maximum parameters.
- Pivots: levels are no longer blanked on a session's last bar. They draw as
  a new Steps plot kind (a flat dash per bar), so sessions never join and
  one- or two-bar sessions still show.
- Range-based volatility estimators keep missing variances NaN instead of
  reading zero during warm-up.
- Dots overlays use a marker pyramid that keeps the earlier point of a pair
  when the later one is blank, so sparse markers (fractals) survive zooming
  out and still count toward autoscaling.

Co-authored-by: Cursor <[email protected]>

* fix(chart): zoomed-out step dashes stay inside their group

The last group at a reduced level can hold fewer than 2^k bars; its dash now starts at the group boundary instead of reaching back over the previous session.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
2026-10-19 19:23:32 -04:00
Michael ShimelesandCursor f6adbca97c fix(macos): name the app Terminal and give it the candles icon (#16)
The bundle is now Terminal.app with a Terminal executable, so Finder, the
Dock, the app menu and its About, Hide and Quit items all read Terminal. It
ships AppIcon.icns, rendered from the site logo by scripts/build-icon.sh, and
the app sets the same icon at runtime instead of eframe's default egui logo.
The DMG volume is named Terminal; its file name stays terminal-rs-universal.dmg
because the download page links to it. Data, keychain and bundle id are
unchanged, so existing settings carry over.

Co-authored-by: Cursor <[email protected]>
2026-10-19 18:32:32 -04:00
Michael ShimelesandCursor 48963f33e4 feat(app): motion pass to match the site demo (#13)
* feat(chart): load-in sweep for bars, volume, indicators and lines

Bars grow in from their open behind a front that sweeps across the visible
range, lines trace up to it, and a pane-wide opacity lets a pane fade. The
reveal rides in the existing uniform slot, so a finished reveal costs nothing.
Autoscale is exposed so callers can ease the price axis.

Co-authored-by: Cursor <[email protected]>

* feat(app): motion pass across charts, panes and overlays

- Charts sweep in on load and on symbol or interval changes; Congress markers
  pop in behind the sweep; the last-price tag fades in at the end.
- Quick ranges, reset, keyboard zoom and pan glide to the new view; the price
  axis eases instead of jumping.
- Splits, closes and layout switches glide panes to their new places, new panes
  rise in, and switching tabs cross-fades.
- The option chain cascades in row by row.
- The command palette and command-line suggestions fade and slide in, with a
  selection highlight that glides between rows.
- The GO key presses in on Enter; toasts slide in with a slight overshoot,
  restack with a spring and fade out.
- Everything snaps when Reduce Motion is on.

Co-authored-by: Cursor <[email protected]>

* fix(app): glide layout switches, per-pane option cascade, no toast fade with Reduce Motion

- The dock animates when its arrangement changes (split direction, order),
  not only when group membership does; divider drags stay immediate.
- Option rows cascade per pane, when a chain for another expiration
  arrives rather than when it is requested.
- With Reduce Motion, toasts stay visible until removed instead of fading.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
2026-10-19 17:34:28 -04:00
Michael ShimelesandCursor eca41e7a93 Sign, notarize and package universal macOS releases (#14)
scripts/release-macos.sh builds the Apple Silicon and Intel binaries, merges
them, signs the app with a Developer ID certificate and the hardened runtime,
notarizes and staples it, then packages, signs, notarizes and staples
terminal-rs-universal.dmg, the asset the download page links to.
bundle-macos.sh now wraps any binary path, and the site lists both
architectures.

Co-authored-by: Cursor <[email protected]>
2026-10-19 17:06:19 -04:00
Michael ShimelesandCursor 9dba5760d9 Rebuild the site around an animated demo with sound (#12)
* Rebuild the site around an animated demo with sound

The page now opens with a 20-second demo of the app. AAPL is typed and
loads a chart, 5 switches it to five-minute bars, NVDA OMON opens the
option chain, and a Congress alert arrives. Captions follow each step.
"Play with sound" replays it once with sounds synthesized in the browser,
so there are no audio files. Six feature cards loop small animations of
the chart, world markets, the command palette, the Congress feed, the
portfolio and the themes, each only while it is on screen.

The design follows the Terminal landing in Paper: dark only, SF fonts,
glass controls and the new T icon, with no orange gradients. The page
now hydrates because the demo needs JavaScript. With reduced motion the
demo and cards show one still frame and nothing fades in on scroll.

Co-authored-by: Cursor <[email protected]>

* Use the candlestick logo

Swap the T monogram for the three rising candlesticks from the Paper logo options, in the page logo and in assets-src/icon.svg, and regenerate the icons and share card.

Co-authored-by: Cursor <[email protected]>

* Move the theme switch into the nav as an icon button

The button now switches the whole page, not just the demo. The page,
the demo and the feature cards all have light styles, the choice is
saved, and app.html applies it before the first paint. The demo
captures are CSS backgrounds so only the current theme's image loads.

Co-authored-by: Cursor <[email protected]>

* Remove the build-from-source block from the Install section

Co-authored-by: Cursor <[email protected]>

* Show revealed sections when reduced motion turns on, and name the chapter buttons

Turning on reduced motion reran the reveal attachment, which disconnected the observer and returned early, leaving unread sections at opacity 0. Teardown now marks the element shown. The chapter buttons lose their only text at 640px and below, so they get an aria-label.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
2026-10-19 16:22:17 -04:00
Michael ShimelesandCursor cdc4c585f6 Rename the site to Terminal and add full share metadata (#11)
* Rename the site to Terminal and fill in its share metadata

The page still called the app terminal-rs and pointed its canonical URL,
og:url and og:image at terminal-rs.vercel.app, while the site now lives at
terminal.rasmic.xyz. Move SITE_URL to the custom domain, build every
absolute URL from it, and show the name as Terminal in the title, alt text,
Open Graph and Twitter tags and the share card. The tagline stays next to
the name so it still reads as a market terminal, not Apple's Terminal.

Fill the gaps link previews and crawlers look for: og:site_name, og:locale,
og:image:type, twitter:image:alt, light and dark theme-color, a
SoftwareApplication JSON-LD block, and a sitemap that robots.txt points to.
The description is trimmed to 157 characters so search results don't cut
it off.

og.jpg is re-rendered from assets-src/og.html with scripts/build-images.sh;
the other generated images came out byte-identical. The GitHub repo, DMG
and package names keep -rs.

Co-authored-by: Cursor <[email protected]>

* Make theme-color follow the theme the page shows

The two theme-color tags followed the system appearance through their
media queries, but the toggle lets you pick and save the other theme, so
choosing light on a dark Mac left the browser chrome dark, before and
after a reload.

Move the tag into app.html ahead of the theme script and let apply() set
it with the page's data-theme. It now matches on first paint, after a
saved choice is restored, on every toggle and when the system appearance
changes. The page ships no framework JavaScript (csr = false), so nothing
rewrites the tag later. Without JavaScript the page is always dark, and
the tag's static value is the dark background too.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
2026-10-19 12:56:51 -04:00
Michael ShimelesandCursor 7df3cb0f0b Remove the grid behind the hero (#10)
The faint 40px grid was the last thing drawn behind the hero. Drop the
backdrop element, the .page wrapper that only existed to layer it, and the
--grid colours. The page markup moves out one indent level; git diff -w
shows the 25 deleted lines.

Co-authored-by: Cursor <[email protected]>
2026-10-18 21:50:00 -04:00
Michael ShimelesandCursor 2289312ff9 Add the download page under site/ (#9)
* Add the download page under site/

A one-page SvelteKit site for Vercel with a /download route that
redirects to the latest release DMG. It is its own npm project, apart
from the Cargo workspace; deploy it with the Vercel Root Directory set
to site.

Co-authored-by: Cursor <[email protected]>

* Remove the amber glow behind the hero

The backdrop keeps its grid and fade. Drop the --glow colour too,
since nothing else used it.

Co-authored-by: Cursor <[email protected]>

* Document the site's Node minimum and find Chrome on Linux

SvelteKit needs Node 22.17 or later, and engine-strict makes npm
install stop on older versions, so declare it in engines and say so
in the README. build-images.sh now finds Chrome or Chromium on PATH
before falling back to the macOS app, and stops with a clear message
when neither is there.

Co-authored-by: Cursor <[email protected]>

* Let CHROME name a command on PATH

The executable check added in 875b484 only accepted paths, so an override like
CHROME=google-chrome-stable stopped working. Resolve the override with
command -v, the same way discovery resolves its candidates.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
2026-10-18 15:52:50 -04:00
Michael ShimelesandClaude Opus 5.5 97c96dd09a Demo account by default, one-click trading, SnapTrade throttling fix (#7)
* feat: demo account by default, one-click trading, SnapTrade throttling fix

Demo account
- New tr_broker::demo: a persistent paper-trading book ($100k virtual cash)
  with market fills at the live quote, resting limit/stop/stop-limit orders
  that fill when the quote crosses, buying-power reservations, no shorting,
  IOC/FOK, Day expiry, bounded history; saved to demo-account.json.
- The DEMO account is selected by default; the toolbar badge (red LIVE with a
  red rule in live mode), `DEMO` / `MODE LIVE`, the ticket and Settings ›
  Trading switch modes. Going live asks first and is remembered.
- Settings › Trading: demo stats and Reset Demo Account (custom cash).

Easy buying and selling
- Buy/Sell buttons on charts, hover Buy/Sell on watchlist rows, Buy/Sell/
  Close on positions, Cancel on open orders; ⇧B / ⇧S for the focused security.
- ⌘K offers Buy/Sell for a typed ticker (and Sell all when held); commands
  `BUY AAPL 10`, `SELL ALL TSLA`.
- One compact ticket: side, shares or $, Market/Limit (stops under More),
  live estimated cost against buying power, Max / Sell all. Demo submits in
  one click (Enter works, debounced); live reviews with SnapTrade and opens a
  single confirmation. Coloured toasts for fills and rejections.

Throttling (51 accounts)
- Trading access is read once per brokerage connection (/authorizations),
  mapped to accounts locally, cached for an hour (also on disk) with a
  Refresh button, and backs off quietly on failures.
- SnapTrade client: at most 4 requests in flight, a shared cooldown, GETs
  retried on 429 honouring Retry-After (or DRF's "available in N seconds"),
  orders never retried, and throttle errors shown as a friendly line.
- Live portfolios load only while live accounts are selected.

Tests: demo engine (fills, resting orders, reservations, expiry, persistence,
history bounds), throttle retry/no-resend against a mock, command parsing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix: address review findings on the demo engine and SnapTrade cooldown

- Demo submit no longer panics when a full history trims the order it just
  placed; the result is copied before trimming.
- Overdue Day orders expire before any quote is applied, so a late quote
  (after sleep) can never fill them.
- Dollar amounts round shares down so they never cost more than stated.
- Demo save failures are shown in toasts, the ticket and Settings, retried
  every few seconds and on exit, instead of only logged.
- SELL ALL keeps following the chosen account's whole position until the
  quantity is edited.
- The shared SnapTrade cooldown lasts the full Retry-After and re-checks after
  sleeping; while a long pause is active, requests fail fast unsent instead of
  hanging (an order is never held for minutes).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix: never send an order after its preview expires during a rate-limit pause

The cooldown wait now re-checks its limit on every wake-up and takes a
deadline: placing an order refuses (unsent) when the pause would reach the
preview's expiry, even if another response extends the cooldown mid-wait.
SELL ALL no longer overwrites a quantity typed before an account is chosen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 01:06:09 -04:00
Michael ShimelesandClaude Opus 5.5 f0ad44a072 feat(app): Cmd+K command palette (#6)
* feat(app): Cmd+K command palette

A centred, theme-matched overlay (Cmd+K on macOS, Ctrl+K elsewhere) that
fuzzy-searches every function code, tickers, new panels, layout presets and
saved layouts, themes, Settings tabs and workspace actions. Rows show their
shortcut; recent picks come first and persist in palette.json. Arrows, Tab,
Enter and Esc drive it; unmatched input runs as a command line.

The model (items, ranking, recents) lives in app/src/palette.rs and the
overlay in app/src/app/palette_view.rs. CommandPalette is a new rebindable
action, so it appears in the hotkey table.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): palette review fixes

- Typed command lines run as typed, so paths and titles keep their case.
- Rank after the text field updates, so Enter in the same frame as typing
  or a paste runs the new search.
- Recent picks show their live row (current marker, shortcut hint) and
  deleted themes and saved layouts drop out.
- Keep the card inside short windows; paint it on the dim layer once its
  height is known.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 22:32:09 -04:00
153 changed files with 19983 additions and 1264 deletions

No files matched your search

+9 -9
View File
@@ -22,7 +22,7 @@ jobs:
arch: x86_64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
@@ -40,9 +40,9 @@ jobs:
TR_PROVIDER: sample
TR_CONGRESS_REFRESH_MIN: "0"
TR_SMOKE_SECS: "8"
TR_DATA_DIR: ${{ runner.temp }}/terminal-rs
TR_DATA_DIR: ${{ runner.temp }}/terminal
run: |
./target/release/terminal-rs > smoke.log 2>&1 &
./target/release/terminal > smoke.log 2>&1 &
pid=$!
sleep 6
screencapture -x screenshot-${{ matrix.arch }}.png || true
@@ -56,9 +56,9 @@ jobs:
env:
TR_SMOKE_SECS: "12"
TR_CONGRESS_REFRESH_MIN: "0"
TR_DATA_DIR: ${{ runner.temp }}/terminal-rs-live
TR_DATA_DIR: ${{ runner.temp }}/terminal-live
run: |
./target/release/terminal-rs > live.log 2>&1 &
./target/release/terminal > live.log 2>&1 &
pid=$!
sleep 10
screencapture -x screenshot-live-${{ matrix.arch }}.png || true
@@ -67,7 +67,7 @@ jobs:
grep -q "smoke:" live.log
- name: Bundle .app
run: ./scripts/bundle-macos.sh
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: always()
with:
name: macos-${{ matrix.arch }}
@@ -75,12 +75,12 @@ jobs:
screenshot-*.png
smoke.log
live.log
target/release/terminal-rs.app
target/release/Terminal.app
linux:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
@@ -95,4 +95,4 @@ jobs:
TR_PROVIDER: sample
TR_CONGRESS_REFRESH_MIN: "0"
TR_SMOKE_SECS: "6"
run: xvfb-run -a ./target/release/terminal-rs 2>&1 | tee smoke.log && grep -q "smoke:" smoke.log
run: xvfb-run -a ./target/release/terminal 2>&1 | tee smoke.log && grep -q "smoke:" smoke.log
+121
View File
@@ -0,0 +1,121 @@
name: deploy site
# Builds the browser app with scripts/build-web.sh and deploys the site with it to Vercel:
# production on every push to master, a preview for each pull request. Vercel's own Git
# deployments are off (site/vercel.json) because Vercel's build doesn't run Rust.
on:
push:
branches: [master]
pull_request:
workflow_dispatch:
# Production runs queue instead of cancelling each other, so the newest commit always deploys
# last. A new push to a pull request cancels the preview run for the old one.
concurrency:
group: deploy-site-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
jobs:
wasm:
name: Build the browser app
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
with:
# Pull requests build the branch head, like Vercel's Git previews did, not the merge commit.
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- name: Read the wasm-bindgen version from Cargo.lock
id: lock
run: echo "wasm-bindgen=$(awk '/^name = "wasm-bindgen"$/ { getline; gsub(/version = |"/, ""); print; exit }' Cargo.lock)" >> "$GITHUB_OUTPUT"
- uses: taiki-e/install-action@v2
with:
tool: wasm-bindgen@${{ steps.lock.outputs.wasm-bindgen }}
- name: Install wasm-opt (binaryen)
env:
BINARYEN: version_133
SHA256: 2dc9c7813f5375db93d96ead4b78222fcc3e2677bbb832297af4797782a37489
run: |
curl -sSfLo "$RUNNER_TEMP/binaryen.tar.gz" "https://github.com/WebAssembly/binaryen/releases/download/$BINARYEN/binaryen-$BINARYEN-x86_64-linux.tar.gz"
echo "$SHA256 $RUNNER_TEMP/binaryen.tar.gz" | sha256sum -c -
tar xzf "$RUNNER_TEMP/binaryen.tar.gz" -C "$RUNNER_TEMP"
echo "$RUNNER_TEMP/binaryen-$BINARYEN/bin" >> "$GITHUB_PATH"
- run: scripts/build-web.sh
- uses: actions/upload-artifact@v7
with:
name: web-app
path: site/static/app
if-no-files-found: error
retention-days: 7
deploy:
name: Deploy to Vercel
needs: wasm
# Pull requests from forks get no secrets, so they stop after the build.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-24.04
environment:
name: ${{ github.ref == 'refs/heads/master' && 'production' || 'preview' }}
url: ${{ steps.deploy.outputs.url }}
env:
TARGET: ${{ github.ref == 'refs/heads/master' && 'production' || 'preview' }}
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
steps:
- name: Check the Vercel secrets
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
run: |
missing=0
for name in VERCEL_TOKEN VERCEL_ORG_ID VERCEL_PROJECT_ID; do
if [ -z "${!name}" ]; then
echo "::error::The $name repository secret is not set (see site/README.md)"
missing=1
fi
done
exit $missing
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: actions/download-artifact@v8
with:
name: web-app
path: site/static/app
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
cache-dependency-path: site/package-lock.json
- run: npm install --global [email protected]
# Run from the repository root, because the Vercel project's Root Directory is site/.
- name: Pull the project settings and environment variables
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
run: vercel pull --yes --environment="$TARGET" --token="$VERCEL_TOKEN"
# The build gets no token, since it runs the site's npm install and build scripts.
- name: Build
run: |
if [ "$TARGET" = production ]; then vercel build --prod; else vercel build; fi
test -f .vercel/output/static/app/index.html
- name: Deploy
id: deploy
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
run: |
if [ "$TARGET" = production ]; then
url=$(vercel deploy --prebuilt --prod --token="$VERCEL_TOKEN")
else
url=$(vercel deploy --prebuilt --token="$VERCEL_TOKEN")
fi
echo "url=$url" >> "$GITHUB_OUTPUT"
echo "Deployed $TARGET: $url ($url/app)" >> "$GITHUB_STEP_SUMMARY"
Generated
+34 -8
View File
@@ -463,8 +463,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
@@ -3700,6 +3702,15 @@ version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "send_wrapper"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
dependencies = [
"futures-core",
]
[[package]]
name = "serde"
version = "1.0.229"
@@ -4086,14 +4097,17 @@ dependencies = [
]
[[package]]
name = "terminal-rs"
version = "0.1.0"
name = "terminal"
version = "0.1.1"
dependencies = [
"anyhow",
"chrono",
"eframe",
"egui",
"egui-wgpu",
"epaint_default_fonts",
"futures",
"js-sys",
"raw-window-handle",
"serde",
"serde_json",
@@ -4107,6 +4121,11 @@ dependencies = [
"tr-macos",
"tracing",
"tracing-subscriber",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"web-time",
"wgpu",
]
[[package]]
@@ -4405,7 +4424,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tr-broker"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"anyhow",
"base64",
@@ -4424,7 +4443,7 @@ dependencies = [
[[package]]
name = "tr-chart"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"bytemuck",
"chrono",
@@ -4438,7 +4457,7 @@ dependencies = [
[[package]]
name = "tr-congress"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"anyhow",
"chrono",
@@ -4460,35 +4479,42 @@ dependencies = [
[[package]]
name = "tr-core"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"chrono",
"criterion",
"serde",
"serde_json",
]
[[package]]
name = "tr-data"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"anyhow",
"async-trait",
"chrono",
"futures",
"js-sys",
"reqwest",
"rusqlite",
"rustls",
"send_wrapper",
"serde",
"serde_json",
"tokio",
"tokio-tungstenite",
"tr-core",
"tracing",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"web-time",
]
[[package]]
name = "tr-macos"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
+11 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/core", "crates/broker", "crates/congress", "crates/data", "cr
default-members = ["app"]
[workspace.package]
version = "0.1.0"
version = "0.1.1"
edition = "2024"
rust-version = "1.85"
@@ -36,3 +36,13 @@ debug = 1
[profile.dev.package."*"]
opt-level = 2
# The browser build (scripts/build-web.sh): one fully optimised unit, no debug info, so the
# .wasm is as small as it gets before wasm-opt.
[profile.web]
inherits = "release"
opt-level = "s"
lto = "fat"
codegen-units = 1
debug = false
strip = true
+132 -36
View File
@@ -1,4 +1,4 @@
# terminal-rs
# Terminal
A fast, keyboard-driven market terminal in pure Rust: TradingView-class GPU charting with a
Bloomberg Terminal look (dense tiled panes, monospace numbers, a command line like `AAPL <GO>`)
@@ -9,7 +9,7 @@ systems** and **your own brokerage accounts through [SnapTrade](https://snaptrad
to Solarized, and a fully dockable
workspace with saved layouts.
![terminal-rs: AAPL chart with congress trade markers and RSI, the Congress trades feed, world markets and a multi-asset watchlist](docs/img/overview.png)
![Terminal: AAPL chart with congress trade markers and RSI, the Congress trades feed, world markets and a multi-asset watchlist](docs/img/overview.png)
> **Built from reverse-engineering research.** Chart UX was studied behaviourally on
> tradingview.com with [REA](https://github.com/morluto/rea) in website mode (passive captures of
@@ -48,9 +48,21 @@ gets the native title bar with traffic lights, window vibrancy behind the sideba
matches across all asset classes (catalog instantly, plus Yahoo search after a short pause);
↑/↓, Tab, Enter; history with ↑/↓. `SECF gold cmdty` opens a full search panel.
- **Charts** — GPU-rendered candles, OHLC bars, line and area; 1m, 5m, 15m, 30m, 1H, 4H, 1D, 1W,
1M plus quick ranges (1D … ALL); volume; SMA, EMA, VWAP, Bollinger overlays; RSI and MACD
sub-panes; `CMP <SYM>` compare; log scale; crosshair with OHLC legend; smooth pan and zoom over
a million bars. Live crypto bars from the Binance websocket (Coinbase fallback).
1M plus quick ranges (1D … ALL); volume; 151 indicators; `CMP <SYM>` compare; log scale;
crosshair with OHLC legend; smooth pan and zoom over a million bars. Live crypto bars from the
Binance websocket (Coinbase fallback).
- **Indicators** — 151 built-in studies: 23 moving averages (SMA, EMA, WMA, Hull, DEMA/TEMA, KAMA,
ALMA, T3, McGinley, VIDYA, FRAMA, MAMA/FAMA, Ehlers filters, GMMA…), 8 bands and channels
(Bollinger, Keltner, Donchian, STARC, standard-error and VWAP bands…), 43 momentum oscillators
(RSI, MACD, Stochastic, StochRSI, CCI, Williams %R, TSI, KST, Fisher, Schaff, QQE, WaveTrend,
Connors RSI…), 22 trend tools (ADX/DMI, Aroon, Vortex, Parabolic SAR, SuperTrend, Ichimoku,
Alligator, ZigZag, fractals, Chandelier and Chande Kroll stops…), 16 volatility measures (ATR,
close-to-close and Parkinson, Garman-Klass, Rogers-Satchell and Yang-Zhang estimators, Ulcer,
Mass index, squeeze momentum…), 23 volume studies (OBV, A/D, CMF, MFI, Klinger, NVI/PVI, VZO…),
11 rolling statistics and five pivot-point families. Type a code with optional parameters
(`RSI`, `SMA 200`, `BB 20 2.5`), browse and search with `IND`, or right-click the chart.
Oscillators get their own panes (up to six) with reference levels; moving averages can be added
several times; indicators are saved with the layout.
- **Drawings** — trendline (Alt+T), horizontal (Alt+H), vertical (Alt+V) and fib retracement
(Alt+F), stored in (time, price) so they survive zoom and timeframe changes; magnet (Alt+M) snaps
to OHLC; click to select, Del to remove.
@@ -110,7 +122,7 @@ gets the native title bar with traffic lights, window vibrancy behind the sideba
row in a linked watchlist or world-markets pane loads it into the group.
- **Saved layouts**: save, name, rename, duplicate, delete and switch (`LAYOUT <name>`, the
sidebar list, ⌥1…⌥9); the active layout autosaves and is restored on launch. Each layout is a
JSON file in `~/Library/Application Support/terminal-rs/layouts` (`$XDG_DATA_HOME` on Linux)
JSON file in `~/Library/Application Support/terminal/layouts` (`$XDG_DATA_HOME` on Linux)
and can be imported or exported (`LAYOUT EXPORT`/`IMPORT`, or Settings › Layouts). Per-pane
settings (chart type, indicators, drawings, filters, depth, link, tab title) and, optionally,
the theme (`LAYOUT THEME ON`) save with the layout.
@@ -142,18 +154,21 @@ gets the native title bar with traffic lights, window vibrancy behind the sideba
access. Without credentials the panes show a setup screen; offline (`TR_PROVIDER=sample` or
`TR_BROKER=sample`) they show a clearly labelled simulated portfolio. See
[Brokerage setup](#brokerage-setup-snaptrade).
- **Trading (opt-in)** — off by default; Settings › Trading enables it after an explicit
warning. `BUY AAPL 10`, `SELL MSFT 5 LMT 410`, `SELL TSLA 3 STP 250 LMT 245`, `BUY NVDA $500`,
`TRADE` (or `AAPL BUY`) open an order ticket, as do Buy/Sell on watchlist quotes and positions.
The ticket has an account picker, buy/sell, market, limit, stop and stop-limit, shares or a
dollar amount, and Day/GTC/FOK/IOC. **Preview** runs SnapTrade's order-impact check at your
broker (estimated price, cost, commission, cash after); only that preview, unchanged and
unexpired, can be placed, and only through a confirmation dialog showing account, symbol, side,
quantity and estimated cost. Open orders cancel from `ORD` (right-click › Cancel Order, also
confirmed). Accounts whose brokerage does not trade through SnapTrade, or whose connection is
read-only or disabled, are marked read-only and cannot preview. An optional maximum order value
refuses large orders. The sample portfolio simulates fills locally. See
- **Demo account and easy trading** — a **DEMO** paper-trading account ($100,000 virtual cash) is
selected by default; the toolbar badge shows DEMO or a red **LIVE** and switches between them
(switching to live asks first and is remembered). Buy/Sell buttons sit on charts, watchlist rows
and positions (plus **Close**), ⇧B / ⇧S trade the security in focus, and `BUY AAPL 10`,
`SELL ALL TSLA`, `BUY NVDA $500`, `SELL MSFT 5 LMT 410` work from the command line or ⌘K. One
compact ticket: side, shares or dollars, market/limit (stops under More), live estimated cost
against buying power. Demo orders fill in one click at the live quote (limits and stops when
the quote crosses) and persist in the app data folder; Settings › Trading resets the account.
Live orders stay opt-in and go through SnapTrade's preview and a single confirmation. See
[Trading](#trading-opt-in).
- **Command palette** — ⌘K (Ctrl+K on Linux and Windows) opens a centred, theme-matched palette
that fuzzy-searches every function code (`PORT`, `POS`, `ORD`, `OMON`, `THEME`, …), tickers
(load the quote and chart), new panels, layout presets and saved layouts, themes, Settings tabs
and workspace actions (move the sidebar, open Brokerage settings). Each row shows its shortcut;
recent picks come first. ↑↓ select, ↵ open, Esc close.
- **Hotkeys and preferences** — every hotkey is rebindable from a native shortcut table in
Settings › Hotkeys and Help (click a binding, press the new keys); defaults for new charts and
order books; everything persists in `prefs.json`.
@@ -166,16 +181,74 @@ TR_PROVIDER=sample cargo run --release # offline
APCA_API_KEY_ID=… APCA_API_SECRET_KEY=… cargo run --release # Alpaca
```
Data lives in `~/Library/Application Support/terminal-rs` (macOS) or
`$XDG_DATA_HOME/terminal-rs` (Linux), or `TR_DATA_DIR`. The first congress ingest covers the
current and previous year (a few minutes; it runs in the background while you work).
Data lives in `~/Library/Application Support/terminal` (macOS) or
`$XDG_DATA_HOME/terminal` (Linux), or `TR_DATA_DIR`. Versions from before the project was renamed
used a `terminal-rs` folder in the same place; the first launch moves it to `terminal` and leaves
a link at the old path, so builds that still use the old name find the same files. The first
congress ingest covers the current and previous year (a few minutes; it runs in the background
while you work).
`cargo run -p tr-congress --example ingest -- congress.sqlite3` runs one ingest from the CLI.
Appearance: `TR_THEME=light|dark|<theme name>` overrides the saved theme, `TR_MSAA=1|2|4|8` sets
chart anti-aliasing (default 4 on macOS), `TR_VIBRANCY=0` disables the translucent sidebar, and
⌃⌘S (Ctrl+Shift+S elsewhere) toggles the sidebar. `scripts/bundle-macos.sh` builds a retina-capable
`terminal-rs.app`; `scripts/check-macos-cross.sh` compiles and lints the whole workspace for
aarch64-apple-darwin from Linux (zig as the C cross-compiler; linking needs the macOS SDK).
`Terminal.app` (icon from `scripts/build-icon.sh`); `scripts/release-macos.sh` builds the universal (Apple Silicon + Intel) app,
signs it with a Developer ID certificate, notarizes it and packages `Terminal.dmg`,
plus the zip and signed `appcast.xml` that installed copies update from (one-time credential
setup is at the top of the script). The app checks that feed once a day through
[Sparkle](https://sparkle-project.org) and offers to install a newer release; Terminal › Check
for Updates… or `UPDATE` checks at once. `scripts/check-macos-cross.sh` compiles
and lints the whole workspace for aarch64-apple-darwin from Linux (zig as the C cross-compiler;
linking needs the macOS SDK).
## In the browser
The same code also builds for WebAssembly and runs in a browser tab on WebGPU:
```sh
rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version 0.2.129 --locked # must match Cargo.lock
brew install binaryen # wasm-opt
scripts/build-web.sh # writes site/static/app
cd site && npm run dev # then open http://localhost:5173/app/index.html
```
`scripts/build-web.sh <dir>` writes somewhere else instead; any static file server works. Add
`?sample` to the URL to run offline on sample data, or `?fps` for the frame-time readout.
Every push to `master` builds it and deploys it with the site to
[terminal.rasmic.xyz/app](https://terminal.rasmic.xyz/app/). Pull requests get a preview;
[site/README.md](site/README.md#deploys) covers the workflow and the secrets it needs.
![The browser build in Chrome: a live BTCUSD chart from Binance beside a demo-account position](docs/img/web-live-crypto.png)
What the browser build can and can't do yet:
- Crypto is live, because Binance and Coinbase allow cross-origin requests. History, quotes, order
books and websocket bars all come from them. Yahoo, Treasury.gov, Alpaca and CoinDesk don't allow
them, so every other market shows sample data and the toolbar says "Crypto live". A same-origin
proxy can fix that later.
- Congress disclosures, SnapTrade accounts and live order entry need the desktop app. The paper
trading demo account works.
- Layouts, settings, themes and the demo account are saved in `localStorage` under `terminal/`.
The first load copies anything saved under the old `terminal-rs/` prefix, without overwriting
newer entries.
- Charts need WebGPU, since they read bars from storage buffers in the vertex shader. Browsers
without a WebGPU adapter get a second, larger build on WebGL2 where everything except the chart
series works.
Measured with Playwright driving Chrome 155 on an Apple M4 Pro:
| | WebGPU build | WebGL2 build |
|---|---|---|
| `.wasm` | 5.60 MB (2.26 MB gzip, 1.69 MB brotli) | 7.48 MB (2.99 MB gzip, 2.22 MB brotli) |
| JS glue | 115 KB | 169 KB |
- A cold load from localhost draws its first frame 91 to 97 ms after navigation, the medians of
three runs of 9 loads, two headless and one in a Retina window. On a 50 Mbit/s link with 40 ms
of latency it takes 446 ms, and 1.64 s on 10 Mbit/s with 80 ms.
- Panning and zooming a chart costs 1.3 to 1.5 ms of CPU in the UI code and 2.4 to 3.3 ms for the
whole frame (medians across runs), and holds the display's 60 Hz.
- egui's two emoji fonts, 740 KB together, load after the first frame instead of inside the `.wasm`.
## Brokerage setup (SnapTrade)
@@ -200,8 +273,9 @@ the portal, and **Disconnect** removes a connection and its accounts from SnapTr
explicit confirmation.
**Where credentials live.** On macOS the client ID, consumer key, user ID and user secret are
stored together in the **Keychain** (generic password, service `terminal-rs`, account
`snaptrade`). Elsewhere they go in `<data dir>/secrets/snaptrade.json` with owner-only permissions
stored together in the **Keychain** (generic password, service `terminal`, account
`snaptrade`); credentials saved under the old service, `terminal-rs`, move there the first time
the app reads them. Elsewhere they go in `<data dir>/secrets/snaptrade.json` with owner-only permissions
(0600 in a 0700 directory). They are never written to the repository, layouts, prefs or logs;
request errors are scrubbed of the URL (which carries the user secret), and the settings screen
masks secrets. **Forget Credentials** removes them. `SNAPTRADE_CLIENT_ID`,
@@ -219,19 +293,24 @@ are repriced with the terminal's live quotes where a market key exists.
## Trading (opt-in)
Trading is **off** until you enable it in **Settings › Trading**, which asks you to acknowledge
that confirmed orders are real. Then:
The **demo account** needs no setup: it is a local paper-trading account (`demo-account.json` in
the app data folder) with no commissions, no short selling and Day orders expiring at the 4 p.m.
New York close. Switch to your brokerage accounts with the toolbar badge, `MODE LIVE`, or
Settings › Trading (`DEMO` switches back).
Live trading is **off** until you enable it in **Settings › Trading**, which asks you to
acknowledge that confirmed orders are real. Then:
1. Make sure the connection allows trading. Connections made with **Connect Brokerage** are
read-only; **Reconnect with Trading Access…** in Settings › Trading opens the Connection Portal
asking for trading access where the broker supports it (`trade-if-available`). Settings ›
Trading lists, per brokerage, whether trading is available, from SnapTrade's
`allows_trading` flag and each connection's access type.
2. Open a ticket (`BUY AAPL 10`, right-click a position › Sell …), pick the account and order type,
and click **Preview**. The broker checks the order through SnapTrade's
2. Open a ticket (`BUY AAPL 10`, a Buy/Sell button, ⇧B / ⇧S), pick the account and order type,
and click **Review**. The broker checks the order through SnapTrade's
[order impact](https://docs.snaptrade.com/reference/Trading/Trading_getOrderImpact) endpoint;
nothing is placed.
3. **Place Order…** opens the confirmation dialog. **Confirm** places exactly the previewed trade
3. The broker's estimate opens one confirmation dialog. **Send** places exactly the previewed trade
(`POST /trade/{tradeId}`). Editing the ticket after previewing, or waiting past the preview's
validity (SnapTrade expires it after five minutes), requires a new preview.
A preview is used up when it is sent. If the answer is lost (network error, timeout, 5xx),
@@ -244,8 +323,13 @@ orders at brokers that support notional orders; fractional share quantities are
at brokers SnapTrade reports as not supporting them. Settings › Trading also has an optional
**maximum order value** and a default account.
Trading access is read once per brokerage connection (`/authorizations`), cached for an hour (on
disk too) with a **Refresh** button. SnapTrade requests are limited to four at a time; a
rate-limited read waits for `Retry-After` and retries, and a rate limit is shown as a quiet
"retrying" note rather than an error. Orders are never retried automatically.
Tests never place real orders: the trading client is exercised only against a local mock server,
and the app's flows against the simulated portfolio.
and the app's flows against the simulated portfolio and the demo account.
## Keyboard shortcuts and commands
@@ -254,6 +338,7 @@ Settings › Hotkeys. On Linux and Windows ⌘ is Ctrl and ⌥ is Alt.
| Keys | Action |
|---|---|
| ⌘K (Ctrl+K) | command palette: fuzzy search over commands, tickers, panels, layouts, themes, settings and actions |
| F1 · F2 · F3 · F4 · F5 · F6 · F12 | help · Congress feed · politicians · alerts · watchlist · refresh filings · frame timing |
| F7 · F8 · F9 | brokerage portfolio · positions · orders |
| Tab · ⇧Tab | next · previous pane |
@@ -271,7 +356,7 @@ Settings › Hotkeys. On Linux and Windows ⌘ is Ctrl and ⌥ is Alt.
| `AAPL` · `AAPL US EQUITY <GO>` | load a security in the focused chart |
| `BTCUSD <CRNCY>` · `EURUSD <CRNCY>` · `US10Y <GOVT>` · `CL1 <CMDTY>` · `SPX <INDEX>` | other asset classes |
| `5` `1H` `D` `1W` `1M` · `YTD` `5Y` `ALL` | interval · quick range |
| `RSI` `MACD` `BB` `SMA 50` `EMA 20` `VWAP` · `CLEAR` | toggle indicators |
| `RSI` `MACD` `SMA 200` `BB 20 2.5` · `IND` `IND VOLUME` · `CLEAR` | toggle any of the 151 indicators (optional parameters) · indicator picker · remove all |
| `CMP MSFT` · `LOG` · `CANDLE` `BAR` `LINE` `AREA` | compare · log scale · chart type |
| `AAPL OMON` · `AAPL CN` · `BTCUSD CRNCY OB` · `WEI` · `GC` · `SECF <query>` | options · news · order book · world markets · yield curve · search |
| `CONG` · `NVDA CONG` · `POL PELOSI` · `TRACK PELOSI` · `ALERTS` · `REFRESH` | Congress feed · who traded it · profile · track · alerts · refresh |
@@ -280,7 +365,8 @@ Settings › Hotkeys. On Linux and Windows ⌘ is Ctrl and ⌥ is Alt.
| `THEME BLOOMBERG` · `THEME NEXT` · `THEME SYSTEM` · `THEME EDIT` · `THEME COMPACT` · `LAYOUT THEME ON` | themes |
| `PORT` · `ACCT` · `POS` · `ORD` · `ACTV` · `AAPL POS` · `AAPL ORD` | brokerage portfolio · accounts and balances · positions · orders · activity · one security |
| `PORT REFRESH` · `PORT CONNECT` · `PORT SETUP` (`BROKER`) | reload accounts · connect a broker in the browser · SnapTrade keys |
| `BUY AAPL 10` · `SELL MSFT 5 LMT 410` · `SELL TSLA 3 STP 250 LMT 245` · `BUY NVDA $500` · `TRADE` | order ticket, prefilled (trading must be enabled; preview and confirm before anything is sent) |
| `BUY AAPL 10` · `SELL ALL TSLA` · `SELL MSFT 5 LMT 410` · `SELL TSLA 3 STP 250 LMT 245` · `BUY NVDA $500` · `TRADE` | order ticket, prefilled (demo: one click; live: review and confirm) |
| `DEMO` · `MODE LIVE` · ⇧B · ⇧S | switch account mode · buy / sell the security in focus |
| `SIDEBAR RIGHT` · `SIDEBAR` · `PREFS` · `HELP` | sidebar side or visibility · settings · help |
| right-click chart / watchlist / feed row / sidebar toggle | context menu (chart type, indicators, draw, add to watchlist, who traded it, View menu) |
@@ -294,9 +380,14 @@ app/ eframe shell: command line, docked panes, chart widget, panels
themes.rs theme model, built-in themes, TOML/JSON theme files and the themes folder
app/ workspace (drag and drop, pop-out windows, link groups), panels, settings, chrome
worker.rs tokio runtime: all network, SQLite, pyramid and indicator work (UI never blocks)
crates/core Bar/Series, timeframes, market keys + security catalog, indicators, sample data
storage.rs saved files: the data folder on desktop, localStorage in the browser
web.rs browser entry point (eframe web runner), console logging, frame timings
web/ the page that hosts the browser build
crates/core Bar/Series, timeframes, market keys + security catalog, indicators and their
registry (codes, parameters, panes, levels), sample data
crates/data MarketData trait; per-class router (Binance/Coinbase, Treasury.gov/FRED, Yahoo,
Alpaca), websocket live feeds, sample provider with simulated ticks; bar cache
Alpaca), websocket live feeds, sample provider with simulated ticks; bar cache;
the browser router (crypto live, the rest sampled) and a tokio/browser task shim
crates/chart wgpu renderer, LOD pyramids, viewport math, axes, drawings, egui paint callback
crates/congress Senate eFD + House Clerk parsers, rosters, SQLite store, refresh, alerts, performance
crates/broker SnapTrade client (request signing, accounts, balances, positions, orders, activity),
@@ -344,7 +435,8 @@ Measured on Linux with Mesa **llvmpipe (a CPU software rasterizer)** — a real
is the benchmark above).
- **Indicators over 1M bars** (`cargo bench -p tr-core`): SMA 1.8 ms, EMA 2.2 ms, VWAP 7.0 ms,
Bollinger 29 ms; RSI and MACD are single-pass (≈ 12 ms and 26 ms including first-touch of their
output memory).
output memory). The whole 151-indicator catalogue at default parameters over 100k one-minute
bars takes ≈ 134 ms on macOS (Ichimoku, the slowest, 6.6 ms).
## Tests
@@ -364,7 +456,11 @@ cargo bench -p tr-core && cargo bench -p tr-chart
chart/spark/search. Market-key parsing, catalog search and symbol mapping per source are unit
tested; opt-in live tests hit the real sources for every asset class.
- Indicators are checked against reference values (Wilder's RSI example, hand-computed EMA, MACD,
population-SD Bollinger, session VWAP).
population-SD Bollinger, session VWAP) and hand-computed windows (stochastic, %R, CCI, WMA,
true range, Donchian, envelopes, %B, OBV and A/D, CMF, Aroon, pivots). Every catalogue entry
runs on intraday and daily random walks and on degenerate input (no bars, one to three bars,
flat zero-volume bars) at its default, minimum and maximum parameters, checking alignment,
declared ranges and no panics; layouts saved in the old indicator format still load.
- Layouts: the dock tree (insert, split, tab, move, remove/normalise, resize clamping, presets,
JSON round trip and repair of hand-edited files), layout documents (every panel kind and its
settings, links, popped windows, older formats), the store (save, list, rename, duplicate,
+43 -6
View File
@@ -1,9 +1,14 @@
[package]
name = "terminal-rs"
name = "terminal"
description = "Bloomberg-style trading terminal with GPU charts and congressional trade tracking"
version.workspace = true
edition.workspace = true
default-run = "terminal-rs"
default-run = "terminal"
[features]
# Browser build only: add wgpu's WebGL2 backend for browsers without WebGPU (about 1 MB more,
# and charts stay off there because WebGL2 has no storage buffers).
webgl = ["eframe/wgpu"]
[dependencies]
tr-core.workspace = true
@@ -16,13 +21,45 @@ chrono.workspace = true
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
futures = "0.3"
egui = "0.36"
eframe = { version = "0.36", default-features = false, features = ["wgpu", "default_fonts", "wayland", "x11"] }
egui = { version = "0.36", default-features = false }
eframe = { version = "0.36", default-features = false, features = ["wgpu_no_default_features", "wayland", "x11"] }
tr-macos.workspace = true
web-time = "1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
eframe = { version = "0.36", default-features = false, features = ["wgpu", "default_fonts"] }
tokio.workspace = true
tracing-subscriber.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
raw-window-handle = "0.6"
# The browser build (see `src/web.rs` and scripts/build-web.sh): WebGPU only, unless the
# `webgl` feature adds the WebGL2 fallback.
[target.'cfg(target_arch = "wasm32")'.dependencies]
wgpu = { version = "30", default-features = false, features = ["webgpu", "wgsl"] }
egui-wgpu = { version = "0.36", default-features = false, features = ["fragile-send-sync-non-atomic-wasm"] }
# egui's text fonts only; the page fetches its emoji fonts after start-up (see theme::base_fonts).
epaint_default_fonts = "0.36"
tokio = { version = "1", default-features = false, features = ["sync", "macros"] }
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "registry", "std"] }
chrono = { workspace = true, features = ["wasmbind"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = [
"Blob",
"Document",
"Element",
"HtmlAnchorElement",
"HtmlCanvasElement",
"Location",
"Performance",
"Response",
"Storage",
"Url",
"Window",
"console",
] }
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

+134 -2
View File
@@ -1,7 +1,86 @@
//! Spring animations (SwiftUI-like), integrated per frame and keyed by egui `Id`.
//! Spring animations (SwiftUI-like), integrated per frame and keyed by egui `Id`, plus
//! timed transitions and easing curves. Everything snaps when Reduce Motion is on.
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use egui::{Context, Id};
static REDUCED: AtomicBool = AtomicBool::new(false);
static KEYS: AtomicU64 = AtomicU64::new(1);
/// A key no other [`progress`] transition has used, so replacing an object (a pane
/// reloaded under the same id) never mistakes its transition for a finished one.
pub fn fresh_key() -> u64 {
KEYS.fetch_add(1, Ordering::Relaxed)
}
/// Whether the system asks for reduced motion.
pub fn reduced() -> bool {
REDUCED.load(Ordering::Relaxed)
}
pub fn set_reduced(on: bool) {
REDUCED.store(on, Ordering::Relaxed);
}
pub fn ease_out(t: f32) -> f32 {
1.0 - (1.0 - t.clamp(0.0, 1.0)).powi(3)
}
/// Ease out with a slight overshoot past 1 before settling.
pub fn ease_out_back(t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
let (c1, c3) = (1.70158, 2.70158);
1.0 + c3 * (t - 1.0).powi(3) + c1 * (t - 1.0).powi(2)
}
/// `t` rescaled so `a..b` maps to 0..1 (clamped), for staggering parts of one transition.
pub fn span(t: f32, a: f32, b: f32) -> f32 {
((t - a) / (b - a)).clamp(0.0, 1.0)
}
/// Linear progress (0..1) of a `secs`-long transition that restarts whenever `key`
/// changes. The first call for an `id` starts it too. Requests repaints until done.
pub fn progress(ctx: &Context, id: Id, key: u64, secs: f32) -> f32 {
if reduced() {
return 1.0;
}
let now = ctx.input(|i| i.time);
let (k, start) = ctx.data_mut(|d| *d.get_temp_mut_or_insert_with(id, || (key, now)));
let start = if k == key {
start
} else {
ctx.data_mut(|d| d.insert_temp(id, (key, now)));
now
};
since(ctx, now - start, secs)
}
/// Entrance progress (0..1) of something drawn while a condition holds (a popover, an
/// overlay): it restarts whenever `id` was not shown on the previous frame.
pub fn appear(ctx: &Context, id: Id, secs: f32) -> f32 {
let frame = ctx.cumulative_frame_nr();
let now = ctx.input(|i| i.time);
let start = match ctx.data(|d| d.get_temp::<(u64, f64)>(id)) {
Some((last, start)) if last + 1 >= frame => start,
_ => now,
};
ctx.data_mut(|d| d.insert_temp(id, (frame, start)));
since(ctx, now - start, secs)
}
/// Progress of a transition that began `elapsed` seconds ago.
pub fn since(ctx: &Context, elapsed: f64, secs: f32) -> f32 {
if reduced() {
return 1.0;
}
let t = (elapsed / secs.max(1e-3) as f64).clamp(0.0, 1.0) as f32;
if t < 1.0 {
ctx.request_repaint();
}
t
}
/// A damped spring moving `pos` toward a target.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Spring {
@@ -50,7 +129,7 @@ pub fn spring(ctx: &Context, id: Id, target: f32) -> f32 {
pub fn spring_with(ctx: &Context, id: Id, target: f32, response: f32, damping: f32) -> f32 {
let dt = ctx.input(|i| i.stable_dt).clamp(0.0, 0.1);
let mut s = ctx.data_mut(|d| *d.get_temp_mut_or_insert_with(id, || Spring::at(target)));
if s.settled(target, target) {
if reduced() || s.settled(target, target) {
s = Spring::at(target);
} else {
s.step(target, dt, response, damping);
@@ -99,6 +178,59 @@ mod tests {
assert!(s.pos.is_finite() && s.pos.abs() < 2.0);
}
#[test]
fn easings_hit_their_ends() {
for f in [ease_out, ease_out_back] {
assert!(f(0.0).abs() < 1e-6);
assert!((f(1.0) - 1.0).abs() < 1e-6);
}
let peak = (0..=100).map(|i| ease_out_back(i as f32 / 100.0)).fold(0.0, f32::max);
assert!(peak > 1.05 && peak < 1.15, "overshoot {peak}");
assert!(ease_out(0.5) > 0.5);
assert_eq!(span(0.5, 0.25, 0.75), 0.5);
assert_eq!(span(0.1, 0.25, 0.75), 0.0);
}
#[test]
fn progress_restarts_when_the_key_changes() {
let ctx = Context::default();
let id = Id::new("p");
let frame = |key: u64, t: f64| {
let input = egui::RawInput { time: Some(t), ..Default::default() };
let mut out = 0.0;
ctx.run_ui(input, |ui| out = progress(ui.ctx(), id, key, 0.5)).textures_delta.clear();
out
};
assert_eq!(frame(1, 10.0), 0.0);
assert!((frame(1, 10.25) - 0.5).abs() < 1e-4);
assert_eq!(frame(1, 11.0), 1.0);
assert_eq!(frame(2, 12.0), 0.0);
assert!((frame(2, 12.1) - 0.2).abs() < 1e-4);
}
#[test]
fn appear_restarts_after_a_hidden_frame() {
let ctx = Context::default();
let id = Id::new("a");
let frame = |t: f64, show: bool| {
let input = egui::RawInput { time: Some(t), ..Default::default() };
let mut out = None;
ctx.run_ui(input, |ui| {
if show {
out = Some(appear(ui.ctx(), id, 0.2));
}
})
.textures_delta
.clear();
out
};
assert_eq!(frame(1.0, true), Some(0.0));
assert!((frame(1.1, true).unwrap() - 0.5).abs() < 1e-4);
assert_eq!(frame(1.3, true), Some(1.0));
frame(1.4, false);
assert_eq!(frame(1.5, true), Some(0.0), "reopened");
}
#[test]
fn context_helper_snaps_then_animates() {
let ctx = Context::default();
+105 -6
View File
@@ -6,8 +6,10 @@
mod broker;
mod chrome;
mod demo;
mod hotkeys_view;
mod options_view;
mod palette_view;
mod panels;
mod portfolio_view;
mod settings_view;
@@ -18,7 +20,7 @@ mod workspace;
pub use broker::PosSort;
use std::collections::HashMap;
use std::time::{Duration, Instant};
use std::time::Duration;
use egui::{Color32, Event, Key, Modifiers, Rect, Ui, UiBuilder, pos2, vec2};
use serde::{Deserialize, Serialize};
@@ -26,6 +28,7 @@ use tr_chart::drawings::Tool;
use tr_congress::Trade;
use tr_congress::db::{Alert, PoliticianSummary};
use tr_data::{NewsItem, OrderBook, Quote, SearchHit, YieldCurves};
use web_time::Instant;
use crate::anim;
use crate::chart_view::ChartState;
@@ -400,6 +403,9 @@ fn migrate_legacy(s: &Settings, defaults: &prefs::PanelDefaults) -> Option<Layou
Some(LayoutDoc::preset("Default", s.layout.unwrap_or(Layout::Grid2x2), panes))
}
/// How long an alert toast stays up.
const TOAST_SECS: u64 = 10;
/// A pane drag from its tab or header: pending until the pointer moves past a small
/// threshold (so clicks still select tabs), then active until release.
#[derive(Debug, Clone, Copy)]
@@ -420,6 +426,7 @@ pub struct TerminalApp {
/// Preset last applied, for the toolbar picker (cleared by manual rearranging).
preset: Option<Layout>,
drag: Option<Drag>,
dock_motion: workspace::DockMotion,
/// Tab being renamed inline: pane and edit buffer.
renaming_tab: Option<(PaneId, String)>,
@@ -495,6 +502,7 @@ pub struct TerminalApp {
system_accent: Color32,
last_accent_poll: Option<Instant>,
window_styled: bool,
updater_started: bool,
/// The window is see-through behind the sidebar (macOS vibrancy).
vibrancy: bool,
/// Measured macOS title bar geometry (polled; changes with full screen).
@@ -504,6 +512,10 @@ pub struct TerminalApp {
broker: broker::BrokerState,
/// Order ticket and trading dialogs.
trading: trading::TradingState,
/// The paper-trading demo account (selected by default).
demo: demo::DemoState,
/// ⌘K command palette.
palette: crate::palette::Palette,
}
/// Resolve a run of words (`BTCUSD CRNCY AAPL US EQUITY MSFT`) into market keys.
@@ -557,11 +569,13 @@ impl TerminalApp {
};
}
let settings: Option<Settings> =
std::fs::read(settings_path()).ok().and_then(|b| serde_json::from_slice(&b).ok());
crate::storage::read(&settings_path()).ok().and_then(|b| serde_json::from_slice(&b).ok());
let broker = broker::BrokerState::load(provider.eq_ignore_ascii_case("sample"));
let (demo, demo_note) = demo::DemoState::load(demo::demo_path());
let mut app = Self {
broker,
trading: Default::default(),
demo,
worker,
provider,
panes: Vec::new(),
@@ -571,6 +585,7 @@ impl TerminalApp {
last_opened: None,
preset: None,
drag: None,
dock_motion: Default::default(),
renaming_tab: None,
store: Store::new(data_dir().join("layouts")),
layout_name: "Default".into(),
@@ -626,9 +641,11 @@ impl TerminalApp {
system_accent,
last_accent_poll: None,
window_styled: false,
updater_started: false,
vibrancy: false,
chrome: None,
last_chrome_poll: None,
palette: crate::palette::Palette::load(&data_dir().join("palette.json")),
};
let mut doc = None;
if let Some(s) = &settings {
@@ -659,9 +676,23 @@ impl TerminalApp {
app.worker.send(Request::Latest { limit: 2_000 });
app.worker.send(Request::SearchPoliticians(String::new()));
app.worker.send(Request::Alerts);
if let Some(note) = demo_note {
app.trade_toast(note, false);
}
app
}
/// Replace the status bar message.
pub fn set_status(&mut self, status: String) {
self.status = status;
}
/// Show the frame-time readout (as F12 does); the browser's `?fps` flag.
#[cfg(target_arch = "wasm32")]
pub fn show_perf(&mut self) {
self.show_perf = true;
}
fn save_settings(&self) {
let s = Settings {
watchlist: self.watchlist.clone(),
@@ -671,7 +702,7 @@ impl TerminalApp {
panes: None,
};
if let Ok(json) = serde_json::to_vec_pretty(&s)
&& let Err(e) = crate::layouts::write_atomic(&settings_path(), &json)
&& let Err(e) = crate::storage::write_atomic(&settings_path(), &json)
{
tracing::warn!("saving settings: {e}");
}
@@ -771,6 +802,7 @@ impl TerminalApp {
Function::Timeframe(tf) => self.with_chart(|c, w| c.set_timeframe(tf, w)),
Function::ChartType(t) => self.with_chart(|c, _| c.chart_type = t),
Function::Indicator(spec) => self.with_chart(|c, w| c.toggle_indicator(spec, w)),
Function::IndicatorPicker(q) => self.palette.open_scoped(crate::palette::Kind::Indicator, &q),
Function::ClearIndicators => self.with_chart(|c, _| c.clear_indicators()),
Function::Compare(s) => self.with_chart(|c, w| c.set_compare(s, w)),
Function::LogScale => self.with_chart(|c, _| c.log = !c.log),
@@ -840,6 +872,15 @@ impl TerminalApp {
self.open_panel(PaneKind::OrderBook(Box::new(BookPane::new(&sym, depth, 0.0))));
}
Function::Settings => self.open_panel(PaneKind::Settings),
Function::Update => {
if !tr_macos::updater::check_for_updates() {
let url = format!("{}/latest", tr_macos::updater::RELEASES);
self.status = match broker::open_external(&url) {
Ok(()) => "Opened the latest release on GitHub".into(),
Err(e) => format!("Could not open {url}: {e}"),
};
}
}
Function::Options => {
let sym = self.optionable_symbol();
self.open_panel(PaneKind::Options(Box::new(OptionsPane::new(&sym, 12, true))));
@@ -884,6 +925,13 @@ impl TerminalApp {
}
Function::Portfolio(view, arg) => self.portfolio_command(view, &arg, None),
Function::Trade(t) => self.open_ticket(t, None),
Function::AccountMode(live) => {
let mode = if live { prefs::AccountMode::Live } else { prefs::AccountMode::Demo };
if mode == self.prefs.trading.account_mode {
self.status = format!("Already using the {} account", if live { "LIVE" } else { "DEMO" });
}
self.set_account_mode(mode, false);
}
}
}
@@ -948,9 +996,11 @@ impl TerminalApp {
if q.len() > 20 {
self.wei_updated = Some(format!("updated {}", chrono::Local::now().format("%H:%M:%S")));
}
let keys: Vec<String> = q.iter().map(|x| x.symbol.clone()).collect();
for x in q {
self.quotes.insert(x.symbol.clone(), x);
}
self.demo_on_quotes(&keys);
}
Response::Latest(t) => self.latest = t,
Response::TickerTrades { pane, symbol, trades } => {
@@ -1048,6 +1098,9 @@ impl TerminalApp {
/// Freshness line for the Congress feed: last check, newest filing, next check.
fn congress_freshness(&self) -> String {
if cfg!(target_arch = "wasm32") {
return "desktop app only for now".into();
}
let newest = self.latest.iter().map(|t| t.filed_date).max();
let minutes = std::env::var("TR_CONGRESS_REFRESH_MIN")
.ok()
@@ -1122,6 +1175,18 @@ impl TerminalApp {
}
return;
}
// ⌘K (Ctrl+K) opens and closes the palette from anywhere, even from a text field.
if let Some(&(key, m)) =
presses.iter().find(|(k, m)| self.prefs.hotkeys.action_for(*k, *m) == Some(prefs::Action::CommandPalette))
{
ctx.input_mut(|i| i.consume_key(m, key));
self.palette.toggle();
return;
}
// While it is open, the palette owns the keyboard.
if self.palette.open {
return;
}
let cmd_id = egui::Id::new("command-line");
let focused = ctx.memory(|m| m.focused());
let cmd_focused = focused == Some(cmd_id);
@@ -1130,19 +1195,26 @@ impl TerminalApp {
return;
}
// Chorded bindings (⌘, Ctrl, Alt) work even while the command line has text.
let mut swallow_text = false;
for &(key, m) in &presses {
let Some(action) = self.prefs.hotkeys.action_for(key, m) else { continue };
let chorded = m.command || m.ctrl || m.alt;
if !chorded && !self.cmd.is_empty() {
continue;
}
// ⇧B / ⇧S type letters: never while the command line is being typed into.
let letter = !chorded && Self::types_text(key);
if letter && cmd_focused {
continue;
}
if !self.hotkey_applies(action) {
continue;
}
ctx.input_mut(|i| i.consume_key(m, key));
swallow_text |= letter;
self.run_hotkey(action);
}
if !cmd_focused {
if !cmd_focused && !swallow_text {
// Type-anywhere: capture keystrokes until the command line has focus.
// Judge modifiers per key event: the Alt release can arrive in the same frame
// as Alt+T, so the frame-level modifier state is unreliable.
@@ -1200,6 +1272,12 @@ impl TerminalApp {
}
}
/// Keys that also produce text (a bare or shifted letter, digit or symbol).
fn types_text(key: Key) -> bool {
let n = key.name();
n.len() == 1 || matches!(key, Key::Space | Key::Minus | Key::Plus | Key::Equals | Key::Period | Key::Comma)
}
fn hotkey_applies(&self, a: prefs::Action) -> bool {
use prefs::Action as A;
match a {
@@ -1227,6 +1305,8 @@ impl TerminalApp {
A::Portfolio => self.apply(Function::Portfolio(command::PortView::Summary, String::new())),
A::Positions => self.apply(Function::Portfolio(command::PortView::Positions, String::new())),
A::Orders => self.apply(Function::Portfolio(command::PortView::Orders, String::new())),
A::Buy => self.quick_trade(true),
A::Sell => self.quick_trade(false),
A::NextPane => self.cycle_focus(1),
A::PrevPane => self.cycle_focus(-1),
A::ToggleSidebar => self.sidebar_open = !self.sidebar_open,
@@ -1247,6 +1327,7 @@ impl TerminalApp {
self.status = format!("Saved layout {:?}", self.layout_name);
}
A::Settings => self.apply(Function::Settings),
A::CommandPalette => self.palette.toggle(),
A::DrawTrendline => self.with_chart(|c, _| c.start_tool(Tool::Trendline)),
A::DrawHorizontal => self.with_chart(|c, _| c.start_tool(Tool::Horizontal)),
A::DrawVertical => self.with_chart(|c, _| c.start_tool(Tool::Vertical)),
@@ -1318,11 +1399,13 @@ impl TerminalApp {
self.last_feed = Some(Instant::now());
}
self.save_dirty_themes(false);
if self.last_save.elapsed() > Duration::from_secs(10) {
// A browser tab can close without an exit callback, so save sooner there.
let every = Duration::from_secs(if cfg!(target_arch = "wasm32") { 2 } else { 10 });
if self.last_save.elapsed() > every {
self.autosave();
self.last_save = Instant::now();
}
self.toasts.retain(|(_, t)| t.elapsed() < Duration::from_secs(10));
self.toasts.retain(|(_, t)| t.elapsed() < Duration::from_secs(TOAST_SECS));
}
/// Save the active layout (only when it changed) and app settings.
@@ -1385,6 +1468,10 @@ impl eframe::App for TerminalApp {
if !self.window_styled {
self.window_styled = true;
self.vibrancy = style_native_window(frame, self.prefs.appearance.vibrancy);
} else if !self.updater_started {
// From the second frame, so loading Sparkle doesn't delay the first.
self.updater_started = true;
tr_macos::updater::start();
}
if self.last_chrome_poll.is_none_or(|t| t.elapsed() > Duration::from_millis(750)) {
let c = native_chrome(frame);
@@ -1398,6 +1485,7 @@ impl eframe::App for TerminalApp {
if let Some([r, g, b]) = tr_macos::accent_color() {
self.system_accent = Color32::from_rgb(r, g, b);
}
anim::set_reduced(tr_macos::reduce_motion());
self.last_accent_poll = Some(Instant::now());
}
self.system_theme = ctx.system_theme();
@@ -1435,6 +1523,7 @@ impl eframe::App for TerminalApp {
);
self.hotkeys(&ctx);
self.command_palette(&ctx);
let mut sidebar_cmds = Vec::new();
if sb_w >= 1.0 {
// The sidebar slides in from its edge.
@@ -1488,6 +1577,13 @@ impl eframe::App for TerminalApp {
}
let cpu = frame_start.elapsed().as_secs_f64() * 1e3;
self.perf_cpu_ms = self.perf_cpu_ms * 0.9 + cpu * 0.1;
#[cfg(target_arch = "wasm32")]
crate::web::record_frame(&crate::web::FrameStats {
ui_ms: self.perf_cpu_ms,
interval_ms: self.perf_dt_ms,
last_cpu_ms: frame.info().cpu_usage.map_or(0.0, |s| f64::from(s) * 1e3),
frames: ctx.cumulative_frame_nr(),
});
if self.show_perf {
// Measure continuously while the readout is visible.
ctx.request_repaint();
@@ -1501,6 +1597,9 @@ impl eframe::App for TerminalApp {
fn on_exit(&mut self) {
self.autosave();
if self.demo.save_error.is_some() {
self.demo.save();
}
}
}
+39 -12
View File
@@ -3,9 +3,10 @@
//! refresh scheduling and the `PORT`/`ACCT`/`POS`/`ORD`/`ACTV` commands. Order entry is in
//! `trading`.
use std::time::{Duration, Instant};
use std::time::Duration;
use tr_broker::{Credentials, Portfolio, Position, SecretStore};
use web_time::Instant;
use super::{PaneKind, TerminalApp};
use crate::command::PortView;
@@ -100,9 +101,15 @@ pub struct BrokerState {
impl BrokerState {
pub fn load(offline: bool) -> Self {
let store = SecretStore::platform(&data_dir());
Self::with_store(SecretStore::platform(&data_dir()), offline)
}
/// [`Self::load`] with credentials from `store` instead of the platform's.
pub fn with_store(store: SecretStore, offline: bool) -> Self {
let mut message = None;
let stored = match store.load() {
// The browser build can't reach SnapTrade, and keys don't belong in page storage.
let stored = if cfg!(target_arch = "wasm32") { Ok(None) } else { store.load() };
let stored = match stored {
Ok(c) => c.unwrap_or_default(),
Err(e) => {
tracing::warn!("SnapTrade credentials unavailable: {e:#}");
@@ -112,7 +119,9 @@ impl BrokerState {
};
let mut creds = stored.clone();
let from_env = creds.apply_env(|k| std::env::var(k).ok());
let simulate = offline || std::env::var("TR_BROKER").is_ok_and(|v| v.eq_ignore_ascii_case("sample"));
let simulate = offline
|| cfg!(target_arch = "wasm32")
|| std::env::var("TR_BROKER").is_ok_and(|v| v.eq_ignore_ascii_case("sample"));
Self {
store,
saved: stored,
@@ -157,6 +166,7 @@ impl BrokerState {
}
/// Open a URL or path with the system handler.
#[cfg(not(target_arch = "wasm32"))]
pub fn open_external(target: &str) -> std::io::Result<()> {
let opener = if cfg!(target_os = "macos") {
"open"
@@ -168,11 +178,23 @@ pub fn open_external(target: &str) -> std::io::Result<()> {
std::process::Command::new(opener).arg(target).spawn().map(|_| ())
}
/// Open a URL in a new browser tab.
#[cfg(target_arch = "wasm32")]
pub fn open_external(target: &str) -> std::io::Result<()> {
let window = web_sys::window().ok_or_else(|| std::io::Error::other("no browser window"))?;
window
.open_with_url_and_target_and_features(target, "_blank", "noopener")
.map(|_| ())
.map_err(|_| std::io::Error::other("the browser blocked the new tab"))
}
/// A stable, non-identifying SnapTrade user id for this installation.
pub fn new_user_id() -> String {
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
let mix = nanos ^ (u128::from(std::process::id()) << 64);
format!("terminal-rs-{:016x}", (mix as u64) ^ ((mix >> 64) as u64))
let nanos = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
// Browsers have no process ids.
let pid = if cfg!(target_arch = "wasm32") { 0 } else { std::process::id() };
let mix = nanos ^ (u128::from(pid) << 64);
format!("terminal-{:016x}", (mix as u64) ^ ((mix >> 64) as u64))
}
impl TerminalApp {
@@ -198,11 +220,13 @@ impl TerminalApp {
let b = &self.broker;
let connecting = b.connecting_since.is_some_and(|t| t.elapsed() < CONNECT_WINDOW);
let every = if connecting { CONNECT_POLL } else { REFRESH };
if !b.loading && b.mode() != BrokerMode::Setup && b.fetched.is_none_or(|t| t.elapsed() > every) {
// The demo account is local: live accounts load only while they are shown.
let live = !self.demo_mode();
if live && !b.loading && b.mode() != BrokerMode::Setup && b.fetched.is_none_or(|t| t.elapsed() > every) {
self.refresh_portfolio();
}
// Reprice holdings with the terminal's own quotes.
if let Some(pf) = &self.broker.portfolio
if let Some(pf) = self.shown_portfolio()
&& self.broker.last_quotes.is_none_or(|t| t.elapsed() > Duration::from_secs(20))
{
let mut keys: Vec<String> = pf
@@ -421,7 +445,10 @@ impl TerminalApp {
pub(super) fn portfolio_command(&mut self, view: PortView, arg: &str, symbol: Option<String>) {
match arg.trim() {
"REFRESH" | "SYNC" | "RELOAD" => {
if self.broker.mode() == BrokerMode::Setup {
if self.demo_mode() {
self.status =
"The demo account is local and always current (MODE LIVE for brokerage accounts)".into();
} else if self.broker.mode() == BrokerMode::Setup {
self.status = "Brokerage not set up: PORT SETUP".into();
} else {
self.refresh_portfolio();
@@ -437,7 +464,7 @@ impl TerminalApp {
let mut pane = PortfolioPane::new(view);
pane.symbol = symbol;
self.open_panel(PaneKind::Portfolio(Box::new(pane)));
if self.broker.portfolio.is_none() && !self.broker.loading {
if !self.demo_mode() && self.broker.portfolio.is_none() && !self.broker.loading {
self.refresh_portfolio();
}
}
@@ -779,7 +806,7 @@ mod tests {
let a = new_user_id();
std::thread::sleep(Duration::from_millis(1));
let b = new_user_id();
assert!(a.starts_with("terminal-rs-") && a.len() == "terminal-rs-".len() + 16);
assert!(a.starts_with("terminal-") && a.len() == "terminal-".len() + 16);
assert_ne!(a, b);
}
+111 -29
View File
@@ -1,13 +1,14 @@
//! Window chrome in the style of a SwiftUI macOS app: a unified toolbar holding the
//! command line, a translucent (vibrancy) sidebar, rounded pane cards and a status bar.
use std::time::Instant;
use std::time::Duration;
use egui::{
Align2, Color32, CornerRadius, CursorIcon, Id, Key, Modifiers, Rect, RichText, Sense, Shadow, Stroke, StrokeKind,
Ui, UiBuilder, ViewportCommand, pos2, vec2,
};
use tr_data::SearchHit;
use web_time::Instant;
use super::{PaneKind, TerminalApp};
use crate::anim;
@@ -101,14 +102,38 @@ impl TerminalApp {
resp.context_menu(|ui| self.view_menu(ui));
// Data source chip (the window title itself is redundant in a compact toolbar).
let live = !self.provider.eq_ignore_ascii_case("sample");
let chip_text = p.layout_no_wrap(if live { "Live" } else { "Offline" }.into(), ui_font(11.0), muted());
let (label, live) = self.source_chip();
let chip_text = p.layout_no_wrap(label.into(), ui_font(11.0), muted());
let chip = Rect::from_min_size(pos2(x, cy - 9.0), vec2(chip_text.size().x + 20.0, 18.0));
p.rect_filled(chip, 9.0, theme::control());
p.circle_filled(pos2(chip.left() + 8.0, cy), 3.0, if live { theme::up() } else { amber() });
p.galley(pos2(chip.left() + 14.0, cy - chip_text.size().y / 2.0), chip_text, muted());
ui.interact(chip, Id::new("source-chip"), Sense::hover()).on_hover_text(self.provider_label());
x = chip.right() + 10.0;
x = chip.right() + 8.0;
// Which account trades: DEMO (paper) or LIVE (real money, in red). Click to switch.
let live_mode = !self.demo_mode();
let (label, fill) = if live_mode { ("LIVE", theme::down()) } else { ("DEMO", theme::accent()) };
let badge_text = p.layout_no_wrap(label.into(), mono(11.0), Color32::WHITE);
let badge = Rect::from_min_size(pos2(x, cy - 10.0), vec2(badge_text.size().x + 18.0, 20.0));
let resp = ui.interact(badge, Id::new("account-mode-badge"), Sense::click());
let fill = if resp.hovered() { fill } else { fill.gamma_multiply(0.9) };
p.rect_filled(badge, 10.0, fill);
p.galley(badge.center() - badge_text.size() / 2.0, badge_text, Color32::WHITE);
let tip = if live_mode {
"LIVE: portfolio panes and orders use your real brokerage accounts. Click to switch to the demo account."
} else {
"DEMO: paper trading with virtual cash. Click to switch to your live brokerage accounts."
};
if resp.on_hover_text(tip).clicked() {
let next = if live_mode { crate::prefs::AccountMode::Demo } else { crate::prefs::AccountMode::Live };
self.set_account_mode(next, false);
}
if live_mode {
// A red rule along the toolbar so live mode is never mistaken for the demo.
p.hline(rect.x_range(), rect.bottom() - 1.0, Stroke::new(2.0, theme::down()));
}
x = badge.right() + 10.0;
// Right-hand cluster, laid out from the right edge: one-line clock (UTC on hover).
let now = chrono::Local::now();
@@ -257,9 +282,28 @@ impl TerminalApp {
}
}
/// The data-source chip's label, and whether any of the data is live.
fn source_chip(&self) -> (&'static str, bool) {
let n = self.provider.to_lowercase();
if n == "sample" {
("Offline", false)
} else if n.contains("sample") {
("Crypto live", true)
} else {
("Live", true)
}
}
fn provider_label(&self) -> String {
let n = self.provider.to_lowercase();
if n.contains("sample") { "Offline · sample data".into() } else { format!("Live · {}", self.provider) }
if n == "sample" {
"Offline · sample data".into()
} else if n.contains("sample") {
// The browser build (tr_data::web::WebMarkets).
"Live crypto from Binance and Coinbase; every other market is sample data in the browser".into()
} else {
format!("Live · {}", self.provider)
}
}
/// The Bloomberg command line, drawn as a rounded macOS search field.
@@ -311,17 +355,25 @@ impl TerminalApp {
Icon::Search,
muted(),
);
// A small "GO" key cap at the trailing edge, Bloomberg-style.
// A small "GO" key cap at the trailing edge, Bloomberg-style. It lights up once
// there is something to run and presses in on a click or Enter.
let go = Rect::from_center_size(pos2(field.right() - 18.0, field.center().y), vec2(28.0, 15.0));
p.rect_filled(go, 4.0, if self.cmd.trim().is_empty() { theme::control_hover() } else { amber() });
let go_resp = ui.interact(go, id.with("go"), Sense::click()).on_hover_text("<GO> (Enter)");
let now = ui.input(|i| i.time);
let entered = ui.ctx().data(|d| d.get_temp::<f64>(id.with("go-entered"))).is_some_and(|t| now - t < 0.09);
let held = go_resp.is_pointer_button_down_on() || entered;
let press = anim::spring_with(ui.ctx(), id.with("go-press"), if held { 1.0 } else { 0.0 }, 0.3, 0.55);
let lit = anim::spring_bool(ui.ctx(), id.with("go-lit"), !self.cmd.trim().is_empty());
let cap = Rect::from_center_size(go.center(), go.size() * (1.0 - 0.14 * press));
p.rect_filled(cap, 4.0, widgets::lerp_color(theme::control_hover(), amber(), lit));
p.text(
go.center(),
cap.center(),
Align2::CENTER_CENTER,
"GO",
mono(10.5),
if self.cmd.trim().is_empty() { muted() } else { Color32::BLACK },
widgets::lerp_color(muted(), Color32::BLACK, lit),
);
let go_clicked = ui.interact(go, id.with("go"), Sense::click()).on_hover_text("<GO> (Enter)").clicked();
let go_clicked = go_resp.clicked();
let inner = Rect::from_min_max(pos2(field.left() + 28.0, field.top()), pos2(go.left() - 6.0, field.bottom()));
let mut child = ui.new_child(UiBuilder::new().max_rect(inner));
@@ -366,6 +418,10 @@ impl TerminalApp {
)
});
if (resp.lost_focus() && enter) || go_clicked {
if enter {
ui.ctx().data_mut(|d| d.insert_temp(id.with("go-entered"), now));
ui.ctx().request_repaint();
}
let input = std::mem::take(&mut self.cmd);
match self.suggestion.take().and_then(|i| suggestions.get(i)) {
Some(pick) => self.execute(&pick.key),
@@ -373,8 +429,12 @@ impl TerminalApp {
None => {}
}
self.cmd_hist_pos = None;
// The command line stays live, Bloomberg-style.
self.focus_cmd = true;
// The command line stays live, Bloomberg-style, unless the command opened the
// order ticket (which takes the keyboard for its quantity) or the palette.
self.focus_cmd = !self.palette.open && !self.trading.ticket.as_ref().is_some_and(|t| t.focus_qty > 0);
if !self.focus_cmd {
resp.surrender_focus();
}
} else if had_focus && esc {
self.cmd.clear();
resp.surrender_focus();
@@ -420,10 +480,12 @@ impl TerminalApp {
}
let mut clicked = None;
let width = below.width().clamp(420.0, 680.0);
let shown = anim::ease_out(anim::appear(ctx, Id::new("cmd-suggestions-in"), 0.18));
egui::Area::new(Id::new("cmd-suggestions"))
.order(egui::Order::Foreground)
.fixed_pos(below.left_bottom() + vec2(0.0, 6.0))
.fixed_pos(below.left_bottom() + vec2(0.0, 6.0 - 6.0 * (1.0 - shown)))
.show(ctx, |ui| {
ui.multiply_opacity(shown);
egui::Frame::new()
.fill(theme::card())
.stroke(Stroke::new(1.0, theme::border()))
@@ -432,10 +494,13 @@ impl TerminalApp {
.inner_margin(6.0)
.show(ui, |ui| {
ui.set_width(width);
let glider = widgets::Glider::new(ui, Id::new("cmd-suggestions-sel"));
for (i, h) in hits.iter().enumerate() {
let (r, resp) = ui.allocate_exact_size(vec2(width, 26.0), Sense::click());
let selected = self.suggestion == Some(i);
widgets::hover_wash(ui, r, &resp, selected);
if self.suggestion == Some(i) {
glider.on(ui, r);
}
widgets::hover_wash(ui, r, &resp, false);
let p = ui.painter();
let class_c = markets_view::class_color(h.class);
let pill = Rect::from_min_size(pos2(r.left() + 6.0, r.center().y - 8.0), vec2(52.0, 16.0));
@@ -496,7 +561,7 @@ impl TerminalApp {
let head = Rect::from_min_size(rect.min, vec2(rect.width(), self.toolbar_h()));
window_drag_region(ui, head, "sidebar");
if !cfg!(target_os = "macos") {
p.text(head.left_center() + vec2(14.0, 0.0), Align2::LEFT_CENTER, "TERMINAL-RS", mono(12.5), amber());
p.text(head.left_center() + vec2(14.0, 0.0), Align2::LEFT_CENTER, "TERMINAL", mono(12.5), amber());
}
let body = Rect::from_min_max(pos2(rect.left(), head.bottom()), rect.max);
@@ -600,7 +665,7 @@ impl TerminalApp {
PaneKind::Portfolio(pp) => Some(pp.view),
_ => None,
});
let pf = self.broker.portfolio.as_deref();
let pf = self.shown_portfolio();
let value_badge = pf.filter(|p| !p.accounts.is_empty()).map(|p| {
let t = p.totals(None, |pos| self.broker.live_price(&self.quotes, pos));
compact_money(t.value)
@@ -824,27 +889,44 @@ impl TerminalApp {
p.text(pos2(rect.right() - 10.0, cy), Align2::RIGHT_CENTER, status, ui_font(11.5), muted());
}
/// Alerts stacked at the top right: each slides in from the edge with a slight
/// overshoot, the stack glides up as older ones leave, and they fade out at the end.
pub(super) fn toasts(&self, ctx: &egui::Context) {
let screen = ctx.content_rect();
let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("toasts")));
let layer = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("toasts")));
let life = super::TOAST_SECS as f32;
for (k, (msg, at)) in self.toasts.iter().enumerate() {
let slide = anim::spring(ctx, Id::new(("toast", k, *at)), 0.0);
let r = Rect::from_min_size(
pos2(screen.right() - 432.0 + slide, screen.top() + self.toolbar_h() + 10.0 + k as f32 * 58.0),
vec2(420.0, 50.0),
);
let age = at.elapsed().as_secs_f32();
let enter = anim::ease_out_back(anim::since(ctx, age as f64, 0.55));
// Reduce Motion skips the fade: the toast stays until it is removed.
let fade = if anim::reduced() { 0.0 } else { 0.35 };
let leave = if fade > 0.0 { anim::span(age, life - fade, life) } else { 0.0 };
// Wake for the fade-out (and the removal at the end of it).
ctx.request_repaint_after(Duration::from_secs_f32((life - fade - age).max(0.0)));
let y =
anim::spring(ctx, Id::new(("toast-y", *at)), screen.top() + self.toolbar_h() + 10.0 + k as f32 * 58.0);
let x = screen.right() - 432.0 + (1.0 - enter) * 444.0;
let r = Rect::from_min_size(pos2(x, y), vec2(420.0, 50.0));
let mut p = layer.clone();
p.multiply_opacity(1.0 - leave);
widgets::card_on(&p, r, 0.0);
// Order entry prefixes its toasts (`+` filled/ok, `!` rejected); everything else
// is a Congress alert.
let (title, msg, color) = match msg.strip_prefix(super::trading::TOAST_PREFIX) {
Some(rest) => match rest.split_at_checked(1) {
Some(("+", m)) => ("Trading", m, theme::up()),
Some(("!", m)) => ("Trading · not done", m, theme::down()),
_ => ("Trading", rest, amber()),
},
None => ("Congress Alert", msg.as_str(), amber()),
};
p.rect_filled(Rect::from_min_size(r.min + vec2(1.0, 6.0), vec2(3.0, r.height() - 12.0)), 1.5, color);
widgets::draw_icon(
&p,
Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(18.0, 18.0)),
Icon::Bell,
amber(),
color,
);
// Order entry prefixes its toasts; everything else is a Congress alert.
let (title, msg) = match msg.strip_prefix(super::trading::TOAST_PREFIX) {
Some(rest) => ("Trading", rest),
None => ("Congress Alert", msg.as_str()),
};
p.text(pos2(r.left() + 42.0, r.top() + 15.0), Align2::LEFT_CENTER, title, ui_font(12.5), text());
p.text(pos2(r.left() + 42.0, r.top() + 33.0), Align2::LEFT_CENTER, msg, ui_font(11.5), muted());
}
+284
View File
@@ -0,0 +1,284 @@
//! The demo (paper-trading) account inside the app: loading and saving the book, feeding it
//! the terminal's quotes so resting orders fill, and switching between the demo and live
//! accounts.
//!
//! The demo account is selected on first launch and stays selected until the user switches
//! to live accounts, which takes a confirmation and is remembered in the preferences. Every
//! demo change is saved at once to `demo-account.json` in the app data folder.
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Context;
use tr_broker::demo::{DemoBook, Event, STARTING_CASH};
use tr_broker::trading::OrderRequest;
use tr_broker::{InstrumentKind, Order, Portfolio};
use web_time::Instant;
use super::TerminalApp;
use crate::prefs::AccountMode;
use crate::worker::{Request, data_dir};
/// How often quotes are requested for demo positions and open demo orders.
const QUOTES_EVERY: Duration = Duration::from_secs(10);
pub struct DemoState {
pub book: DemoBook,
/// `book.portfolio()`, rebuilt after every change so panes can borrow it.
pub pf: Box<Portfolio>,
path: PathBuf,
last_quotes: Option<Instant>,
/// Settings › Trading: the reset confirmation is open, with this starting cash.
pub confirm_reset: bool,
pub reset_cash: f64,
/// The last save failed (disk full, folder unwritable): shown until a save succeeds,
/// and retried periodically and on exit.
pub save_error: Option<String>,
last_save_try: Option<Instant>,
}
pub fn demo_path() -> PathBuf {
data_dir().join("demo-account.json")
}
/// Terminal market key for a demo symbol (`AAPL` → `AAPL US Equity`).
pub fn market_key(symbol: &str) -> Option<String> {
tr_broker::model::market_key(InstrumentKind::Stock, symbol, "USD")
}
impl DemoState {
/// Load the saved book. A damaged file is moved aside (never overwritten) and a fresh
/// account starts; the returned message says so.
pub fn load(path: PathBuf) -> (Self, Option<String>) {
let now = chrono::Utc::now();
let saved = match crate::storage::read(&path) {
Ok(bytes) => DemoBook::from_json(&bytes),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(DemoBook::new(now)),
Err(e) => Err(anyhow::Error::new(e).context("reading the demo account")),
};
let (book, note) = match saved {
Ok(b) => (b, None),
Err(e) => {
let aside = path.with_extension(format!("damaged-{}.json", now.format("%Y%m%d%H%M%S")));
let moved = crate::storage::rename(&path, &aside).is_ok();
tracing::warn!("demo account: {e:#}");
let note = if moved {
format!(
"The demo account file was damaged and was kept as {}; a new demo account started.",
aside.display()
)
} else {
format!("The demo account file could not be read ({e:#}); a new demo account started.")
};
(DemoBook::new(now), Some(note))
}
};
let pf = Box::new(book.portfolio());
(
Self {
book,
pf,
path,
last_quotes: None,
confirm_reset: false,
reset_cash: STARTING_CASH,
save_error: None,
last_save_try: None,
},
note,
)
}
/// The book changed: rebuild the view and save.
fn changed(&mut self) {
*self.pf = self.book.portfolio();
self.save();
}
/// Save now. Returns whether the book is on disk.
pub fn save(&mut self) -> bool {
self.last_save_try = Some(Instant::now());
let saved = self
.book
.to_json()
.and_then(|json| crate::storage::write_atomic(&self.path, &json).context("saving the demo account"));
match saved {
Ok(()) => {
self.save_error = None;
true
}
Err(e) => {
tracing::warn!("saving the demo account: {e:#}");
self.save_error = Some(format!("{e:#}"));
false
}
}
}
/// Retry a failed save every few seconds.
fn retry_save(&mut self) {
if self.save_error.is_some() && self.last_save_try.is_none_or(|t| t.elapsed() > Duration::from_secs(5)) {
self.save();
}
}
}
impl TerminalApp {
pub(super) fn demo_mode(&self) -> bool {
self.prefs.trading.account_mode == AccountMode::Demo
}
/// The portfolio the panes show: the demo account, or the live (or sample) brokerage.
pub(super) fn shown_portfolio(&self) -> Option<&Portfolio> {
if self.demo_mode() { Some(&self.demo.pf) } else { self.broker.portfolio.as_deref() }
}
/// Keep quotes coming for demo positions and open orders, and expire Day orders.
pub(super) fn demo_periodic(&mut self) {
let had_error = self.demo.save_error.is_some();
self.demo.retry_save();
if had_error && self.demo.save_error.is_none() {
self.trade_toast("Demo account saved", true);
}
let now = chrono::Utc::now();
let expired = self.demo.book.expire(now);
if !expired.is_empty() {
self.demo.changed();
self.demo_events(expired);
}
let watched = self.demo.book.watched();
let wanted = self.demo_mode() || self.demo.book.orders.iter().any(Order::is_open);
if !wanted || watched.is_empty() || self.demo.last_quotes.is_some_and(|t| t.elapsed() < QUOTES_EVERY) {
return;
}
let keys: Vec<String> = watched.iter().filter_map(|s| market_key(s)).collect();
if !keys.is_empty() {
self.worker.send(Request::Quotes(keys));
}
self.demo.last_quotes = Some(Instant::now());
}
/// New quotes arrived: mark demo positions and fill resting demo orders they cross.
pub(super) fn demo_on_quotes(&mut self, keys: &[String]) {
let now = chrono::Utc::now();
// Expire overdue Day orders before any quote can fill them.
let mut events = self.demo.book.expire(now);
let mut touched = !events.is_empty();
for sym in self.demo.book.watched() {
let Some(k) = market_key(&sym).filter(|k| keys.contains(k)) else { continue };
let Some(q) = self.quotes.get(&k).map(|q| q.last).filter(|x| x.is_finite() && *x > 0.0) else { continue };
events.extend(self.demo.book.on_price(&sym, q, now));
touched = true;
}
if touched {
self.demo.changed();
}
self.demo_events(events);
}
fn demo_events(&mut self, events: Vec<Event>) {
for e in events {
let ok = matches!(e, Event::Filled(_));
self.trade_toast(format!("Demo: {}{}", e.message(), self.unsaved_note()), ok);
}
}
/// ` (not saved yet: …)` while the demo book cannot be written.
fn unsaved_note(&self) -> String {
self.demo.save_error.as_ref().map_or(String::new(), |e| format!(" — not saved yet ({e}); retrying"))
}
/// Place a demo order now (one click: no preview, no confirmation).
pub(super) fn demo_submit(&mut self, req: &OrderRequest, quote: Option<f64>) -> Result<Order, String> {
let result = self.demo.book.submit(req, quote, chrono::Utc::now());
match &result {
Ok(o) => {
self.demo.changed();
let msg = match o.status.as_str() {
"EXECUTED" => tr_broker::demo::fill_message(o),
"CANCELED" => format!("{} cancelled (not fillable now)", tr_broker::demo::order_line(o)),
_ => format!("{} working", tr_broker::demo::order_line(o)),
};
let saved = self.demo.save_error.is_none();
self.trade_toast(format!("Demo: {msg}{}", self.unsaved_note()), saved && o.status != "CANCELED");
}
Err(e) => self.trade_toast(format!("Demo order not placed: {e}"), false),
}
result
}
pub(super) fn demo_cancel(&mut self, id: &str) {
match self.demo.book.cancel(id, chrono::Utc::now()) {
Ok(o) => {
self.demo.changed();
let note = self.unsaved_note();
self.trade_toast(format!("Demo: cancelled {}{note}", tr_broker::demo::order_line(&o)), note.is_empty());
}
Err(e) => self.trade_toast(format!("Could not cancel: {e}"), false),
}
}
/// Sell a whole demo position at market.
pub(super) fn demo_close(&mut self, symbol: &str) {
let Some(req) = self.demo.book.close_request(symbol) else {
return self.trade_toast(format!("No {symbol} shares to close (check open sell orders)"), false);
};
let quote = market_key(symbol).and_then(|k| self.quotes.get(&k)).map(|q| q.last);
let _ = self.demo_submit(&req, quote);
}
pub(super) fn reset_demo(&mut self, cash: f64) {
self.demo.book.reset(cash, chrono::Utc::now());
self.demo.changed();
self.demo.confirm_reset = false;
if let Some(t) = &mut self.trading.ticket {
t.error = None;
t.done = None;
}
self.trade_toast(
format!("Demo account reset to {}", super::portfolio_view::money(self.demo.book.cash, "USD")),
true,
);
}
/// Switch accounts. Going live asks first (see `live_dialog`); going back to the demo
/// account is immediate. The choice is saved.
pub(super) fn set_account_mode(&mut self, mode: AccountMode, confirmed: bool) {
if mode == self.prefs.trading.account_mode {
return;
}
if mode == AccountMode::Live && !confirmed {
self.trading.live_prompt = true;
return;
}
self.prefs.trading.account_mode = mode;
self.trading.live_prompt = false;
if let Err(e) = self.prefs.save(&super::prefs_path()) {
self.status = format!("Could not save preferences: {e}");
}
// The ticket's account belongs to the other mode now.
if let Some(t) = &mut self.trading.ticket {
t.account = None;
t.preview = None;
t.confirming = false;
t.review = false;
t.error = None;
t.done = None;
}
match mode {
AccountMode::Demo => {
self.trade_toast("Switched to the DEMO account: orders are simulated with virtual cash", true);
if let Some(t) = &mut self.trading.ticket {
t.account = Some(tr_broker::demo::ACCOUNT_ID.into());
}
}
AccountMode::Live => {
self.trade_toast("Switched to LIVE accounts: orders go to your real brokerage", true);
if self.broker.portfolio.as_ref().is_none_or(|p| p.demo) && !self.broker.loading {
self.refresh_portfolio();
}
self.ensure_capabilities(false);
}
}
}
}
+36 -4
View File
@@ -1,8 +1,10 @@
//! Native keyboard-shortcut table (click a binding to record a new one) and the Help pane.
use egui::{Align2, Color32, Id, Rect, RichText, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use tr_core::indicators::registry::{self, Category};
use super::TerminalApp;
use crate::command::IndicatorSpec;
use crate::prefs::{Action, Keymap};
use crate::theme::{self, amber, dp, mono, muted, text, ui_font};
@@ -35,13 +37,15 @@ fn para(ui: &mut Ui, s: &str) {
ui.label(RichText::new(s).font(ui_font(12.5)).color(muted()));
}
fn chip(ui: &mut Ui, code: &str) -> egui::Response {
ui.label(RichText::new(format!(" {code} ")).font(mono(12.0)).color(amber()).background_color(theme::control()))
}
fn chips(ui: &mut Ui, codes: &[&str]) {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
for c in codes {
ui.label(
RichText::new(format!(" {c} ")).font(mono(12.0)).color(amber()).background_color(theme::control()),
);
chip(ui, c);
}
});
}
@@ -256,6 +260,7 @@ impl TerminalApp {
(&["POL", "POL PELOSI", "TRACK PELOSI"], "Politicians, profiles and alerts"),
(&["WL", "WL ADD SOLUSD CRNCY"], "Watchlist"),
(&["PREFS"], "Settings: theme, fonts, density, hotkeys, layouts"),
(&["UPDATE"], "Check for updates"),
],
);
@@ -297,7 +302,7 @@ impl TerminalApp {
(&["1", "5", "1H", "D", "1W"], "Interval"),
(&["1D", "5D", "YTD", "1Y", "ALL"], "Quick range"),
(&["CANDLE", "BAR", "LINE", "AREA", "LOG"], "Chart type and log scale"),
(&["SMA 50", "EMA 20", "BB 20 2", "RSI", "MACD", "VWAP"], "Toggle indicators (CLEAR removes all)"),
(&["SMA 50", "RSI", "IND"], "Indicators (see below)"),
(&["CMP MSFT"], "Compare, rebased to the main symbol"),
(&["TL", "HL", "VL", "FIB", "MAGNET"], "Drawing tools and snapping"),
],
@@ -307,6 +312,33 @@ impl TerminalApp {
"Wheel zooms, drag pans, drag an axis to stretch, double-click resets. Right-click for the chart menu.",
);
section(ui, &format!("Indicators ({})", registry::all().len()));
command_table(
ui,
"help-ind",
&[
(&["RSI", "SMA 200", "BB 20 2.5"], "Toggle an indicator; numbers set its parameters"),
(&["IND", "IND VOLUME"], "Indicator picker: search by name, code or category"),
(&["AAPL ADX"], "Load a security and add the indicator"),
(&["CLEAR"], "Remove all indicators"),
],
);
para(
ui,
"Missing parameters take their defaults and out-of-range ones are clamped. Moving averages can be \
added several times with different lengths. Hover a code for its name and defaults.",
);
for cat in Category::ALL {
ui.add_space(dp(4.0));
ui.label(RichText::new(cat.label()).font(ui_font(12.5)).color(text()));
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
for d in registry::all().iter().filter(|d| d.category == cat) {
chip(ui, d.code).on_hover_text(format!("{} · {}", d.name, IndicatorSpec::of(d).label()));
}
});
}
section(ui, "Workspace");
command_table(
ui,
+10 -1
View File
@@ -6,6 +6,7 @@ use tr_core::options::Greeks;
use tr_data::OptionContract;
use super::OptionsPane;
use crate::anim;
use crate::congress_view::Action;
use crate::theme::{self, amber, dp, mono, muted, text, ui_font};
use crate::widgets;
@@ -245,16 +246,24 @@ pub fn options_chain(ui: &mut Ui, o: &mut OptionsPane) -> (Option<Action>, bool)
let spot = ch.spot;
let itm_wash = theme::amber().gamma_multiply(0.07);
// Rows cascade in when the chain comes into view or a chain for another expiration
// arrives (not when one is requested: the old rows stay until then).
let cascade = anim::appear(ui.ctx(), ui.id().with(("omon-rows", &o.symbol, ch.expiration)), 0.9) * 0.9;
egui::ScrollArea::vertical().id_salt(("omon", &o.symbol)).auto_shrink([false, false]).show_rows(
ui,
row_h,
rows.len(),
|ui, range| {
let mut spot_line = None;
let first = range.start;
for ri in range {
let row = &rows[ri];
let (r, resp) = ui.allocate_exact_size(vec2(w, row_h), Sense::click());
let p = ui.painter_at(r);
let delay = ((ri - first) as f32 * 0.03).min(0.45);
let e = anim::ease_out(anim::span(cascade, delay, delay + 0.35));
let r = r.translate(vec2(0.0, 6.0 * (1.0 - e)));
let mut p = ui.painter_at(r);
p.multiply_opacity(e);
let call_side = Rect::from_min_max(pos2(strike_x - side_w, r.top()), pos2(strike_x, r.bottom()));
let put_side = Rect::from_min_max(
pos2(strike_x + strike_w, r.top()),
+682
View File
@@ -0,0 +1,682 @@
//! Command palette overlay (⌘K, Ctrl+K elsewhere): the live catalogue of items built
//! from the app's state, the centred search card, and running the picked item.
use egui::{Color32, Id, Key, Modifiers, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2};
use tr_core::indicators::registry;
use super::TerminalApp;
use super::settings_view::SettingsTab;
use crate::anim;
use crate::command::{self, Function, IndicatorSpec};
use crate::layout::Layout;
use crate::palette::{Item, Kind, Target};
use crate::prefs::Action;
use crate::theme::{self, dp, mono, muted, text, ui_font};
use crate::widgets;
const INPUT_ID: &str = "command-palette-input";
const ROW_H: f32 = 34.0;
const VISIBLE_ROWS: usize = 10;
/// Function codes offered by the palette: code, description (empty uses the command
/// line's own hint), extra search words, and the action whose shortcut is shown.
const COMMANDS: &[(&str, &str, &str, Option<Action>)] = &[
("PORT", "", "portfolio prtu brokerage holdings value", Some(Action::Portfolio)),
("ACCT", "", "accounts balances brokerage", None),
("POS", "", "positions holdings brokerage", Some(Action::Positions)),
("ORD", "", "orders blotter brokerage", Some(Action::Orders)),
("ACTV", "", "activity transactions dividends brokerage", None),
("TRADE", "", "order ticket buy sell emsx", None),
("PORT REFRESH", "", "brokerage sync reload", None),
("PORT CONNECT", "", "brokerage link snaptrade", None),
("OMON", "", "options chain calls puts greeks", Some(Action::NewOptions)),
("CONG", "Congressional trades feed", "congress disclosures", Some(Action::Congress)),
("POL", "Politicians", "congress members profile", Some(Action::Politicians)),
("ALRT", "", "alerts tracked filings", Some(Action::Alerts)),
("REFRESH", "", "congress disclosures sync", Some(Action::Refresh)),
("WL", "", "watchlist monitor", Some(Action::Watchlist)),
("N", "", "news headlines", None),
("OB", "", "order book depth dom", None),
("WEI", "", "world equity indices markets", None),
("GC", "", "yield curve treasury rates", None),
("SECF", "Security search", "search find", None),
("LAYOUTS", "", "saved layouts workspaces", None),
("LAYOUT NEW", "Start a new saved layout", "workspace", None),
("SPLIT", "", "split right pane", Some(Action::SplitRight)),
("SPLIT DOWN", "", "split below pane", Some(Action::SplitDown)),
("POP", "", "pop out window undock", Some(Action::PopOut)),
("CLOSE", "", "close pane", Some(Action::ClosePane)),
("LINK OFF", "", "unlink colour group", None),
("THEME NEXT", "Cycle to the next theme", "appearance", Some(Action::NextTheme)),
("THEME LIGHT", "Use the light theme", "appearance", None),
("THEME DARK", "Use the dark theme", "appearance", None),
("THEME SYSTEM", "Follow the OS appearance", "appearance auto", None),
("THEME COMPACT", "Compact density", "appearance spacing", None),
("THEME COMFORTABLE", "Comfortable density", "appearance spacing", None),
("SIDEBAR LEFT", "Sidebar on the left", "dock side", None),
("SIDEBAR RIGHT", "Sidebar on the right", "dock side", None),
("PREFS", "", "settings preferences config", Some(Action::Settings)),
("UPDATE", "", "check for updates new version upgrade", None),
("HELP", "", "help functions reference", Some(Action::Help)),
("CANDLE", "", "chart type candlesticks", None),
("BAR", "", "chart type ohlc", None),
("LINE", "", "chart type", None),
("AREA", "", "chart type mountain", None),
("LOG", "", "log scale chart", Some(Action::LogScale)),
("IND", "", "indicators studies technical analysis picker", None),
("CLEAR", "", "indicators remove", None),
("CMP", "", "compare comparison remove", None),
("TL", "", "trendline draw", Some(Action::DrawTrendline)),
("HL", "", "horizontal line draw", Some(Action::DrawHorizontal)),
("VL", "", "vertical line draw", Some(Action::DrawVertical)),
("FIB", "", "fibonacci retracement draw", Some(Action::DrawFib)),
("MAGNET", "", "snap ohlc drawings", Some(Action::Magnet)),
("CLEAR DRAW", "", "drawings remove", None),
("1", "", "interval timeframe minute", None),
("5", "", "interval timeframe minutes", None),
("15", "", "interval timeframe minutes", None),
("1H", "", "interval timeframe hour", None),
("4H", "", "interval timeframe hours", None),
("1D", "", "interval timeframe daily", None),
("1W", "", "interval timeframe weekly", None),
("1M", "", "interval timeframe monthly", None),
("RANGE 5D", "", "quick range history", None),
("RANGE 1M", "", "quick range history month", None),
("RANGE 3M", "", "quick range history months", None),
("RANGE 6M", "", "quick range history months", None),
("YTD", "", "quick range year to date", None),
("RANGE 1Y", "", "quick range history year", None),
("RANGE 5Y", "", "quick range history years", None),
("RANGE ALL", "", "quick range full history", None),
];
/// Workspace actions run through their hotkey handler, with extra search words.
const ACTIONS: &[(Action, &str)] = &[
(Action::ToggleSidebar, "show hide sidebar"),
(Action::SwapSidebarSide, "swap flip sidebar side left right"),
(Action::NextPane, "focus cycle"),
(Action::PrevPane, "focus cycle"),
(Action::NewTab, "chart tab"),
(Action::SaveLayout, "layout workspace"),
(Action::ResetView, "chart zoom fit"),
(Action::FrameTiming, "fps performance perf"),
];
fn settings_blurb(tab: SettingsTab) -> &'static str {
match tab {
SettingsTab::Appearance => "Theme, light and dark choices, sidebar, vibrancy",
SettingsTab::Themes => "Edit, duplicate, import and export themes",
SettingsTab::Hotkeys => "Rebind keyboard shortcuts",
SettingsTab::Panels => "Defaults for new charts and order books",
SettingsTab::Layouts => "Saved layouts and presets",
SettingsTab::Brokerage => "SnapTrade keys and connected brokerages",
SettingsTab::Trading => "Enable trading, order limits and confirmations",
}
}
/// The static part of the catalogue: commands, panels, actions, presets and settings tabs.
pub(super) fn static_items() -> Vec<Item> {
let mut v = Vec::new();
for &(code, desc, words, hotkey) in COMMANDS {
let subtitle = if desc.is_empty() { command::hint(code) } else { desc.to_string() };
let mut it = Item::new(Kind::Command, code, subtitle, Target::Run(code.into())).keywords(words);
it.hotkey = hotkey;
v.push(it);
}
for a in Action::ALL {
if let Some(spec) = a.panel_spec() {
let it = Item::new(Kind::Panel, a.label(), format!("NEW {spec}"), Target::Run(format!("NEW {spec}")));
v.push(it.keywords("panel pane add open").hotkey(a));
}
}
v.push(
Item::new(Kind::Panel, "New Portfolio pane", "NEW PORT", Target::Run("NEW PORT".into()))
.keywords("panel pane brokerage"),
);
v.push(
Item::new(
Kind::Action,
"Open Brokerage settings",
"BROKER · SnapTrade keys and connections",
Target::Run("BROKER".into()),
)
.keywords("snaptrade keys connect broker"),
);
for (a, words) in
[(Action::Buy, "trade order ticket purchase long"), (Action::Sell, "trade order ticket close exit")]
{
v.push(
Item::new(Kind::Action, a.label(), "Trading · order ticket", Target::Hotkey(a)).keywords(words).hotkey(a),
);
}
v.push(
Item::new(
Kind::Action,
"Switch to the demo account",
"DEMO · paper trading, virtual cash",
Target::Run("DEMO".into()),
)
.keywords("paper practice simulated trading account mode"),
);
v.push(
Item::new(
Kind::Action,
"Switch to live accounts",
"MODE LIVE · real brokerage accounts (asks first)",
Target::Run("MODE LIVE".into()),
)
.keywords("real money brokerage snaptrade trading account mode"),
);
v.push(
Item::new(Kind::Action, "Reset the demo account", "Settings › Trading", Target::Settings("Trading".into()))
.keywords("paper demo restart cash"),
);
for &(a, words) in ACTIONS {
v.push(Item::new(Kind::Action, a.label(), a.category(), Target::Hotkey(a)).keywords(words).hotkey(a));
}
for l in Layout::ALL {
let n = l.slots();
v.push(
Item::new(
Kind::Layout,
format!("Grid preset {}", l.label()),
format!("{n} pane{} · LAYOUT {}", if n == 1 { "" } else { "s" }, l.label()),
Target::Run(format!("LAYOUT {}", l.label())),
)
.keywords("grid arrange panes"),
);
}
for tab in SettingsTab::ALL {
v.push(
Item::new(
Kind::Settings,
format!("Settings › {}", tab.label()),
settings_blurb(tab),
Target::Settings(tab.label().into()),
)
.keywords("settings preferences"),
);
}
v
}
/// One row per catalogue indicator. Those already on the target chart say so and run
/// their exact label, so picking them removes them.
fn indicator_items(active: &[IndicatorSpec]) -> Vec<Item> {
registry::all()
.iter()
.map(|d| {
let on = active.iter().find(|s| s.code == d.code);
let label = on.map_or_else(|| IndicatorSpec::of(d).label(), IndicatorSpec::label);
let sub = format!("{label} · {}{}", d.category.label(), if on.is_some() { " · on chart" } else { "" });
let words = format!("{} {} {} indicator study", d.code, d.aliases.join(" "), d.category.label());
let line = if on.is_some() { label } else { d.code.to_string() };
Item::new(Kind::Indicator, d.name, sub, Target::Run(line)).keywords(words)
})
.collect()
}
fn ticker_item(key: &str) -> Item {
let name = tr_core::Security::parse(key).and_then(|s| s.name()).unwrap_or("");
Item::new(
Kind::Ticker,
key,
if name.is_empty() { "Load in the focused chart" } else { name },
Target::Run(key.into()),
)
.keywords(name)
}
fn kind_color(k: Kind) -> Color32 {
match k {
Kind::Command => theme::amber(),
Kind::Ticker => theme::cyan(),
Kind::Panel => theme::accent(),
Kind::Layout => theme::series(2),
Kind::Theme => theme::series(4),
Kind::Settings => theme::muted(),
Kind::Action => theme::up(),
Kind::Indicator => theme::series(3),
}
}
impl TerminalApp {
/// Every item the palette can find right now, including tickers for the query.
pub(super) fn palette_items(&self) -> Vec<Item> {
let mut v = static_items();
for (i, name) in self.layout_names.iter().enumerate() {
let current = name.eq_ignore_ascii_case(&self.layout_name);
let mut it = Item::new(
Kind::Layout,
name.clone(),
if current { "Saved layout · current" } else { "Saved layout" },
Target::SwitchLayout(name.clone()),
)
.keywords("layout workspace switch");
it.hotkey = Action::ALL.into_iter().find(|a| a.layout_slot() == Some(i));
v.push(it);
}
for name in self.theme_choices() {
let mut sub = match crate::themes::find(&self.themes, &name) {
_ if name.eq_ignore_ascii_case(crate::themes::SYSTEM) => "Follow the OS appearance".to_string(),
Some(t) if t.dark => "Dark theme".into(),
Some(_) => "Light theme".into(),
None => "Theme".into(),
};
if name.eq_ignore_ascii_case(&self.prefs.appearance.theme) {
sub.push_str(" · current");
}
v.push(Item::new(Kind::Theme, name.clone(), sub, Target::Theme(name)).keywords("theme appearance colours"));
}
for key in &self.watchlist {
v.push(ticker_item(key).keywords("watchlist"));
}
v.extend(indicator_items(self.peek_chart().map_or(&[][..], |c| c.indicators.as_slice())));
let q = self.palette.query.trim();
let offered = |v: &[Item]| v.iter().any(|it| matches!(&it.target, Target::Run(l) if l.eq_ignore_ascii_case(q)));
if !q.is_empty() {
for l in tr_core::catalog::search(q, 12) {
v.push(ticker_item(&l.key()));
}
// Whatever the command line would do with the query, offered as-is.
match command::parse(q) {
// An indicator with custom parameters (`SMA 200`, `BB 20 3`).
Some(command::Command::Function(Function::Indicator(spec)))
if !offered(&v) && spec.def().is_some_and(|d| IndicatorSpec::of(d) != spec) =>
{
let sub = format!("{} · toggle on the chart", spec.name());
v.push(Item::new(Kind::Indicator, spec.label(), sub, Target::Run(q.into())));
}
Some(command::Command::Symbol(key)) => {
v.push(ticker_item(&key));
let sec = tr_data::security(&key);
if matches!(sec.class, tr_core::AssetClass::Equity | tr_core::AssetClass::Etf) {
let t = &sec.ticker;
let mode = if self.demo_mode() { "demo" } else { "LIVE" };
v.push(
Item::new(
Kind::Command,
format!("Buy {t}"),
format!("Order ticket · {mode}"),
Target::Run(format!("BUY {key}")),
)
.keywords("trade order"),
);
v.push(
Item::new(
Kind::Command,
format!("Sell {t}"),
format!("Order ticket · {mode}"),
Target::Run(format!("SELL {key}")),
)
.keywords("trade order"),
);
let held = self.shown_portfolio().is_some_and(|pf| {
pf.positions.iter().any(|p| p.units > 0.0 && p.symbol.eq_ignore_ascii_case(t))
});
if held {
v.push(
Item::new(
Kind::Command,
format!("Sell all {t}"),
format!("Close the whole position · {mode}"),
Target::Run(format!("SELL ALL {key}")),
)
.keywords("close exit position"),
);
}
}
}
// Kept as typed: arguments such as paths and titles are case-sensitive.
Some(_) if !offered(&v) => {
v.push(Item::new(Kind::Command, q, command::hint(q), Target::Run(q.into())));
}
Some(_) => {}
None => {}
}
}
v
}
/// Run a picked item.
fn run_palette_target(&mut self, t: Target) {
match t {
Target::Run(line) => self.execute(&line),
Target::Hotkey(a) => self.run_hotkey(a),
Target::Settings(label) => {
if let Some(tab) = SettingsTab::ALL.into_iter().find(|t| t.label() == label) {
self.settings_tab = tab;
}
self.apply(Function::Settings);
}
Target::SwitchLayout(name) => self.switch_layout(&name),
Target::Theme(name) => {
if !self.select_theme(&name) {
self.status = format!("No theme {name:?}");
}
}
}
}
/// Keyboard handling, the overlay, and running the pick. Runs before the panes so
/// arrows, Enter and Esc go to the palette while it is open.
pub(super) fn command_palette(&mut self, ctx: &egui::Context) {
let input_id = Id::new(INPUT_ID);
if !self.palette.open {
// Give the keyboard back once closed (by ⌘K, a pick or Esc).
if ctx.memory(|m| m.has_focus(input_id)) {
ctx.memory_mut(|m| m.surrender_focus(input_id));
}
return;
}
let (up, down, enter, esc) = ctx.input_mut(|i| {
let up = i.consume_key(Modifiers::NONE, Key::ArrowUp) || i.consume_key(Modifiers::SHIFT, Key::Tab);
let down = i.consume_key(Modifiers::NONE, Key::ArrowDown) || i.consume_key(Modifiers::NONE, Key::Tab);
let enter = i.consume_key(Modifiers::NONE, Key::Enter);
let esc = i.consume_key(Modifiers::NONE, Key::Escape);
(up, down, enter, esc)
});
let keyed = up || down;
let scoped = self.palette.scope.is_some();
let screen = ctx.content_rect();
let mut picked = None;
let mut close = esc;
// Opening fades the dim in and floats the card up into place.
let shown = anim::ease_out(anim::appear(ctx, Id::new("command-palette-in"), 0.2));
let results = egui::Area::new(Id::new("command-palette"))
.order(egui::Order::Foreground)
.fixed_pos(screen.min)
.show(ctx, |ui| {
ui.multiply_opacity(shown);
let (_, bg) = ui.allocate_exact_size(screen.size(), Sense::click());
let w = (screen.width() - dp(40.0)).clamp(320.0, 660.0);
let top = screen.top() + (screen.height() * 0.14).clamp(dp(16.0), dp(160.0)) + dp(10.0) * (1.0 - shown);
let left = screen.center().x - w / 2.0;
let pad = vec2(dp(10.0), dp(6.0));
let field_h = dp(46.0);
let footer_h = dp(28.0);
let field = Rect::from_min_size(pos2(left, top) + pad, vec2(w - 2.0 * pad.x, field_h));
// The query first, so results (and Enter) see this frame's typing.
let edit = egui::TextEdit::singleline(&mut self.palette.query)
.id(input_id)
.font(ui_font(16.0))
.frame(egui::Frame::NONE)
.text_color(text())
.hint_text(if scoped {
"Search indicators by name, code or category…"
} else {
"Search commands, tickers, panels, layouts, themes, settings…"
})
.desired_width(field.width() - dp(36.0));
let field_rect = Rect::from_min_max(pos2(field.left() + dp(28.0), field.top()), field.max);
let resp = ui
.scope_builder(
UiBuilder::new().max_rect(field_rect).layout(egui::Layout::left_to_right(egui::Align::Center)),
|ui| ui.add(edit),
)
.inner;
if self.palette.just_opened || !resp.has_focus() {
resp.request_focus();
self.palette.just_opened = false;
}
if resp.changed() {
self.palette.selected = 0;
}
let items = self.palette_items();
let results = self.palette.results(&items);
let recents = self.palette.recent_count(&items);
if up {
self.palette.step(-1, results.len());
}
if down {
self.palette.step(1, results.len());
}
self.palette.selected = self.palette.selected.min(results.len().saturating_sub(1));
if enter {
picked = Some(self.palette.selected);
}
// Size the list to the results, but keep the card inside the window.
let rows = results.len().clamp(1, VISIBLE_ROWS);
let headers = if recents > 0 { 2.0 * dp(22.0) } else { 0.0 };
let room = screen.bottom() - dp(12.0) - field.bottom() - footer_h - 2.0 * pad.y;
let list_h =
(rows as f32 * dp(ROW_H) + headers).min(VISIBLE_ROWS as f32 * dp(ROW_H)).min(room).max(dp(ROW_H));
let card =
Rect::from_min_size(pos2(left, top), vec2(w, field_h + list_h + footer_h + 2.0 * pad.y + dp(4.0)));
if bg.clicked() && bg.interact_pointer_pos().is_some_and(|p| !card.contains(p)) {
close = true;
}
// Dim everything beneath, then the card, on a layer above the panes and
// below this one.
let mut under =
ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Middle, Id::new("command-palette-dim")));
under.multiply_opacity(shown);
let dark = theme::palette().dark;
under.rect_filled(screen, 0.0, Color32::from_black_alpha(if dark { 120 } else { 70 }));
widgets::card_on(&under, card, 1.0);
let inner = card.shrink2(pad);
widgets::draw_icon(
ui.painter(),
Rect::from_center_size(pos2(field.left() + dp(12.0), field.center().y), vec2(16.0, 16.0)),
widgets::Icon::Search,
muted(),
);
ui.painter().hline(inner.x_range(), field.bottom(), Stroke::new(1.0, theme::border()));
let list =
Rect::from_min_size(pos2(inner.left(), field.bottom() + dp(4.0)), vec2(inner.width(), list_h));
let mut list_ui = ui.new_child(UiBuilder::new().max_rect(list).id_salt("palette-list"));
egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, true]).show(&mut list_ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
let glider = widgets::Glider::new(ui, Id::new("palette-selection"));
if results.is_empty() {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), dp(ROW_H)), Sense::hover());
ui.painter().text(
pos2(r.left() + dp(10.0), r.center().y),
egui::Align2::LEFT_CENTER,
"No matches. Enter runs it as a command if it parses.",
ui_font(13.0),
muted(),
);
}
for (i, it) in results.iter().enumerate() {
if recents > 0 && (i == 0 || i == recents) {
section_header(ui, if i == 0 { "Recent" } else { "All" });
}
if self.palette_row(ui, it, (i == self.palette.selected).then_some(&glider), keyed) {
picked = Some(i);
}
}
});
let foot = Rect::from_min_max(
pos2(inner.left(), inner.bottom() - footer_h),
pos2(inner.right(), inner.bottom()),
);
let p = ui.painter();
p.hline(inner.x_range(), foot.top(), Stroke::new(1.0, theme::border()));
p.text(
pos2(foot.left() + dp(4.0), foot.center().y),
egui::Align2::LEFT_CENTER,
if scoped {
"↑↓ select ↵ add/remove esc close"
} else {
"↑↓ select ↵ open esc close"
},
mono(11.0),
muted(),
);
p.text(
pos2(foot.right() - dp(4.0), foot.center().y),
egui::Align2::RIGHT_CENTER,
format!("{} result{}", results.len(), if results.len() == 1 { "" } else { "s" }),
mono(11.0),
muted(),
);
results
})
.inner;
if let Some(item) = picked.and_then(|i| results.get(i)).cloned() {
// The indicator picker stays open to toggle several, and leaves recents alone
// so rows do not reorder under the selection.
if !scoped {
self.palette.close();
self.palette.record(&item);
}
self.run_palette_target(item.target);
} else if picked.is_some() && !self.palette.query.trim().is_empty() {
// Nothing matched: behave like the command line.
let line = self.palette.query.trim().to_string();
self.palette.close();
self.execute(&line);
} else if close {
self.palette.close();
}
if !self.palette.open {
ctx.memory_mut(|m| m.surrender_focus(input_id));
}
}
/// One result row: kind badge, title, subtitle and shortcut caps. `selected` carries
/// the selection highlight when this row has it. Returns whether it was clicked.
fn palette_row(&self, ui: &mut Ui, it: &Item, selected: Option<&widgets::Glider>, keyed: bool) -> bool {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), dp(ROW_H)), Sense::click());
if let Some(glider) = selected {
if keyed {
resp.scroll_to_me(None);
}
glider.on(ui, r);
}
widgets::hover_wash(ui, r, &resp, false);
let p = ui.painter();
let cy = r.center().y;
// Kind badge.
let kc = kind_color(it.kind);
let badge_g = p.layout_no_wrap(it.kind.label().to_string(), mono(9.5), kc);
let badge = Rect::from_min_size(pos2(r.left() + dp(8.0), cy - 9.0), vec2(dp(64.0), 18.0));
p.rect_filled(badge, 4.0, kc.gamma_multiply(0.14));
p.rect_stroke(badge, 4.0, Stroke::new(1.0, kc.gamma_multiply(0.5)), StrokeKind::Inside);
p.galley(pos2(badge.center().x - badge_g.size().x / 2.0, cy - badge_g.size().y / 2.0), badge_g, kc);
// Shortcut hint on the right.
let mut right = r.right() - dp(8.0);
if let Some(chord) = it.hotkey.and_then(|a| self.prefs.hotkeys.chord(a)) {
right -= super::hotkeys_view::paint_key_caps(p, right, cy, &chord.tokens()) + dp(10.0);
}
// Title then subtitle, clipped before the shortcut.
let x = badge.right() + dp(12.0);
let clip = Rect::from_min_max(pos2(x, r.top()), pos2(right.max(x), r.bottom()));
let p = p.with_clip_rect(clip.intersect(p.clip_rect()));
let title_font = if it.kind == Kind::Command { mono(13.0) } else { ui_font(13.5) };
let title = p.layout_no_wrap(it.title.clone(), title_font, text());
let tw = title.size().x;
p.galley(pos2(x, cy - title.size().y / 2.0), title, text());
if !it.subtitle.is_empty() {
p.text(pos2(x + tw + dp(10.0), cy), egui::Align2::LEFT_CENTER, &it.subtitle, ui_font(12.0), muted());
}
resp.clicked()
}
}
fn section_header(ui: &mut Ui, label: &str) {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), dp(22.0)), Sense::hover());
ui.painter().text(
pos2(r.left() + dp(8.0), r.center().y + 2.0),
egui::Align2::LEFT_CENTER,
label.to_uppercase(),
mono(10.0),
muted(),
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::palette::rank;
#[test]
fn every_palette_command_parses() {
for &(code, ..) in COMMANDS {
assert!(command::parse(code).is_some(), "{code} does not parse");
}
for it in static_items() {
if let Target::Run(line) = &it.target {
assert!(command::parse(line).is_some(), "{line} does not parse");
}
}
}
#[test]
fn catalogue_covers_commands_panels_actions_and_settings() {
let items = static_items();
let has = |kind: Kind, title: &str| items.iter().any(|i| i.kind == kind && i.title == title);
for code in ["PORT", "POS", "ORD", "OMON", "THEME NEXT", "CONG", "WEI", "GC", "HELP"] {
assert!(has(Kind::Command, code), "{code}");
}
assert!(has(Kind::Panel, "New Options chain pane"));
assert!(has(Kind::Action, "Move sidebar to the other side"));
assert!(has(Kind::Action, "Open Brokerage settings"));
assert!(has(Kind::Action, "Buy the current security") && has(Kind::Action, "Switch to live accounts"));
assert!(has(Kind::Layout, "Grid preset 4"));
for tab in SettingsTab::ALL {
assert!(has(Kind::Settings, &format!("Settings › {}", tab.label())));
}
assert!(
items.iter().all(|i| i.target != Target::Hotkey(Action::CommandPalette)),
"the palette does not list itself"
);
// Function codes and their shortcuts.
let pos = items.iter().find(|i| i.title == "POS").unwrap();
assert_eq!(pos.hotkey, Some(Action::Positions));
assert!(pos.subtitle.contains("positions"), "{}", pos.subtitle);
}
#[test]
fn typical_queries_find_their_item() {
let items = static_items();
let top = |q: &str| rank(&items, &[], q).first().map(|i| i.title.clone()).unwrap_or_default();
assert_eq!(top("omon"), "OMON");
assert_eq!(top("port"), "PORT");
assert_eq!(top("swap sidebar"), "Move sidebar to the other side");
assert_eq!(top("hotkeys"), "Settings › Hotkeys");
assert_eq!(top("theme"), "THEME NEXT");
let best2 = |q: &str| rank(&items, &[], q).into_iter().take(2).map(|i| i.title).collect::<Vec<_>>();
let b = best2("brokerage settings");
assert!(b.contains(&"Open Brokerage settings".to_string()) && b.contains(&"Settings › Brokerage".to_string()));
let b = best2("yield curve");
assert!(b.contains(&"GC".to_string()), "{b:?}");
}
#[test]
fn indicators_are_found_by_code_name_and_category() {
let active = [IndicatorSpec::new("RSI", &[21.0]).unwrap()];
let items: Vec<Item> = static_items().into_iter().chain(indicator_items(&active)).collect();
let top = |q: &str| rank(&items, &[], q).first().map(|i| i.title.clone()).unwrap_or_default();
assert_eq!(top("rsi"), "Relative Strength Index");
assert_eq!(top("macd"), "Moving Average Convergence Divergence");
assert_eq!(top("bollinger"), "Bollinger Bands");
assert_eq!(top("psar"), "Parabolic SAR");
assert_eq!(top("ichimoku"), "Ichimoku Cloud");
assert_eq!(top("supertrend"), "SuperTrend");
assert_eq!(top("on balance volume"), "On-Balance Volume");
assert_eq!(top("ind"), "IND");
let rsi = items.iter().find(|i| i.title == "Relative Strength Index").unwrap();
assert_eq!(rsi.subtitle, "RSI 21 · Momentum · on chart");
assert_eq!(rsi.target, Target::Run("RSI 21".into()), "picking an active indicator removes it");
let n = indicator_items(&[]).len();
assert_eq!(n, registry::all().len());
let volume = rank(&items, &[], "volume").iter().filter(|i| i.kind == Kind::Indicator).count();
assert!(volume >= 10, "category words match: {volume}");
for it in indicator_items(&[]) {
let Target::Run(line) = &it.target else { panic!() };
assert!(matches!(command::parse(line), Some(command::Command::Function(Function::Indicator(_)))), "{line}");
}
}
}
+18 -5
View File
@@ -1,6 +1,6 @@
//! Pane content for every panel kind.
use std::time::Instant;
use web_time::Instant;
use egui::{Align2, Color32, Rect, RichText, Sense, Stroke, Ui, UiBuilder, pos2, vec2};
use tr_data::OrderBook;
@@ -81,7 +81,9 @@ impl TerminalApp {
}
}
PaneKind::Portfolio(pp) => {
for a in super::portfolio_view::portfolio(ui, pp, &self.broker, &self.quotes) {
let demo =
(self.prefs.trading.account_mode == crate::prefs::AccountMode::Demo).then_some(&*self.demo.pf);
for a in super::portfolio_view::portfolio(ui, pp, &self.broker, demo, &self.quotes) {
use super::portfolio_view::PortAction as P;
match a {
P::Core(a) => actions.push(a),
@@ -100,6 +102,11 @@ impl TerminalApp {
account,
),
P::CancelOrder { account, id } => self.request_cancel(&account, &id),
P::ClosePosition { account, key } => self.close_position(&account, &key),
P::TradingSettings => {
self.settings_tab = super::settings_view::SettingsTab::Trading;
self.open_panel(PaneKind::Settings);
}
P::Open(url) => {
if let Err(e) = super::broker::open_external(url) {
self.status = format!("Could not open {url}: {e}");
@@ -180,11 +187,17 @@ impl TerminalApp {
p.text(pos2(col(r, 4), r.center().y), Align2::RIGHT_CENTER, "…", mono(12.5), muted());
}
}
if resp.clicked() {
out.push(Action::OpenChart(s.clone()));
let tradable = matches!(sec.class, tr_core::AssetClass::Equity | tr_core::AssetClass::Etf);
let pills = if tradable { widgets::buy_sell_pills().to_vec() } else { Vec::new() };
let tips = [format!("Buy {} (⇧B)", sec.ticker), format!("Sell {} (⇧S)", sec.ticker)];
let tips: Vec<&str> = tips.iter().map(String::as_str).collect();
match widgets::row_pills(ui, r, ("wl-pills", s), &pills, &tips) {
Some(i) => out.push(Action::Command(format!("{} {s}", if i == 0 { "BUY" } else { "SELL" }))),
None if resp.clicked() => out.push(Action::OpenChart(s.clone())),
None => {}
}
resp.context_menu(|ui| {
if matches!(sec.class, tr_core::AssetClass::Equity | tr_core::AssetClass::Etf) {
if tradable {
for (label, side) in [("Buy", "BUY"), ("Sell", "SELL")] {
if ui.button(format!("{label} {}…", sec.ticker)).clicked() {
out.push(Action::Command(format!("{side} {s}")));
+107 -17
View File
@@ -31,11 +31,18 @@ pub enum PortAction {
account: Option<String>,
units: Option<f64>,
},
/// Ask to cancel an open order (confirmed in a dialog).
/// Ask to cancel an open order (confirmed in a dialog for live accounts).
CancelOrder {
account: String,
id: String,
},
/// Open Settings › Trading.
TradingSettings,
/// Sell a whole position (at once in the demo account; through the ticket live).
ClosePosition {
account: String,
key: String,
},
}
// ---- formatting ---------------------------------------------------------------------------
@@ -279,16 +286,23 @@ fn security_menu(ui: &mut Ui, key: &str, symbol: &str, out: &mut Vec<PortAction>
// ---- pane ---------------------------------------------------------------------------------
/// A portfolio pane. `demo` is the demo account's portfolio when it is the selected account;
/// otherwise the pane shows the brokerage (live or sample) from `b`.
pub fn portfolio(
ui: &mut Ui,
pane: &mut PortfolioPane,
b: &BrokerState,
demo: Option<&Portfolio>,
quotes: &HashMap<String, Quote>,
) -> Vec<PortAction> {
let mut out = Vec::new();
toolbar(ui, pane, b, &mut out);
toolbar(ui, pane, b, demo, &mut out);
ui.add_space(dp(4.0));
match (b.mode(), b.portfolio.as_deref()) {
let (mode, pf, error) = match demo {
Some(d) => (BrokerMode::Simulated, Some(d), None),
None => (b.mode(), b.portfolio.as_deref(), b.error.as_ref()),
};
match (mode, pf) {
(BrokerMode::Setup, _) => setup_screen(ui, b, &mut out),
(_, None) if b.loading => {
ui.horizontal(|ui| {
@@ -296,18 +310,18 @@ pub fn portfolio(
ui.label(RichText::new("Loading brokerage accounts…").color(muted()));
});
}
(_, None) => match &b.error {
(_, None) => match error {
Some(e) => error_card(ui, e, &mut out),
None => note(ui, "Waiting for the first sync…"),
},
(_, Some(pf)) if pf.accounts.is_empty() => {
if let Some(e) = &b.error {
if let Some(e) = error {
error_card(ui, e, &mut out);
}
empty_state(ui, &mut out);
}
(_, Some(pf)) => {
if let Some(e) = &b.error {
if let Some(e) = error {
ui.label(RichText::new(format!("Last refresh failed: {e}")).font(ui_font(11.5)).color(theme::down()));
}
if pane.account.as_ref().is_some_and(|a| pf.account(a).is_none()) {
@@ -342,13 +356,32 @@ pub fn portfolio(
out
}
fn toolbar(ui: &mut Ui, pane: &mut PortfolioPane, b: &BrokerState, out: &mut Vec<PortAction>) {
fn toolbar(
ui: &mut Ui,
pane: &mut PortfolioPane,
b: &BrokerState,
demo: Option<&Portfolio>,
out: &mut Vec<PortAction>,
) {
ui.horizontal(|ui| {
let labels: Vec<&str> = PortView::ALL.iter().map(|v| v.label()).collect();
let sel = PortView::ALL.iter().position(|v| *v == pane.view);
if let Some(i) = widgets::segmented(ui, "port-view", &labels, sel, 22.0) {
pane.view = PortView::ALL[i];
}
if demo.is_some() {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::icon_button(ui, widgets::Icon::Gear, "Trading settings: demo account, live accounts", false)
.clicked()
{
out.push(PortAction::TradingSettings);
}
ui.label(RichText::new("Paper trading · virtual cash").font(ui_font(11.0)).color(muted()));
super::trading::mode_badge(ui, false)
.on_hover_text("The demo account is selected. Switch to live accounts from the toolbar badge.");
});
return;
}
if let Some(pf) = b.portfolio.as_deref()
&& pf.accounts.len() > 1
{
@@ -421,7 +454,7 @@ fn setup_screen(ui: &mut Ui, b: &BrokerState, out: &mut Vec<PortAction>) {
ui.add_space(dp(4.0));
note(
ui,
"terminal-rs reads accounts, balances, positions, orders and activity through SnapTrade. Access is read-only \
"Terminal reads accounts, balances, positions, orders and activity through SnapTrade. Access is read-only \
unless you turn on trading in Settings › Trading.",
);
ui.add_space(dp(8.0));
@@ -696,12 +729,44 @@ fn position_row(
(None, _) => resp.on_hover_text("Not available as a terminal market key"),
};
if let Some(k) = &key {
if resp.clicked() {
out.push(PortAction::Core(Action::OpenChart(k.clone())));
let tradable = matches!(p.kind, tr_broker::InstrumentKind::Stock | tr_broker::InstrumentKind::Etf)
&& matches!(tr_data::security(k).class, tr_core::AssetClass::Equity | tr_core::AssetClass::Etf);
let mut pills = Vec::new();
if tradable {
pills.extend(widgets::buy_sell_pills());
if p.units > 0.0 {
pills.push(("Close", theme::control(), text()));
}
}
let tips = ["Buy more (⇧B)", "Sell (⇧S)", "Close: sell the whole position"];
let pill = widgets::row_pills(ui, r, ("pos-pills", &p.account_id, &p.symbol), &pills, &tips);
match pill {
Some(0) => out.push(PortAction::Trade {
key: k.clone(),
buy: true,
account: Some(p.account_id.clone()),
units: None,
}),
Some(1) => out.push(PortAction::Trade {
key: k.clone(),
buy: false,
account: Some(p.account_id.clone()),
units: None,
}),
Some(_) => out.push(PortAction::ClosePosition { account: p.account_id.clone(), key: k.clone() }),
None if resp.clicked() => out.push(PortAction::Core(Action::OpenChart(k.clone()))),
None => {}
}
resp.context_menu(|ui| {
if p.kind != tr_broker::InstrumentKind::Option {
trade_items(ui, k, Some(&p.account_id), Some(p.units), out);
if tradable && p.units > 0.0 {
if ui.button(format!("Close Position ({} {})…", qty(p.units), p.short_label())).clicked() {
out.push(PortAction::ClosePosition { account: p.account_id.clone(), key: k.clone() });
ui.close();
}
ui.separator();
}
}
security_menu(ui, k, &p.symbol, out)
});
@@ -879,7 +944,17 @@ fn orders(ui: &mut Ui, pane: &mut PortfolioPane, pf: &Portfolio, out: &mut Vec<P
if let Some(i) = widgets::segmented(ui, "port-orders", &["Open", "All"], sel, 20.0) {
pane.open_only = i == 0;
}
note(ui, format!("Last {} days · right-click an open order to cancel it", tr_broker::snaptrade::ORDER_DAYS));
if pf.demo {
note(ui, "Demo orders · hover an open order to cancel it");
} else {
note(
ui,
format!(
"Last {} days · hover or right-click an open order to cancel it",
tr_broker::snaptrade::ORDER_DAYS
),
);
}
});
let sel = selected(pane);
let list: Vec<_> = pf
@@ -952,12 +1027,16 @@ fn orders(ui: &mut Ui, pane: &mut PortfolioPane, pf: &Portfolio, out: &mut Vec<P
);
}
let key = o.market_key();
if let Some(k) = &key
let cancellable = o.is_open() && !o.id.is_empty();
let pills: &[widgets::Pill<'_>] =
if cancellable { &[("Cancel", theme::down(), Color32::WHITE)] } else { &[] };
if widgets::row_pills(ui, r, ("ord-pill", &o.account_id, &o.id), pills, &["Cancel this order"]).is_some() {
out.push(PortAction::CancelOrder { account: o.account_id.clone(), id: o.id.clone() });
} else if let Some(k) = &key
&& resp.clicked()
{
out.push(PortAction::Core(Action::OpenChart(k.clone())));
}
let cancellable = o.is_open() && !o.id.is_empty();
if cancellable || key.is_some() {
resp.context_menu(|ui| {
if cancellable {
@@ -1095,20 +1174,31 @@ mod tests {
#[test]
fn renders_every_view_without_panicking() {
let ctx = egui::Context::default();
let mut b = BrokerState::load(true);
// A store that holds nothing, so the test never reads (or moves) the user's Keychain item.
let none = std::env::temp_dir().join(format!("tr-portfolio-test-{}/snaptrade.json", std::process::id()));
let mut b = BrokerState::with_store(tr_broker::SecretStore::file(none), true);
b.creds = tr_broker::Credentials::default();
b.portfolio = Some(Box::new(tr_broker::sample::portfolio()));
let quotes = HashMap::new();
for view in PortView::ALL {
let mut pane = PortfolioPane::new(view);
let mut out = ctx.run_ui(Default::default(), |ui| {
let _ = portfolio(ui, &mut pane, &b, &quotes);
let _ = portfolio(ui, &mut pane, &b, None, &quotes);
});
out.textures_delta.clear();
pane.account = Some("sim-ira".into());
pane.symbol = Some("SPY US Equity".into());
let mut out = ctx.run_ui(Default::default(), |ui| {
let _ = portfolio(ui, &mut pane, &b, &quotes);
let _ = portfolio(ui, &mut pane, &b, None, &quotes);
});
out.textures_delta.clear();
}
// The demo account.
let demo = tr_broker::demo::DemoBook::new(chrono::Utc::now()).portfolio();
for view in PortView::ALL {
let mut pane = PortfolioPane::new(view);
let mut out = ctx.run_ui(Default::default(), |ui| {
let _ = portfolio(ui, &mut pane, &b, Some(&demo), &quotes);
});
out.textures_delta.clear();
}
@@ -1116,7 +1206,7 @@ mod tests {
b.simulate = false;
let mut pane = PortfolioPane::new(PortView::Summary);
let mut out = ctx.run_ui(Default::default(), |ui| {
let _ = portfolio(ui, &mut pane, &b, &quotes);
let _ = portfolio(ui, &mut pane, &b, None, &quotes);
});
out.textures_delta.clear();
}
+6 -8
View File
@@ -24,7 +24,7 @@ pub enum SettingsTab {
}
impl SettingsTab {
const ALL: [SettingsTab; 7] = [
pub(super) const ALL: [SettingsTab; 7] = [
SettingsTab::Appearance,
SettingsTab::Themes,
SettingsTab::Hotkeys,
@@ -34,7 +34,7 @@ impl SettingsTab {
SettingsTab::Trading,
];
fn label(self) -> &'static str {
pub(super) fn label(self) -> &'static str {
match self {
SettingsTab::Appearance => "Appearance",
SettingsTab::Themes => "Themes",
@@ -471,7 +471,7 @@ impl TerminalApp {
match self.theme_store.save(&self.themes[i]) {
Ok(path) => {
if let Some(f) = old_file.filter(|f| *f != path) {
let _ = std::fs::remove_file(f);
let _ = crate::storage::remove(&f);
}
self.themes[i].source = Some(path);
}
@@ -519,7 +519,7 @@ impl TerminalApp {
expand(given).with_extension(op)
};
self.status = match themes::ThemeStore::export(&self.themes[idx], &path) {
Ok(()) => format!("Exported {name:?} to {}", path.display()),
Ok(to) => format!("Exported {name:?} to {to}"),
Err(e) => format!("Export failed: {e:#}"),
};
}
@@ -551,7 +551,7 @@ impl TerminalApp {
}
"import" => {
let path = expand(self.theme_io.trim());
match themes::load_file(&path) {
match themes::import_file(&path) {
Ok(mut t) => {
t.name = themes::unique_name(&self.themes, &t.name);
t.source = None;
@@ -566,9 +566,7 @@ impl TerminalApp {
}
}
"folder" => {
let _ = std::fs::create_dir_all(&self.theme_store.dir);
let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
if let Err(e) = std::process::Command::new(opener).arg(&self.theme_store.dir).spawn() {
if let Err(e) = crate::storage::reveal(&self.theme_store.dir) {
self.status = format!("Could not open {}: {e}", self.theme_store.dir.display());
}
}
+2 -1
View File
@@ -2,11 +2,12 @@
//! configured dark/light pair), cycle, the THEME command, copy-on-write edits of built-ins,
//! and debounced saving of edited custom themes.
use std::time::{Duration, Instant};
use std::time::Duration;
use super::TerminalApp;
use crate::prefs::{Density, UiFont};
use crate::themes::{self, SYSTEM, Theme};
use web_time::Instant;
impl TerminalApp {
/// Index of the theme currently in effect.
+1007 -366
View File
File diff suppressed because it is too large. Load diff
+181 -8
View File
@@ -37,6 +37,109 @@ pub const PANELS: [(&str, &str); 14] = [
("Help", "HELP"),
];
/// Length of a dock transition (panes added, closed, moved, or a layout switch).
const DOCK_SECS: f32 = 0.42;
/// Eases the dock between arrangements: when panes are added, closed or moved, groups
/// glide from where they were to their new rectangles and new groups rise and fade in
/// (staggered, for a layout switch). Window and divider resizes follow immediately.
#[derive(Default)]
pub(super) struct DockMotion {
/// The arrangement last frame, to notice structural changes.
shape: Vec<ShapePart>,
/// Rectangles shown last frame, by group key.
shown: HashMap<PaneId, Rect>,
/// Where each surviving group started the current transition.
from: HashMap<PaneId, Rect>,
/// Groups new in the current transition, by key, with their stagger rank.
new: HashMap<PaneId, usize>,
start: Option<f64>,
}
/// The dock's arrangement without its proportions: split directions, child order and the
/// panes in each group. A layout switch or a swap changes it; dragging a divider does not.
#[derive(Debug, PartialEq)]
enum ShapePart {
Split(Dir),
End,
Group(Vec<PaneId>),
}
fn shape_of(node: &Node, out: &mut Vec<ShapePart>) {
match node {
Node::Tabs { panes, .. } => {
let mut p = panes.clone();
p.sort_unstable();
out.push(ShapePart::Group(p));
}
Node::Split { dir, children, .. } => {
out.push(ShapePart::Split(*dir));
children.iter().for_each(|c| shape_of(c, out));
out.push(ShapePart::End);
}
}
}
impl DockMotion {
/// A group keeps its key while it keeps its oldest pane.
fn key(g: &crate::dock::Group) -> PaneId {
g.panes.iter().copied().min().unwrap_or(0)
}
/// Move `groups` (laid out from `dock`) to where they are shown this frame; returns
/// how far each has appeared (1 = fully).
fn apply(&mut self, ctx: &egui::Context, dock: &Node, groups: &mut [crate::dock::Group]) -> Vec<f32> {
let mut shape = Vec::new();
shape_of(dock, &mut shape);
let now = ctx.input(|i| i.time);
if shape != self.shape {
// The first arrangement shows as is.
if !self.shape.is_empty() {
self.from.clear();
self.new.clear();
for g in groups.iter() {
let k = Self::key(g);
match self.shown.get(&k) {
Some(r) => {
self.from.insert(k, *r);
}
None => {
let rank = self.new.len();
self.new.insert(k, rank);
}
}
}
self.start = Some(now);
}
self.shape = shape;
}
let t = self.start.map_or(1.0, |s| anim::since(ctx, now - s, DOCK_SECS));
if t >= 1.0 {
self.start = None;
}
self.shown.clear();
groups
.iter_mut()
.map(|g| {
let k = Self::key(g);
let mut appear = 1.0;
if t < 1.0 {
if let Some(from) = self.from.get(&k) {
let e = anim::ease_out(anim::span(t, 0.0, 0.8));
g.rect = Rect::from_min_max(from.min.lerp(g.rect.min, e), from.max.lerp(g.rect.max, e));
} else if let Some(&rank) = self.new.get(&k) {
let delay = 0.1 + (rank as f32 * 0.08).min(0.3);
appear = anim::ease_out(anim::span(t, delay, delay + 0.6));
g.rect = g.rect.translate(vec2(0.0, 14.0 * (1.0 - appear)));
}
}
self.shown.insert(k, g.rect);
appear
})
.collect()
}
}
/// Saved-layout operations from the sidebar, settings and command line.
#[derive(Debug, Clone, PartialEq)]
pub enum LayoutOp {
@@ -526,6 +629,19 @@ impl TerminalApp {
id
}
/// The existing chart [`Self::chart_target`] would pick, without revealing or creating one.
pub(super) fn peek_chart(&self) -> Option<&crate::chart_view::ChartState> {
let chart = |id: PaneId| {
self.idx(id).and_then(|i| match &self.panes[i].kind {
PaneKind::Chart(c) => Some(&**c),
_ => None,
})
};
chart(self.focus)
.or_else(|| self.dock.visible().into_iter().find_map(chart))
.or_else(|| self.panes.iter().find_map(|p| chart(p.id)))
}
pub(super) fn with_chart(&mut self, f: impl FnOnce(&mut crate::chart_view::ChartState, &Worker)) {
let id = self.chart_target();
self.focus = id;
@@ -838,8 +954,8 @@ impl TerminalApp {
let dir = if downloads.is_dir() { downloads } else { self.store.dir.join("exports") };
dir.join(format!("{}.json", crate::layouts::Store::file_stem(&name)))
});
self.store.export(&name, &path)?;
Ok(format!("Exported {name:?} to {}", path.display()))
let to = self.store.export(&name, &path)?;
Ok(format!("Exported {name:?} to {to}"))
}
LayoutOp::Import(path) => {
let name = self.store.import(&path)?;
@@ -848,9 +964,7 @@ impl TerminalApp {
Ok(format!("Imported {} as {name:?}", path.display()))
}
LayoutOp::OpenFolder => {
std::fs::create_dir_all(&self.store.dir)?;
let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
std::process::Command::new(opener).arg(&self.store.dir).spawn()?;
crate::storage::reveal(&self.store.dir)?;
Ok(format!("Opened {}", self.store.dir.display()))
}
})();
@@ -868,9 +982,13 @@ impl TerminalApp {
if self.dock.is_empty() {
self.empty_state(ui, area, &mut cmds);
}
let (groups, dividers) = self.dock.layout(area, gap);
for g in &groups {
let (mut groups, dividers) = self.dock.layout(area, gap);
let appear = self.dock_motion.apply(ui.ctx(), &self.dock, &mut groups);
for (g, a) in groups.iter().zip(appear) {
let opacity = ui.opacity();
ui.multiply_opacity(a);
self.render_group(ui, g, &mut actions, &mut cmds);
ui.set_opacity(opacity);
}
for d in &dividers {
let hit = d.rect.expand(2.0);
@@ -1130,7 +1248,12 @@ impl TerminalApp {
// Content.
let content = Rect::from_min_max(pos2(inner.left(), strip.bottom()), inner.max);
let Some(i) = self.idx(active) else { return };
// Switching tabs fades the new content in.
let fade = anim::ease_out(anim::progress(ui.ctx(), Id::new(("tab-fade", DockMotion::key(g))), active, 0.22));
let opacity = ui.opacity();
ui.multiply_opacity(fade);
let (mut acts, clicked) = self.render_content(ui, i, content, focused);
ui.set_opacity(opacity);
// Clicks in a linked list panel drive its link group.
if let Some(l) = self.panes[i].link
&& !self.panes[i].kind.follows_symbol()
@@ -1322,7 +1445,7 @@ impl TerminalApp {
let Some(geom) = self.panes[i].window else { continue };
let title = self.panes[i].display_title();
let builder = ViewportBuilder::default()
.with_title(format!("{title} — terminal-rs"))
.with_title(format!("{title} — Terminal"))
.with_inner_size([geom.w, geom.h])
.with_position([geom.x, geom.y])
.with_min_inner_size([320.0, 220.0]);
@@ -1471,4 +1594,54 @@ mod tests {
m.insert_at(&t.path, t.zone, 1);
assert!(matches!(&m, Node::Split { dir: Dir::Column, .. }));
}
#[test]
fn dock_motion_glides_splits_and_follows_resizes() {
let ctx = egui::Context::default();
let mut motion = DockMotion::default();
let mut step = |n: &Node, t: f64, width: f32| {
let area = Rect::from_min_size(pos2(0.0, 0.0), vec2(width, 600.0));
let mut gs = n.layout(area, 6.0).0;
let target: Vec<Rect> = gs.iter().map(|g| g.rect).collect();
let mut appear = Vec::new();
let input = egui::RawInput { time: Some(t), ..Default::default() };
ctx.run_ui(input, |ui| appear = motion.apply(ui.ctx(), n, &mut gs)).textures_delta.clear();
(gs.iter().map(|g| g.rect).collect::<Vec<_>>(), target, appear)
};
// The first arrangement and plain resizes show as laid out.
let one = Node::preset(Layout::One, &[1]);
let (shown, target, appear) = step(&one, 0.0, 1000.0);
assert_eq!((shown, appear), (target, vec![1.0]));
let (shown, target, _) = step(&one, 0.1, 900.0);
assert_eq!(shown, target);
// Splitting: the old pane starts where it was and glides; the new one rises in.
let mut two = one.clone();
two.insert(1, Zone::Right, 2);
let (shown, target, appear) = step(&two, 1.0, 900.0);
assert_eq!(shown[0].width(), 900.0, "starts at its old size");
assert_eq!(appear[1], 0.0);
assert!(shown[1].top() > target[1].top());
let (shown, target, appear) = step(&two, 1.0 + DOCK_SECS as f64 * 0.5, 900.0);
assert!(shown[0].width() < 900.0 && shown[0].width() > target[0].width());
assert!(appear[1] > 0.0 && appear[1] < 1.0);
let (shown, target, appear) = step(&two, 2.0, 900.0);
assert_eq!((shown, appear), (target, vec![1.0, 1.0]));
// Switching between layouts of the same groups glides, and so does a swap.
let rows = Node::preset(Layout::TwoRows, &[1, 2]);
let (shown, target, _) = step(&rows, 3.0, 900.0);
assert_ne!(shown, target, "starts from the columns");
let (shown, target, _) = step(&rows, 4.0, 900.0);
assert_eq!(shown, target);
let swapped = Node::split(Dir::Column, vec![Node::leaf(2), Node::leaf(1)]);
let (shown, target, _) = step(&swapped, 5.0, 900.0);
assert_ne!(shown, target, "starts from the old order");
step(&swapped, 6.0, 900.0);
// Dragging a divider follows immediately.
let dragged = Node::split_with(Dir::Column, vec![Node::leaf(2), Node::leaf(1)], vec![0.3, 0.7]);
let (shown, target, _) = step(&dragged, 6.1, 900.0);
assert_eq!(shown, target);
}
}
+327 -78
View File
@@ -8,11 +8,12 @@ use egui::{
vec2,
};
use tr_chart::drawings::{self, Anchor, Drawing, Shape, Tool};
use tr_chart::gpu::{ChartCallback, Frame, LineKind, LineRef, Style};
use tr_chart::gpu::{ChartCallback, Frame, LineKind, LineRef, Reveal, Style};
use tr_chart::scale;
use tr_chart::scene::{self, ChartType, Overlay, PricePane, SeriesGpu};
use tr_chart::viewport::{PriceScale, Viewport};
use tr_congress::{Trade, TxType};
use tr_core::indicators::registry::{self, Category};
use tr_core::{AssetClass, Bar, Security, Timeframe};
use crate::command::{IndicatorSpec, Range};
@@ -29,6 +30,36 @@ const RANGE_H: f32 = 18.0;
/// Height of the interval / chart-type segmented controls.
pub const CONTROL_H: f32 = 20.0;
const DEFAULT_VISIBLE: usize = 160;
/// Oscillator panes shown under the price pane. GPU frame ids are `id * 8 + 1 + k`, so a
/// chart has room for at most seven.
const MAX_SUB_PANES: usize = 6;
/// Length of the load-in sweep across the visible bars.
const REVEAL_SECS: f32 = 0.7;
/// Length of an eased jump (quick ranges, reset, keyboard zoom and pan).
const GLIDE_SECS: f32 = 0.32;
/// Time constant of the autoscale easing.
const SCALE_TAU: f64 = 0.07;
/// A programmatic viewport change in progress; `start` is set on the first frame shown.
#[derive(Debug, Clone, Copy)]
struct Glide {
from: Viewport,
to: Viewport,
start: Option<f64>,
}
impl Glide {
/// Pans linearly and zooms geometrically, so a zoom feels even all the way.
fn at(&self, e: f64) -> Viewport {
let (a, b) = (self.from.bar_px.ln(), self.to.bar_px.ln());
Viewport {
right: self.from.right + (self.to.right - self.from.right) * e,
bar_px: (a + (b - a) * e).exp(),
..self.to
}
}
}
#[derive(Default)]
struct DrawState {
@@ -55,6 +86,13 @@ pub struct ChartState {
generation: u64,
viewport: Option<Viewport>,
manual_scale: Option<PriceScale>,
glide: Option<Glide>,
/// Shown autoscale bounds (transformed) and whether they are log, easing to the target.
shown_scale: Option<(f64, f64, bool)>,
/// Renewed when a load's first bars arrive, which replays the load-in sweep.
reveal_key: u64,
/// Renewed when a load's first Congress markers arrive, which pops them in.
markers_key: u64,
loading: bool,
paging: bool,
exhausted: bool,
@@ -68,8 +106,8 @@ pub struct ChartState {
hover_marker: Option<usize>,
/// Resolved security for `symbol`.
sec: Security,
last_live: Option<std::time::Instant>,
last_indicator_req: Option<std::time::Instant>,
last_live: Option<web_time::Instant>,
last_indicator_req: Option<web_time::Instant>,
/// App-level actions requested from this chart (context menu), drained by the app.
pub actions: Vec<Action>,
/// False while a pane is being dragged over the workspace: no crosshair or hover cards.
@@ -94,7 +132,7 @@ impl ChartState {
magnet: false,
show_volume: true,
show_congress: true,
indicators: vec![IndicatorSpec::Sma(20), IndicatorSpec::Sma(50)],
indicators: crate::command::default_indicators(),
drawings: Vec::new(),
compare: None,
bars: Arc::new(Vec::new()),
@@ -102,6 +140,10 @@ impl ChartState {
generation: 0,
viewport: None,
manual_scale: None,
glide: None,
shown_scale: None,
reveal_key: 0,
markers_key: 0,
loading: false,
paging: false,
exhausted: false,
@@ -135,6 +177,7 @@ impl ChartState {
self.error = None;
self.viewport = None;
self.manual_scale = None;
self.glide = None;
self.compare_line = None;
self.markers.clear();
// Never show the previous symbol's bars under the new title.
@@ -180,7 +223,23 @@ impl ChartState {
self.tf = r.timeframe();
self.load(w);
} else {
self.apply_range();
self.glide(Self::apply_range);
}
}
/// Apply `change` to the view (on top of any glide's destination) and ease to it.
fn glide(&mut self, change: impl FnOnce(&mut Self)) {
let shown = self.viewport;
if let Some(g) = self.glide.take() {
self.viewport = Some(g.to);
}
change(self);
if let (Some(from), Some(to)) = (shown, self.viewport)
&& from != to
&& !crate::anim::reduced()
{
self.viewport = Some(from);
self.glide = Some(Glide { from, to, start: None });
}
}
@@ -208,6 +267,13 @@ impl ChartState {
self.request_indicators(w);
}
/// Remove every instance of the indicator `code` (all moving averages of one kind).
fn remove_indicator(&mut self, code: &str, w: &Worker) {
self.indicators.retain(|s| s.code != code);
self.built.retain(|b| b.spec.code != code);
self.request_indicators(w);
}
pub fn clear_indicators(&mut self) {
self.indicators.clear();
self.built.clear();
@@ -245,25 +311,54 @@ impl ChartState {
}
pub fn reset_view(&mut self) {
if let Some(v) = self.viewport.as_mut() {
*v = Viewport::fit_last(self.bars.len(), DEFAULT_VISIBLE, v.width_px);
}
self.manual_scale = None;
self.glide(|s| {
if let Some(v) = s.viewport.as_mut() {
*v = Viewport::fit_last(s.bars.len(), DEFAULT_VISIBLE, v.width_px);
}
s.manual_scale = None;
});
}
pub fn pan_bars(&mut self, n: f64) {
if let Some(v) = self.viewport.as_mut() {
v.pan_bars(n);
v.clamp(self.bars.len());
}
self.glide(|s| {
if let Some(v) = s.viewport.as_mut() {
v.pan_bars(n);
v.clamp(s.bars.len());
}
});
}
pub fn zoom(&mut self, factor: f64) {
if let Some(v) = self.viewport.as_mut() {
let anchor = v.width_px * 0.85;
v.zoom(factor, anchor);
v.clamp(self.bars.len());
}
self.glide(|s| {
if let Some(v) = s.viewport.as_mut() {
let anchor = v.width_px * 0.85;
v.zoom(factor, anchor);
v.clamp(s.bars.len());
}
});
}
/// Ease the shown autoscale range toward `target`, so the axis glides as bars scroll
/// in rather than jumping. Snaps on new data and when switching log scale.
fn ease_scale(&mut self, ctx: &egui::Context, target: PriceScale) -> PriceScale {
let (a, b) = target.bounds_t();
let (lo, hi) = match self.shown_scale {
Some((lo, hi, log)) if log == target.log && !crate::anim::reduced() => {
let dt = ctx.input(|i| i.stable_dt).clamp(0.0, 0.1) as f64;
let k = 1.0 - (-dt / SCALE_TAU).exp();
let (lo, hi) = (lo + (a - lo) * k, hi + (b - hi) * k);
if (lo - a).abs() + (hi - b).abs() < (b - a).abs() * 1e-4 {
(a, b)
} else {
ctx.request_repaint();
(lo, hi)
}
}
_ => (a, b),
};
self.shown_scale = Some((lo, hi, target.log));
let inv = |x: f64| if target.log { x.exp() } else { x };
PriceScale { lo: inv(lo), hi: inv(hi), ..target }
}
fn request_indicators(&self, w: &Worker) {
@@ -309,10 +404,20 @@ impl ChartState {
return;
}
if live {
self.last_live = Some(std::time::Instant::now());
self.last_live = Some(web_time::Instant::now());
}
let old_len = self.bars.len();
let new_len = bars.len();
if old_len == 0 && new_len > 0 {
self.reveal_key = crate::anim::fresh_key();
self.shown_scale = None;
}
// Keep a glide in progress on the same bars as the data shifts under it.
if let Some(g) = self.glide.as_mut() {
let shift = if prepended > 0 { prepended as f64 } else { new_len as f64 - old_len as f64 };
g.from.right += shift;
g.to.right += shift;
}
self.bars = bars;
self.gpu = Some(gpu);
self.paging = false;
@@ -338,7 +443,7 @@ impl ChartState {
let due = self.last_indicator_req.is_none_or(|t| t.elapsed().as_millis() >= 1500);
if !live || due {
self.request_indicators(w);
self.last_indicator_req = Some(std::time::Instant::now());
self.last_indicator_req = Some(web_time::Instant::now());
}
if !live {
self.request_compare(w);
@@ -370,6 +475,9 @@ impl ChartState {
pub fn on_markers(&mut self, symbol: &str, trades: Vec<Trade>) {
if symbol == self.symbol {
if self.markers.is_empty() && !trades.is_empty() {
self.markers_key = crate::anim::fresh_key();
}
self.markers = trades;
}
}
@@ -538,12 +646,25 @@ impl ChartState {
// Split plot height between the price pane and oscillator sub-panes.
let pane_specs: Vec<usize> = (0..self.built.len()).filter(|&i| self.built[i].spec.is_pane()).collect();
let n_sub = pane_specs.len().min(3);
let sub_h = if n_sub > 0 { (plot_all.height() * 0.2).clamp(50.0, 160.0) } else { 0.0 };
let n_sub = pane_specs.len().min(MAX_SUB_PANES);
// Sub-panes share at most 60% of the plot so prices stay readable.
let sub_h = if n_sub > 0 {
(plot_all.height() * 0.2).clamp(50.0, 160.0).min(plot_all.height() * 0.6 / n_sub as f32)
} else {
0.0
};
let price_rect =
Rect::from_min_max(plot_all.min, pos2(plot_all.right(), plot_all.bottom() - sub_h * n_sub as f32));
let len = self.bars.len();
if let Some(g) = self.glide.as_mut() {
let now = ui.input(|i| i.time);
let t = crate::anim::since(ui.ctx(), now - *g.start.get_or_insert(now), GLIDE_SECS);
self.viewport = Some(g.at(crate::anim::ease_out(t) as f64));
if t >= 1.0 {
self.glide = None;
}
}
let v =
self.viewport.get_or_insert_with(|| Viewport::fit_last(len, DEFAULT_VISIBLE, price_rect.width() as f64));
v.set_width(price_rect.width() as f64);
@@ -566,6 +687,9 @@ impl ChartState {
if plot_resp.hovered() || axis_resp.hovered() || time_resp.hovered() {
let (scroll, zoom_delta) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta()));
if scroll != egui::Vec2::ZERO || zoom_delta != 1.0 {
self.glide = None;
}
let v = self.viewport.as_mut().expect("viewport");
if let Some(p) = ui.input(|i| i.pointer.hover_pos()) {
let anchor = (p.x - price_rect.left()).clamp(0.0, price_rect.width()) as f64;
@@ -580,6 +704,9 @@ impl ChartState {
v.pan_px(scroll.x as f64);
}
}
if (self.draw.tool.is_none() && plot_resp.dragged()) || time_resp.dragged() {
self.glide = None;
}
if self.draw.tool.is_none() && plot_resp.dragged() {
let d = plot_resp.drag_delta();
self.viewport.as_mut().expect("viewport").pan_px(d.x as f64);
@@ -633,14 +760,18 @@ impl ChartState {
scale,
});
}
let reveal_t =
crate::anim::progress(ui.ctx(), egui::Id::new(("chart-reveal", self.id)), self.reveal_key, REVEAL_SECS);
let reveal = Reveal { opacity: ui.opacity(), ..Reveal::sweep(reveal_t, v.left(), v.visible_bars()) };
let style = Style {
up: theme::rgba(theme::candle_up()),
down: theme::rgba(theme::candle_down()),
color: theme::rgba(amber()),
pixels_per_point: ui.ctx().pixels_per_point(),
reveal,
..Style::default()
};
let out = scene::price_pane(&PricePane {
let mut pane = PricePane {
series: &gpu,
chart_type: self.chart_type,
show_volume: self.show_volume,
@@ -653,7 +784,16 @@ impl ChartState {
s
}),
style,
};
pane.manual_scale = Some(match pane.manual_scale {
Some(s) => {
let (a, b) = s.bounds_t();
self.shown_scale = Some((a, b, s.log));
s
}
None => self.ease_scale(ui.ctx(), scene::autoscale(&pane)),
});
let out = scene::price_pane(&pane);
let mut scale = out.scale;
scale.height_px = price_rect.height() as f64;
if axis_resp.dragged() {
@@ -686,20 +826,17 @@ impl ChartState {
let b = &self.built[bi];
let top = price_rect.bottom() + sub_h * k as f32;
let r = Rect::from_min_max(pos2(plot_all.left(), top), pos2(plot_all.right(), top + sub_h));
let fixed = matches!(b.spec, IndicatorSpec::Rsi(_)).then_some((0.0, 100.0));
let def = b.spec.def();
let ovs: Vec<Overlay> = b.overlays.iter().map(|o| o.1.clone()).collect();
let mut s = style;
s.thickness_px = 1.3;
let so = scene::sub_pane(&ovs, &v, r.height() as f64, fixed, s);
let so = scene::sub_pane(&ovs, &v, r.height() as f64, def.and_then(|d| d.range), s);
let p = ui.painter_at(r);
p.hline(r.x_range(), r.top(), Stroke::new(1.0, theme::border()));
if matches!(b.spec, IndicatorSpec::Rsi(_)) {
for lvl in [30.0, 70.0] {
p.hline(
r.x_range(),
r.top() + so.scale.y_of(lvl) as f32,
Stroke::new(1.0, Color32::from_rgb(60, 45, 80)),
);
for &lvl in def.map_or(&[][..], |d| d.levels) {
let y = r.top() + so.scale.y_of(lvl) as f32;
if r.y_range().contains(y) {
p.hline(r.x_range(), y, Stroke::new(1.0, muted().gamma_multiply(0.35)));
}
}
let frame = Frame { pane: self.id * 8 + 1 + k as u64, frame_no, cmds: so.cmds };
@@ -736,7 +873,7 @@ impl ChartState {
axis_painter.text(
pos2(plot_all.right() + 6.0, y),
Align2::LEFT_CENTER,
format!("{t:.1}"),
scale::format_value(t),
mono(10.5),
muted(),
);
@@ -759,8 +896,11 @@ impl ChartState {
}
}
// Last price line + axis tag.
if let Some(last) = self.bars.last() {
// Last price line + axis tag, fading in as the sweep reaches the last bar.
let tag_fade = crate::anim::span(reveal_t, 0.8, 1.0);
if let Some(last) = self.bars.last()
&& tag_fade > 0.0
{
let y = price_rect.top() + scale.y_of(last.close) as f32;
if price_rect.y_range().contains(y) {
let col = if last.is_up() { theme::up() } else { theme::down() };
@@ -769,18 +909,18 @@ impl ChartState {
price_painter.hline(
xx..=(xx + 3.0).min(price_rect.right()),
y,
Stroke::new(1.0, col.gamma_multiply(0.6)),
Stroke::new(1.0, col.gamma_multiply(0.6 * tag_fade)),
);
xx += 6.0;
}
let tag = Rect::from_min_size(pos2(plot_all.right() + 1.0, y - 8.0), vec2(AXIS_W - 2.0, 16.0));
axis_painter.rect_filled(tag, 0.0, col);
axis_painter.rect_filled(tag, 0.0, col.gamma_multiply(tag_fade));
axis_painter.text(
tag.left_center() + vec2(5.0, 0.0),
Align2::LEFT_CENTER,
scale::format_price(last.close, self.sec.decimals(last.close)),
mono(11.0),
Color32::BLACK,
Color32::BLACK.gamma_multiply(tag_fade),
);
}
}
@@ -791,7 +931,7 @@ impl ChartState {
// ---- congress markers ----
self.hover_marker = None;
if self.show_congress {
self.paint_markers(ui, price_rect, &v, &scale, hover);
self.paint_markers(ui, price_rect, &v, &scale, hover, &reveal);
}
// ---- crosshair + legend ----
@@ -826,11 +966,39 @@ impl ChartState {
}
}
self.paint_legend(ui, price_rect, hovered_bar.unwrap_or(len.saturating_sub(1)), &sub_rects);
if matches!(self.sec.class, tr_core::AssetClass::Equity | tr_core::AssetClass::Etf) {
clicked |= self.trade_buttons(ui, plot_all);
}
let _ = focused; // the surrounding card shows focus
clicked
}
/// Buy / Sell buttons in the plot's top-right corner: they open the order ticket.
fn trade_buttons(&mut self, ui: &mut Ui, plot: Rect) -> bool {
let h = 20.0;
let mut x = plot.right() - 8.0;
let mut clicked = false;
for (label, cmd, color, tip) in
[("SELL", "SELL", theme::down(), "Sell (⇧S)"), ("BUY", "BUY", theme::up(), "Buy (⇧B)")]
{
let g = ui.painter().layout_no_wrap(label.into(), mono(11.0), Color32::WHITE);
let w = g.size().x + 18.0;
let r = Rect::from_min_size(pos2(x - w, plot.top() + 6.0), vec2(w, h));
x -= w + 6.0;
let resp = ui.interact(r, ui.id().with((self.id, "trade", label)), Sense::click()).on_hover_text(tip);
let fill = if resp.hovered() { color } else { color.gamma_multiply(0.8) };
let p = ui.painter();
p.rect_filled(r, h / 2.0, fill);
p.galley(r.center() - g.size() / 2.0, g, Color32::WHITE);
if resp.clicked() {
self.actions.push(Action::Command(format!("{cmd} {}", self.symbol)));
clicked = true;
}
}
clicked
}
fn context_menu(&mut self, ui: &mut Ui, w: &Worker) {
ui.set_min_width(190.0);
if ui.button("Reset View").clicked() {
@@ -846,21 +1014,40 @@ impl ChartState {
}
});
ui.menu_button("Indicators", |ui| {
for spec in [
IndicatorSpec::Sma(20),
IndicatorSpec::Sma(50),
IndicatorSpec::Ema(20),
IndicatorSpec::Vwap,
IndicatorSpec::Bollinger(20, 2.0),
IndicatorSpec::Rsi(14),
IndicatorSpec::Macd(12, 26, 9),
] {
let on = self.indicators.contains(&spec);
if ui.checkbox(&mut on.clone(), spec.label()).clicked() {
self.toggle_indicator(spec, w);
ui.set_min_width(220.0);
if ui.button("Search Indicators…").clicked() {
self.actions.push(Action::Command("IND".into()));
ui.close();
}
if !self.indicators.is_empty() {
ui.separator();
ui.label(RichText::new("On this chart").small().color(muted()));
for spec in self.indicators.clone() {
if ui.checkbox(&mut true, spec.label()).on_hover_text(spec.name()).clicked() {
self.toggle_indicator(spec, w);
}
}
}
ui.separator();
for cat in Category::ALL {
ui.menu_button(cat.label(), |ui| {
ui.set_min_width(260.0);
egui::ScrollArea::vertical().max_height(440.0).show(ui, |ui| {
for def in registry::all().iter().filter(|d| d.category == cat) {
let on = self.indicators.iter().any(|s| s.code == def.code);
let hover = IndicatorSpec::of(def).label();
if ui.checkbox(&mut on.clone(), def.name).on_hover_text(hover).clicked() {
if on {
self.remove_indicator(def.code, w);
} else {
self.toggle_indicator(IndicatorSpec::of(def), w);
}
}
}
});
});
}
ui.separator();
if ui.button("Remove All").clicked() {
self.clear_indicators();
ui.close();
@@ -923,20 +1110,12 @@ impl ChartState {
let mut ly = y + 16.0;
let value_at = |l: &LineRef| l.pyramid.data.get(i).map(|p| p[1] as f64).filter(|v| v.is_finite());
for bi in self.built.iter().filter(|b| !b.spec.is_pane()) {
let vals: Vec<String> =
bi.overlays.iter().filter_map(|(_, o)| value_at(&o.line).map(|v| format!("{v:.2}"))).collect();
let c = bi
let vals: Vec<String> = bi
.overlays
.get(bi.overlays.len() / 2)
.map(|o| {
Color32::from_rgba_unmultiplied(
(o.1.color[0] * 255.0) as u8,
(o.1.color[1] * 255.0) as u8,
(o.1.color[2] * 255.0) as u8,
255,
)
})
.unwrap_or(text());
.iter()
.filter_map(|(_, o)| value_at(&o.line).map(|v| format!("{v:.prec$}", prec = dec.min(4))))
.collect();
let c = bi.overlays.get(bi.overlays.len() / 2).map_or(text(), |o| opaque(&o.1));
p.text(
pos2(price_rect.left() + 6.0, ly),
Align2::LEFT_TOP,
@@ -946,6 +1125,13 @@ impl ChartState {
);
ly += 14.0;
}
let hidden = self.built.iter().filter(|b| b.spec.is_pane()).count().saturating_sub(sub_rects.len());
if hidden > 0 {
let note =
format!("+{hidden} more oscillator{} (max {MAX_SUB_PANES} panes)", if hidden == 1 { "" } else { "s" });
p.text(pos2(price_rect.left() + 6.0, ly), Align2::LEFT_TOP, note, mono(11.0), muted());
ly += 14.0;
}
if let (Some(sym), Some(line)) = (&self.compare, &self.compare_line) {
// Raw price plus performance relative to the left edge of the view.
let raw = value_at(line);
@@ -973,15 +1159,17 @@ impl ChartState {
}
for (r, bi, _) in sub_rects {
let b = &self.built[*bi];
let vals: Vec<String> =
b.overlays.iter().filter_map(|(n, o)| value_at(&o.line).map(|v| format!("{n} {v:.2}"))).collect();
ui.painter_at(*r).text(
r.left_top() + vec2(90.0, 3.0),
Align2::LEFT_TOP,
vals.join(" "),
mono(10.5),
text(),
);
let painter = ui.painter_at(*r);
// Values start after the pane's label (drawn with the axes).
let mut x = r.left() + 18.0 + painter.layout_no_wrap(b.spec.label(), mono(10.5), muted()).size().x;
for (name, o) in &b.overlays {
let Some(v) = value_at(&o.line) else { continue };
let color = if o.kind == LineKind::Hist { text() } else { opaque(o) };
let g = painter.layout_no_wrap(format!("{name} {}", scale::format_value(v)), mono(10.5), color);
let w = g.size().x;
painter.galley(pos2(x, r.top() + 3.0), g, color);
x += w + 10.0;
}
}
}
@@ -1099,16 +1287,27 @@ impl ChartState {
}
}
fn paint_markers(&mut self, ui: &Ui, rect: Rect, v: &Viewport, s: &PriceScale, hover: Option<Pos2>) {
/// Congress trade markers. They pop in once the reveal sweep has passed their bar, and
/// left to right when they arrive after it.
fn paint_markers(
&mut self,
ui: &Ui,
rect: Rect,
v: &Viewport,
s: &PriceScale,
hover: Option<Pos2>,
reveal: &Reveal,
) {
if self.markers.is_empty() || self.bars.is_empty() {
return;
}
let arrive = crate::anim::progress(ui.ctx(), egui::Id::new(("chart-markers", self.id)), self.markers_key, 0.6);
let p = ui.painter_at(rect);
let len = self.bars.len();
let (lo, hi) = v.visible_range(len);
// Stack multiple trades on the same bar.
let mut stack: std::collections::HashMap<(usize, bool), u32> = std::collections::HashMap::new();
let r = (v.bar_px as f32 * 0.45).clamp(4.0, 7.0);
let full_r = (v.bar_px as f32 * 0.45).clamp(4.0, 7.0);
let mut best: Option<(f32, usize, Pos2)> = None;
for (mi, t) in self.markers.iter().enumerate() {
let Some(ts) = t.tx_date.and_hms_opt(12, 0, 0).map(|d| d.and_utc().timestamp()) else { continue };
@@ -1121,7 +1320,15 @@ impl ChartState {
let sell = t.tx_type.is_sell();
let n = stack.entry((i, sell)).or_insert(0);
let x = rect.left() + v.x_center(i) as f32;
let off = 6.0 + r + *n as f32 * (2.0 * r + 1.0);
let off = 6.0 + full_r + *n as f32 * (2.0 * full_r + 1.0);
let swept = ((reveal.front - (i as f64 + 0.5)) / reveal.ramp as f64).clamp(0.0, 1.0) as f32;
let lead = 0.5 * (x - rect.left()) / rect.width().max(1.0);
let pop = crate::anim::ease_out_back(swept.min(crate::anim::span(arrive, lead, lead + 0.5)));
if pop <= 0.01 {
*n += 1;
continue;
}
let r = full_r * pop;
let y = if sell {
rect.top() + s.y_of(bar.high) as f32 - off
} else {
@@ -1131,13 +1338,14 @@ impl ChartState {
let c = Pos2::new(x, y);
let col = theme::party_color(t.party);
p.circle_filled(c, r, col);
if r >= 5.0 {
if full_r >= 5.0 {
let letter = match t.tx_type {
TxType::Purchase => "B",
TxType::Sale | TxType::SalePartial => "S",
_ => "E",
};
p.text(c, Align2::CENTER_CENTER, letter, mono(r * 1.6), Color32::WHITE);
let fade = crate::anim::span(pop, 0.5, 1.0);
p.text(c, Align2::CENTER_CENTER, letter, mono(full_r * 1.6), Color32::WHITE.gamma_multiply(fade));
} else {
p.circle_stroke(c, r, Stroke::new(1.0, Color32::WHITE));
}
@@ -1156,6 +1364,11 @@ impl ChartState {
}
}
fn opaque(o: &Overlay) -> Color32 {
let [r, g, b, _] = o.color.map(|c| (c.clamp(0.0, 1.0) * 255.0) as u8);
Color32::from_rgb(r, g, b)
}
fn dashed_h(p: &egui::Painter, x: egui::Rangef, y: f32, s: Stroke) {
let mut a = x.min;
while a < x.max {
@@ -1221,3 +1434,39 @@ fn hover_card(ui: &Ui, at: Pos2, t: &Trade) {
p.text(card.left_top() + vec2(7.0, 4.0 + k as f32 * 14.0), Align2::LEFT_TOP, l, mono(11.5), *c);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn glide_pans_linearly_and_zooms_evenly() {
let from = Viewport { right: 100.0, bar_px: 2.0, width_px: 800.0 };
let to = Viewport { right: 200.0, bar_px: 8.0, width_px: 800.0 };
let g = Glide { from, to, start: None };
assert_eq!(g.at(0.0), from);
let end = g.at(1.0);
assert_eq!(end.right, to.right);
assert!((end.bar_px - to.bar_px).abs() < 1e-9);
let mid = g.at(0.5);
assert_eq!(mid.right, 150.0);
assert!((mid.bar_px - 4.0).abs() < 1e-9, "geometric midpoint");
}
#[test]
fn view_changes_glide_from_what_is_shown() {
let mut c = ChartState::new(1, "AAPL", Timeframe::D1);
c.bars = Arc::new(tr_core::sample::generate("AAPL", Timeframe::D1, 500, 1_790_000_000));
let shown = Viewport::fit_last(500, 100, 800.0);
c.viewport = Some(shown);
c.pan_bars(-20.0);
assert_eq!(c.viewport, Some(shown), "stays where it is shown until the glide runs");
assert_eq!(c.glide.map(|g| g.to.right), Some(shown.right - 20.0));
// A second press adds to the destination and keeps gliding from the shown view.
c.pan_bars(-20.0);
let g = c.glide.expect("gliding");
assert_eq!((g.from, g.to.right), (shown, shown.right - 40.0));
c.reset_view();
assert_eq!(c.glide.map(|g| g.to), Some(Viewport::fit_last(500, DEFAULT_VISIBLE, 800.0)));
}
}
+112 -63
View File
@@ -1,49 +1,16 @@
//! Bloomberg-style command line: `<SECURITY> [<FUNCTION> args] <GO>`.
//!
//! Examples: `AAPL`, `AAPL US EQUITY <GO>`, `5`, `1W`, `RSI`, `SMA 50`, `CMP MSFT`,
//! `AAPL CONG`, `CONG`, `POL PELOSI`, `TRACK PELOSI`, `LAYOUT 4`, `FIB`, `HELP`.
//! Examples: `AAPL`, `AAPL US EQUITY <GO>`, `5`, `1W`, `RSI`, `SMA 50`, `ICHIMOKU`, `IND MOMENTUM`,
//! `CMP MSFT`, `AAPL CONG`, `CONG`, `POL PELOSI`, `TRACK PELOSI`, `LAYOUT 4`, `FIB`, `HELP`.
use tr_chart::drawings::Tool;
use tr_chart::scene::ChartType;
pub use tr_core::indicators::registry::Spec as IndicatorSpec;
use tr_core::{Security, Timeframe, YellowKey};
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum IndicatorSpec {
Sma(usize),
Ema(usize),
Vwap,
Bollinger(usize, f64),
Rsi(usize),
Macd(usize, usize, usize),
}
impl IndicatorSpec {
pub fn label(&self) -> String {
match self {
IndicatorSpec::Sma(n) => format!("SMA {n}"),
IndicatorSpec::Ema(n) => format!("EMA {n}"),
IndicatorSpec::Vwap => "VWAP".into(),
IndicatorSpec::Bollinger(n, k) => format!("BB {n} {k}"),
IndicatorSpec::Rsi(n) => format!("RSI {n}"),
IndicatorSpec::Macd(a, b, c) => format!("MACD {a} {b} {c}"),
}
}
/// Oscillators get their own sub-pane.
pub fn is_pane(&self) -> bool {
matches!(self, IndicatorSpec::Rsi(_) | IndicatorSpec::Macd(..))
}
/// Same indicator family (used to toggle and replace).
pub fn same_kind(&self, other: &IndicatorSpec) -> bool {
std::mem::discriminant(self) == std::mem::discriminant(other)
&& match (self, other) {
(IndicatorSpec::Sma(a), IndicatorSpec::Sma(b)) | (IndicatorSpec::Ema(a), IndicatorSpec::Ema(b)) => {
a == b
}
_ => true,
}
}
/// Indicators on a new chart: the 20 and 50 bar simple moving averages.
pub fn default_indicators() -> Vec<IndicatorSpec> {
[20.0, 50.0].iter().filter_map(|n| IndicatorSpec::new("SMA", &[*n])).collect()
}
/// Views of the brokerage portfolio pane (`PORT`, `ACCT`, `POS`, `ORD`, `ACTV`).
@@ -97,6 +64,8 @@ pub struct TradeArgs {
pub notional: Option<f64>,
pub limit: Option<f64>,
pub stop: Option<f64>,
/// `SELL ALL TSLA`: the whole position.
pub all: bool,
}
impl TradeArgs {
@@ -136,6 +105,18 @@ impl TradeArgs {
if t.units.is_some() && t.notional.is_some() {
return None;
}
// `ALL` means the whole position when another word names the security (`ALL` alone
// is Allstate's ticker).
if words.len() > 1
&& let Some(i) = words.iter().position(|w| w.eq_ignore_ascii_case("ALL"))
{
if t.buy == Some(true) || t.units.is_some() || t.notional.is_some() {
return None;
}
words.remove(i);
t.all = true;
t.buy = Some(false);
}
if !words.is_empty() {
let (sec, used) = Security::resolve_tokens(&words)?;
if used != words.len() {
@@ -229,7 +210,10 @@ impl Range {
pub enum Function {
Timeframe(Timeframe),
ChartType(ChartType),
/// Add or remove an indicator (any code from the catalog: `RSI`, `BB 20 2.5`, `ICHIMOKU`).
Indicator(IndicatorSpec),
/// Open the indicator picker, pre-filled with a search (`IND`, `IND VOLUME`).
IndicatorPicker(String),
ClearIndicators,
Compare(Option<String>),
LogScale,
@@ -271,6 +255,8 @@ pub enum Function {
/// `SIDEBAR LEFT|RIGHT|TOGGLE|SHOW|HIDE|SWAP`.
Sidebar(String),
Settings,
/// Check for a newer release of the app.
Update,
/// `THEME LIGHT|DARK|SYSTEM|COMPACT|REGULAR|COMFORTABLE`.
Theme(String),
/// Rename the focused tab (empty resets).
@@ -287,6 +273,8 @@ pub enum Function {
Portfolio(PortView, String),
/// Open the order ticket (`BUY`, `SELL`, `TRADE`).
Trade(TradeArgs),
/// Switch between the demo account and live accounts (`DEMO`, `MODE LIVE`); `true` = live.
AccountMode(bool),
}
fn num<T: std::str::FromStr>(t: Option<&&str>) -> Option<T> {
@@ -302,19 +290,13 @@ fn parse_function(head: &str, rest: &[&str]) -> Option<Function> {
"BAR" | "BARS" | "OHLC" => Function::ChartType(ChartType::Bars),
"LINE" => Function::ChartType(ChartType::Line),
"AREA" | "MOUNTAIN" => Function::ChartType(ChartType::Area),
"SMA" => Function::Indicator(IndicatorSpec::Sma(num(rest.first()).unwrap_or(20))),
"EMA" => Function::Indicator(IndicatorSpec::Ema(num(rest.first()).unwrap_or(20))),
"VWAP" => Function::Indicator(IndicatorSpec::Vwap),
"BB" | "BOLL" | "BOLLINGER" => Function::Indicator(IndicatorSpec::Bollinger(
num(rest.first()).unwrap_or(20),
num(rest.get(1)).unwrap_or(2.0),
)),
"RSI" => Function::Indicator(IndicatorSpec::Rsi(num(rest.first()).unwrap_or(14))),
"MACD" => Function::Indicator(IndicatorSpec::Macd(
num(rest.first()).unwrap_or(12),
num(rest.get(1)).unwrap_or(26),
num(rest.get(2)).unwrap_or(9),
)),
// `IND RSI 21` adds directly; anything else searches the picker.
"IND" | "INDICATOR" | "INDICATORS" | "STUDY" | "STUDIES" => {
match rest.split_first().and_then(|(code, args)| IndicatorSpec::parse(code, args)) {
Some(spec) => Function::Indicator(spec),
None => Function::IndicatorPicker(joined()),
}
}
"CLEAR" | "CLR" => match words().first().map(String::as_str) {
Some("DRAW") | Some("DRAWINGS") => Function::ClearDrawings,
_ => Function::ClearIndicators,
@@ -357,6 +339,7 @@ fn parse_function(head: &str, rest: &[&str]) -> Option<Function> {
"OMON" | "OMST" | "OPT" | "OPTIONS" | "CHAIN" => Function::Options,
"SIDEBAR" | "SB" => Function::Sidebar(words().join(" ")),
"PREFS" | "SETTINGS" | "PDF" | "CONFIG" => Function::Settings,
"UPDATE" | "UPDATES" => Function::Update,
"THEME" => Function::Theme(words().join(" ")),
"TITLE" | "RENAME" => Function::Title(joined()),
"CLOSE" => Function::ClosePane,
@@ -375,14 +358,22 @@ fn parse_function(head: &str, rest: &[&str]) -> Option<Function> {
"BUY" => Function::Trade(TradeArgs::parse(Some(true), rest)?),
"SELL" => Function::Trade(TradeArgs::parse(Some(false), rest)?),
"TRADE" | "TICKET" | "EMSX" => Function::Trade(TradeArgs::parse(None, rest)?),
"DEMO" | "PAPER" if rest.is_empty() => Function::AccountMode(false),
"MODE" => match words().join(" ").as_str() {
"DEMO" | "PAPER" => Function::AccountMode(false),
"LIVE" | "REAL" => Function::AccountMode(true),
_ => return None,
},
"RANGE" => Function::Range(Range::parse(rest.first()?)?),
// Timeframes win where spellings overlap (`1D`, `1M`); otherwise try a range (`5D`, `YTD`).
// Timeframes win where spellings overlap (`1D`, `1M`); otherwise try a range (`5D`, `YTD`),
// then the indicator catalog (`RSI`, `BB 20 2.5`).
_ => {
return head
.parse::<Timeframe>()
.ok()
.map(Function::Timeframe)
.or_else(|| Range::parse(&up).map(Function::Range));
.or_else(|| Range::parse(&up).map(Function::Range))
.or_else(|| IndicatorSpec::parse(head, rest).map(Function::Indicator));
}
})
}
@@ -403,6 +394,21 @@ pub fn parse(input: &str) -> Option<Command> {
let (sec, used) = Security::resolve_tokens(&tokens)?;
match tokens[used..].split_first() {
None => Some(Command::Symbol(sec.key())),
// `TSLA SELL ALL`: the security is already named, so `ALL` is the whole position.
Some((&f, args))
if ["SELL", "TRADE"].iter().any(|h| f.eq_ignore_ascii_case(h))
&& args.iter().any(|a| a.eq_ignore_ascii_case("ALL")) =>
{
let rest: Vec<&str> = args.iter().copied().filter(|a| !a.eq_ignore_ascii_case("ALL")).collect();
match parse_function(f, &rest)? {
Function::Trade(mut t) if t.symbol.is_none() && t.units.is_none() && t.notional.is_none() => {
t.all = true;
t.buy = Some(false);
Some(Command::SymbolFunction(sec.key(), Function::Trade(t)))
}
_ => None,
}
}
Some((&f, args)) => parse_function(f, args).map(|func| Command::SymbolFunction(sec.key(), func)),
}
}
@@ -438,7 +444,9 @@ fn describe(f: &Function) -> String {
}
),
Function::ChartType(c) => format!("Chart type {}", c.label()),
Function::Indicator(i) => format!("Toggle {}", i.label()),
Function::Indicator(i) => format!("Toggle {} — {}", i.label(), i.name()),
Function::IndicatorPicker(q) if q.is_empty() => "Browse indicators".into(),
Function::IndicatorPicker(q) => format!("Search indicators for \"{q}\""),
Function::ClearIndicators => "Remove all indicators".into(),
Function::Compare(Some(s)) => format!("Compare with {s}"),
Function::Compare(None) => "Remove comparison".into(),
@@ -477,6 +485,7 @@ fn describe(f: &Function) -> String {
Function::Sidebar(s) if s.is_empty() => "Toggle the sidebar".into(),
Function::Sidebar(s) => format!("Sidebar {}", s.to_lowercase()),
Function::Settings => "Settings: theme, fonts, density, hotkeys, panels, layouts".into(),
Function::Update => "Check for updates".into(),
Function::Theme(t) => format!("Theme {t}"),
Function::Title(t) => format!("Rename tab to {t:?}"),
Function::ClosePane => "Close focused pane".into(),
@@ -518,8 +527,13 @@ fn describe(f: &Function) -> String {
if let Some(p) = t.limit {
s += &format!(" limit {p}");
}
s + " (preview and confirm before anything is sent)"
if t.all {
s += " (whole position)";
}
s
}
Function::AccountMode(false) => "Switch to the DEMO account (paper trading)".into(),
Function::AccountMode(true) => "Switch to LIVE brokerage accounts (asks first)".into(),
}
}
@@ -560,10 +574,19 @@ mod tests {
#[test]
fn functions() {
assert_eq!(parse("rsi"), Some(Command::Function(Function::Indicator(IndicatorSpec::Rsi(14)))));
assert_eq!(parse("SMA 50"), Some(Command::Function(Function::Indicator(IndicatorSpec::Sma(50)))));
assert_eq!(parse("bb 20 2.5"), Some(Command::Function(Function::Indicator(IndicatorSpec::Bollinger(20, 2.5)))));
assert_eq!(parse("MACD"), Some(Command::Function(Function::Indicator(IndicatorSpec::Macd(12, 26, 9)))));
let ind = |code: &str, params: &[f64]| {
Some(Command::Function(Function::Indicator(IndicatorSpec::new(code, params).unwrap())))
};
assert_eq!(parse("rsi"), ind("RSI", &[14.0]));
assert_eq!(parse("SMA 50"), ind("SMA", &[50.0]));
assert_eq!(parse("bb 20 2.5"), ind("BB", &[20.0, 2.5]));
assert_eq!(parse("MACD"), ind("MACD", &[12.0, 26.0, 9.0]));
assert_eq!(parse("ichimoku"), ind("ICHIMOKU", &[]));
assert_eq!(parse("IND STOCH 5 3 3"), ind("STOCH", &[5.0, 3.0, 3.0]));
assert_eq!(parse("IND"), Some(Command::Function(Function::IndicatorPicker(String::new()))));
assert_eq!(parse("study volume"), Some(Command::Function(Function::IndicatorPicker("volume".into()))));
let Some(Command::SymbolFunction(_, Function::Indicator(adx))) = parse("AAPL ADX") else { panic!("AAPL ADX") };
assert_eq!(adx.label(), "ADX 14 14");
assert_eq!(parse("CMP msft"), Some(Command::Function(Function::Compare(Some("MSFT".into())))));
assert_eq!(parse("CMP"), Some(Command::Function(Function::Compare(None))));
assert_eq!(parse("FIB"), Some(Command::Function(Function::Draw(Tool::Fib))));
@@ -593,6 +616,7 @@ mod tests {
Some(Command::SymbolFunction("BTCUSD Curncy".into(), Function::OrderBook(Some(50))))
);
assert_eq!(parse("PREFS"), Some(Command::Function(Function::Settings)));
assert_eq!(parse("update"), Some(Command::Function(Function::Update)));
assert_eq!(parse("AAPL OMON"), Some(Command::SymbolFunction("AAPL US Equity".into(), Function::Options)));
assert_eq!(parse("SPX INDEX OMON"), Some(Command::SymbolFunction("SPX Index".into(), Function::Options)));
assert_eq!(parse("OMON"), Some(Command::Function(Function::Options)));
@@ -661,6 +685,15 @@ mod tests {
assert_eq!(t("BUY AAPL LMT"), None);
assert_eq!(t("BUY AAPL LMT -5"), None);
assert!(hint("BUY AAPL 10 LMT 150").contains("Buy 10 AAPL limit 150"));
let all = t("SELL ALL TSLA").unwrap();
assert_eq!((all.buy, all.all, all.symbol.as_deref()), (Some(false), true, Some("TSLA US Equity")));
assert_eq!(t("sell tsla all"), t("SELL ALL TSLA"));
assert_eq!(t("TSLA SELL ALL").map(|a| a.all), Some(true));
assert!(!t("SELL ALL").unwrap().all, "ALL alone is Allstate");
assert_eq!(t("BUY ALL TSLA"), None);
assert_eq!(t("SELL ALL TSLA 5"), None);
assert_eq!(parse("DEMO"), Some(Command::Function(Function::AccountMode(false))));
assert_eq!(parse("MODE LIVE"), Some(Command::Function(Function::AccountMode(true))));
}
#[test]
@@ -690,8 +723,24 @@ mod tests {
#[test]
fn indicator_identity() {
assert!(IndicatorSpec::Rsi(14).same_kind(&IndicatorSpec::Rsi(21)));
assert!(!IndicatorSpec::Sma(20).same_kind(&IndicatorSpec::Sma(50)));
assert!(IndicatorSpec::Macd(12, 26, 9).is_pane());
let spec = |code: &str, params: &[f64]| IndicatorSpec::new(code, params).unwrap();
assert!(spec("RSI", &[14.0]).same_kind(&spec("RSI", &[21.0])));
assert!(!spec("SMA", &[20.0]).same_kind(&spec("SMA", &[50.0])));
assert!(spec("MACD", &[]).is_pane());
}
/// Every indicator code and alias reaches the indicator: no function, timeframe or range
/// spelling shadows it.
#[test]
fn every_indicator_code_parses_to_its_indicator() {
for def in tr_core::indicators::registry::all() {
for code in std::iter::once(&def.code).chain(def.aliases) {
match parse(code) {
Some(Command::Function(Function::Indicator(s))) => assert_eq!(s.code, def.code, "{code}"),
other => panic!("{code} parses as {other:?}"),
}
assert_eq!(parse(&format!("IND {code}")), parse(code), "IND {code}");
}
}
}
}
+10 -1
View File
@@ -9,6 +9,10 @@ use tr_congress::{Chamber, Party, Trade, TxType};
use crate::theme::{self, amber, mono, muted, text, ui_font};
use crate::worker::Profile;
/// Shown instead of Congress data in the browser build, which can't fetch disclosures yet.
pub const WEB_NOTE: &str =
"Congress data needs the desktop app for now: browsers can't reach Senate eFD or the House Clerk.";
/// What a click inside a panel asks the app to do.
#[derive(Debug, Clone, PartialEq)]
pub enum Action {
@@ -192,7 +196,12 @@ pub fn feed(ui: &mut Ui, trades: &[Trade], filter: &mut FeedFilter, status: &str
header(ui, "FILED TRADED LAG POLITICIAN PTY C TYPE TICKER AMOUNT OWNER ASSET");
let rows: Vec<&Trade> = trades.iter().filter(|t| filter.matches(t)).collect();
if rows.is_empty() {
plain(ui, "No disclosures yet. Ingest runs in the background (REFRESH to force).", muted());
let empty = if cfg!(target_arch = "wasm32") {
WEB_NOTE
} else {
"No disclosures yet. Ingest runs in the background (REFRESH to force)."
};
plain(ui, empty, muted());
}
let row_h = 16.0;
ScrollArea::both().id_salt("feed").auto_shrink([false, false]).show_rows(ui, row_h, rows.len(), |ui, range| {
+18 -29
View File
@@ -1,6 +1,6 @@
//! Named, saved workspace layouts: the dock tree, each pane's kind and settings, link
//! groups and popped-out windows. Stored one JSON file per layout in
//! `<data dir>/layouts/` (`~/Library/Application Support/terminal-rs/layouts` on macOS),
//! `<data dir>/layouts/` (`~/Library/Application Support/terminal/layouts` on macOS),
//! so they can be shared, hand-edited, imported and exported.
use std::collections::HashSet;
@@ -16,6 +16,7 @@ use crate::command::IndicatorSpec;
use crate::congress_view::FeedFilter;
use crate::dock::{Node, PaneId};
use crate::layout::Layout;
use crate::storage;
pub const FORMAT_VERSION: u32 = 1;
@@ -267,7 +268,7 @@ pub fn chart_settings(symbol: &str, d: &crate::prefs::PanelDefaults) -> PaneSett
tf: d.timeframe,
chart_type: d.chart_type,
log: false,
indicators: vec![IndicatorSpec::Sma(20), IndicatorSpec::Sma(50)],
indicators: crate::command::default_indicators(),
drawings: Vec::new(),
compare: None,
show_volume: d.show_volume,
@@ -276,16 +277,6 @@ pub fn chart_settings(symbol: &str, d: &crate::prefs::PanelDefaults) -> PaneSett
}
}
/// Write via a temp file and rename, so a crash never leaves a half-written file.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, bytes)?;
std::fs::rename(tmp, path)
}
/// The layouts folder and its files.
pub struct Store {
pub dir: PathBuf,
@@ -314,19 +305,17 @@ impl Store {
/// Saved layout names, sorted case-insensitively. Names come from the documents, so a
/// file copied in under any file name keeps its display name.
pub fn list(&self) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(&self.dir)
let mut names: Vec<String> = storage::list(&self.dir)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
.filter_map(|e| {
let bytes = std::fs::read(e.path()).ok()?;
.filter(|p| p.extension().is_some_and(|x| x == "json"))
.filter_map(|p| {
let bytes = storage::read(&p).ok()?;
let doc: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
Some(
doc["name"]
.as_str()
.map(str::to_string)
.unwrap_or_else(|| e.path().file_stem().unwrap_or_default().to_string_lossy().into_owned()),
.unwrap_or_else(|| p.file_stem().unwrap_or_default().to_string_lossy().into_owned()),
)
})
.collect();
@@ -343,12 +332,12 @@ impl Store {
/// that name (e.g. dropped into the folder by hand).
fn find_file(&self, name: &str) -> Option<PathBuf> {
let p = self.path(name);
if p.exists() {
if storage::exists(&p) {
return Some(p);
}
std::fs::read_dir(&self.dir).ok()?.flatten().map(|e| e.path()).find(|p| {
storage::list(&self.dir).into_iter().find(|p| {
p.extension().is_some_and(|x| x == "json")
&& std::fs::read(p)
&& storage::read(p)
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.is_some_and(|v| v["name"].as_str().is_some_and(|n| n.eq_ignore_ascii_case(name)))
@@ -357,7 +346,7 @@ impl Store {
pub fn load(&self, name: &str) -> Result<LayoutDoc> {
let path = self.find_file(name).with_context(|| format!("no saved layout named {name:?}"))?;
let mut doc = LayoutDoc::from_json(&std::fs::read(&path)?).with_context(|| format!("{}", path.display()))?;
let mut doc = LayoutDoc::from_json(&storage::read(&path)?).with_context(|| format!("{}", path.display()))?;
if doc.name.trim().is_empty() {
doc.name = name.to_string();
}
@@ -366,13 +355,13 @@ impl Store {
pub fn save(&self, doc: &LayoutDoc) -> Result<PathBuf> {
let path = self.path(&doc.name);
write_atomic(&path, &doc.to_json()).with_context(|| format!("writing {}", path.display()))?;
storage::write_atomic(&path, &doc.to_json()).with_context(|| format!("writing {}", path.display()))?;
Ok(path)
}
pub fn delete(&self, name: &str) -> Result<()> {
let path = self.find_file(name).with_context(|| format!("no saved layout named {name:?}"))?;
std::fs::remove_file(path)?;
storage::remove(&path)?;
Ok(())
}
@@ -411,16 +400,16 @@ impl Store {
(2..).map(|i| format!("{base} {i}")).find(|n| !self.exists(n)).expect("unbounded")
}
/// Write a layout to an arbitrary path.
pub fn export(&self, name: &str, to: &Path) -> Result<()> {
/// Write a layout to an arbitrary path (a download in the browser). Returns where it went.
pub fn export(&self, name: &str, to: &Path) -> Result<String> {
let doc = self.load(name)?;
write_atomic(to, &doc.to_json()).with_context(|| format!("writing {}", to.display()))
storage::export(to, &doc.to_json()).with_context(|| format!("writing {}", to.display()))
}
/// Copy a layout file into the store (renaming on conflict). Returns its name.
pub fn import(&self, from: &Path) -> Result<String> {
let mut doc =
LayoutDoc::from_json(&std::fs::read(from).with_context(|| format!("reading {}", from.display()))?)?;
LayoutDoc::from_json(&storage::import(from).with_context(|| format!("reading {}", from.display()))?)?;
if doc.name.trim().is_empty() {
doc.name = from.file_stem().unwrap_or_default().to_string_lossy().into_owned();
}
+53 -18
View File
@@ -1,4 +1,4 @@
//! terminal-rs: a keyboard-driven, Bloomberg-style market terminal with GPU charts and
//! Terminal: a keyboard-driven, Bloomberg-style market terminal with GPU charts and
//! congressional (STOCK Act) trade tracking.
mod anim;
@@ -10,14 +10,60 @@ mod dock;
mod layout;
mod layouts;
mod markets_view;
mod palette;
mod prefs;
mod storage;
mod theme;
mod themes;
#[cfg(target_arch = "wasm32")]
mod web;
mod widgets;
mod worker;
use std::time::Instant;
use app::TerminalApp;
use web_time::Instant;
/// Build the terminal once eframe has a GPU: register the chart renderer, start the worker
/// and restore the saved workspace. Shared by the desktop and browser entry points.
fn create_app(
cc: &eframe::CreationContext<'_>,
provider: tr_data::Provider,
msaa: u32,
t0: Instant,
) -> Result<TerminalApp, Box<dyn std::error::Error + Send + Sync>> {
let rs = cc.wgpu_render_state.as_ref().ok_or("Terminal requires the wgpu renderer")?;
// Charts read their series from storage buffers in the vertex shader. WebGL2 has none,
// so there chart panes keep their axes and labels but draw no series.
let charts = rs.adapter.get_downlevel_capabilities().flags.contains(eframe::wgpu::DownlevelFlags::VERTEX_STORAGE);
if charts {
rs.renderer.write().callback_resources.insert(tr_chart::gpu::ChartRenderer::new(
&rs.device,
rs.target_format,
msaa,
));
}
let name = provider.name().to_string();
let worker = worker::Worker::spawn(cc.egui_ctx.clone(), provider)?;
let info = rs.adapter.get_info();
tracing::info!("window + GPU ready in {:?} (adapter: {}, backend: {:?})", t0.elapsed(), info.name, info.backend);
let mut app = TerminalApp::new(&cc.egui_ctx, worker, name);
if !charts {
tracing::warn!("this GPU backend has no vertex storage buffers; charts are off");
app.set_status(if cfg!(target_arch = "wasm32") {
"Charts need WebGPU. This browser fell back to WebGL2, so they are off.".into()
} else {
"This GPU backend has no vertex storage buffers, so charts are off.".into()
});
}
Ok(app)
}
#[cfg(target_arch = "wasm32")]
fn main() {
web::start();
}
#[cfg(not(target_arch = "wasm32"))]
fn main() -> eframe::Result {
let t0 = Instant::now();
tracing_subscriber::fmt()
@@ -34,8 +80,9 @@ fn main() -> eframe::Result {
.filter(|n| matches!(n, 1 | 2 | 4 | 8))
.unwrap_or(if cfg!(target_os = "macos") { 4 } else { 1 });
let mut viewport = egui::ViewportBuilder::default()
.with_title("terminal-rs")
.with_app_id("terminal-rs")
.with_title("Terminal")
.with_app_id("terminal")
.with_icon(eframe::icon_data::from_png_bytes(include_bytes!("../assets/icon.png")).expect("app icon"))
.with_inner_size([1600.0, 960.0])
.with_min_inner_size([900.0, 560.0]);
if cfg!(target_os = "macos") {
@@ -54,20 +101,8 @@ fn main() -> eframe::Result {
..Default::default()
};
eframe::run_native(
"terminal-rs",
"terminal",
options,
Box::new(move |cc| {
let rs = cc.wgpu_render_state.as_ref().ok_or("terminal-rs requires the wgpu renderer")?;
rs.renderer.write().callback_resources.insert(tr_chart::gpu::ChartRenderer::new(
&rs.device,
rs.target_format,
u32::from(msaa),
));
let provider = tr_data::from_env();
let name = provider.name().to_string();
let worker = worker::Worker::spawn(cc.egui_ctx.clone(), provider)?;
tracing::info!("window + GPU ready in {:?} (adapter: {})", t0.elapsed(), rs.adapter.get_info().name);
Ok(Box::new(app::TerminalApp::new(&cc.egui_ctx, worker, name)))
}),
Box::new(move |cc| Ok(Box::new(create_app(cc, tr_data::from_env(), u32::from(msaa), t0)?))),
)
}
+446
View File
@@ -0,0 +1,446 @@
//! Command palette (⌘K, Ctrl+K elsewhere): fuzzy search over every command, ticker,
//! panel, layout, theme, settings tab and workspace action.
//!
//! This module is the model: items, ranking and the persisted list of recent picks. The
//! overlay and the catalogue of live items (themes, saved layouts, watchlist) live in
//! `app/palette_view.rs`.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::prefs::Action;
/// How many recent picks are remembered.
pub const MAX_RECENTS: usize = 10;
/// Rows shown for a query (the full list is still searchable).
pub const MAX_RESULTS: usize = 60;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Kind {
Command,
Ticker,
Panel,
Layout,
Theme,
Settings,
Action,
Indicator,
}
impl Kind {
pub fn label(self) -> &'static str {
match self {
Kind::Command => "CMD",
Kind::Ticker => "TICKER",
Kind::Panel => "PANEL",
Kind::Layout => "LAYOUT",
Kind::Theme => "THEME",
Kind::Settings => "SETTINGS",
Kind::Action => "ACTION",
Kind::Indicator => "STUDY",
}
}
}
/// What picking an item does.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Target {
/// Run a command line, exactly as if typed and sent with <GO>.
Run(String),
/// Run a rebindable action.
Hotkey(Action),
/// Open Settings on the tab with this label.
Settings(String),
/// Switch to a saved layout by name.
SwitchLayout(String),
/// Select a theme (or System) by exact name.
Theme(String),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Item {
pub kind: Kind,
pub title: String,
pub subtitle: String,
/// Extra words that match but are not shown (aliases, full names).
#[serde(default)]
pub keywords: String,
/// The action whose binding is shown as the shortcut hint.
#[serde(default)]
pub hotkey: Option<Action>,
pub target: Target,
}
impl Item {
pub fn new(kind: Kind, title: impl Into<String>, subtitle: impl Into<String>, target: Target) -> Self {
Self { kind, title: title.into(), subtitle: subtitle.into(), keywords: String::new(), hotkey: None, target }
}
pub fn keywords(mut self, k: impl Into<String>) -> Self {
self.keywords = k.into();
self
}
pub fn hotkey(mut self, a: Action) -> Self {
self.hotkey = Some(a);
self
}
/// Two items are the same pick when they do the same thing.
pub fn same(&self, other: &Item) -> bool {
self.target == other.target
}
}
/// Fuzzy subsequence score of `needle` in `hay` (case-insensitive), or `None` if some
/// character is missing. Rewards matches at word starts, runs of consecutive characters
/// and an exact or prefix match; penalises gaps.
pub fn fuzzy(needle: &str, hay: &str) -> Option<i32> {
let n: Vec<char> = needle.chars().flat_map(char::to_lowercase).collect();
if n.is_empty() {
return Some(0);
}
let h: Vec<char> = hay.chars().collect();
let lower: Vec<char> = h.iter().map(|c| c.to_lowercase().next().unwrap_or(*c)).collect();
let mut score = 0;
let mut ni = 0;
let mut prev: Option<usize> = None;
for (i, &c) in lower.iter().enumerate() {
if ni == n.len() {
break;
}
if c != n[ni] {
continue;
}
let word_start = i == 0
|| !h[i - 1].is_alphanumeric()
|| (h[i].is_uppercase() && h[i - 1].is_lowercase())
|| (h[i].is_ascii_digit() && !h[i - 1].is_ascii_digit());
score += 1;
if word_start {
score += 8;
}
match prev {
Some(p) if p + 1 == i => score += 6,
Some(p) => score -= ((i - p - 1) as i32).min(5),
None => score -= (i as i32).min(8),
}
prev = Some(i);
ni += 1;
}
if ni < n.len() {
return None;
}
let hay_l: String = lower.iter().collect();
let needle_l: String = n.iter().collect();
if hay_l == needle_l {
score += 60;
} else if hay_l.starts_with(&needle_l) {
score += 30;
}
Some(score)
}
/// Score an item for a query: every whitespace-separated word must match the title,
/// keywords or subtitle (title weighs most).
pub fn score(item: &Item, query: &str) -> Option<i32> {
let mut total = 0;
for word in query.split_whitespace() {
let best = [
fuzzy(word, &item.title).map(|s| s * 3),
fuzzy(word, &item.keywords).map(|s| s * 2),
fuzzy(word, &item.subtitle),
]
.into_iter()
.flatten()
.max()?;
total += best;
}
// A whole-query hit on the title (multi-word commands like `SMA 50`).
if query.split_whitespace().nth(1).is_some()
&& let Some(s) = fuzzy(query.trim(), &item.title)
{
total = total.max(s * 3) + 20;
}
Some(total)
}
/// Palette state: open flag, query, selection and recent picks.
#[derive(Debug, Default)]
pub struct Palette {
pub open: bool,
pub query: String,
pub selected: usize,
pub recents: Vec<Item>,
/// Only items of this kind are offered (the indicator picker).
pub scope: Option<Kind>,
/// Set when opened, to focus the search field on the next frame.
pub just_opened: bool,
path: Option<PathBuf>,
}
impl Palette {
/// Load recent picks from `path` (missing or unreadable files start empty).
pub fn load(path: &Path) -> Self {
let recents = crate::storage::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
Self { recents, path: Some(path.to_path_buf()), ..Default::default() }
}
pub fn open(&mut self) {
self.open = true;
self.just_opened = true;
self.query.clear();
self.selected = 0;
self.scope = None;
}
/// Open showing only `kind`, searching for `query`.
pub fn open_scoped(&mut self, kind: Kind, query: &str) {
self.open();
self.scope = Some(kind);
self.query = query.trim().to_string();
}
fn in_scope(&self, it: &Item) -> bool {
self.scope.is_none_or(|k| it.kind == k)
}
pub fn close(&mut self) {
self.open = false;
}
pub fn toggle(&mut self) {
if self.open { self.close() } else { self.open() }
}
/// Move the selection by `delta` rows, wrapping around `len`.
pub fn step(&mut self, delta: i32, len: usize) {
if len == 0 {
self.selected = 0;
return;
}
self.selected = (self.selected.min(len - 1) as i32 + delta).rem_euclid(len as i32) as usize;
}
/// Remember a pick (most recent first) and persist the list.
pub fn record(&mut self, item: &Item) {
self.recents.retain(|r| !r.same(item));
self.recents.insert(0, item.clone());
self.recents.truncate(MAX_RECENTS);
if let Some(path) = &self.path
&& let Ok(json) = serde_json::to_vec_pretty(&self.recents)
&& let Err(e) = crate::storage::write_atomic(path, &json)
{
tracing::warn!("saving palette recents: {e}");
}
}
/// Ranked results for the current query over `items`.
pub fn results(&self, items: &[Item]) -> Vec<Item> {
let (items, recents) = self.scoped(items);
rank(&items, &recents, &self.query)
}
/// How many of [`Self::results`] are recent picks listed first (none while searching).
pub fn recent_count(&self, items: &[Item]) -> usize {
if !self.query.trim().is_empty() {
return 0;
}
let (items, recents) = self.scoped(items);
live_recents(&items, &recents).len()
}
fn scoped(&self, items: &[Item]) -> (Vec<Item>, Vec<Item>) {
let keep = |v: &[Item]| -> Vec<Item> { v.iter().filter(|it| self.in_scope(it)).cloned().collect() };
(keep(items), keep(&self.recents))
}
}
/// Recent picks refreshed from the live catalogue: a pick still offered shows its current
/// row (markers, shortcut hints); saved layouts and themes that no longer exist are
/// dropped. Other picks (a searched ticker, a typed command) are kept as recorded.
pub fn live_recents(items: &[Item], recents: &[Item]) -> Vec<Item> {
recents
.iter()
.filter_map(|r| match items.iter().find(|it| it.same(r)) {
Some(live) => Some(live.clone()),
None if matches!(r.target, Target::SwitchLayout(_) | Target::Theme(_)) => None,
None => Some(r.clone()),
})
.collect()
}
/// Rank `items` for `query`. With no query: recent picks first, then everything in
/// catalogue order. With a query: best fuzzy matches, recent picks boosted, ties kept
/// in catalogue order.
pub fn rank(items: &[Item], recents: &[Item], query: &str) -> Vec<Item> {
let recents = live_recents(items, recents);
let recents = recents.as_slice();
let recent_rank = |it: &Item| recents.iter().position(|r| r.same(it));
if query.trim().is_empty() {
let mut out: Vec<Item> = recents.to_vec();
out.extend(items.iter().filter(|it| recent_rank(it).is_none()).cloned());
return out;
}
// Recent picks that are no longer in the catalogue (a searched ticker) still match.
let pool = items.iter().chain(recents.iter().filter(|r| !items.iter().any(|it| it.same(r))));
let mut scored: Vec<(i32, usize, &Item)> = pool
.enumerate()
.filter_map(|(i, it)| {
let s = score(it, query)?;
let boost = recent_rank(it).map_or(0, |r| 40 - 3 * r as i32);
Some((s + boost, i, it))
})
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
let mut out: Vec<Item> = Vec::new();
for (_, _, it) in scored {
if !out.iter().any(|o| o.same(it)) {
out.push(it.clone());
}
if out.len() == MAX_RESULTS {
break;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn cmd(code: &str, desc: &str) -> Item {
Item::new(Kind::Command, code, desc, Target::Run(code.into()))
}
fn catalogue() -> Vec<Item> {
vec![
cmd("PORT", "Brokerage portfolio"),
cmd("POS", "Brokerage positions"),
cmd("ORD", "Brokerage orders"),
cmd("OMON", "Option chain").keywords("options chain"),
cmd("SMA 50", "Toggle SMA 50"),
Item::new(Kind::Theme, "Nord", "Dark theme", Target::Run("THEME Nord".into())),
Item::new(Kind::Settings, "Settings › Brokerage", "SnapTrade keys", Target::Settings("Brokerage".into())),
Item::new(Kind::Action, "Move sidebar to the other side", "", Target::Hotkey(Action::SwapSidebarSide)),
Item::new(Kind::Ticker, "AAPL US Equity", "Apple Inc", Target::Run("AAPL US Equity".into()))
.keywords("Apple Inc"),
]
}
fn titles(v: &[Item]) -> Vec<&str> {
v.iter().map(|i| i.title.as_str()).collect()
}
#[test]
fn fuzzy_matches_subsequences_and_prefers_word_starts() {
assert!(fuzzy("omn", "OMON").is_some());
assert!(fuzzy("xyz", "OMON").is_none());
assert_eq!(fuzzy("", "anything"), Some(0));
// Exact beats prefix beats scattered.
let exact = fuzzy("pos", "POS").unwrap();
let prefix = fuzzy("pos", "POSITIONS").unwrap();
let scattered = fuzzy("pos", "PORT LIST S").unwrap();
assert!(exact > prefix && prefix > scattered, "{exact} {prefix} {scattered}");
// Word-start initials match well: "mss" → "Move Sidebar to the other Side".
assert!(fuzzy("mss", "Move sidebar to the other side").is_some());
assert!(fuzzy("brk", "Settings › Brokerage").unwrap() > fuzzy("brk", "abcbrrrk").unwrap());
}
#[test]
fn query_ranks_best_match_first() {
let items = catalogue();
assert_eq!(titles(&rank(&items, &[], "pos"))[0], "POS");
assert_eq!(titles(&rank(&items, &[], "ord"))[0], "ORD");
assert_eq!(titles(&rank(&items, &[], "omon"))[0], "OMON");
assert_eq!(titles(&rank(&items, &[], "options"))[0], "OMON", "keywords match");
assert_eq!(titles(&rank(&items, &[], "apple"))[0], "AAPL US Equity");
assert_eq!(titles(&rank(&items, &[], "brokerage set"))[0], "Settings › Brokerage", "all words must match");
assert_eq!(titles(&rank(&items, &[], "sma 50"))[0], "SMA 50");
assert!(rank(&items, &[], "qqqzzz").is_empty());
}
#[test]
fn recents_come_first_and_boost_matches() {
let items = catalogue();
let nord = items.iter().find(|i| i.title == "Nord").unwrap().clone();
let ord = items.iter().find(|i| i.title == "ORD").unwrap().clone();
let recents = vec![nord.clone(), ord.clone()];
let empty = rank(&items, &recents, "");
assert_eq!(titles(&empty)[..2], ["Nord", "ORD"]);
assert_eq!(empty.len(), items.len(), "recents are not listed twice");
// "or" matches ORD best; Nord is recent and also matches, so it moves up too.
let r = rank(&items, &recents, "nor");
assert_eq!(titles(&r)[0], "Nord");
// A recent pick no longer in the catalogue (a searched ticker) is still found.
let msft = Item::new(Kind::Ticker, "MSFT US Equity", "Microsoft", Target::Run("MSFT US Equity".into()));
let r = rank(&items, std::slice::from_ref(&msft), "msft");
assert_eq!(titles(&r), ["MSFT US Equity"]);
}
#[test]
fn recents_follow_the_live_catalogue() {
let theme = |name: &str, sub: &str| Item::new(Kind::Theme, name, sub, Target::Theme(name.into()));
let layout = |name: &str| Item::new(Kind::Layout, name, "Saved layout", Target::SwitchLayout(name.into()));
let ticker = Item::new(Kind::Ticker, "MSFT US Equity", "Microsoft", Target::Run("MSFT US Equity".into()));
let recents =
vec![theme("Nord", "Dark theme · current"), layout("Desk"), theme("Gone", "Dark theme"), ticker.clone()];
let live = vec![theme("Nord", "Dark theme"), layout("Desk").hotkey(Action::Layout2)];
let r = live_recents(&live, &recents);
assert_eq!(titles(&r), ["Nord", "Desk", "MSFT US Equity"], "deleted themes and layouts drop out");
assert_eq!(r[0].subtitle, "Dark theme", "the current marker follows the live row");
assert_eq!(r[1].hotkey, Some(Action::Layout2), "shortcut hints follow the live row");
assert_eq!(titles(&rank(&live, &recents, "")), ["Nord", "Desk", "MSFT US Equity"]);
assert!(rank(&live, &recents, "gone").is_empty());
}
#[test]
fn scoped_palette_offers_only_its_kind() {
let rsi = Item::new(Kind::Indicator, "Relative Strength Index", "RSI 14", Target::Run("RSI".into()));
let mut items = catalogue();
items.push(rsi.clone());
let mut p = Palette { recents: vec![items[0].clone(), rsi.clone()], ..Default::default() };
p.open_scoped(Kind::Indicator, " ");
assert_eq!(p.query, "");
assert_eq!(p.results(&items), std::slice::from_ref(&rsi));
assert_eq!(p.recent_count(&items), 1, "recents outside the scope are hidden");
p.query = "port".into();
assert!(p.results(&items).is_empty());
p.open();
assert_eq!(p.scope, None, "a plain open drops the scope");
assert_eq!(p.results(&items).len(), items.len());
assert_eq!(p.recent_count(&items), 2);
}
#[test]
fn selection_wraps_and_recents_persist() {
let mut p = Palette::default();
p.open();
assert!(p.open && p.just_opened && p.query.is_empty());
p.step(-1, 3);
assert_eq!(p.selected, 2);
p.step(1, 3);
assert_eq!(p.selected, 0);
p.step(1, 0);
assert_eq!(p.selected, 0);
p.toggle();
assert!(!p.open);
let dir = std::env::temp_dir().join(format!("tr-palette-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("palette.json");
let mut p = Palette::load(&path);
assert!(p.recents.is_empty());
let items = catalogue();
for it in items.iter().chain(items.iter()) {
p.record(it);
}
p.record(&items[0]);
assert_eq!(p.recents.len(), items.len().min(MAX_RECENTS));
assert_eq!(p.recents[0].title, "PORT", "re-picking moves an item to the front");
let back = Palette::load(&path);
assert_eq!(back.recents, p.recents);
std::fs::remove_dir_all(&dir).unwrap();
}
}
+38 -7
View File
@@ -194,11 +194,23 @@ impl Default for PanelDefaults {
}
}
/// Order entry. Disabled by default; enabling it in Settings › Trading takes an explicit
/// acknowledgement that orders are real.
/// Which account the terminal trades and shows: the local paper-trading demo account, or
/// the real brokerage accounts connected through SnapTrade.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum AccountMode {
#[default]
Demo,
Live,
}
/// Order entry. The demo account is selected by default and trades without any setup;
/// switching to live accounts is an explicit choice that is remembered. Sending live orders
/// additionally needs `enabled`, which takes an explicit acknowledgement that orders are real.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct TradingPrefs {
pub account_mode: AccountMode,
/// Live orders may be sent (Settings › Trading).
pub enabled: bool,
/// Refuse orders whose estimated value exceeds this (`None` = no limit).
pub max_order_value: Option<f64>,
@@ -219,6 +231,8 @@ pub enum Action {
Portfolio,
Positions,
Orders,
Buy,
Sell,
NextPane,
PrevPane,
ToggleSidebar,
@@ -231,6 +245,7 @@ pub enum Action {
PopOut,
SaveLayout,
Settings,
CommandPalette,
NewChart,
NewWatchlist,
NewNews,
@@ -261,7 +276,7 @@ pub enum Action {
}
impl Action {
pub const ALL: [Action; 49] = [
pub const ALL: [Action; 52] = [
Action::Help,
Action::Congress,
Action::Politicians,
@@ -272,6 +287,8 @@ impl Action {
Action::Portfolio,
Action::Positions,
Action::Orders,
Action::Buy,
Action::Sell,
Action::NextPane,
Action::PrevPane,
Action::ToggleSidebar,
@@ -284,6 +301,7 @@ impl Action {
Action::PopOut,
Action::SaveLayout,
Action::Settings,
Action::CommandPalette,
Action::NewChart,
Action::NewWatchlist,
Action::NewNews,
@@ -325,6 +343,8 @@ impl Action {
Action::Portfolio => "Brokerage portfolio".into(),
Action::Positions => "Brokerage positions".into(),
Action::Orders => "Brokerage orders".into(),
Action::Buy => "Buy the current security".into(),
Action::Sell => "Sell the current security".into(),
Action::NextPane => "Next pane".into(),
Action::PrevPane => "Previous pane".into(),
Action::ToggleSidebar => "Toggle sidebar".into(),
@@ -337,6 +357,7 @@ impl Action {
Action::PopOut => "Pop out pane".into(),
Action::SaveLayout => "Save layout".into(),
Action::Settings => "Settings".into(),
Action::CommandPalette => "Command palette".into(),
Action::NewChart => "New Chart pane".into(),
Action::NewWatchlist => "New Watchlist pane".into(),
Action::NewNews => "New News pane".into(),
@@ -375,7 +396,8 @@ impl Action {
| A::NewTab
| A::ClosePane
| A::PopOut
| A::Settings => "Workspace",
| A::Settings
| A::CommandPalette => "Workspace",
a if a.layout_slot().is_some() || a == A::SaveLayout => "Layouts",
A::DrawTrendline
| A::DrawHorizontal
@@ -384,11 +406,13 @@ impl Action {
| A::ResetView
| A::LogScale
| A::Magnet => "Chart",
A::Buy | A::Sell => "Trading",
_ => "Panels and data",
}
}
pub const CATEGORIES: [&'static str; 5] = ["New panels", "Panels and data", "Workspace", "Layouts", "Chart"];
pub const CATEGORIES: [&'static str; 6] =
["New panels", "Panels and data", "Trading", "Workspace", "Layouts", "Chart"];
/// The panel command word for `New…` actions.
pub fn panel_spec(self) -> Option<&'static str> {
@@ -558,6 +582,9 @@ impl Default for Keymap {
bind(Action::Portfolio, "F7");
bind(Action::Positions, "F8");
bind(Action::Orders, "F9");
// Shift keeps plain typing free for the type-anywhere command line.
bind(Action::Buy, "Shift+B");
bind(Action::Sell, "Shift+S");
bind(Action::NextPane, "Tab");
bind(Action::PrevPane, "Shift+Tab");
bind(Action::ToggleSidebar, if mac { "Ctrl+Cmd+S" } else { "Ctrl+Shift+S" });
@@ -568,6 +595,8 @@ impl Default for Keymap {
bind(Action::PopOut, "Cmd+Shift+P");
bind(Action::SaveLayout, "Cmd+S");
bind(Action::Settings, "Cmd+Comma");
// ⌘K on macOS, Ctrl+K elsewhere (`Cmd` is the platform command key).
bind(Action::CommandPalette, "Cmd+K");
bind(Action::DrawTrendline, "Alt+T");
bind(Action::DrawHorizontal, "Alt+H");
bind(Action::DrawVertical, "Alt+V");
@@ -655,7 +684,8 @@ impl Default for Prefs {
impl Prefs {
pub fn load(path: &std::path::Path) -> Prefs {
let mut p: Prefs = std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
let mut p: Prefs =
crate::storage::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
p.migrate();
p.hotkeys = p.hotkeys.with_defaults();
p.appearance.font_scale = p.appearance.font_scale.clamp(0.7, 1.6);
@@ -727,7 +757,7 @@ impl Prefs {
}
pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> {
crate::layouts::write_atomic(path, &serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?)
crate::storage::write_atomic(path, &serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?)
}
}
@@ -763,6 +793,7 @@ mod tests {
assert_eq!(k.action_for(Key::Num1, Modifiers::COMMAND), Some(Action::NewChart));
assert_eq!(k.action_for(Key::Num0, Modifiers::COMMAND), Some(Action::NewSearch));
assert_eq!(k.action_for(Key::Num3, Modifiers::ALT), Some(Action::Layout3));
assert_eq!(k.action_for(Key::K, Modifiers::COMMAND), Some(Action::CommandPalette));
// Every action has a default binding, and no two share one.
let chords: Vec<Chord> = Action::ALL.iter().filter_map(|a| k.chord(*a)).collect();
assert_eq!(chords.len(), Action::ALL.len());
+469
View File
@@ -0,0 +1,469 @@
//! Where preferences, settings, layouts, themes, palette history and the demo account are
//! kept: files under the data directory on the desktop, `localStorage` entries keyed by path
//! in the browser. Every saved document is UTF-8 text (JSON or TOML).
//!
//! [`export`], [`import`] and [`reveal`] are for files the user names: on the desktop they are
//! ordinary files and folders; in the browser an export is a download, and there is no import
//! or folder yet.
use std::io;
use std::path::{Path, PathBuf};
pub use imp::*;
/// Saved documents keyed by path, as the browser keeps them.
#[cfg(any(target_arch = "wasm32", test))]
trait KeyStore {
fn keys(&self) -> Vec<String>;
fn get(&self, key: &str) -> Option<String>;
/// False when the store refuses the write (full or blocked).
fn set(&mut self, key: &str, value: &str) -> bool;
fn remove(&mut self, key: &str);
}
/// Copies every key under `old/` to the same path under `new/`, once. Returns whether to use
/// `new`. Keys already under `new/` are never overwritten, and the old keys stay as they are. A
/// marker key records that the copy finished, so a file deleted afterwards doesn't come back on
/// the next load. If the store refuses a write, the copies made so far are removed and this
/// returns false, so the page keeps its complete old set and the next load tries again.
#[cfg(any(target_arch = "wasm32", test))]
fn copy_keys(store: &mut impl KeyStore, old: &str, new: &str) -> bool {
let marker = marker(new);
if store.get(&marker).is_some() {
return true;
}
let keys: Vec<(String, String)> = store
.keys()
.into_iter()
.filter_map(|k| Some((format!("{new}/{}", k.strip_prefix(old)?.strip_prefix('/')?), k)))
.collect();
if keys.is_empty() {
return true;
}
let mut copied = Vec::new();
let mut ok = true;
for (to, from) in keys {
if store.get(&to).is_some() {
continue;
}
let Some(value) = store.get(&from) else { continue };
if !store.set(&to, &value) {
ok = false;
break;
}
copied.push(to);
}
if ok && store.set(&marker, old) {
tracing::info!("copied {} saved files from {old}/ to {new}/", copied.len());
return true;
}
for k in &copied {
store.remove(k);
}
tracing::warn!("browser storage refused copying saved files from {old}/ to {new}/; using {old}/ for now");
false
}
/// The key [`copy_keys`] sets once everything under `new/` is in place.
#[cfg(any(target_arch = "wasm32", test))]
fn marker(new: &str) -> String {
format!("{new}/.migrated")
}
/// Where a page that kept the old prefix (because [`copy_keys`] failed) should read and write
/// `key`. Once another tab has finished the copy, keys under `old` map to `new`, so this page's
/// saves land where later loads look instead of under keys they no longer read.
#[cfg(any(target_arch = "wasm32", test))]
fn redirect(store: &impl KeyStore, old: &str, new: &str, key: String) -> String {
match key.strip_prefix(old) {
Some(rest) if (rest.is_empty() || rest.starts_with('/')) && store.get(&marker(new)).is_some() => {
format!("{new}{rest}")
}
_ => key,
}
}
#[cfg(not(target_arch = "wasm32"))]
mod imp {
use super::*;
/// Moves the data folder from `old` (its name before the app was renamed) to `new` the first
/// time, and leaves a link at the old path so builds that still use the old name find the
/// same files. Returns the folder to use: `new`, or `old` if it couldn't be moved. Never
/// replaces an existing `new`.
pub fn migrate(old: &Path, new: &Path) -> PathBuf {
if new.exists() || !old.is_dir() {
return new.to_path_buf();
}
if let Err(e) = std::fs::rename(old, new) {
// Another copy of the app may have moved it first.
if new.is_dir() {
return new.to_path_buf();
}
tracing::warn!("could not move {} to {} ({e}); using it where it is", old.display(), new.display());
return old.to_path_buf();
}
tracing::info!("moved {} to {}", old.display(), new.display());
#[cfg(unix)]
{
// Name the sibling rather than repeating `new`, which may be relative (an unset HOME
// or a relative XDG_DATA_HOME) and would then resolve from the link's folder.
let target = match new.file_name() {
Some(name) if old.parent() == new.parent() => PathBuf::from(name),
_ => std::path::absolute(new).unwrap_or_else(|_| new.to_path_buf()),
};
if let Err(e) = std::os::unix::fs::symlink(&target, old) {
tracing::warn!("linking {} to {}: {e}", old.display(), target.display());
}
}
new.to_path_buf()
}
pub fn read(path: &Path) -> io::Result<Vec<u8>> {
std::fs::read(path)
}
/// Write via a temp file and rename, so a crash never leaves a half-written file.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, bytes)?;
std::fs::rename(tmp, path)
}
/// Entries directly inside `dir` (none if it doesn't exist).
pub fn list(dir: &Path) -> Vec<PathBuf> {
std::fs::read_dir(dir).into_iter().flatten().flatten().map(|e| e.path()).collect()
}
pub fn exists(path: &Path) -> bool {
path.exists()
}
pub fn remove(path: &Path) -> io::Result<()> {
std::fs::remove_file(path)
}
pub fn rename(from: &Path, to: &Path) -> io::Result<()> {
std::fs::rename(from, to)
}
/// Write a file the user exports. Returns where it went, for the status line.
pub fn export(path: &Path, bytes: &[u8]) -> io::Result<String> {
write_atomic(path, bytes)?;
Ok(path.display().to_string())
}
/// Read a file the user imports.
pub fn import(path: &Path) -> io::Result<Vec<u8>> {
std::fs::read(path)
}
/// Open a folder in the system file manager, creating it first.
pub fn reveal(dir: &Path) -> io::Result<()> {
std::fs::create_dir_all(dir)?;
let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
std::process::Command::new(opener).arg(dir).spawn().map(|_| ())
}
}
#[cfg(target_arch = "wasm32")]
mod imp {
use super::*;
fn store() -> io::Result<web_sys::Storage> {
web_sys::window()
.and_then(|w| w.local_storage().ok().flatten())
.ok_or_else(|| io::Error::new(io::ErrorKind::Unsupported, "browser storage is unavailable"))
}
thread_local! {
/// The old and new prefixes while this page keeps the old one (see [`migrate`]).
static FALLBACK: std::cell::RefCell<Option<(String, String)>> = const { std::cell::RefCell::new(None) };
}
fn key(path: &Path) -> String {
let key = path.to_string_lossy().into_owned();
let Some((old, new)) = FALLBACK.with_borrow(Clone::clone) else { return key };
match store() {
Ok(s) => redirect(&s, &old, &new, key),
Err(_) => key,
}
}
impl KeyStore for web_sys::Storage {
fn keys(&self) -> Vec<String> {
(0..self.length().unwrap_or(0)).filter_map(|i| self.key(i).ok().flatten()).collect()
}
fn get(&self, key: &str) -> Option<String> {
self.get_item(key).ok().flatten()
}
fn set(&mut self, key: &str, value: &str) -> bool {
self.set_item(key, value).is_ok()
}
fn remove(&mut self, key: &str) {
let _ = self.remove_item(key);
}
}
/// Copies what the page saved under `old/` (its prefix before the app was renamed) to
/// `new/`, once (see [`copy_keys`]). Returns the prefix to use. If the copy fails, the page
/// keeps `old`, and its keys follow [`redirect`] from then on, in case another tab finishes
/// the copy while this one is open.
pub fn migrate(old: &Path, new: &Path) -> PathBuf {
let Ok(mut s) = store() else { return new.to_path_buf() };
let (old, new) = (key(old), key(new));
if copy_keys(&mut s, &old, &new) {
return PathBuf::from(new);
}
FALLBACK.with_borrow_mut(|f| *f = Some((old.clone(), new)));
PathBuf::from(old)
}
pub fn read(path: &Path) -> io::Result<Vec<u8>> {
match store()?.get_item(&key(path)) {
Ok(Some(text)) => Ok(text.into_bytes()),
Ok(None) => Err(io::ErrorKind::NotFound.into()),
Err(_) => Err(io::Error::other("browser storage refused the read")),
}
}
/// A single `setItem`, which is already all-or-nothing.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
let text = std::str::from_utf8(bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
store()?.set_item(&key(path), text).map_err(|_| io::Error::other("browser storage is full or blocked"))
}
/// Keys directly under `dir/`.
pub fn list(dir: &Path) -> Vec<PathBuf> {
let Ok(s) = store() else { return Vec::new() };
let prefix = format!("{}/", key(dir).trim_end_matches('/'));
(0..s.length().unwrap_or(0))
.filter_map(|i| s.key(i).ok().flatten())
.filter(|k| k.strip_prefix(&prefix).is_some_and(|rest| !rest.is_empty() && !rest.contains('/')))
.map(PathBuf::from)
.collect()
}
pub fn exists(path: &Path) -> bool {
store().ok().and_then(|s| s.get_item(&key(path)).ok().flatten()).is_some()
}
pub fn remove(path: &Path) -> io::Result<()> {
if !exists(path) {
return Err(io::ErrorKind::NotFound.into());
}
store()?.remove_item(&key(path)).map_err(|_| io::Error::other("browser storage refused the delete"))
}
pub fn rename(from: &Path, to: &Path) -> io::Result<()> {
write_atomic(to, &read(from)?)?;
remove(from)
}
/// Download the exported file under its file name. Returns where it went, for the status line.
pub fn export(path: &Path, bytes: &[u8]) -> io::Result<String> {
use wasm_bindgen::JsCast;
let fail = |what: &str| io::Error::other(format!("the browser refused the download ({what})"));
let name = path.file_name().map_or_else(|| "export".into(), |n| n.to_string_lossy().into_owned());
let doc = web_sys::window().and_then(|w| w.document()).ok_or_else(|| fail("no document"))?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let blob = web_sys::Blob::new_with_u8_array_sequence(&parts).map_err(|_| fail("blob"))?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(|_| fail("object URL"))?;
let link: web_sys::HtmlAnchorElement =
doc.create_element("a").map_err(|_| fail("link"))?.dyn_into().map_err(|_| fail("link"))?;
link.set_href(&url);
link.set_download(&name);
link.click();
let _ = web_sys::Url::revoke_object_url(&url);
Ok(format!("your downloads as {name}"))
}
// TODO(web): read imports through a file picker instead of a typed path.
pub fn import(_path: &Path) -> io::Result<Vec<u8>> {
Err(io::Error::new(io::ErrorKind::Unsupported, "importing files needs the desktop app for now"))
}
pub fn reveal(_dir: &Path) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::Unsupported, "the browser keeps these in its local storage, not a folder"))
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
#[test]
fn round_trip() {
let dir = std::env::temp_dir().join(format!("tr-storage-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let a = dir.join("sub/a.json");
assert!(list(&dir.join("sub")).is_empty());
assert_eq!(read(&a).unwrap_err().kind(), io::ErrorKind::NotFound);
write_atomic(&a, b"{}").unwrap();
assert!(exists(&a));
assert_eq!(list(&dir.join("sub")), vec![a.clone()]);
let b = dir.join("sub/b.json");
rename(&a, &b).unwrap();
assert!(!exists(&a));
assert_eq!(read(&b).unwrap(), b"{}");
remove(&b).unwrap();
assert!(list(&dir.join("sub")).is_empty());
std::fs::remove_dir_all(&dir).unwrap();
}
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("tr-migrate-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn migrate_moves_the_old_folder_and_links_it() {
let root = scratch("move");
let (old, new) = (root.join("terminal-rs"), root.join("terminal"));
write_atomic(&old.join("layouts/Desk.json"), b"{}").unwrap();
assert_eq!(migrate(&old, &new), new);
assert_eq!(read(&new.join("layouts/Desk.json")).unwrap(), b"{}");
#[cfg(unix)]
{
assert_eq!(std::fs::read_link(&old).unwrap(), Path::new("terminal"), "relative to the link's folder");
assert_eq!(read(&old.join("layouts/Desk.json")).unwrap(), b"{}", "old builds still find the files");
}
// Later launches find the new folder and leave everything as it is.
write_atomic(&new.join("settings.json"), b"[]").unwrap();
assert_eq!(migrate(&old, &new), new);
assert_eq!(read(&new.join("settings.json")).unwrap(), b"[]");
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn migrate_never_replaces_an_existing_folder() {
let root = scratch("keep");
let (old, new) = (root.join("terminal-rs"), root.join("terminal"));
write_atomic(&old.join("settings.json"), b"old").unwrap();
write_atomic(&new.join("settings.json"), b"new").unwrap();
assert_eq!(migrate(&old, &new), new);
assert_eq!(read(&new.join("settings.json")).unwrap(), b"new");
assert_eq!(read(&old.join("settings.json")).unwrap(), b"old", "the old folder is left alone");
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn migrate_without_an_old_folder_creates_nothing() {
let root = scratch("fresh");
let (old, new) = (root.join("terminal-rs"), root.join("terminal"));
assert_eq!(migrate(&old, &new), new);
assert!(!old.exists() && !new.exists());
std::fs::remove_dir_all(&root).unwrap();
}
#[cfg(unix)]
#[test]
fn migrate_keeps_using_the_old_folder_when_it_cannot_move() {
use std::os::unix::fs::PermissionsExt;
let root = scratch("stuck");
let (old, new) = (root.join("terminal-rs"), root.join("terminal"));
write_atomic(&old.join("settings.json"), b"{}").unwrap();
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o555)).unwrap();
if std::fs::create_dir(root.join("probe")).is_ok() {
// Running as root, where a read-only folder doesn't stop the move.
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::remove_dir_all(&root).unwrap();
return;
}
let used = migrate(&old, &new);
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(used, old);
assert_eq!(read(&old.join("settings.json")).unwrap(), b"{}");
std::fs::remove_dir_all(&root).unwrap();
}
/// `localStorage` stand-in: a map that refuses writes once it holds `room` keys.
struct Keys {
map: std::collections::BTreeMap<String, String>,
room: usize,
}
impl Keys {
fn new(entries: &[(&str, &str)]) -> Self {
Self { map: entries.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), room: usize::MAX }
}
}
impl KeyStore for Keys {
fn keys(&self) -> Vec<String> {
self.map.keys().cloned().collect()
}
fn get(&self, key: &str) -> Option<String> {
self.map.get(key).cloned()
}
fn set(&mut self, key: &str, value: &str) -> bool {
if !self.map.contains_key(key) && self.map.len() >= self.room {
return false;
}
self.map.insert(key.into(), value.into());
true
}
fn remove(&mut self, key: &str) {
self.map.remove(key);
}
}
#[test]
fn copy_keys_copies_once_without_overwriting() {
let mut s = Keys::new(&[
("terminal-rs/settings.json", "old settings"),
("terminal-rs/layouts/Desk.json", "desk"),
("terminal/settings.json", "newer settings"),
("other-site-key", "untouched"),
]);
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
assert_eq!(s.get("terminal/settings.json").as_deref(), Some("newer settings"), "newer data wins");
assert_eq!(s.get("terminal/layouts/Desk.json").as_deref(), Some("desk"));
assert_eq!(s.get("terminal-rs/layouts/Desk.json").as_deref(), Some("desk"), "the old keys stay");
assert_eq!(s.get("other-site-key").as_deref(), Some("untouched"));
// A layout deleted after the copy doesn't come back on the next load.
s.remove("terminal/layouts/Desk.json");
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
assert_eq!(s.get("terminal/layouts/Desk.json"), None);
}
#[test]
fn copy_keys_undoes_a_partial_copy() {
let mut s = Keys::new(&[("terminal-rs/a.json", "a"), ("terminal-rs/b.json", "b")]);
s.room = 3;
assert!(!copy_keys(&mut s, "terminal-rs", "terminal"), "storage is full: keep using the old keys");
assert_eq!(s.keys(), ["terminal-rs/a.json", "terminal-rs/b.json"]);
s.room = usize::MAX;
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
assert_eq!(s.get("terminal/b.json").as_deref(), Some("b"));
}
#[test]
fn a_page_left_on_the_old_prefix_follows_another_tabs_copy() {
let mut s = Keys::new(&[("terminal-rs/settings.json", "a")]);
s.room = 1;
// Tab A can't copy, so it keeps the old prefix.
assert!(!copy_keys(&mut s, "terminal-rs", "terminal"));
let key = |s: &Keys, k: &str| redirect(s, "terminal-rs", "terminal", k.into());
assert_eq!(key(&s, "terminal-rs/settings.json"), "terminal-rs/settings.json");
// Space frees up and tab B finishes the copy; tab A's saves now go where loads read.
s.room = usize::MAX;
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
assert_eq!(key(&s, "terminal-rs/settings.json"), "terminal/settings.json");
assert_eq!(key(&s, "terminal-rs/layouts"), "terminal/layouts");
assert_eq!(key(&s, "terminal-rs"), "terminal");
assert_eq!(key(&s, "terminal-rsx/a.json"), "terminal-rsx/a.json", "only that prefix");
}
#[test]
fn copy_keys_with_nothing_saved_writes_nothing() {
let mut s = Keys::new(&[]);
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
assert!(s.keys().is_empty());
}
}
+40 -1
View File
@@ -53,6 +53,9 @@ thread_local! {
static DENSITY: Cell<f32> = const { Cell::new(1.0) };
static UI_MONO: Cell<bool> = const { Cell::new(false) };
static FONTS: Cell<Option<MonoFont>> = const { Cell::new(None) };
/// Fonts fetched after start-up (the browser build's emoji fallbacks).
#[cfg(target_arch = "wasm32")]
static LATE_FONTS: std::cell::RefCell<Vec<(String, std::sync::Arc<FontData>)>> = const { std::cell::RefCell::new(Vec::new()) };
}
/// Spacing multiplier from the density preference (compact < 1 < comfortable).
@@ -176,13 +179,49 @@ pub fn ui_font(size: f32) -> FontId {
pub const CARD_RADIUS: u8 = 10;
pub const CONTROL_RADIUS: u8 = 6;
/// egui's bundled fonts. The browser build starts with only the two text fonts, which keeps
/// the emoji fonts out of the download; they arrive later through [`add_late_font`].
pub fn base_fonts() -> FontDefinitions {
#[cfg(not(target_arch = "wasm32"))]
let fonts = FontDefinitions::default();
#[cfg(target_arch = "wasm32")]
let fonts = {
use std::sync::Arc;
let mut f = FontDefinitions::empty();
f.font_data.insert("Hack".into(), Arc::new(FontData::from_static(epaint_default_fonts::HACK_REGULAR)));
f.font_data.insert("Ubuntu-Light".into(), Arc::new(FontData::from_static(epaint_default_fonts::UBUNTU_LIGHT)));
f.families.insert(FontFamily::Monospace, vec!["Hack".into(), "Ubuntu-Light".into()]);
f.families.insert(FontFamily::Proportional, vec!["Ubuntu-Light".into()]);
LATE_FONTS.with_borrow(|late| {
for (name, data) in late {
f.font_data.insert(name.clone(), data.clone());
for family in [FontFamily::Proportional, FontFamily::Monospace] {
f.families.entry(family).or_default().push(name.clone());
}
}
});
f
};
fonts
}
/// Add a font fetched after start-up as the last fallback of every family.
#[cfg(target_arch = "wasm32")]
pub fn add_late_font(ctx: &egui::Context, name: &str, data: FontData) {
LATE_FONTS.with_borrow_mut(|late| late.push((name.to_owned(), std::sync::Arc::new(data))));
// Reinstall with the current monospace choice (or let the first configure pick it up).
if let Some(mono) = FONTS.take() {
install_fonts(ctx, mono);
}
}
/// Use the system UI fonts when present (read from the OS at runtime, not bundled).
fn install_fonts(ctx: &egui::Context, mono: MonoFont) {
if FONTS.with(Cell::get) == Some(mono) {
return;
}
FONTS.with(|f| f.set(Some(mono)));
let mut fonts = FontDefinitions::default();
let mut fonts = base_fonts();
let ui_paths: &[&str] = &[
"/System/Library/Fonts/SFNS.ttf",
"/System/Library/Fonts/SFNSText.ttf",
+20 -12
View File
@@ -10,6 +10,7 @@ use egui::Color32;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::prefs::{Density, MonoFont, UiFont};
use crate::storage;
use crate::theme::Palette;
/// Selection that follows the OS appearance using the configured dark/light pair.
@@ -561,11 +562,8 @@ impl ThemeStore {
/// built-in or earlier name is listed with a suffix rather than shadowing it.
pub fn load_all(&self) -> Vec<Theme> {
let mut out = builtins();
let mut files: Vec<PathBuf> = std::fs::read_dir(&self.dir)
let mut files: Vec<PathBuf> = storage::list(&self.dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "toml" || x == "json"))
.collect();
files.sort();
@@ -606,7 +604,7 @@ impl ThemeStore {
.clone()
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
.unwrap_or_else(|| self.path(&theme.name));
crate::layouts::write_atomic(&path, theme.to_toml().as_bytes())
storage::write_atomic(&path, theme.to_toml().as_bytes())
.with_context(|| format!("writing {}", path.display()))?;
Ok(path)
}
@@ -617,23 +615,33 @@ impl ThemeStore {
bail!("built-in themes cannot be deleted");
}
let path = theme.source.clone().unwrap_or_else(|| self.path(&theme.name));
if !path.exists() {
if !storage::exists(&path) {
bail!("no file for theme {:?}", theme.name);
}
Ok(std::fs::remove_file(path)?)
Ok(storage::remove(&path)?)
}
/// Write a theme to `to` as JSON (`.json`) or TOML (anything else).
pub fn export(theme: &Theme, to: &Path) -> Result<()> {
/// Write a theme to `to` as JSON (`.json`) or TOML (anything else); a download in the
/// browser. Returns where it went.
pub fn export(theme: &Theme, to: &Path) -> Result<String> {
let json = to.extension().is_some_and(|x| x.eq_ignore_ascii_case("json"));
let text = if json { theme.to_json() } else { theme.to_toml() };
crate::layouts::write_atomic(to, text.as_bytes()).with_context(|| format!("writing {}", to.display()))
storage::export(to, text.as_bytes()).with_context(|| format!("writing {}", to.display()))
}
}
/// Read a TOML or JSON theme file.
/// Read a TOML or JSON theme file from the themes folder.
pub fn load_file(path: &Path) -> Result<Theme> {
let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
parse_file(path, storage::read(path).with_context(|| format!("reading {}", path.display()))?)
}
/// Read a theme file the user imports.
pub fn import_file(path: &Path) -> Result<Theme> {
parse_file(path, storage::import(path).with_context(|| format!("reading {}", path.display()))?)
}
fn parse_file(path: &Path, bytes: Vec<u8>) -> Result<Theme> {
let text = String::from_utf8(bytes).with_context(|| format!("{} is not UTF-8 text", path.display()))?;
let json = path.extension().is_some_and(|x| x.eq_ignore_ascii_case("json"));
let mut t = Theme::parse(&text, json)?;
t.source = Some(path.to_path_buf());
+186
View File
@@ -0,0 +1,186 @@
//! Browser entry point and page glue: start eframe's web runner on the page's canvas, log to
//! the console, and publish frame timings on `window.terminalPerf` for measurement.
//!
//! Query flags: `?sample` serves offline sample data only (like `TR_PROVIDER=sample`), and
//! `?fps` shows the frame-time readout and repaints continuously (like `TR_FPS`).
use std::cell::Cell;
use tracing::{Level, Metadata};
use tracing_subscriber::fmt::MakeWriter;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use wasm_bindgen::{JsCast, JsValue};
use web_time::Instant;
pub fn start() {
init_tracing();
let t0 = Instant::now();
let query = web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default();
let offline = has_flag(&query, "sample");
let perf = has_flag(&query, "fps");
wasm_bindgen_futures::spawn_local(async move {
let Some(canvas) = canvas() else { return fail("the page has no #terminal canvas") };
let started = eframe::WebRunner::new()
.start(
canvas,
eframe::WebOptions::default(),
Box::new(move |cc| {
// The first frame needs fonts; set them before it (set_fonts applies from the next pass).
cc.egui_ctx.set_fonts(crate::theme::base_fonts());
wasm_bindgen_futures::spawn_local(load_emoji_fonts(cc.egui_ctx.clone()));
let mut app = crate::create_app(cc, tr_data::for_browser(offline), 1, t0)?;
if perf {
app.show_perf();
}
Ok(Box::new(app))
}),
)
.await;
if let Err(e) = started {
fail(&format!("{e:?}"));
}
});
}
/// Fetch egui's emoji fonts, which scripts/build-web.sh puts in `fonts/` next to the page, and
/// add them as fallbacks. Until they arrive, emoji and some symbols draw as boxes.
async fn load_emoji_fonts(ctx: egui::Context) {
let fonts = [("NotoEmoji-Regular", 0.81), ("emoji-icon-font", 0.90)];
let fetched = futures::future::join_all(fonts.iter().map(|(name, _)| fetch(format!("fonts/{name}.ttf")))).await;
for ((name, scale), bytes) in fonts.into_iter().zip(fetched) {
match bytes {
Ok(bytes) => {
let tweak = egui::FontTweak { scale, ..Default::default() };
crate::theme::add_late_font(&ctx, name, egui::FontData::from_owned(bytes).tweak(tweak));
}
Err(e) => tracing::warn!("font {name}: {e}"),
}
}
ctx.request_repaint();
}
async fn fetch(url: String) -> Result<Vec<u8>, String> {
use wasm_bindgen_futures::JsFuture;
let window = web_sys::window().ok_or("no window")?;
let resp: web_sys::Response = JsFuture::from(window.fetch_with_str(&url))
.await
.map_err(|e| format!("{e:?}"))?
.dyn_into()
.map_err(|_| "not a response")?;
if !resp.ok() {
return Err(format!("HTTP {}", resp.status()));
}
let buf = JsFuture::from(resp.array_buffer().map_err(|e| format!("{e:?}"))?).await.map_err(|e| format!("{e:?}"))?;
Ok(js_sys::Uint8Array::new(&buf).to_vec())
}
fn has_flag(query: &str, flag: &str) -> bool {
query.trim_start_matches('?').split('&').any(|p| p.split('=').next() == Some(flag))
}
fn canvas() -> Option<web_sys::HtmlCanvasElement> {
web_sys::window()?.document()?.get_element_by_id("terminal")?.dyn_into().ok()
}
/// Show why the app couldn't start in place of the loading message.
fn fail(why: &str) {
tracing::error!("terminal failed to start: {why}");
if let Some(el) = web_sys::window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("status")) {
el.set_text_content(Some(&format!("Terminal couldn't start: {why}")));
}
}
/// Per-frame numbers from `TerminalApp::ui` (milliseconds).
pub struct FrameStats {
/// Smoothed CPU time inside `ui()`.
pub ui_ms: f64,
/// Smoothed time between frames.
pub interval_ms: f64,
/// eframe's CPU time for the whole previous frame (UI, tessellation and painting).
pub last_cpu_ms: f64,
pub frames: u64,
}
thread_local! {
static FIRST_FRAME: Cell<bool> = const { Cell::new(true) };
}
pub fn record_frame(s: &FrameStats) {
let Some(w) = web_sys::window() else { return };
let perf = match js_sys::Reflect::get(&w, &"terminalPerf".into()) {
Ok(v) if v.is_object() => v,
_ => {
let v: JsValue = js_sys::Object::new().into();
let _ = js_sys::Reflect::set(&w, &"terminalPerf".into(), &v);
v
}
};
let set = |k: &str, v: f64| {
let _ = js_sys::Reflect::set(&perf, &k.into(), &v.into());
};
if FIRST_FRAME.replace(false) {
let at = w.performance().map_or(0.0, |p| p.now());
tracing::info!("first frame built {at:.0} ms after navigation start");
set("firstFrameMs", at);
if let Some(el) = w.document().and_then(|d| d.get_element_by_id("status")) {
el.remove();
}
}
set("uiMs", s.ui_ms);
set("intervalMs", s.interval_ms);
set("cpuMs", s.last_cpu_ms);
set("frames", s.frames as f64);
}
fn init_tracing() {
let filter = tracing_subscriber::filter::Targets::new()
.with_default(Level::INFO)
.with_target("wgpu_core", Level::WARN)
.with_target("wgpu_hal", Level::WARN)
.with_target("naga", Level::WARN);
let fmt = tracing_subscriber::fmt::layer().with_writer(Console).without_time().with_ansi(false);
let _ = tracing_subscriber::registry().with(fmt).with(filter).try_init();
}
/// Sends each formatted log line to the browser console at its level.
struct Console;
struct Line {
level: Level,
buf: Vec<u8>,
}
impl std::io::Write for Line {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.buf.extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl Drop for Line {
fn drop(&mut self) {
let text = JsValue::from_str(String::from_utf8_lossy(&self.buf).trim_end());
match self.level {
Level::ERROR => web_sys::console::error_1(&text),
Level::WARN => web_sys::console::warn_1(&text),
_ => web_sys::console::log_1(&text),
}
}
}
impl<'a> MakeWriter<'a> for Console {
type Writer = Line;
fn make_writer(&'a self) -> Line {
Line { level: Level::INFO, buf: Vec::new() }
}
fn make_writer_for(&'a self, meta: &Metadata<'_>) -> Line {
Line { level: *meta.level(), buf: Vec::new() }
}
}
+80
View File
@@ -265,6 +265,35 @@ pub fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 {
Color32::from_rgba_unmultiplied(l(a.r(), b.r()), l(a.g(), b.g()), l(a.b(), b.b()), l(a.a(), b.a()))
}
/// A selection highlight that glides between list rows. Create it before the rows (it
/// keeps a place under them) and call [`Glider::on`] with the selected row.
pub struct Glider {
id: egui::Id,
slot: egui::layers::ShapeIdx,
/// Top of the list; positions are kept relative to it so scrolling does not lag.
origin: f32,
}
impl Glider {
pub fn new(ui: &Ui, id: egui::Id) -> Self {
Self { id, slot: ui.painter().add(Shape::Noop), origin: ui.cursor().top() }
}
pub fn on(&self, ui: &Ui, row: Rect) {
let ctx = ui.ctx();
// A list that was just opened shows its selection in place.
let frame = ctx.cumulative_frame_nr();
let seen = self.id.with("frame");
if ctx.data(|d| d.get_temp::<u64>(seen)).is_none_or(|f| f + 1 < frame) {
ctx.data_mut(|d| d.remove::<anim::Spring>(self.id));
}
ctx.data_mut(|d| d.insert_temp(seen, frame));
let y = anim::spring_with(ctx, self.id, row.top() - self.origin, 0.22, 0.86);
let r = row.translate(vec2(0.0, self.origin + y - row.top()));
ui.painter().set(self.slot, RectShape::filled(r, theme::CONTROL_RADIUS, theme::accent_wash()));
}
}
/// Animated hover wash for list rows.
pub fn hover_wash(ui: &Ui, rect: Rect, resp: &Response, selected: bool) {
let t = anim::spring_bool(ui.ctx(), resp.id.with("wash"), resp.hovered());
@@ -275,6 +304,57 @@ pub fn hover_wash(ui: &Ui, rect: Rect, resp: &Response, selected: bool) {
}
}
/// A small action pill: label, fill and label colour.
pub type Pill<'a> = (&'a str, Color32, Color32);
/// Buy / Sell / Close pills over the right end of a table row while the pointer is on it,
/// on a backdrop so they never overlap the row's text. Returns the index clicked.
pub fn row_pills(
ui: &mut Ui,
row: Rect,
id: impl std::hash::Hash + std::fmt::Debug,
pills: &[Pill<'_>],
tips: &[&str],
) -> Option<usize> {
if !ui.rect_contains_pointer(row) || pills.is_empty() {
return None;
}
let h = (row.height() - 6.0).clamp(14.0, 18.0);
let font = ui_font(10.5);
let galleys: Vec<_> =
pills.iter().map(|(l, _, c)| ui.painter().layout_no_wrap((*l).to_string(), font.clone(), *c)).collect();
let total: f32 = galleys.iter().map(|g| g.size().x + 14.0).sum::<f32>() + 4.0 * (pills.len() - 1) as f32;
let strip = Rect::from_min_max(
pos2(row.right() - total - 12.0, row.top() + 1.0),
pos2(row.right() - 1.0, row.bottom() - 1.0),
);
ui.painter().rect_filled(strip, theme::CONTROL_RADIUS, theme::card());
let mut x = strip.left() + 6.0;
let mut clicked = None;
let base = ui.id().with(id);
for (i, ((_, fill, _), g)) in pills.iter().zip(galleys).enumerate() {
let w = g.size().x + 14.0;
let r = Rect::from_min_size(pos2(x, row.center().y - h / 2.0), vec2(w, h));
x += w + 4.0;
let mut resp = ui.interact(r, base.with(i), Sense::click());
let fill = if resp.hovered() { *fill } else { fill.gamma_multiply(0.82) };
ui.painter().rect_filled(r, h / 2.0, fill);
ui.painter().galley(r.center() - g.size() / 2.0, g, Color32::WHITE);
if let Some(t) = tips.get(i) {
resp = resp.on_hover_text(*t);
}
if resp.clicked() {
clicked = Some(i);
}
}
clicked
}
/// The usual Buy / Sell pills.
pub fn buy_sell_pills() -> [Pill<'static>; 2] {
[("B", theme::up(), Color32::WHITE), ("S", theme::down(), Color32::WHITE)]
}
#[cfg(test)]
mod tests {
use super::*;
+158 -82
View File
@@ -1,22 +1,30 @@
//! Background worker. All network, SQLite, indicator and LOD-pyramid work runs on a
//! tokio runtime; the UI thread only sends [`Request`]s and drains [`Response`]s, so it
//! never blocks.
//!
//! In the browser (`wasm32`) the same requests run as tasks on the page's event loop
//! ([`tr_data::rt`]). There is no bar cache or Congress ingest there, and CPU work such as
//! indicators runs inline.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, mpsc};
use std::time::Duration;
use chrono::Datelike;
use tokio::sync::Mutex;
use tr_chart::gpu::{LineKind, LineRef};
use tr_chart::scene::{Overlay, SeriesGpu, line_ref};
use tr_chart::scene::{Overlay, SeriesGpu, line_ref, marker_ref};
use tr_congress::db::{Alert, PoliticianSummary};
use tr_congress::model::NewTradeAlert;
use tr_congress::performance::{self, Performance, TradeReturn};
use tr_congress::refresh::{self, HttpSource, NewTradeAlert, RefreshConfig};
#[cfg(not(target_arch = "wasm32"))]
use tr_congress::refresh::{self, HttpSource, RefreshConfig};
use tr_congress::{Db, Politician, Trade};
use tr_core::{Bar, Security, Series, Timeframe, indicators};
use tr_data::{BarCache, Provider, Quote, SearchHit, YieldCurves};
use tr_core::indicators::registry::{Input, Plot, Tone};
use tr_core::{Bar, Security, Series, Timeframe};
#[cfg(not(target_arch = "wasm32"))]
use tr_data::BarCache;
use tr_data::{Provider, Quote, SearchHit, YieldCurves, rt};
use crate::command::IndicatorSpec;
use crate::theme;
@@ -148,7 +156,8 @@ pub enum TradeRequest {
creds: tr_broker::Credentials,
dark: bool,
},
/// Which accounts can trade (brokerage support and connection access).
/// Which brokerage connections can trade (brokerage support and connection access): one
/// `/authorizations` request, however many accounts there are.
Capabilities {
creds: tr_broker::Credentials,
},
@@ -170,7 +179,17 @@ pub enum TradeResponse {
result: Result<(), String>,
},
Portal(Result<String, String>),
Capabilities(Result<std::collections::HashMap<String, tr_broker::trading::Capability>, String>),
/// Capabilities by connection (SnapTrade authorization) id.
Capabilities(Result<std::collections::HashMap<String, tr_broker::trading::Capability>, CapsError>),
}
/// Why trading access could not be read. A rate limit is reported apart from real errors so
/// the app can back off quietly instead of showing an error.
#[derive(Debug, Clone, PartialEq)]
pub struct CapsError {
pub message: String,
pub throttled: bool,
pub retry_after: Option<Duration>,
}
pub struct BuiltIndicator {
@@ -257,8 +276,11 @@ pub enum Response {
},
Politicians(Vec<PoliticianSummary>),
Alerts(Vec<Alert>),
// Only the desktop's Congress ingest raises these.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
NewAlerts(Vec<NewTradeAlert>),
Status(String),
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
CongressRefreshed(String),
Curves(Box<YieldCurves>),
SearchHits {
@@ -271,6 +293,7 @@ pub enum Response {
pub struct Worker {
tx: tokio::sync::mpsc::UnboundedSender<Request>,
pub rx: mpsc::Receiver<Response>,
#[cfg(not(target_arch = "wasm32"))]
_rt: tokio::runtime::Runtime,
}
@@ -282,6 +305,7 @@ struct Shared {
/// Authoritative bars per chart pane (history, paging and live ticks all merge here),
/// tagged with the pane's load generation.
series: std::sync::Mutex<SeriesMap>,
#[cfg(not(target_arch = "wasm32"))]
cache: std::sync::Mutex<Option<BarCache>>,
db: Mutex<Db>,
out: mpsc::Sender<Response>,
@@ -295,22 +319,35 @@ impl Shared {
}
}
/// Where saved files live. In the browser this is the key prefix in `localStorage` (see
/// [`crate::storage`]). The first call moves files saved under the app's old name, terminal-rs,
/// to the new one ([`crate::storage::migrate`]).
pub fn data_dir() -> PathBuf {
if let Some(d) = std::env::var_os("TR_DATA_DIR") {
return PathBuf::from(d);
}
let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
if cfg!(target_os = "macos") {
home.join("Library/Application Support/terminal-rs")
} else {
std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home.join(".local/share"))
.join("terminal-rs")
}
static DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
DIR.get_or_init(|| {
// Unit tests never read, write or move the real folder.
if cfg!(test) {
return std::env::temp_dir().join(format!("tr-test-data-{}", std::process::id()));
}
let parent = if cfg!(target_arch = "wasm32") {
PathBuf::new()
} else if let Some(d) = std::env::var_os("TR_DATA_DIR") {
return PathBuf::from(d);
} else {
let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
if cfg!(target_os = "macos") {
home.join("Library/Application Support")
} else {
std::env::var_os("XDG_DATA_HOME").map(PathBuf::from).unwrap_or_else(|| home.join(".local/share"))
}
};
crate::storage::migrate(&parent.join("terminal-rs"), &parent.join("terminal"))
})
.clone()
}
impl Worker {
#[cfg(not(target_arch = "wasm32"))]
pub fn spawn(ctx: egui::Context, provider: Provider) -> anyhow::Result<Self> {
let dir = data_dir();
std::fs::create_dir_all(&dir)?;
@@ -365,11 +402,28 @@ impl Worker {
Ok(Self { tx, rx, _rt: rt })
}
/// The browser worker: tasks on the page's event loop, no disk cache, an empty Congress store.
#[cfg(target_arch = "wasm32")]
pub fn spawn(ctx: egui::Context, provider: Provider) -> anyhow::Result<Self> {
let (tx, mut req_rx) = tokio::sync::mpsc::unbounded_channel::<Request>();
let (out, rx) = mpsc::channel();
let shared =
Arc::new(Shared { provider, series: Default::default(), db: Mutex::new(Db::open_in_memory()?), out, ctx });
rt::spawn(async move {
while let Some(req) = req_rx.recv().await {
let s = shared.clone();
rt::spawn(async move { handle(&s, req).await });
}
});
Ok(Self { tx, rx })
}
pub fn send(&self, req: Request) {
let _ = self.tx.send(req);
}
}
#[cfg(not(target_arch = "wasm32"))]
async fn congress_refresh(s: &Shared) {
s.send(Response::Status("Refreshing congressional disclosures (Senate eFD + House Clerk)…".into()));
let source = match HttpSource::new() {
@@ -388,61 +442,80 @@ async fn congress_refresh(s: &Shared) {
}
}
fn session_key(tf: Timeframe) -> impl Fn(i64) -> i64 {
move |ts| {
let d = chrono::DateTime::from_timestamp(ts, 0).unwrap_or_default();
match tf {
t if t.is_intraday() => ts.div_euclid(86_400),
Timeframe::D1 => (d.year() * 12 + d.month() as i32) as i64,
_ => d.year() as i64,
}
// TODO(web proxy): Senate eFD and the House Clerk don't allow cross-origin requests.
#[cfg(target_arch = "wasm32")]
async fn congress_refresh(s: &Shared) {
s.send(Response::Status(crate::congress_view::WEB_NOTE.into()));
}
/// Bars saved by an earlier session, to paint before the network answers.
#[cfg(not(target_arch = "wasm32"))]
fn cached_bars(s: &Shared, symbol: &str, tf: Timeframe, limit: usize) -> Option<Vec<Bar>> {
s.cache.lock().ok().and_then(|c| c.as_ref().and_then(|c| c.load(symbol, tf, limit).ok()))
}
#[cfg(not(target_arch = "wasm32"))]
fn cache_bars(s: &Shared, symbol: &str, tf: Timeframe, bars: &[Bar]) {
if let Ok(mut c) = s.cache.lock()
&& let Some(c) = c.as_mut()
{
let _ = c.store(symbol, tf, bars);
}
}
fn overlay(values: &[f64], color: egui::Color32, kind: LineKind) -> Overlay {
Overlay { line: line_ref(values), color: theme::rgba(color), kind, scale: 1.0 }
#[cfg(target_arch = "wasm32")]
fn cached_bars(_: &Shared, _: &str, _: Timeframe, _: usize) -> Option<Vec<Bar>> {
None
}
#[cfg(target_arch = "wasm32")]
fn cache_bars(_: &Shared, _: &str, _: Timeframe, _: &[Bar]) {}
fn overlay(values: &[f64], color: egui::Color32, kind: LineKind) -> Overlay {
let line = if kind == LineKind::Dots { marker_ref(values) } else { line_ref(values) };
Overlay { line, color: theme::rgba(color), kind, scale: 1.0 }
}
/// Compute every indicator on `bars`. The `i`-th indicator takes the `i`-th series colour;
/// its signal lines, outer bands and up/down lines take theme roles.
pub fn build_indicators(
bars: &[Bar],
tf: Timeframe,
specs: &[IndicatorSpec],
palette: &theme::Palette,
) -> Vec<BuiltIndicator> {
let closes: Vec<f64> = bars.iter().map(|b| b.close).collect();
let input = Input::new(bars, tf);
let colors = palette.series.len();
specs
.iter()
.enumerate()
.map(|(i, spec)| {
let c = palette.series[i % palette.series.len()];
let overlays = match *spec {
IndicatorSpec::Sma(n) => vec![(spec.label(), overlay(&indicators::sma(&closes, n), c, LineKind::Line))],
IndicatorSpec::Ema(n) => vec![(spec.label(), overlay(&indicators::ema(&closes, n), c, LineKind::Line))],
IndicatorSpec::Vwap => {
vec![(spec.label(), overlay(&indicators::vwap_by(bars, session_key(tf)), c, LineKind::Line))]
}
IndicatorSpec::Bollinger(n, k) => {
let b = indicators::bollinger(&closes, n, k);
let dim = c.gamma_multiply(0.7);
vec![
("BB upper".into(), overlay(&b.upper, dim, LineKind::Line)),
("BB basis".into(), overlay(&b.middle, c, LineKind::Line)),
("BB lower".into(), overlay(&b.lower, dim, LineKind::Line)),
]
}
IndicatorSpec::Rsi(n) => {
vec![(spec.label(), overlay(&indicators::rsi(&closes, n), palette.series[1], LineKind::Line))]
}
IndicatorSpec::Macd(f, sl, sg) => {
let m = indicators::macd(&closes, f, sl, sg);
vec![
("Hist".into(), overlay(&m.histogram, egui::Color32::WHITE, LineKind::Hist)),
("MACD".into(), overlay(&m.macd, palette.cyan, LineKind::Line)),
("Signal".into(), overlay(&m.signal, palette.amber, LineKind::Line)),
]
}
};
BuiltIndicator { spec: *spec, overlays }
let main = palette.series[i % colors];
let mut outputs = spec.compute(&input);
// Columns first, so lines and markers draw over them.
outputs.sort_by_key(|o| o.plot != Plot::Hist);
let overlays = outputs
.into_iter()
.map(|o| {
let color = match o.tone {
Tone::Main => main,
Tone::Signal if main == palette.amber => palette.cyan,
Tone::Signal => palette.amber,
Tone::Dim => main.gamma_multiply(0.6),
Tone::Up => palette.up,
Tone::Down => palette.down,
Tone::Series(k) => palette.series[(i + 1 + k as usize) % colors],
};
let kind = match o.plot {
Plot::Line => LineKind::Line,
Plot::Hist => LineKind::Hist,
Plot::Dots => LineKind::Dots,
Plot::Steps => LineKind::Steps,
};
(o.name.to_string(), overlay(&o.values, color, kind))
})
.collect();
BuiltIndicator { spec: spec.clone(), overlays }
})
.collect()
}
@@ -485,8 +558,7 @@ async fn handle(s: &Arc<Shared>, req: Request) {
return; // a newer load for this pane is already running
}
// Paint from the disk cache immediately, then refresh from the network.
let cached = s.cache.lock().ok().and_then(|c| c.as_ref().and_then(|c| c.load(&symbol, tf, limit).ok()));
if let Some(bars) = cached.filter(|b| !b.is_empty()) {
if let Some(bars) = cached_bars(s, &symbol, tf, limit).filter(|b| !b.is_empty()) {
let bars = Arc::new(bars);
let gpu = build_gpu(bars.clone()).await;
s.send(Response::History {
@@ -504,11 +576,7 @@ async fn handle(s: &Arc<Shared>, req: Request) {
}
match s.provider.history(&symbol, tf, None, limit).await {
Ok(bars) if !bars.is_empty() => {
if let Ok(mut c) = s.cache.lock()
&& let Some(c) = c.as_mut()
{
let _ = c.store(&symbol, tf, &bars);
}
cache_bars(s, &symbol, tf, &bars);
let exhausted = bars.len() < limit / 2;
let bars = Arc::new(bars);
if !store_series(s, pane, generation, bars.clone()) {
@@ -568,17 +636,15 @@ async fn handle(s: &Arc<Shared>, req: Request) {
}
}
Request::Indicators { pane, generation, bars, tf, specs, palette } => {
let built = tokio::task::spawn_blocking(move || build_indicators(&bars, tf, &specs, &palette))
.await
.unwrap_or_default();
let built = rt::blocking(move || build_indicators(&bars, tf, &specs, &palette)).await.unwrap_or_default();
s.send(Response::Indicators { pane, generation, built });
}
Request::Compare { pane, generation, symbol, tf, base } => {
let limit = base.len().max(100);
match s.provider.history(&symbol, tf, None, limit).await {
Ok(other) => {
let line = tokio::task::spawn_blocking(move || line_ref(&align_compare(&base, &other))).await;
if let Ok(line) = line {
let line = rt::blocking(move || line_ref(&align_compare(&base, &other))).await;
if let Some(line) = line {
s.send(Response::Compare { pane, generation, symbol, line });
}
}
@@ -727,8 +793,11 @@ async fn trade(t: TradeRequest) -> TradeResponse {
Err(e) => Err(e),
}),
TradeRequest::Capabilities { creds } => TradeResponse::Capabilities(match client(creds) {
Ok(c) => c.capabilities().await.map_err(err),
Err(e) => Err(e),
Ok(c) => c.connection_capabilities().await.map_err(|e| {
let throttle = tr_broker::snaptrade::throttle(&e);
CapsError { message: err(e), throttled: throttle.is_some(), retry_after: throttle.flatten() }
}),
Err(message) => Err(CapsError { message, throttled: false, retry_after: None }),
}),
}
}
@@ -767,8 +836,8 @@ fn current_series(s: &Shared, pane: PaneId, generation: u64) -> Option<Arc<Vec<B
/// four updates a second. Stops when the pane reloads, closes or the feed ends.
fn start_live(s: Arc<Shared>, pane: PaneId, generation: u64, symbol: String, tf: Timeframe) {
let Some(mut feed) = s.provider.live(&symbol, tf) else { return };
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_millis(250));
rt::spawn(async move {
let mut tick = rt::interval(Duration::from_millis(250));
let mut dirty = false;
loop {
tokio::select! {
@@ -810,7 +879,7 @@ fn start_live(s: Arc<Shared>, pane: PaneId, generation: u64, symbol: String, tf:
}
async fn build_gpu(bars: Arc<Vec<Bar>>) -> SeriesGpu {
tokio::task::spawn_blocking(move || SeriesGpu::build(&bars)).await.expect("pyramid build task")
rt::blocking(move || SeriesGpu::build(&bars)).await.expect("pyramid build task")
}
fn resolve_politician(db: &Db, query: &str) -> Option<Politician> {
@@ -893,15 +962,22 @@ mod tests {
#[test]
fn indicators_build_expected_overlays() {
let bars = tr_core::sample::generate("X", Timeframe::D1, 300, 1_790_000_000);
let built = build_indicators(
&bars,
Timeframe::D1,
&[IndicatorSpec::Bollinger(20, 2.0), IndicatorSpec::Macd(12, 26, 9), IndicatorSpec::Vwap],
&crate::themes::terminal_light().palette(theme::DEFAULT_ACCENT),
);
let palette = crate::themes::terminal_light().palette(theme::DEFAULT_ACCENT);
let specs: Vec<IndicatorSpec> =
["BB", "MACD", "VWAP", "PSAR", "PIVOT"].iter().map(|c| IndicatorSpec::new(c, &[]).unwrap()).collect();
let built = build_indicators(&bars, Timeframe::D1, &specs, &palette);
assert_eq!(built[0].overlays.len(), 3);
// The histogram draws first, under the MACD and signal lines.
assert_eq!(built[1].overlays[0].1.kind, LineKind::Hist);
assert_ne!(built[1].overlays[2].1.color, built[1].overlays[1].1.color, "signal stands out from MACD");
assert_eq!(built[2].overlays.len(), 1);
assert!(built[3].overlays.iter().all(|(_, o)| o.kind == LineKind::Dots));
assert_eq!(built[3].overlays[0].1.color, theme::rgba(palette.up));
// Every pair of bars holding a marker keeps one when zoomed out.
let up = &built[3].overlays[0].1.line.pyramid;
let pairs = up.data[..up.offsets[1]].chunks(2).filter(|c| c.iter().any(|p| p[1].is_finite())).count();
assert_eq!(up.data[up.offsets[1]..up.offsets[2]].iter().filter(|p| p[1].is_finite()).count(), pairs);
assert!(built[4].overlays.iter().all(|(_, o)| o.kind == LineKind::Steps));
}
#[test]
+74
View File
@@ -0,0 +1,74 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
<title>Terminal</title>
<link rel="icon" href="data:," />
<script>
// The WebGPU build when the browser has WebGPU, else the larger WebGL2 one (no charts).
// Start downloading now; the module below cancels this if there is no WebGPU adapter.
window.terminalLoad = { build: 'gpu' in navigator ? 'terminal' : 'terminal-webgl', abort: new AbortController() };
// Each build's JS and wasm sit in a folder named after a hash of its files, so they can be
// cached for good; scripts/build-web.sh fills in these paths.
terminalLoad.paths = { terminal: './{terminal}', 'terminal-webgl': './{terminal-webgl}' };
terminalLoad.wasm = fetch(`${terminalLoad.paths[terminalLoad.build]}_bg.wasm`, { signal: terminalLoad.abort.signal });
document.head.append(Object.assign(document.createElement('link'), { rel: 'modulepreload', href: `${terminalLoad.paths[terminalLoad.build]}.js` }));
</script>
<style>
html,
body {
margin: 0;
height: 100%;
overflow: hidden;
background: #1e1e20;
}
@media (prefers-color-scheme: light) {
html,
body {
background: #f5f5f7;
}
}
canvas {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
display: block;
outline: none;
}
#status {
position: fixed;
inset: 0;
display: grid;
place-items: center;
margin: 0;
font: 13px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: #8e8e93;
pointer-events: none;
}
</style>
</head>
<body>
<canvas id="terminal"></canvas>
<p id="status">Loading Terminal…</p>
<script type="module">
const load = window.terminalLoad;
if (load.build === 'terminal' && !(await navigator.gpu.requestAdapter().catch(() => null))) {
load.wasm.catch(() => {}); // the abort below rejects it
load.abort.abort();
load.build = 'terminal-webgl';
load.wasm = fetch(`${load.paths[load.build]}_bg.wasm`);
}
window.terminalBuild = load.build;
try {
const { default: init } = await import(`${load.paths[load.build]}.js`);
await init({ module_or_path: load.wasm }); // runs the app, which removes #status after its first frame
} catch (e) {
console.error(e);
document.getElementById('status').textContent = `Terminal couldn't start: ${e}`;
}
</script>
</body>
</html>
+7
View File
@@ -20,6 +20,13 @@ base64 = "0.23"
[target.'cfg(target_os = "macos")'.dependencies]
security-framework = "3"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tokio.workspace = true
# The browser build never calls SnapTrade (see `SnapTrade::with_base`); the client only compiles.
[target.'cfg(target_arch = "wasm32")'.dependencies]
tokio = { version = "1", default-features = false, features = ["sync", "time"] }
[dev-dependencies]
tokio = { workspace = true, features = ["io-util", "net"] }
reqwest.workspace = true
+586
View File
@@ -0,0 +1,586 @@
//! The demo account: paper trading with virtual cash, kept entirely on this machine.
//!
//! A [`DemoBook`] holds cash, positions, the order history and fills. Market orders fill at
//! the terminal's live quote; limit, stop and stop-limit orders rest until a quote crosses
//! their price ([`DemoBook::on_price`]). Buys need buying power (cash not already set aside
//! for resting buys) and sells need shares not already offered by resting sells: the demo
//! account never borrows or sells short. The book is saved as JSON in the app data folder
//! after every change.
//!
//! Simplifications, stated so nobody mistakes the demo for a broker: no commissions, no
//! partial fills, no market hours (orders fill at the last quote whenever it updates), and
//! Day orders expire at the next 4 p.m. New York close, taken as 20:00 UTC.
use std::path::Path;
use anyhow::{Context, Result};
use chrono::{DateTime, Duration, NaiveTime, Utc};
use serde::{Deserialize, Serialize};
use crate::model::{Account, Activity, InstrumentKind, Order, Portfolio, Position};
use crate::trading::{Amount, OrderRequest, OrderType, Side, TimeInForce, clean_symbol, fmt_units};
/// The demo account's id (never a SnapTrade id, which are UUIDs).
pub const ACCOUNT_ID: &str = "demo-paper";
/// Virtual cash a new or reset demo account starts with.
pub const STARTING_CASH: f64 = 100_000.0;
/// Orders and fills kept in the history (oldest dropped first).
pub const HISTORY: usize = 500;
const FILE_VERSION: u32 = 1;
/// Slack for float comparisons on money and share counts.
const EPS: f64 = 1e-6;
/// Statuses the demo uses (the portfolio model's vocabulary).
const OPEN: &str = "PENDING";
const TRIGGERED: &str = "TRIGGERED";
const FILLED: &str = "EXECUTED";
const CANCELED: &str = "CANCELED";
const REJECTED: &str = "REJECTED";
const EXPIRED: &str = "EXPIRED";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct DemoBook {
pub version: u32,
pub starting_cash: f64,
pub cash: f64,
/// Profit or loss realised by sells since the last reset.
pub realized: f64,
pub positions: Vec<Position>,
/// Newest first.
pub orders: Vec<Order>,
/// Fills, newest first.
pub activities: Vec<Activity>,
pub created: Option<DateTime<Utc>>,
pub updated: Option<DateTime<Utc>>,
seq: u64,
}
impl Default for DemoBook {
fn default() -> Self {
Self {
version: FILE_VERSION,
starting_cash: STARTING_CASH,
cash: STARTING_CASH,
realized: 0.0,
positions: Vec::new(),
orders: Vec::new(),
activities: Vec::new(),
created: None,
updated: None,
seq: 0,
}
}
}
/// Something that happened to a resting order.
#[derive(Debug, Clone, PartialEq)]
pub enum Event {
Filled(Order),
/// Could not fill when its price was reached (no longer enough cash or shares).
Rejected(Order, String),
Expired(Order),
}
impl Event {
pub fn order(&self) -> &Order {
match self {
Event::Filled(o) | Event::Rejected(o, _) | Event::Expired(o) => o,
}
}
/// A line for a toast: `Bought 10 AAPL at $187.40`.
pub fn message(&self) -> String {
match self {
Event::Filled(o) => fill_message(o),
Event::Rejected(o, why) => format!("{} rejected: {why}", order_line(o)),
Event::Expired(o) => format!("{} expired", order_line(o)),
}
}
}
/// What an order would cost or raise, for the ticket.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Estimate {
pub units: f64,
/// The price used: the quote for market orders, the order's own price otherwise.
pub price: f64,
pub value: f64,
pub buying_power: f64,
/// Shares held that are not already offered by resting sells.
pub available: f64,
}
/// `Bought 10 AAPL at $187.40` / `Sold 2.5 MSFT at $410.00`.
pub fn fill_message(o: &Order) -> String {
let units = fmt_units(o.filled.or(o.quantity).unwrap_or(0.0));
let px = o.execution_price.unwrap_or(0.0);
let verb = if o.is_buy() { "Bought" } else { "Sold" };
format!("{verb} {units} {} at ${px:.2}", o.symbol)
}
/// `Buy 10 AAPL limit 180.00`.
pub fn order_line(o: &Order) -> String {
let side = if o.is_buy() { "Buy" } else { "Sell" };
let mut s = format!("{side} {} {}", fmt_units(o.quantity.unwrap_or(0.0)), o.symbol);
match (o.stop_price, o.limit_price) {
(Some(st), Some(l)) => s += &format!(" stop {st:.2} limit {l:.2}"),
(Some(st), None) => s += &format!(" stop {st:.2}"),
(None, Some(l)) => s += &format!(" limit {l:.2}"),
(None, None) => s += " at market",
}
s
}
fn positive(x: f64) -> bool {
x.is_finite() && x > 0.0
}
fn same(a: &str, b: &str) -> bool {
a.eq_ignore_ascii_case(b)
}
/// When a Day order placed at `placed` expires: the first 20:00 UTC after it.
pub fn day_expiry(placed: DateTime<Utc>) -> DateTime<Utc> {
let close = NaiveTime::from_hms_opt(20, 0, 0).expect("valid time");
let today = placed.date_naive().and_time(close).and_utc();
if placed < today { today } else { today + Duration::days(1) }
}
impl DemoBook {
pub fn new(now: DateTime<Utc>) -> Self {
Self { created: Some(now), updated: Some(now), ..Default::default() }
}
/// Load from `path`. A missing file is a fresh account; an unreadable one is an error so
/// the caller can keep it aside instead of overwriting the user's history.
pub fn load(path: &Path, now: DateTime<Utc>) -> Result<Self> {
match std::fs::read(path) {
Ok(bytes) => Self::from_json(&bytes),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::new(now)),
Err(e) => Err(e).context("reading the demo account"),
}
}
/// Parse a saved account, rejecting damaged files.
pub fn from_json(bytes: &[u8]) -> Result<Self> {
let mut b: DemoBook = serde_json::from_slice(bytes).context("the demo account file is damaged")?;
b.version = FILE_VERSION;
if !b.cash.is_finite() || !b.starting_cash.is_finite() {
anyhow::bail!("the demo account file has invalid balances");
}
Ok(b)
}
pub fn to_json(&self) -> Result<Vec<u8>> {
Ok(serde_json::to_vec_pretty(self)?)
}
/// Write atomically (temporary file, then rename).
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, self.to_json()?)?;
std::fs::rename(&tmp, path).context("saving the demo account")
}
/// Start over with `cash` (the default is [`STARTING_CASH`]): positions, orders and
/// history are cleared.
pub fn reset(&mut self, cash: f64, now: DateTime<Utc>) {
let cash = if positive(cash) { cash } else { STARTING_CASH };
*self = Self { starting_cash: cash, cash, ..Self::new(now) };
}
pub fn position(&self, symbol: &str) -> Option<&Position> {
self.positions.iter().find(|p| same(&p.symbol, symbol))
}
pub fn held(&self, symbol: &str) -> f64 {
self.position(symbol).map_or(0.0, |p| p.units)
}
/// Cash set aside for resting buy orders, at their limit or stop price.
pub fn reserved(&self) -> f64 {
self.orders
.iter()
.filter(|o| o.is_open() && o.is_buy())
.map(|o| o.quantity.unwrap_or(0.0) * o.limit_price.or(o.stop_price).unwrap_or(0.0))
.sum()
}
/// Cash available for new buys.
pub fn buying_power(&self) -> f64 {
(self.cash - self.reserved()).max(0.0)
}
/// Shares of `symbol` that can still be sold (held minus resting sells).
pub fn available(&self, symbol: &str) -> f64 {
let offered: f64 = self
.orders
.iter()
.filter(|o| o.is_open() && !o.is_buy() && same(&o.symbol, symbol))
.map(|o| o.quantity.unwrap_or(0.0))
.sum();
(self.held(symbol) - offered).max(0.0)
}
/// Market value of the positions at their last prices.
pub fn invested(&self) -> f64 {
self.positions.iter().filter_map(Position::market_value).sum()
}
pub fn equity(&self) -> f64 {
self.cash + self.invested()
}
/// The account as the portfolio panes show it.
pub fn account(&self) -> Account {
Account {
id: ACCOUNT_ID.into(),
name: "Demo".into(),
number: "DEMO".into(),
institution: "Paper trading".into(),
currency: "USD".into(),
total: Some(self.equity()),
cash: Some(self.cash),
buying_power: Some(self.buying_power()),
kind: "Paper".into(),
status: "open".into(),
is_paper: true,
synced: self.updated,
connection_id: String::new(),
}
}
pub fn portfolio(&self) -> Portfolio {
Portfolio {
accounts: vec![self.account()],
positions: self.positions.clone(),
orders: self.orders.clone(),
activities: self.activities.clone(),
connections: Vec::new(),
fetched: self.updated,
warnings: Vec::new(),
simulated: false,
demo: true,
}
}
/// Units and value of `req` at `quote`, or why it cannot be placed.
pub fn estimate(&self, req: &OrderRequest, quote: Option<f64>) -> Result<Estimate, String> {
req.validate()?;
if req.account_id != ACCOUNT_ID {
return Err("That order is not for the demo account.".into());
}
let symbol = clean_symbol(&req.symbol).ok_or("Enter a symbol.")?;
let quote = quote.filter(|q| positive(*q));
let price = req
.reference_price(quote)
.ok_or_else(|| format!("Waiting for a price for {symbol}; try again in a moment or use a limit order."))?;
let units = match req.amount {
Amount::Units(u) => u,
// Rounded down, so a dollar amount never costs more than it says.
Amount::Notional(n) => (n / price * 1e6).floor() / 1e6,
};
Ok(Estimate {
units,
price,
value: units * price,
buying_power: self.buying_power(),
available: self.available(&symbol),
})
}
/// Place an order. Market orders (and limits that are already marketable) fill at once at
/// `quote`; the rest rest as `PENDING`. Returns the order as it stands afterwards.
pub fn submit(&mut self, req: &OrderRequest, quote: Option<f64>, now: DateTime<Utc>) -> Result<Order, String> {
let est = self.estimate(req, quote)?;
let symbol = clean_symbol(&req.symbol).expect("validated");
if !positive(est.units) {
return Err("Quantity must be greater than zero.".into());
}
match req.side {
Side::Buy if est.value > est.buying_power + EPS => {
return Err(format!(
"Not enough buying power: this costs about ${:.2} and you have ${:.2}.",
est.value, est.buying_power
));
}
Side::Sell if est.units > est.available + EPS => {
let held = self.held(&symbol);
return Err(if held <= EPS {
format!("You don't hold any {symbol}. The demo account can't sell short.")
} else {
format!(
"You can sell up to {} {symbol} ({} held{}).",
fmt_units(round_units(est.available)),
fmt_units(round_units(held)),
if est.available + EPS < held { ", the rest is in open sell orders" } else { "" }
)
});
}
_ => {}
}
self.seq += 1;
let order = Order {
account_id: ACCOUNT_ID.into(),
id: format!("demo-{}", self.seq),
symbol: symbol.clone(),
description: String::new(),
kind: InstrumentKind::Stock,
action: req.side.api().into(),
status: OPEN.into(),
order_type: req.order_type.api().into(),
time_in_force: req.time_in_force.api().into(),
quantity: Some(round_units(est.units)),
filled: Some(0.0),
limit_price: req.limit.filter(|_| req.order_type.needs_limit()),
stop_price: req.stop.filter(|_| req.order_type.needs_stop()),
execution_price: None,
placed: Some(now),
updated: Some(now),
underlying: None,
};
let id = order.id.clone();
self.orders.insert(0, order);
if req.order_type == OrderType::Market {
let px = quote.filter(|q| positive(*q)).expect("estimate needs a quote for market orders");
if let Err(e) = self.fill(0, px, now) {
self.orders.remove(0);
return Err(e);
}
} else if let Some(px) = quote.filter(|q| positive(*q)) {
// A limit or stop that is already through the market behaves as it would at a broker.
self.evaluate(0, px, now);
}
// Immediate-or-cancel and fill-or-kill never rest.
let i = self.index(&id).expect("just inserted");
if self.orders[i].is_open() && matches!(req.time_in_force, TimeInForce::Ioc | TimeInForce::Fok) {
self.orders[i].status = CANCELED.into();
}
// Copy the result first: trimming a full history may drop this (finished) order.
let placed = self.orders[i].clone();
self.trim();
self.updated = Some(now);
Ok(placed)
}
fn index(&self, id: &str) -> Option<usize> {
self.orders.iter().position(|o| o.id == id)
}
/// Cancel a resting order.
pub fn cancel(&mut self, id: &str, now: DateTime<Utc>) -> Result<Order, String> {
let i = self.index(id).ok_or("Order not found.")?;
let o = &mut self.orders[i];
if !o.is_open() {
return Err(format!("The order is {} and cannot be cancelled.", o.status.to_ascii_lowercase()));
}
o.status = CANCELED.into();
o.updated = Some(now);
self.updated = Some(now);
Ok(o.clone())
}
/// A market order that sells the whole position (shares not already in open sells).
pub fn close_request(&self, symbol: &str) -> Option<OrderRequest> {
let units = round_units(self.available(symbol));
positive(units).then(|| OrderRequest {
account_id: ACCOUNT_ID.into(),
symbol: symbol.to_ascii_uppercase(),
side: Side::Sell,
order_type: OrderType::Market,
time_in_force: TimeInForce::Day,
amount: Amount::Units(units),
limit: None,
stop: None,
})
}
/// A new price for `symbol`: marks the position and fills or triggers resting orders it
/// crosses, oldest first. Day orders past their close expire first, so a quote that
/// arrives late (after sleep, say) never fills an order that should have expired.
pub fn on_price(&mut self, symbol: &str, price: f64, now: DateTime<Utc>) -> Vec<Event> {
if !positive(price) {
return Vec::new();
}
for p in self.positions.iter_mut().filter(|p| same(&p.symbol, symbol)) {
p.price = Some(price);
}
let mut events = self.expire(now);
let ids: Vec<String> =
self.orders.iter().rev().filter(|o| o.is_open() && same(&o.symbol, symbol)).map(|o| o.id.clone()).collect();
for id in ids {
let Some(i) = self.index(&id) else { continue };
if let Some(e) = self.evaluate(i, price, now) {
events.push(e);
}
}
if !events.is_empty() {
self.trim();
self.updated = Some(now);
}
events
}
/// Expire Day orders whose session has closed.
pub fn expire(&mut self, now: DateTime<Utc>) -> Vec<Event> {
let mut events = Vec::new();
for o in self.orders.iter_mut().filter(|o| o.is_open() && o.time_in_force == TimeInForce::Day.api()) {
if o.placed.is_some_and(|t| now >= day_expiry(t)) {
o.status = EXPIRED.into();
o.updated = Some(now);
events.push(Event::Expired(o.clone()));
}
}
if !events.is_empty() {
self.updated = Some(now);
}
events
}
/// Symbols that need quotes: positions to mark and open orders to watch.
pub fn watched(&self) -> Vec<String> {
let mut v: Vec<String> = self
.positions
.iter()
.map(|p| p.symbol.clone())
.chain(self.orders.iter().filter(|o| o.is_open()).map(|o| o.symbol.clone()))
.collect();
v.sort();
v.dedup();
v
}
/// Check resting order `i` against `price`; fill, trigger or leave it.
fn evaluate(&mut self, i: usize, price: f64, now: DateTime<Utc>) -> Option<Event> {
let o = &self.orders[i];
let buy = o.is_buy();
let kind = o.order_type.clone();
let crosses = |level: f64| if buy { price <= level + EPS } else { price >= level - EPS };
let stop_hit = |stop: f64| if buy { price >= stop - EPS } else { price <= stop + EPS };
let triggered = o.status == TRIGGERED;
let fill_now = match kind.as_str() {
"Limit" => o.limit_price.is_some_and(crosses),
"Stop" => o.stop_price.is_some_and(stop_hit),
"StopLimit" => {
if !triggered && o.stop_price.is_some_and(stop_hit) {
self.orders[i].status = TRIGGERED.into();
self.orders[i].updated = Some(now);
}
self.orders[i].status == TRIGGERED && self.orders[i].limit_price.is_some_and(crosses)
}
_ => true,
};
if !fill_now {
return None;
}
Some(match self.fill(i, price, now) {
Ok(()) => Event::Filled(self.orders[i].clone()),
Err(why) => {
let o = &mut self.orders[i];
o.status = REJECTED.into();
o.updated = Some(now);
Event::Rejected(o.clone(), why)
}
})
}
/// Execute order `i` in full at `px`: move cash, update the position, record the fill.
fn fill(&mut self, i: usize, px: f64, now: DateTime<Utc>) -> Result<(), String> {
let (buy, units, symbol) = {
let o = &self.orders[i];
(o.is_buy(), o.quantity.unwrap_or(0.0), o.symbol.clone())
};
let value = units * px;
if buy {
// This order's own reservation is part of the cash it may use.
let own = self.orders[i].limit_price.or(self.orders[i].stop_price).map_or(0.0, |l| l * units);
let available = self.cash - (self.reserved() - own);
if value > available + EPS {
return Err(format!("not enough cash (needs ${value:.2}, ${:.2} available)", available.max(0.0)));
}
self.cash -= value;
match self.positions.iter_mut().find(|p| same(&p.symbol, &symbol)) {
Some(p) => {
let total = p.units + units;
p.cost_basis = Some((p.cost_basis.unwrap_or(px) * p.units + value) / total);
p.units = total;
p.price = Some(px);
}
None => self.positions.push(Position {
account_id: ACCOUNT_ID.into(),
symbol: symbol.clone(),
description: String::new(),
kind: InstrumentKind::Stock,
units,
price: Some(px),
cost_basis: Some(px),
multiplier: 1.0,
currency: "USD".into(),
cash_equivalent: false,
underlying: None,
}),
}
} else {
let Some(pi) = self.positions.iter().position(|p| same(&p.symbol, &symbol)) else {
return Err(format!("no {symbol} shares left to sell"));
};
let p = &mut self.positions[pi];
if units > p.units + EPS {
return Err(format!("only {} {symbol} left to sell", fmt_units(round_units(p.units))));
}
self.realized += (px - p.cost_basis.unwrap_or(px)) * units;
self.cash += value;
p.units -= units;
p.price = Some(px);
if p.units.abs() < EPS {
self.positions.remove(pi);
}
}
let o = &mut self.orders[i];
o.status = FILLED.into();
o.filled = Some(units);
o.execution_price = Some(px);
o.updated = Some(now);
self.seq += 1;
self.activities.insert(
0,
Activity {
account_id: ACCOUNT_ID.into(),
id: format!("demo-fill-{}", self.seq),
kind: o.action.clone(),
symbol: Some(symbol),
description: format!("Demo fill: {}", order_line(o)),
units: Some(if buy { units } else { -units }),
price: Some(px),
amount: Some(if buy { -value } else { value }),
fee: Some(0.0),
currency: "USD".into(),
trade_date: Some(now),
settlement_date: Some(now),
},
);
Ok(())
}
/// Keep the history bounded, dropping the oldest finished orders (never an open one).
fn trim(&mut self) {
while self.orders.len() > HISTORY {
match self.orders.iter().rposition(|o| !o.is_open()) {
Some(i) => {
self.orders.remove(i);
}
None => break,
}
}
self.activities.truncate(HISTORY);
}
}
/// Share counts are kept to 6 decimals so notional orders do not accumulate float dust.
fn round_units(u: f64) -> f64 {
(u * 1e6).round() / 1e6
}
+2 -1
View File
@@ -1,12 +1,13 @@
//! Brokerage integration through [SnapTrade](https://snaptrade.com): a signed API client, a
//! normalised portfolio model (accounts, balances, positions, orders, activity), secure
//! credential storage and an offline simulated portfolio.
//! credential storage, an offline simulated portfolio and a local paper-trading [`demo`] account.
//!
//! Order entry is in [`trading`]: every order is previewed with SnapTrade's order-impact check
//! before it can be placed, and the terminal keeps trading off unless the user enables it.
//! Besides orders and registering a user, the only write is removing a brokerage connection
//! when the user explicitly asks to disconnect it.
pub mod demo;
pub mod model;
pub mod sample;
pub mod secrets;
+4 -1
View File
@@ -46,7 +46,7 @@ pub struct Connection {
pub disabled: bool,
pub created: Option<DateTime<Utc>>,
pub updated: Option<DateTime<Utc>>,
/// Whether the connection allows trading (terminal-rs only trades when the user enables it).
/// Whether the connection allows trading (Terminal only trades when the user enables it).
pub trading: bool,
}
@@ -331,6 +331,9 @@ pub struct Portfolio {
pub warnings: Vec<String>,
/// Offline sample data, not a real brokerage.
pub simulated: bool,
/// The local paper-trading account ([`crate::demo`]), not a brokerage.
#[serde(default)]
pub demo: bool,
}
/// Totals for a set of accounts, in the first account's currency.
+1
View File
@@ -215,6 +215,7 @@ pub fn portfolio() -> Portfolio {
fetched: Some(now),
warnings: Vec::new(),
simulated: true,
demo: false,
}
}
+83 -20
View File
@@ -2,9 +2,12 @@
//!
//! The partner keys (`clientId`, `consumerKey`) and the per-user `userId`/`userSecret` are
//! stored together as one small JSON document: in the macOS Keychain (generic password,
//! service `terminal-rs`, account `snaptrade`), or elsewhere in an owner-only (0600) file in
//! service `terminal`, account `snaptrade`), or elsewhere in an owner-only (0600) file in
//! the app's data directory. `SNAPTRADE_*` environment variables override stored values
//! field by field. Nothing is ever written inside the repository.
//!
//! Before the app was renamed from terminal-rs, the Keychain service was `terminal-rs`; the
//! first load moves credentials from there.
use std::fmt;
use std::path::{Path, PathBuf};
@@ -12,7 +15,9 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
pub const KEYCHAIN_SERVICE: &str = "terminal-rs";
pub const KEYCHAIN_SERVICE: &str = "terminal";
/// The service credentials were saved under before the rename.
pub const LEGACY_KEYCHAIN_SERVICE: &str = "terminal-rs";
pub const KEYCHAIN_ACCOUNT: &str = "snaptrade";
/// Environment variables that override stored credentials.
@@ -102,8 +107,8 @@ pub fn mask(secret: &str) -> String {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Backend {
/// macOS Keychain generic password.
Keychain,
/// macOS Keychain generic password under `service`, moved there from `legacy` on first load.
Keychain { service: String, legacy: Option<String> },
/// Owner-only JSON file.
File(PathBuf),
}
@@ -119,10 +124,19 @@ impl SecretStore {
pub fn platform(data_dir: &Path) -> Self {
let file = Backend::File(data_dir.join("secrets").join("snaptrade.json"));
let force_file = std::env::var("TR_SECRET_STORE").is_ok_and(|v| v.eq_ignore_ascii_case("file"));
let backend = if cfg!(target_os = "macos") && !force_file { Backend::Keychain } else { file };
let backend = if cfg!(target_os = "macos") && !force_file {
Backend::Keychain { service: KEYCHAIN_SERVICE.into(), legacy: Some(LEGACY_KEYCHAIN_SERVICE.into()) }
} else {
file
};
Self { backend }
}
/// The Keychain under `service`, taking over an item left under `legacy`.
pub fn keychain(service: &str, legacy: Option<&str>) -> Self {
Self { backend: Backend::Keychain { service: service.into(), legacy: legacy.map(Into::into) } }
}
pub fn file(path: impl Into<PathBuf>) -> Self {
Self { backend: Backend::File(path.into()) }
}
@@ -130,14 +144,14 @@ impl SecretStore {
/// Where credentials live, for the settings screen.
pub fn describe(&self) -> String {
match &self.backend {
Backend::Keychain => format!("macOS Keychain ({KEYCHAIN_SERVICE} / {KEYCHAIN_ACCOUNT})"),
Backend::Keychain { service, .. } => format!("macOS Keychain ({service} / {KEYCHAIN_ACCOUNT})"),
Backend::File(p) => format!("{} (owner-only)", p.display()),
}
}
pub fn load(&self) -> Result<Option<Credentials>> {
let text = match &self.backend {
Backend::Keychain => keychain::get()?,
Backend::Keychain { service, legacy } => load_keychain(service, legacy.as_deref())?,
Backend::File(p) => match std::fs::read_to_string(p) {
Ok(t) => Some(t),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
@@ -155,7 +169,7 @@ impl SecretStore {
pub fn save(&self, creds: &Credentials) -> Result<()> {
let json = serde_json::to_string(&creds.trimmed())?;
match &self.backend {
Backend::Keychain => keychain::set(&json),
Backend::Keychain { service, .. } => keychain::set(service, &json),
Backend::File(p) => write_private(p, &json),
}
}
@@ -163,7 +177,11 @@ impl SecretStore {
/// Remove stored credentials (environment variables are unaffected).
pub fn clear(&self) -> Result<()> {
match &self.backend {
Backend::Keychain => keychain::delete(),
Backend::Keychain { service, legacy } => {
keychain::delete(service)?;
// The old item too, or the credentials would come back from it on the next load.
legacy.as_deref().map_or(Ok(()), keychain::delete)
}
Backend::File(p) => match std::fs::remove_file(p) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
_ => Ok(()),
@@ -172,6 +190,24 @@ impl SecretStore {
}
}
/// Reads the Keychain item under `service`. If there is none, the item under `legacy` moves
/// there: it is deleted only after the copy is written, so a failed move leaves it in place.
fn load_keychain(service: &str, legacy: Option<&str>) -> Result<Option<String>> {
if let Some(text) = keychain::get(service)? {
return Ok(Some(text));
}
let Some(legacy) = legacy else { return Ok(None) };
let Some(text) = keychain::get(legacy)? else { return Ok(None) };
match keychain::set(service, &text) {
Ok(()) => match keychain::delete(legacy) {
Ok(()) => tracing::info!("moved SnapTrade credentials from Keychain service {legacy} to {service}"),
Err(e) => tracing::warn!("removing SnapTrade credentials from Keychain service {legacy}: {e:#}"),
},
Err(e) => tracing::warn!("moving SnapTrade credentials to Keychain service {service}: {e:#}"),
}
Ok(Some(text))
}
/// Write `text` to `path` readable only by the owner (0700 directory, 0600 file), replacing it
/// atomically.
fn write_private(path: &Path, text: &str) -> Result<()> {
@@ -204,26 +240,26 @@ mod keychain {
use anyhow::{Context, Result};
use security_framework::passwords::{delete_generic_password, get_generic_password, set_generic_password};
use super::{KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE};
use super::KEYCHAIN_ACCOUNT;
/// `errSecItemNotFound`.
const NOT_FOUND: i32 = -25300;
pub fn get() -> Result<Option<String>> {
match get_generic_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) {
pub fn get(service: &str) -> Result<Option<String>> {
match get_generic_password(service, KEYCHAIN_ACCOUNT) {
Ok(bytes) => Ok(Some(String::from_utf8(bytes).context("Keychain item is not UTF-8")?)),
Err(e) if e.code() == NOT_FOUND => Ok(None),
Err(e) => Err(anyhow::anyhow!("reading the Keychain: {e}")),
}
}
pub fn set(value: &str) -> Result<()> {
set_generic_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, value.as_bytes())
pub fn set(service: &str, value: &str) -> Result<()> {
set_generic_password(service, KEYCHAIN_ACCOUNT, value.as_bytes())
.map_err(|e| anyhow::anyhow!("writing the Keychain: {e}"))
}
pub fn delete() -> Result<()> {
match delete_generic_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) {
pub fn delete(service: &str) -> Result<()> {
match delete_generic_password(service, KEYCHAIN_ACCOUNT) {
Err(e) if e.code() != NOT_FOUND => Err(anyhow::anyhow!("deleting from the Keychain: {e}")),
_ => Ok(()),
}
@@ -234,13 +270,13 @@ mod keychain {
mod keychain {
use anyhow::{Result, bail};
pub fn get() -> Result<Option<String>> {
pub fn get(_: &str) -> Result<Option<String>> {
bail!("the Keychain is only available on macOS")
}
pub fn set(_: &str) -> Result<()> {
pub fn set(_: &str, _: &str) -> Result<()> {
bail!("the Keychain is only available on macOS")
}
pub fn delete() -> Result<()> {
pub fn delete(_: &str) -> Result<()> {
bail!("the Keychain is only available on macOS")
}
}
@@ -253,7 +289,7 @@ mod tests {
Credentials {
client_id: "PARTNER-ID".into(),
consumer_key: "consumer-key-0123456789".into(),
user_id: "terminal-rs-1".into(),
user_id: "terminal-1".into(),
user_secret: "user-secret-abcdef".into(),
}
}
@@ -313,4 +349,31 @@ mod tests {
assert_eq!(store.load().unwrap(), None);
let _ = std::fs::remove_dir_all(&dir);
}
/// Writes throwaway items (dummy credentials) to the login Keychain, so it runs only when
/// asked: `cargo test -p tr-broker -- --ignored keychain`.
#[cfg(target_os = "macos")]
#[test]
#[ignore = "writes to the login Keychain"]
fn keychain_moves_credentials_from_the_old_service() {
let pid = std::process::id();
let (service, legacy) = (format!("terminal-test-{pid}"), format!("terminal-rs-test-{pid}"));
let store = SecretStore::keychain(&service, Some(&legacy));
let new_only = SecretStore::keychain(&service, None);
let old_only = SecretStore::keychain(&legacy, None);
old_only.save(&creds()).unwrap();
assert_eq!(store.load().unwrap(), Some(creds()), "found under the old service");
assert_eq!(new_only.load().unwrap(), Some(creds()), "and moved to the new one");
assert_eq!(old_only.load().unwrap(), None, "the old item is gone");
// A stale item under the old service never replaces what the new one holds.
old_only.save(&Credentials { client_id: "STALE".into(), ..creds() }).unwrap();
assert_eq!(store.load().unwrap(), Some(creds()));
// Forgetting credentials removes both, so the old item can't bring them back.
store.clear().unwrap();
assert_eq!(old_only.load().unwrap(), None);
assert_eq!(store.load().unwrap(), None);
}
}
+189 -4
View File
@@ -10,7 +10,8 @@
//! This module holds the reads, user registration, the read-only Connection Portal link and
//! deleting a connection on explicit request; order entry lives in [`crate::trading`].
use std::time::Duration;
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
use anyhow::{Context, Result, anyhow, bail};
use base64::Engine;
@@ -32,6 +33,20 @@ pub const ORDER_DAYS: u32 = 30;
pub const ACTIVITY_DAYS: i64 = 90;
pub const ACTIVITY_LIMIT: u32 = 100;
/// At most this many SnapTrade requests are in flight at once, across every client in the
/// process. SnapTrade rate-limits per key, and loading many accounts at once (four requests
/// per account) is what trips it.
pub const MAX_CONCURRENT: usize = 4;
/// A throttled read is retried this many times.
pub const MAX_RETRIES: u32 = 3;
/// Longest `Retry-After` honoured inside one call; longer waits go back to the caller.
pub const MAX_INLINE_WAIT: Duration = Duration::from_secs(20);
static IN_FLIGHT: LazyLock<tokio::sync::Semaphore> = LazyLock::new(|| tokio::sync::Semaphore::new(MAX_CONCURRENT));
/// After a throttled answer nothing is sent until this instant, so one `Retry-After` pauses
/// every request rather than each finding out on its own.
static COOLDOWN: Mutex<Option<Instant>> = Mutex::new(None);
pub struct SnapTrade {
http: reqwest::Client,
base: Url,
@@ -120,6 +135,49 @@ pub fn signature(consumer_key: &str, path: &str, query: &str, body: Option<&Valu
pub struct ApiError {
pub status: u16,
pub message: String,
/// Seconds to wait before trying again, from `Retry-After` or the throttle message.
pub retry_after: Option<u64>,
}
impl ApiError {
/// SnapTrade (or the broker behind it) refused the request for rate limiting.
pub fn throttled(&self) -> bool {
self.status == 429
}
}
/// The error is a SnapTrade rate limit, and how long to wait if SnapTrade said.
pub fn throttle(e: &anyhow::Error) -> Option<Option<Duration>> {
let api = e.downcast_ref::<ApiError>().filter(|a| a.throttled())?;
Some(api.retry_after.map(Duration::from_secs))
}
/// `Retry-After` in seconds (the HTTP-date form is not used by SnapTrade).
fn retry_after_header(h: &reqwest::header::HeaderMap) -> Option<u64> {
h.get(reqwest::header::RETRY_AFTER)?.to_str().ok()?.trim().parse::<u64>().ok()
}
/// DRF's throttle text: `Request was throttled. Expected available in 37 seconds.`
fn throttle_text(body: &str) -> Option<Option<u64>> {
let l = body.to_ascii_lowercase();
if !l.contains("throttled") && !l.contains("rate limit") && !l.contains("too many requests") {
return None;
}
let secs = l.split("available in").nth(1).and_then(|rest| {
rest.split(|c: char| !c.is_ascii_digit() && c != '.').find(|w| !w.is_empty())?.parse::<f64>().ok()
});
Some(secs.map(|s| s.ceil().max(1.0) as u64))
}
/// A short, friendly line for a rate limit; never SnapTrade's raw text.
pub fn throttle_message(retry_after: Option<u64>) -> String {
match retry_after {
Some(s) if s > 90 => {
format!("SnapTrade is rate-limiting requests; trying again in about {} minutes", s.div_ceil(60))
}
Some(s) => format!("SnapTrade is rate-limiting requests; trying again in about {s} s"),
None => "SnapTrade is rate-limiting requests; trying again shortly".into(),
}
}
impl std::fmt::Display for ApiError {
@@ -130,6 +188,18 @@ impl std::fmt::Display for ApiError {
impl std::error::Error for ApiError {}
/// The request was not sent at all (for example its deadline passed while waiting).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NotSent(pub String);
impl std::fmt::Display for NotSent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for NotSent {}
/// A readable message from a SnapTrade error body (`detail`, `message`, `code`), without
/// echoing anything from the request.
fn error_message(status: reqwest::StatusCode, body: &str) -> String {
@@ -140,7 +210,6 @@ fn error_message(status: reqwest::StatusCode, body: &str) -> String {
401 => " (check the client ID and consumer key)",
403 => " (the key may not allow this, or the user secret is wrong)",
404 => " (unknown user or account)",
429 => " (rate limited; try again shortly)",
_ => "",
};
match (detail.is_empty(), code.is_empty()) {
@@ -150,6 +219,36 @@ fn error_message(status: reqwest::StatusCode, body: &str) -> String {
}
}
fn cooldown_left() -> Option<Duration> {
let until = (*COOLDOWN.lock().unwrap_or_else(|p| p.into_inner()))?;
until.checked_duration_since(Instant::now()).filter(|d| !d.is_zero())
}
/// Sleep until the shared cooldown has passed, re-checking after each sleep because another
/// throttled answer may have extended it meanwhile. Gives up (nothing sent) as soon as the
/// time left is longer than [`MAX_INLINE_WAIT`], or would run past `deadline`.
async fn wait_for_cooldown(deadline: Option<Instant>) -> Result<()> {
loop {
let Some(left) = cooldown_left() else { return Ok(()) };
let too_late = deadline.is_some_and(|d| Instant::now() + left >= d);
if left > MAX_INLINE_WAIT || too_late {
let secs = left.as_secs().max(1);
return Err(ApiError { status: 429, message: throttle_message(Some(secs)), retry_after: Some(secs) }.into());
}
tokio::time::sleep(left).await;
}
}
/// Pause every request for the whole `wait` SnapTrade asked for (only the inline retry is
/// capped at [`MAX_INLINE_WAIT`], not the pause).
fn start_cooldown(wait: Duration) {
let until = Instant::now() + wait;
let mut c = COOLDOWN.lock().unwrap_or_else(|p| p.into_inner());
if c.is_none_or(|t| t < until) {
*c = Some(until);
}
}
impl SnapTrade {
/// Client for `SNAPTRADE_BASE_URL` (tests, proxies) or the production API.
pub fn new(creds: Credentials) -> Result<Self> {
@@ -157,18 +256,27 @@ impl SnapTrade {
Self::with_base(creds, &base)
}
#[cfg(not(target_arch = "wasm32"))]
pub fn with_base(creds: Credentials, base: &str) -> Result<Self> {
if !creds.has_keys() {
bail!("SnapTrade client ID and consumer key are not set");
}
let http = reqwest::Client::builder()
.user_agent(concat!("terminal-rs/", env!("CARGO_PKG_VERSION")))
.user_agent(concat!("terminal/", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(30))
.build()?;
let base = Url::parse(base.trim_end_matches('/')).context("invalid SnapTrade base URL")?;
Ok(Self { http, base, creds })
}
/// SnapTrade doesn't answer cross-origin requests, so the browser build can't make a client
/// (and so never sends an order).
/// TODO(web proxy): call SnapTrade through a same-origin proxy that keeps the keys server-side.
#[cfg(target_arch = "wasm32")]
pub fn with_base(_creds: Credentials, _base: &str) -> Result<Self> {
bail!("SnapTrade isn't available in the browser yet; use the desktop app for brokerage accounts")
}
pub fn credentials(&self) -> &Credentials {
&self.creds
}
@@ -194,6 +302,9 @@ impl SnapTrade {
url
}
/// One API call. At most [`MAX_CONCURRENT`] run at once; a throttled read (GET) waits as
/// SnapTrade asks (or backs off exponentially) and is retried up to [`MAX_RETRIES`]
/// times. Writes are never retried here: a throttled order is reported, not resent.
pub(crate) async fn call(
&self,
method: Method,
@@ -201,6 +312,54 @@ impl SnapTrade {
params: &[(&str, String)],
body: Option<Value>,
user: bool,
) -> Result<Value> {
self.call_by(method, path, params, body, user, None).await
}
/// [`Self::call`] that is never sent at or after `deadline` (an order preview's expiry):
/// a rate-limit pause that would reach it fails the call unsent.
pub(crate) async fn call_by(
&self,
method: Method,
path: &str,
params: &[(&str, String)],
body: Option<Value>,
user: bool,
deadline: Option<Instant>,
) -> Result<Value> {
let retry = method == Method::GET;
let mut attempt = 0;
loop {
let result = {
let _permit = IN_FLIGHT.acquire().await.expect("the limiter is never closed");
// A long pause is reported rather than waited out: nothing was sent, and the
// caller (or the user) can try again when it ends.
wait_for_cooldown(deadline).await?;
if deadline.is_some_and(|d| Instant::now() >= d) {
anyhow::bail!(NotSent("the order's preview expired before it could be sent".into()));
}
self.call_once(method.clone(), path, params, body.clone(), user).await
};
let Err(e) = result else { return result };
let Some(after) = throttle(&e) else { return Err(e) };
let wait = after.unwrap_or(Duration::from_millis(1000 << attempt.min(5)));
start_cooldown(wait);
if !retry || attempt >= MAX_RETRIES || wait > MAX_INLINE_WAIT {
return Err(e);
}
attempt += 1;
tracing::debug!("SnapTrade throttled {path}; retry {attempt} in {wait:?}");
tokio::time::sleep(wait).await;
}
}
async fn call_once(
&self,
method: Method,
path: &str,
params: &[(&str, String)],
body: Option<Value>,
user: bool,
) -> Result<Value> {
let url = self.url(path, params, user, chrono::Utc::now().timestamp());
let sig = signature(self.creds.consumer_key.trim(), url.path(), url.query().unwrap_or(""), body.as_ref());
@@ -212,9 +371,20 @@ impl SnapTrade {
// `without_url`: the URL carries the user secret and must never reach logs or the UI.
let resp = req.send().await.map_err(|e| anyhow!("SnapTrade request failed: {}", e.without_url()))?;
let status = resp.status();
let header_wait = retry_after_header(resp.headers());
let text = resp.text().await.map_err(|e| anyhow!("reading SnapTrade response: {}", e.without_url()))?;
if !status.is_success() {
return Err(ApiError { status: status.as_u16(), message: error_message(status, &text) }.into());
// Some throttles arrive with another status but DRF's "Request was throttled" text.
let throttled =
if status.as_u16() == 429 { Some(throttle_text(&text).flatten()) } else { throttle_text(&text) };
return Err(match throttled {
Some(text_wait) => {
let retry_after = header_wait.or(text_wait);
ApiError { status: 429, message: throttle_message(retry_after), retry_after }
}
None => ApiError { status: status.as_u16(), message: error_message(status, &text), retry_after: None },
}
.into());
}
if text.trim().is_empty() {
return Ok(Value::Null);
@@ -400,4 +570,19 @@ mod tests {
assert_eq!(m, "SnapTrade: Unable to verify signature sent [1076] (check the client ID and consumer key)");
assert_eq!(error_message(reqwest::StatusCode::BAD_GATEWAY, "<html>"), "SnapTrade returned HTTP 502");
}
#[test]
fn throttle_messages_are_parsed_and_friendly() {
assert_eq!(
throttle_text(r#"{"detail":"Request was throttled. Expected available in 37 seconds."}"#),
Some(Some(37))
);
assert_eq!(throttle_text(r#"{"detail":"Request was throttled."}"#), Some(None));
assert_eq!(throttle_text(r#"{"detail":"Unknown symbol"}"#), None);
assert_eq!(throttle_message(Some(30)), "SnapTrade is rate-limiting requests; trying again in about 30 s");
assert!(throttle_message(Some(600)).contains("10 minutes"));
let e: anyhow::Error = ApiError { status: 429, message: throttle_message(None), retry_after: Some(5) }.into();
assert_eq!(throttle(&e), Some(Some(Duration::from_secs(5))));
assert_eq!(throttle(&anyhow!("other")), None);
}
}
+25 -9
View File
@@ -427,10 +427,11 @@ impl Capability {
}
}
/// Per-account capabilities from `/accounts` (each account names its `brokerage_authorization`)
/// and `/authorizations` (each connection carries its brokerage and access type).
pub fn parse_capabilities(accounts: &Value, authorizations: &Value) -> std::collections::HashMap<String, Capability> {
let conns: std::collections::HashMap<String, Capability> = authorizations
/// Capabilities per brokerage connection (SnapTrade authorization id) from `/authorizations`,
/// where each connection carries its brokerage and access type. Accounts map onto these
/// through [`crate::Account::connection_id`], so one request covers every account.
pub fn parse_connection_capabilities(authorizations: &Value) -> std::collections::HashMap<String, Capability> {
authorizations
.as_array()
.into_iter()
.flatten()
@@ -452,7 +453,13 @@ pub fn parse_capabilities(accounts: &Value, authorizations: &Value) -> std::coll
},
))
})
.collect();
.collect()
}
/// Per-account capabilities from `/accounts` (each account names its `brokerage_authorization`)
/// and `/authorizations`.
pub fn parse_capabilities(accounts: &Value, authorizations: &Value) -> std::collections::HashMap<String, Capability> {
let conns = parse_connection_capabilities(authorizations);
accounts
.as_array()
.into_iter()
@@ -498,6 +505,9 @@ impl std::fmt::Display for PlaceError {
/// Only a client error (4xx) is a definite "not placed"; everything else after sending is unknown.
pub fn classify_place_error(e: anyhow::Error) -> PlaceError {
let msg = format!("{e:#}");
if e.downcast_ref::<crate::snaptrade::NotSent>().is_some() {
return PlaceError::NotSent(msg);
}
match e.downcast_ref::<crate::snaptrade::ApiError>() {
Some(api) if (400..500).contains(&api.status) => PlaceError::Rejected(msg),
_ => PlaceError::Unknown(msg),
@@ -549,10 +559,11 @@ impl SnapTrade {
return Err(PlaceError::NotSent("the preview expired; preview the order again".into()));
}
let id = segment("trade id", &preview.trade_id).map_err(|e| PlaceError::NotSent(e.to_string()))?;
match self
.call(Method::POST, &format!("/trade/{id}"), &[], Some(json!({ "wait_to_confirm": true })), true)
.await
{
// Never sent once the preview has expired, however long a rate-limit pause lasts.
let left = (preview.expires() - Utc::now()).to_std().unwrap_or_default();
let deadline = Some(std::time::Instant::now() + left);
let body = Some(json!({ "wait_to_confirm": true }));
match self.call_by(Method::POST, &format!("/trade/{id}"), &[], body, true, deadline).await {
Ok(v) => Ok(parse_placed(&preview.request.account_id, &v)),
Err(e) => Err(classify_place_error(e)),
}
@@ -566,6 +577,11 @@ impl SnapTrade {
self.call(Method::POST, &format!("/accounts/{acct}/trading/cancel"), &[], Some(body), true).await.map(|_| ())
}
/// What each brokerage connection can do, in one request however many accounts it holds.
pub async fn connection_capabilities(&self) -> Result<std::collections::HashMap<String, Capability>> {
Ok(parse_connection_capabilities(&self.call(Method::GET, "/authorizations", &[], None, true).await?))
}
/// What each account can do: brokerage support for trading and the connection's access.
pub async fn capabilities(&self) -> Result<std::collections::HashMap<String, Capability>> {
let (accounts, auths) = futures::join!(
+4 -4
View File
@@ -93,7 +93,7 @@ async fn mock(seen: Arc<Mutex<Vec<Seen>>>, failures: Arc<Mutex<Vec<String>>>) ->
|| path.starts_with("/connection/")
|| path == "/snapTrade/login";
if user_scoped
&& (param("userId").as_deref() != Some("terminal-rs-test")
&& (param("userId").as_deref() != Some("terminal-test")
|| param("userSecret").as_deref() != Some("s3cr3t/+="))
{
fail("missing or wrong user credentials".into());
@@ -156,7 +156,7 @@ fn creds() -> Credentials {
Credentials {
client_id: "TEST-CLIENT".into(),
consumer_key: CONSUMER_KEY.into(),
user_id: "terminal-rs-test".into(),
user_id: "terminal-test".into(),
user_secret: "s3cr3t/+=".into(),
}
}
@@ -244,8 +244,8 @@ async fn registers_a_user_opens_the_portal_and_disconnects() {
let partner =
SnapTrade::with_base(Credentials { user_id: String::new(), user_secret: String::new(), ..creds() }, &base)
.unwrap();
let (id, secret) = partner.register_user("terminal-rs-test").await.unwrap();
assert_eq!((id.as_str(), secret.as_str()), ("terminal-rs-test", "fresh-secret"));
let (id, secret) = partner.register_user("terminal-test").await.unwrap();
assert_eq!((id.as_str(), secret.as_str()), ("terminal-test", "fresh-secret"));
let client = SnapTrade::with_base(creds(), &base).unwrap();
let url = client.connection_portal_url(true, None).await.unwrap();
+272
View File
@@ -0,0 +1,272 @@
//! The paper-trading demo account: fills, resting orders, buying power, persistence.
use chrono::{DateTime, Duration, TimeZone, Utc};
use tr_broker::demo::{self, ACCOUNT_ID, DemoBook, Event, STARTING_CASH};
use tr_broker::trading::{Amount, OrderRequest, OrderType, Side, TimeInForce};
fn t0() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 10, 12, 15, 0, 0).unwrap()
}
fn order(side: Side, symbol: &str, units: f64) -> OrderRequest {
OrderRequest {
account_id: ACCOUNT_ID.into(),
symbol: symbol.into(),
side,
order_type: OrderType::Market,
time_in_force: TimeInForce::Day,
amount: Amount::Units(units),
limit: None,
stop: None,
}
}
fn limit(side: Side, symbol: &str, units: f64, px: f64) -> OrderRequest {
OrderRequest { order_type: OrderType::Limit, limit: Some(px), ..order(side, symbol, units) }
}
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
#[test]
fn starts_with_virtual_cash() {
let b = DemoBook::new(t0());
assert_eq!((b.cash, b.buying_power(), b.equity()), (STARTING_CASH, STARTING_CASH, STARTING_CASH));
let pf = b.portfolio();
assert!(pf.demo && !pf.simulated);
assert_eq!(pf.accounts.len(), 1);
assert!(pf.accounts[0].is_paper);
assert_eq!(pf.accounts[0].id, ACCOUNT_ID);
}
#[test]
fn market_orders_fill_at_the_quote() {
let mut b = DemoBook::new(t0());
let o = b.submit(&order(Side::Buy, "aapl", 10.0), Some(200.0), t0()).unwrap();
assert_eq!((o.status.as_str(), o.execution_price, o.filled), ("EXECUTED", Some(200.0), Some(10.0)));
assert_eq!(o.symbol, "AAPL");
assert!(close(b.cash, STARTING_CASH - 2000.0));
assert_eq!(b.held("AAPL"), 10.0);
// Average cost on adding, realised P&L on selling.
b.submit(&order(Side::Buy, "AAPL", 10.0), Some(220.0), t0()).unwrap();
let p = b.position("AAPL").unwrap();
assert!(close(p.cost_basis.unwrap(), 210.0));
b.submit(&order(Side::Sell, "AAPL", 5.0), Some(230.0), t0()).unwrap();
assert!(close(b.realized, 100.0));
assert_eq!(b.held("AAPL"), 15.0);
assert!(close(b.cash, STARTING_CASH - 2000.0 - 2200.0 + 1150.0));
// Marking to market moves equity, not cash.
let cash = b.cash;
b.on_price("AAPL", 250.0, t0());
assert!(close(b.equity(), cash + 15.0 * 250.0));
assert_eq!(b.activities.len(), 3, "every fill is recorded");
assert!(close(b.activities[0].amount.unwrap(), 1150.0));
}
#[test]
fn notional_orders_buy_fractional_shares() {
let mut b = DemoBook::new(t0());
let req = OrderRequest { amount: Amount::Notional(500.0), ..order(Side::Buy, "NVDA", 0.0) };
let o = b.submit(&req, Some(400.0), t0()).unwrap();
assert_eq!(o.filled, Some(1.25));
assert!(close(b.cash, STARTING_CASH - 500.0));
}
#[test]
fn refuses_what_a_cash_account_cannot_do() {
let mut b = DemoBook::new(t0());
let e = b.submit(&order(Side::Buy, "BRK.A", 1.0), Some(700_000.0), t0()).unwrap_err();
assert!(e.contains("Not enough buying power"), "{e}");
let e = b.submit(&order(Side::Sell, "TSLA", 1.0), Some(250.0), t0()).unwrap_err();
assert!(e.contains("can't sell short"), "{e}");
b.submit(&order(Side::Buy, "TSLA", 2.0), Some(250.0), t0()).unwrap();
let e = b.submit(&order(Side::Sell, "TSLA", 3.0), Some(250.0), t0()).unwrap_err();
assert!(e.contains("up to 2 TSLA"), "{e}");
let e = b.submit(&order(Side::Buy, "MSFT", 1.0), None, t0()).unwrap_err();
assert!(e.contains("Waiting for a price"), "market orders need a quote: {e}");
let other = OrderRequest { account_id: "snaptrade-uuid".into(), ..order(Side::Buy, "MSFT", 1.0) };
assert!(b.submit(&other, Some(400.0), t0()).is_err(), "only the demo account");
assert!(b.orders.iter().all(|o| o.status == "EXECUTED"), "refused orders leave no trace");
}
#[test]
fn limit_orders_rest_until_the_price_crosses() {
let mut b = DemoBook::new(t0());
let o = b.submit(&limit(Side::Buy, "AAPL", 10.0, 190.0), Some(200.0), t0()).unwrap();
assert!(o.is_open());
assert!(close(b.buying_power(), STARTING_CASH - 1900.0), "a resting buy sets cash aside");
assert_eq!(b.cash, STARTING_CASH);
assert_eq!(b.watched(), vec!["AAPL".to_string()]);
assert!(b.on_price("AAPL", 195.0, t0()).is_empty(), "not there yet");
let ev = b.on_price("AAPL", 189.5, t0());
assert_eq!(ev.len(), 1);
let Event::Filled(f) = &ev[0] else { panic!("{ev:?}") };
assert_eq!(f.execution_price, Some(189.5), "fills at the crossing quote, at or better than the limit");
assert!(ev[0].message().starts_with("Bought 10 AAPL at $189.50"));
assert!(close(b.cash, STARTING_CASH - 1895.0));
assert!(close(b.buying_power(), b.cash), "the reservation is released");
// A sell limit above the market waits for a rise and holds the shares back.
let s = b.submit(&limit(Side::Sell, "AAPL", 10.0, 210.0), Some(190.0), t0()).unwrap();
assert!(s.is_open());
assert_eq!(b.available("AAPL"), 0.0);
assert!(b.submit(&order(Side::Sell, "AAPL", 1.0), Some(190.0), t0()).is_err(), "shares are spoken for");
b.on_price("AAPL", 211.0, t0());
assert_eq!(b.held("AAPL"), 0.0);
assert!(b.position("AAPL").is_none());
}
#[test]
fn marketable_limits_fill_immediately() {
let mut b = DemoBook::new(t0());
let o = b.submit(&limit(Side::Buy, "MSFT", 2.0, 420.0), Some(410.0), t0()).unwrap();
assert_eq!((o.status.as_str(), o.execution_price), ("EXECUTED", Some(410.0)));
}
#[test]
fn stops_trigger_then_fill() {
let mut b = DemoBook::new(t0());
b.submit(&order(Side::Buy, "TSLA", 10.0), Some(250.0), t0()).unwrap();
let stop = OrderRequest { order_type: OrderType::Stop, stop: Some(240.0), ..order(Side::Sell, "TSLA", 10.0) };
assert!(b.submit(&stop, Some(250.0), t0()).unwrap().is_open());
assert!(b.on_price("TSLA", 245.0, t0()).is_empty());
let ev = b.on_price("TSLA", 238.0, t0());
assert!(matches!(&ev[..], [Event::Filled(o)] if o.execution_price == Some(238.0)));
// Stop-limit: the stop arms the order, then the limit decides the fill.
b.submit(&order(Side::Buy, "TSLA", 10.0), Some(238.0), t0()).unwrap();
let sl = OrderRequest {
order_type: OrderType::StopLimit,
stop: Some(230.0),
limit: Some(228.0),
..order(Side::Sell, "TSLA", 10.0)
};
b.submit(&sl, Some(238.0), t0()).unwrap();
assert!(b.on_price("TSLA", 225.0, t0()).is_empty(), "triggered, but below the sell limit");
assert_eq!(b.orders[0].status, "TRIGGERED");
assert!(b.orders[0].is_open());
let ev = b.on_price("TSLA", 229.0, t0());
assert!(matches!(&ev[..], [Event::Filled(o)] if o.execution_price == Some(229.0)));
}
#[test]
fn resting_buys_are_rejected_if_cash_ran_out() {
let mut b = DemoBook::new(t0());
b.reset(1_000.0, t0());
b.submit(&limit(Side::Buy, "AAPL", 5.0, 150.0), Some(200.0), t0()).unwrap();
// A later order cannot spend the reserved cash.
let e = b.submit(&order(Side::Buy, "F", 30.0), Some(10.0), t0()).unwrap_err();
assert!(e.contains("buying power"), "{e}");
assert!(b.submit(&order(Side::Buy, "F", 25.0), Some(10.0), t0()).is_ok());
let ev = b.on_price("AAPL", 149.0, t0());
assert!(matches!(&ev[..], [Event::Filled(_)]), "its own reservation pays for it: {ev:?}");
}
#[test]
fn cancel_ioc_and_day_expiry() {
let mut b = DemoBook::new(t0());
let o = b.submit(&limit(Side::Buy, "AAPL", 1.0, 100.0), Some(200.0), t0()).unwrap();
b.cancel(&o.id, t0()).unwrap();
assert_eq!(b.orders[0].status, "CANCELED");
assert!(b.cancel(&o.id, t0()).is_err(), "already cancelled");
assert_eq!(b.buying_power(), STARTING_CASH);
let ioc = OrderRequest { time_in_force: TimeInForce::Ioc, ..limit(Side::Buy, "AAPL", 1.0, 100.0) };
assert_eq!(b.submit(&ioc, Some(200.0), t0()).unwrap().status, "CANCELED", "IOC never rests");
let day = b.submit(&limit(Side::Buy, "AAPL", 1.0, 100.0), Some(200.0), t0()).unwrap();
let gtc = OrderRequest { time_in_force: TimeInForce::Gtc, ..limit(Side::Buy, "AAPL", 1.0, 100.0) };
let gtc = b.submit(&gtc, Some(200.0), t0()).unwrap();
assert!(b.expire(t0() + Duration::hours(4)).is_empty(), "before the close");
let ev = b.expire(t0() + Duration::hours(5));
assert!(matches!(&ev[..], [Event::Expired(o)] if o.id == day.id));
assert!(b.orders.iter().find(|o| o.id == gtc.id).unwrap().is_open(), "GTC orders stay");
assert_eq!(
demo::day_expiry(Utc.with_ymd_and_hms(2026, 10, 12, 21, 0, 0).unwrap()).to_rfc3339(),
"2026-10-13T20:00:00+00:00"
);
}
#[test]
fn close_position_sells_everything_available() {
let mut b = DemoBook::new(t0());
b.submit(&order(Side::Buy, "AMD", 12.5), Some(100.0), t0()).unwrap();
let req = b.close_request("amd").unwrap();
assert_eq!((req.side, req.amount), (Side::Sell, Amount::Units(12.5)));
b.submit(&req, Some(101.0), t0()).unwrap();
assert!(b.position("AMD").is_none());
assert!(b.close_request("AMD").is_none());
assert!(close(b.cash, STARTING_CASH + 12.5));
}
#[test]
fn persists_and_resets() {
let dir = std::env::temp_dir().join(format!("tr-demo-test-{}", std::process::id()));
let path = dir.join("demo.json");
let _ = std::fs::remove_dir_all(&dir);
let fresh = DemoBook::load(&path, t0()).unwrap();
assert_eq!(fresh.cash, STARTING_CASH, "a missing file is a new account");
let mut b = DemoBook::new(t0());
b.submit(&order(Side::Buy, "AAPL", 3.0), Some(200.0), t0()).unwrap();
b.submit(&limit(Side::Buy, "AAPL", 1.0, 150.0), Some(200.0), t0()).unwrap();
b.save(&path).unwrap();
let mut back = DemoBook::load(&path, t0()).unwrap();
assert_eq!(back, b);
// Ids keep counting after a reload.
let next = back.submit(&order(Side::Buy, "AAPL", 1.0), Some(200.0), t0()).unwrap();
assert!(b.orders.iter().all(|o| o.id != next.id));
back.reset(50_000.0, t0());
assert_eq!((back.cash, back.starting_cash), (50_000.0, 50_000.0));
assert!(back.positions.is_empty() && back.orders.is_empty() && back.activities.is_empty());
std::fs::write(&path, b"{not json").unwrap();
assert!(DemoBook::load(&path, t0()).is_err(), "a damaged file is reported, not silently replaced");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn history_is_bounded_but_keeps_open_orders() {
let mut b = DemoBook::new(t0());
let open = b.submit(&limit(Side::Buy, "AAPL", 1.0, 1.0), Some(200.0), t0()).unwrap();
for _ in 0..(demo::HISTORY + 20) {
b.submit(&order(Side::Buy, "F", 1.0), Some(1.0), t0()).unwrap();
}
assert_eq!(b.orders.len(), demo::HISTORY);
assert!(b.orders.iter().any(|o| o.id == open.id));
assert!(b.activities.len() <= demo::HISTORY);
}
#[test]
fn a_full_history_never_loses_the_order_just_placed() {
let mut b = DemoBook::new(t0());
for i in 0..demo::HISTORY {
b.submit(&limit(Side::Buy, "F", 1.0, 0.01 + i as f64 * 1e-4), Some(1.0), t0()).unwrap();
}
assert!(b.orders.iter().all(|o| o.is_open()));
let o = b.submit(&order(Side::Buy, "F", 1.0), Some(1.0), t0()).unwrap();
assert_eq!(o.status, "EXECUTED", "the fill is reported even when trimming drops it from history");
assert_eq!(b.held("F"), 1.0);
}
#[test]
fn late_quotes_never_fill_expired_day_orders() {
let mut b = DemoBook::new(t0());
b.submit(&limit(Side::Buy, "AAPL", 10.0, 190.0), Some(200.0), t0()).unwrap();
let ev = b.on_price("AAPL", 185.0, t0() + Duration::days(1));
assert!(matches!(&ev[..], [Event::Expired(_)]), "{ev:?}");
assert_eq!((b.cash, b.held("AAPL")), (STARTING_CASH, 0.0));
}
#[test]
fn dollar_amounts_never_cost_more_than_stated() {
let mut b = DemoBook::new(t0());
let all_in = OrderRequest { amount: Amount::Notional(STARTING_CASH), ..order(Side::Buy, "F", 0.0) };
b.submit(&all_in, Some(7.0), t0()).unwrap();
assert!(b.cash >= 0.0 && b.cash < 0.01, "{}", b.cash);
}
+175
View File
@@ -0,0 +1,175 @@
//! SnapTrade rate limits: throttled reads wait as asked and retry, throttled orders are never
//! resent, and the error the user sees is a friendly line rather than SnapTrade's raw text.
//! (Its own test binary: the client's cooldown is process-wide.)
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tr_broker::trading::{Amount, OrderPreview, OrderRequest, OrderType, PlaceError, Side, SymbolMatch, TimeInForce};
use tr_broker::{Credentials, SnapTrade};
const THROTTLED: &str = r#"{"detail":"Request was throttled. Expected available in 1 seconds."}"#;
/// Answers `/authorizations` with a 429 the first `throttle_reads` times, every
/// `/trade/...` with a 429, and records each request line.
async fn mock(throttle_reads: usize, seen: Arc<Mutex<Vec<String>>>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let reads = Arc::new(AtomicUsize::new(0));
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else { break };
let (seen, reads) = (seen.clone(), reads.clone());
tokio::spawn(async move {
let mut buf = vec![0u8; 8192];
let n = sock.read(&mut buf).await.unwrap_or(0);
let head = String::from_utf8_lossy(&buf[..n]).to_string();
let line = head.lines().next().unwrap_or("").to_string();
let path = line.split(' ').nth(1).unwrap_or("").split('?').next().unwrap_or("").to_string();
let method = line.split(' ').next().unwrap_or("").to_string();
seen.lock().unwrap().push(format!("{method} {path}"));
let (status, extra, body) = if path == "/authorizations" {
if reads.fetch_add(1, Ordering::SeqCst) < throttle_reads {
(429, "Retry-After: 1\r\n", THROTTLED.to_string())
} else {
(200, "", r#"[{"id":"c1","type":"trade","brokerage":{"display_name":"Wealthsimple","allows_trading":true}}]"#.to_string())
}
} else if path.starts_with("/trade/") {
(429, "", THROTTLED.to_string())
} else {
(404, "", r#"{"detail":"Not found"}"#.to_string())
};
let reply = format!(
"HTTP/1.1 {status} X\r\n{extra}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = sock.write_all(reply.as_bytes()).await;
let _ = sock.shutdown().await;
});
}
});
format!("http://{addr}")
}
fn client(base: &str) -> SnapTrade {
let creds = Credentials {
client_id: "TEST".into(),
consumer_key: "key".into(),
user_id: "terminal-test".into(),
user_secret: "s".into(),
};
SnapTrade::with_base(creds, base).unwrap()
}
#[tokio::test]
async fn throttled_reads_retry_and_orders_are_never_resent() {
// A read throttled once waits for Retry-After and succeeds.
let seen = Arc::new(Mutex::new(Vec::new()));
let base = mock(1, seen.clone()).await;
let start = Instant::now();
let caps = client(&base).connection_capabilities().await.unwrap();
assert!(caps["c1"].can_trade());
assert!(start.elapsed().as_millis() >= 900, "waited for Retry-After");
assert_eq!(seen.lock().unwrap().iter().filter(|l| l.ends_with("/authorizations")).count(), 2);
// A read that stays throttled gives up with a friendly message.
let seen = Arc::new(Mutex::new(Vec::new()));
let base = mock(usize::MAX, seen.clone()).await;
let e = client(&base).connection_capabilities().await.unwrap_err();
let msg = format!("{e:#}");
assert!(tr_broker::snaptrade::throttle(&e).is_some());
assert!(msg.contains("rate-limiting") && !msg.contains("Request was throttled"), "{msg}");
assert_eq!(seen.lock().unwrap().len(), 1 + tr_broker::snaptrade::MAX_RETRIES as usize);
// A throttled placement is sent exactly once and reported as not placed.
let seen = Arc::new(Mutex::new(Vec::new()));
let base = mock(0, seen.clone()).await;
let req = OrderRequest {
account_id: "acct-1".into(),
symbol: "AAPL".into(),
side: Side::Buy,
order_type: OrderType::Market,
time_in_force: TimeInForce::Day,
amount: Amount::Units(1.0),
limit: None,
stop: None,
};
let preview = OrderPreview {
trade_id: "139e307a-82f7-4402-b39e-4da7baa87758".into(),
request: req,
symbol: SymbolMatch {
id: "s".into(),
symbol: "AAPL".into(),
description: String::new(),
currency: "USD".into(),
exchange: String::new(),
},
price: None,
units: None,
commission: None,
forex_fees: None,
remaining_cash: None,
currency: "USD".into(),
created: chrono::Utc::now(),
simulated: false,
};
let err = client(&base).place_previewed(&preview).await.unwrap_err();
assert!(matches!(err, PlaceError::Rejected(_)), "{err:?}");
assert!(err.message().contains("rate-limiting"));
assert_eq!(seen.lock().unwrap().iter().filter(|l| l.starts_with("POST /trade/")).count(), 1);
// A pause that would outlast the preview never sends the order late.
let seen = Arc::new(Mutex::new(Vec::new()));
let base = mock_retry_after(4, seen.clone()).await;
let _ = client(&base).connection_capabilities().await; // starts a 4 s pause
let mut soon = preview.clone();
soon.created = chrono::Utc::now()
- chrono::Duration::seconds(tr_broker::trading::PREVIEW_TTL_SECS - tr_broker::trading::PREVIEW_MARGIN_SECS - 2);
let start = Instant::now();
let e = client(&base).place_previewed(&soon).await.unwrap_err();
assert!(start.elapsed().as_secs() < 2, "refused at once, not after the pause");
assert!(!e.uncertain(), "{e:?}");
assert!(seen.lock().unwrap().iter().all(|l| !l.starts_with("POST /trade/")), "the stale order was never sent");
tokio::time::sleep(std::time::Duration::from_secs(4)).await;
// A long Retry-After pauses every request for its whole length, and nothing is sent
// meanwhile: the call fails at once, unsent, with the time left.
let seen = Arc::new(Mutex::new(Vec::new()));
let base = mock_retry_after(120, seen.clone()).await;
let e = client(&base).connection_capabilities().await.unwrap_err();
assert!(tr_broker::snaptrade::throttle(&e).flatten().is_some_and(|d| d.as_secs() >= 100));
let start = Instant::now();
let e = client(&base).place_previewed(&preview).await.unwrap_err();
assert!(start.elapsed().as_secs() < 5, "an order is never held for minutes");
assert!(matches!(e, PlaceError::Rejected(_)) && e.message().contains("minutes"), "{e:?}");
assert_eq!(seen.lock().unwrap().len(), 1, "nothing sent during the pause");
}
/// Answers everything with a 429 asking to wait `secs`.
async fn mock_retry_after(secs: u64, seen: Arc<Mutex<Vec<String>>>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else { break };
let seen = seen.clone();
tokio::spawn(async move {
let mut buf = vec![0u8; 8192];
let n = sock.read(&mut buf).await.unwrap_or(0);
let head = String::from_utf8_lossy(&buf[..n]).to_string();
seen.lock().unwrap().push(head.lines().next().unwrap_or("").to_string());
let body = r#"{"detail":"Request was throttled."}"#;
let reply = format!(
"HTTP/1.1 429 X\r\nRetry-After: {secs}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = sock.write_all(reply.as_bytes()).await;
let _ = sock.shutdown().await;
});
}
});
format!("http://{addr}")
}
+2 -2
View File
@@ -80,7 +80,7 @@ async fn mock(seen: Log, failures: Log) -> String {
let body: Option<Value> = (!body.is_empty()).then(|| serde_json::from_slice(&body).unwrap());
let sig = headers.iter().find(|(k, _)| k == "signature").map(|(_, v)| v.clone()).unwrap_or_default();
let ok = sig == signature(CONSUMER_KEY, path, query, body.as_ref())
&& param(query, "userId").as_deref() == Some("terminal-rs-test")
&& param(query, "userId").as_deref() == Some("terminal-test")
&& param(query, "userSecret").as_deref() == Some("s3cr3t");
let entry = Seen { method: method.clone(), path: path.to_string(), body: body.clone() };
if !ok {
@@ -161,7 +161,7 @@ fn client(base: &str) -> SnapTrade {
let creds = Credentials {
client_id: "TEST-CLIENT".into(),
consumer_key: CONSUMER_KEY.into(),
user_id: "terminal-rs-test".into(),
user_id: "terminal-test".into(),
user_secret: "s3cr3t".into(),
};
SnapTrade::with_base(creds, base).unwrap()
+5 -2
View File
@@ -6,8 +6,9 @@ edition.workspace = true
[dependencies]
tr-core.workspace = true
egui = "0.36"
egui-wgpu = "0.36"
egui = { version = "0.36", default-features = false }
# GPU backends come from the app (eframe), so the browser build can leave out WebGL.
egui-wgpu = { version = "0.36", default-features = false }
bytemuck = { version = "1", features = ["derive"] }
chrono.workspace = true
serde.workspace = true
@@ -15,6 +16,8 @@ pollster = "0.4"
[dev-dependencies]
criterion.workspace = true
# Tests, benches and examples render offscreen on a real backend.
egui-wgpu = "0.36"
[[bench]]
name = "frame"
+46
View File
@@ -28,6 +28,32 @@ pub struct Uniforms {
pub color: [f32; 4],
pub params: [f32; 4],
pub extra: [f32; 4],
pub anim: [f32; 4],
}
/// Load-in sweep for a pane. Bars right of `front` (a fractional bar index) are hidden
/// and the `ramp` bars behind it grow in from their open; lines are drawn up to
/// `line_front`. `opacity` fades everything in the pane.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Reveal {
pub front: f64,
pub ramp: f32,
pub line_front: f64,
pub opacity: f32,
}
impl Reveal {
pub const DONE: Self = Self { front: f64::INFINITY, ramp: 1.0, line_front: f64::INFINITY, opacity: 1.0 };
/// The sweep at progress `t` (0..1) across `visible_bars` bars starting at `left`.
pub fn sweep(t: f32, left: f64, visible_bars: f64) -> Self {
if t >= 1.0 {
return Self::DONE;
}
let ramp = (visible_bars * 0.12).max(2.0);
let front = left + t as f64 * (visible_bars + ramp);
Self { front, ramp: ramp as f32, line_front: front - ramp * 0.5, opacity: 1.0 }
}
}
/// Pane-level transform shared by every draw in a pane.
@@ -60,6 +86,7 @@ pub struct Style {
/// Physical pixels per logical point (2.0 on Retina). Pixel-sized strokes are given
/// in points and scaled by this; pane sizes in [`PaneTransform`] are physical.
pub pixels_per_point: f32,
pub reveal: Reveal,
}
impl Default for Style {
@@ -77,6 +104,7 @@ impl Default for Style {
hist_width_bars: 0.7,
baseline: 0.0,
pixels_per_point: 1.0,
reveal: Reveal::DONE,
}
}
}
@@ -84,6 +112,9 @@ impl Default for Style {
impl Uniforms {
pub fn new(t: &PaneTransform, s: &Style) -> Self {
let left_hi = t.left.floor();
// Relative to `left_hi` for f32 precision on long series.
let rel = |x: f64| if x.is_finite() { (x - left_hi) as f32 } else { 1e9 };
let r = s.reveal;
Self {
rect: [t.width_px, t.height_px, left_hi as f32, (t.left - left_hi) as f32],
view: [t.visible_bars as f32, t.y_lo as f32, t.y_hi as f32, if t.log { 1.0 } else { 0.0 }],
@@ -97,6 +128,7 @@ impl Uniforms {
if s.ohlc_bars { 1.0 } else { 0.0 },
],
extra: [s.vol_frac, s.vol_max, s.hist_width_bars, s.baseline],
anim: [rel(r.front), r.ramp.max(1e-3), rel(r.line_front), r.opacity],
}
}
}
@@ -121,6 +153,10 @@ pub enum LineKind {
Line,
Area,
Hist,
/// A square marker at each finite point.
Dots,
/// A flat dash across each finite point's bar, with no joins between bars.
Steps,
}
#[derive(Clone)]
@@ -152,6 +188,8 @@ enum Pipe {
Line,
Area,
Hist,
Dots,
Steps,
}
struct Resolved {
@@ -167,6 +205,8 @@ pub struct ChartRenderer {
line: wgpu::RenderPipeline,
area: wgpu::RenderPipeline,
hist: wgpu::RenderPipeline,
dots: wgpu::RenderPipeline,
steps: wgpu::RenderPipeline,
uniform_buf: wgpu::Buffer,
uniform_bg: wgpu::BindGroup,
storage_bgl: wgpu::BindGroupLayout,
@@ -277,6 +317,8 @@ impl ChartRenderer {
line: make(&line_mod, "vs_line"),
area: make(&line_mod, "vs_area"),
hist: make(&line_mod, "vs_hist"),
dots: make(&line_mod, "vs_dots"),
steps: make(&line_mod, "vs_steps"),
uniform_buf,
uniform_bg,
storage_bgl,
@@ -352,6 +394,8 @@ impl ChartRenderer {
LineKind::Line => Pipe::Line,
LineKind::Area => Pipe::Area,
LineKind::Hist => Pipe::Hist,
LineKind::Dots => Pipe::Dots,
LineKind::Steps => Pipe::Steps,
};
(pipe, (true, data.key), *range, uniforms)
}
@@ -376,6 +420,8 @@ impl ChartRenderer {
Pipe::Line => (&self.line, 6),
Pipe::Area => (&self.area, 6),
Pipe::Hist => (&self.hist, 6),
Pipe::Dots => (&self.dots, 6),
Pipe::Steps => (&self.steps, 6),
};
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &self.uniform_bg, &[d.slot * SLOT as u32]);
+2 -2
View File
@@ -1,8 +1,8 @@
//! GPU chart rendering for terminal-rs.
//! GPU chart rendering for Terminal.
//!
//! - [`viewport`]: bar-index/price <-> pixel math, pan and zoom.
//! - [`lod`]: level-of-detail pyramids that bound per-frame work to the plot width.
//! - [`gpu`]: wgpu pipelines (candles, OHLC bars, volume, lines, areas, histograms)
//! - [`gpu`]: wgpu pipelines (candles, OHLC bars, volume, lines, areas, histograms, dots)
//! and the egui paint-callback adapter.
//! - [`scale`]: axis ticks and labels. [`drawings`]: user drawing model and hit testing.
+29 -2
View File
@@ -133,15 +133,25 @@ pub struct LinePyramid {
}
impl LinePyramid {
/// Keeps the later point of each pair (its value is the group's close).
pub fn build(values: &[f64]) -> Self {
Self::reduce(values, |_, later| later)
}
/// For sparse markers: a pair whose later point is blank keeps the earlier one, so
/// markers survive zooming out.
pub fn build_markers(values: &[f64]) -> Self {
Self::reduce(values, |earlier, later| if later[1].is_nan() { earlier } else { later })
}
fn reduce(values: &[f64], merge: impl Fn([f32; 2], [f32; 2]) -> [f32; 2]) -> Self {
let mut data: Vec<[f32; 2]> = values.iter().enumerate().map(|(i, v)| [i as f32 + 0.5, *v as f32]).collect();
let mut offsets = vec![0];
let (mut start, mut n) = (0, values.len());
while n > 1 {
let next_start = data.len();
for j in (0..n).step_by(2) {
// Keep the later point of each pair (its value is the group's close).
let p = if j + 1 < n { data[start + j + 1] } else { data[start + j] };
let p = if j + 1 < n { merge(data[start + j], data[start + j + 1]) } else { data[start + j] };
data.push(p);
}
offsets.push(next_start);
@@ -251,4 +261,21 @@ mod tests {
assert_eq!((a, b), (2, 7));
assert_eq!(p.segment_range(0, 0, 0), (0, 1));
}
#[test]
fn marker_pyramid_keeps_sparse_points() {
let mut v = vec![f64::NAN; 16];
v[2] = 5.0;
v[9] = 7.0;
let lines = LinePyramid::build(&v);
assert_eq!(lines.extent(1, 0, 16), Some((7.0, 7.0)), "lines keep the later point");
let marks = LinePyramid::build_markers(&v);
// Both survive every level where they fall in different groups, at their own bars.
for k in 0..marks.levels() - 1 {
assert_eq!(marks.extent(k, 0, 16), Some((5.0, 7.0)), "level {k}");
let level = &marks.data[marks.offsets[k]..marks.offsets[k + 1]];
assert!(level.contains(&[2.5, 5.0]) && level.contains(&[9.5, 7.0]), "level {k}");
}
assert_eq!(marks.data[marks.offsets[marks.levels() - 1]], [9.5, 7.0]);
}
}
+21
View File
@@ -60,6 +60,19 @@ pub fn format_volume(v: f64) -> String {
}
}
/// Indicator values of any magnitude in a few characters: 1.25B, 37.9K, 245.3, 61.42,
/// 0.734, 0.0012.
pub fn format_value(v: f64) -> String {
match v.abs() {
x if x >= 1e4 => format_volume(v),
x if x >= 100.0 => format!("{v:.1}"),
x if x >= 1.0 || x == 0.0 => format!("{v:.2}"),
x if x >= 0.01 => format!("{v:.3}"),
x if x >= 1e-4 => format!("{v:.4}"),
_ => format!("{v:.1e}"),
}
}
/// How significant a time boundary is; larger boundaries get labels first.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Boundary {
@@ -152,6 +165,14 @@ mod tests {
fn formatting() {
assert_eq!(format_volume(37_881_199.0), "37.88M");
assert_eq!(format_volume(101_820.0), "101.8K");
assert_eq!(format_value(-1_250_000_000.0), "-1.25B");
assert_eq!(format_value(37_900.0), "37.9K");
assert_eq!(format_value(245.31), "245.3");
assert_eq!(format_value(61.416), "61.42");
assert_eq!(format_value(0.0), "0.00");
assert_eq!(format_value(0.7341), "0.734");
assert_eq!(format_value(-0.00123), "-0.0012");
assert_eq!(format_value(0.000012), "1.2e-5");
assert_eq!(decimals_for(0.25), 2);
assert_eq!(decimals_for(0.001), 3);
assert_eq!(decimals_for(10.0), 0);
+173 -15
View File
@@ -81,6 +81,11 @@ pub fn line_ref(values: &[f64]) -> LineRef {
LineRef { key: next_key(), version: 1, pyramid: Arc::new(LinePyramid::build(values)) }
}
/// A [`LineKind::Dots`] series, reduced so sparse markers stay visible when zoomed out.
pub fn marker_ref(values: &[f64]) -> LineRef {
LineRef { key: next_key(), version: 1, pyramid: Arc::new(LinePyramid::build_markers(values)) }
}
/// A line overlay with its colour.
#[derive(Clone)]
pub struct Overlay {
@@ -143,7 +148,7 @@ pub fn visible_extent(
}
_ => p.extent(k, lo, hi)?,
};
for o in overlays.iter().filter(|o| o.kind == LineKind::Line) {
for o in overlays.iter().filter(|o| matches!(o.kind, LineKind::Line | LineKind::Dots | LineKind::Steps)) {
let kk = level_for(v.bar_px, o.line.pyramid.levels());
if let Some((a, b)) = o.line.pyramid.extent(kk, lo, hi) {
l = l.min(a * o.scale);
@@ -153,6 +158,24 @@ pub fn visible_extent(
Some((l, h, vmax))
}
/// The scale `price_pane` uses when `manual_scale` is `None`.
pub fn autoscale(p: &PricePane<'_>) -> PriceScale {
autoscale_from(p, visible_extent(p.series, p.overlays, p.viewport, p.chart_type))
}
fn autoscale_from(p: &PricePane<'_>, ext: Option<(f64, f64, f64)>) -> PriceScale {
let (l, h) = ext.map(|e| (e.0, e.1)).unwrap_or((0.0, 1.0));
// Leave room at the bottom for the volume overlay.
let mut s = PriceScale::padded(l, h, 0.08, p.log, p.height_px);
if p.show_volume {
let (a, b) = s.bounds_t();
let extra = (b - a) * 0.12;
let lo_t = a - extra;
s.lo = if s.log { lo_t.exp() } else { lo_t };
}
s
}
pub fn price_pane(p: &PricePane<'_>) -> PaneOutput {
let v = p.viewport;
let pyr = &p.series.bars.pyramid;
@@ -160,18 +183,7 @@ pub fn price_pane(p: &PricePane<'_>) -> PaneOutput {
let k = level_for(v.bar_px, pyr.levels());
let ext = visible_extent(p.series, p.overlays, v, p.chart_type);
let vol_max = ext.map(|e| e.2).unwrap_or(1.0);
let scale = p.manual_scale.unwrap_or_else(|| {
let (l, h) = ext.map(|e| (e.0, e.1)).unwrap_or((0.0, 1.0));
// Leave room at the bottom for the volume overlay.
let mut s = PriceScale::padded(l, h, 0.08, p.log, p.height_px);
if p.show_volume {
let (a, b) = s.bounds_t();
let extra = (b - a) * 0.12;
let lo_t = a - extra;
s.lo = if s.log { lo_t.exp() } else { lo_t };
}
s
});
let scale = p.manual_scale.unwrap_or_else(|| autoscale_from(p, ext));
let t = transform(v, p.height_px, &scale, p.style.pixels_per_point);
let mut style = p.style;
style.ohlc_bars = p.chart_type == ChartType::Bars;
@@ -243,8 +255,9 @@ fn overlay_cmds(overlays: &[Overlay], v: &Viewport, t: &PaneTransform, base: &St
}
}
let range = match o.kind {
LineKind::Hist => {
s.hist_width_bars = (base.body_frac as f64 * (1u64 << k) as f64) as f32;
LineKind::Hist | LineKind::Dots | LineKind::Steps => {
let frac = if o.kind == LineKind::Steps { 1.0 } else { base.body_frac as f64 };
s.hist_width_bars = (frac * (1u64 << k) as f64) as f32;
let n = lp.level_len(k);
let off = lp.offsets[k];
let a = (lo >> k).min(n);
@@ -361,6 +374,68 @@ mod tests {
assert_eq!(one.scale, two.scale);
}
#[test]
fn autoscale_is_what_price_pane_uses() {
let s = SeriesGpu::build(&sample::generate("X", Timeframe::D1, 500, 1_790_000_000));
let v = Viewport::fit_last(500, 120, 600.0);
for (show_volume, log) in [(true, false), (false, true)] {
let p = PricePane {
series: &s,
chart_type: ChartType::Candles,
show_volume,
overlays: &[],
viewport: &v,
height_px: 300.0,
log,
manual_scale: None,
style: Style::default(),
};
assert_eq!(autoscale(&p), price_pane(&p).scale);
}
}
#[test]
fn reveal_sweeps_relative_to_the_left_edge() {
use crate::gpu::Reveal;
let s = SeriesGpu::build(&sample::generate("X", Timeframe::D1, 500, 1_790_000_000));
let v = Viewport::fit_last(500, 100, 400.0);
let anim = |reveal: Reveal| {
let out = price_pane(&PricePane {
series: &s,
chart_type: ChartType::Candles,
show_volume: true,
overlays: &[],
viewport: &v,
height_px: 300.0,
log: false,
manual_scale: None,
style: Style { reveal, ..Style::default() },
});
out.cmds
.iter()
.map(|c| match c {
DrawCmd::Candles { uniforms, .. } | DrawCmd::Volume { uniforms, .. } => uniforms.anim,
DrawCmd::Line { uniforms, .. } => uniforms.anim,
})
.collect::<Vec<_>>()
};
// Done: everything drawn, nothing clipped.
for a in anim(Reveal::DONE) {
assert_eq!(a, [1e9, 1.0, 1e9, 1.0]);
}
assert_eq!(Reveal::sweep(1.0, v.left(), v.visible_bars()), Reveal::DONE);
// Start: the front sits on the left edge, so no bar has grown yet.
let start = Reveal::sweep(0.0, v.left(), v.visible_bars());
let left_lo = (v.left() - v.left().floor()) as f32;
for a in anim(start) {
assert!((a[0] - left_lo).abs() < 1e-3, "{a:?}");
assert!(a[2] < a[0] && a[1] >= 2.0);
}
// Midway the front is inside the visible range, lines trailing it.
let mid = Reveal::sweep(0.5, v.left(), v.visible_bars());
assert!(mid.front > v.left() && mid.front < v.right && mid.line_front < mid.front);
}
#[test]
fn sub_pane_fixed_and_auto() {
let vals: Vec<f64> = (0..100).map(|i| (i as f64 * 0.2).sin()).collect();
@@ -371,4 +446,87 @@ mod tests {
let fixed = sub_pane(&[o], &v, 100.0, Some((0.0, 100.0)), Style::default());
assert_eq!((fixed.scale.lo, fixed.scale.hi), (0.0, 100.0));
}
#[test]
fn dots_draw_one_instance_per_point_and_scale_the_price_pane() {
let bars = sample::generate("X", Timeframe::D1, 200, 1_790_000_000);
let s = SeriesGpu::build(&bars);
let v = Viewport::fit_last(200, 200, 800.0);
let top = bars.iter().map(|b| b.high).fold(f64::MIN, f64::max) * 2.0;
let mut marks = vec![f64::NAN; 200];
marks[150] = top;
let o = Overlay { line: line_ref(&marks), color: [1.0; 4], kind: LineKind::Dots, scale: 1.0 };
let out = price_pane(&PricePane {
series: &s,
chart_type: ChartType::Candles,
show_volume: false,
overlays: std::slice::from_ref(&o),
viewport: &v,
height_px: 400.0,
log: false,
manual_scale: None,
style: Style::default(),
});
let Some(DrawCmd::Line { kind: LineKind::Dots, range, .. }) = out.cmds.last() else { panic!("no dots drawn") };
assert_eq!(range.1 - range.0, 200);
assert!(out.scale.hi >= top);
}
#[test]
fn zoomed_out_markers_keep_sparse_points() {
let bars = sample::generate("X", Timeframe::M1, 4096, 1_790_000_000);
let s = SeriesGpu::build(&bars);
let v = Viewport { right: 4096.0, bar_px: 0.2, width_px: 800.0 };
let top = bars.iter().map(|b| b.high).fold(f64::MIN, f64::max) * 2.0;
// A fractal-like marker whose neighbours are blank.
let mut marks = vec![f64::NAN; 4096];
marks[2002] = top;
let pane = |line: LineRef| {
let o = Overlay { line, color: [1.0; 4], kind: LineKind::Dots, scale: 1.0 };
let out = price_pane(&PricePane {
series: &s,
chart_type: ChartType::Candles,
show_volume: false,
overlays: std::slice::from_ref(&o),
viewport: &v,
height_px: 400.0,
log: false,
manual_scale: None,
style: Style::default(),
});
let Some(DrawCmd::Line { data, range, .. }) = out.cmds.last() else { panic!("no dots drawn") };
let drawn = data.pyramid.data[range.0 as usize..range.1 as usize].contains(&[2002.5, top as f32]);
(out.level, drawn, out.scale.hi >= top)
};
let (level, drawn, scaled) = pane(marker_ref(&marks));
assert!(level > 0 && drawn && scaled, "level {level}: drawn {drawn}, scaled {scaled}");
assert_eq!(pane(line_ref(&marks)), (level, false, false), "line reduction drops the marker");
}
#[test]
fn steps_draw_one_full_bar_dash_per_point() {
let s = SeriesGpu::build(&sample::generate("X", Timeframe::H4, 200, 1_790_000_000));
let levels: Vec<f64> = (0..200).map(|i| if i / 2 % 2 == 0 { 1.0 } else { 1e6 }).collect();
let o = Overlay { line: line_ref(&levels), color: [1.0; 4], kind: LineKind::Steps, scale: 1.0 };
for (bar_px, group) in [(4.0, 1.0), (0.5, 4.0)] {
let v = Viewport { right: 200.0, bar_px, width_px: 800.0 };
let out = price_pane(&PricePane {
series: &s,
chart_type: ChartType::Candles,
show_volume: false,
overlays: std::slice::from_ref(&o),
viewport: &v,
height_px: 400.0,
log: false,
manual_scale: None,
style: Style::default(),
});
let Some(DrawCmd::Line { kind: LineKind::Steps, range, uniforms, .. }) = out.cmds.last() else {
panic!("no steps drawn")
};
assert_eq!(uniforms.extra[2], group, "a dash spans its whole group of bars");
assert_eq!(range.1 - range.0, 200 / group as u32);
assert!(out.scale.hi >= 1e6);
}
}
}
+36 -8
View File
@@ -14,6 +14,8 @@ struct Uniforms {
params: vec4<f32>,
// vol_frac, vol_max, hist_width_bars, baseline
extra: vec4<f32>,
// reveal front, ramp (bars), line front (both relative to left_hi), pane opacity
anim: vec4<f32>,
};
@group(0) @binding(0) var<uniform> u: Uniforms;
@@ -55,11 +57,24 @@ fn corner(vi: u32) -> vec2<f32> {
return c[vi % 6u];
}
// Premultiplied colour with the pane opacity applied.
fn shade(color: vec4<f32>) -> vec4<f32> {
let a = color.a * u.anim.w;
return vec4<f32>(color.rgb * a, a);
}
// How far the bar centred at `idx` has grown in (0..1, eased) behind the reveal front.
fn grow(idx: f32) -> f32 {
let f = clamp((u.anim.x - (idx - u.rect.z)) / u.anim.y, 0.0, 1.0);
let r = 1.0 - f;
return 1.0 - r * r * r;
}
fn rect(vi: u32, x0: f32, x1: f32, y0: f32, y1: f32, color: vec4<f32>) -> VsOut {
let c = corner(vi);
var out: VsOut;
out.pos = vec4<f32>(mix(x0, x1, c.x), mix(y0, y1, c.y), 0.0, 1.0);
out.color = vec4<f32>(color.rgb * color.a, color.a);
out.color = shade(color);
return out;
}
@@ -95,7 +110,16 @@ struct Bar {
@vertex
fn vs_candle(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VsOut {
let b = bars[ii];
let color = select(u.down, u.up, b.close >= b.open);
let g = grow(b.x + b.w * 0.5);
if (g <= 0.0) {
return degenerate();
}
// Growing in: the bar opens out from its open price.
let high = mix(b.open, b.high, g);
let low = mix(b.open, b.low, g);
let close = mix(b.open, b.close, g);
var color = select(u.down, u.up, b.close >= b.open);
color.a = color.a * clamp(g * 3.0, 0.0, 1.0);
let cx = x_ndc(b.x + b.w * 0.5);
let half_body = max((b.w * u.params.x * 0.5) / u.view.x * 2.0, px_to_ndc_x(0.5));
let half_wick = px_to_ndc_x(u.params.y * 0.5);
@@ -103,34 +127,38 @@ fn vs_candle(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -
let one_px = px_to_ndc_y(1.0);
if (u.params.w < 0.5) {
if (r == 0u) {
return rect(vi, cx - half_wick, cx + half_wick, y_ndc(b.low), max(y_ndc(b.high), y_ndc(b.low) + one_px), color);
return rect(vi, cx - half_wick, cx + half_wick, y_ndc(low), max(y_ndc(high), y_ndc(low) + one_px), color);
}
if (r == 1u) {
let y0 = y_ndc(min(b.open, b.close));
let y1 = max(y_ndc(max(b.open, b.close)), y0 + one_px);
let y0 = y_ndc(min(b.open, close));
let y1 = max(y_ndc(max(b.open, close)), y0 + one_px);
return rect(vi, cx - half_body, cx + half_body, y0, y1, color);
}
return degenerate();
}
let tick = px_to_ndc_y(max(u.params.y, 1.0) * 0.5);
if (r == 0u) {
return rect(vi, cx - half_wick, cx + half_wick, y_ndc(b.low), max(y_ndc(b.high), y_ndc(b.low) + one_px), color);
return rect(vi, cx - half_wick, cx + half_wick, y_ndc(low), max(y_ndc(high), y_ndc(low) + one_px), color);
}
if (r == 1u) {
let y = y_ndc(b.open);
return rect(vi, cx - half_body, cx, y - tick, y + tick, color);
}
let y = y_ndc(b.close);
let y = y_ndc(close);
return rect(vi, cx, cx + half_body, y - tick, y + tick, color);
}
@vertex
fn vs_volume(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VsOut {
let b = bars[ii];
let g = grow(b.x + b.w * 0.5);
if (g <= 0.0) {
return degenerate();
}
var color = select(u.down, u.up, b.close >= b.open);
color.a = color.a * u.color.a;
let cx = x_ndc(b.x + b.w * 0.5);
let half = max((b.w * u.params.x * 0.5) / u.view.x * 2.0, px_to_ndc_x(0.5));
let h = clamp(b.volume / max(u.extra.y, 1e-30), 0.0, 1.0) * u.extra.x * 2.0;
let h = clamp(b.volume / max(u.extra.y, 1e-30), 0.0, 1.0) * u.extra.x * 2.0 * g;
return rect(vi, cx - half, cx + half, -1.0, -1.0 + h, color);
}
+52 -9
View File
@@ -15,14 +15,24 @@ fn px_to_ndc(p: vec2<f32>) -> vec2<f32> {
return vec2<f32>(p.x / u.rect.x * 2.0 - 1.0, p.y / u.rect.y * 2.0 - 1.0);
}
// Segment end `b` cut back to the line front while the pane is revealing.
fn traced(a: vec2<f32>, b: vec2<f32>) -> vec2<f32> {
let fa = a.x - u.rect.z;
let fb = b.x - u.rect.z;
if (fb <= u.anim.z) {
return b;
}
return mix(a, b, clamp((u.anim.z - fa) / max(fb - fa, 1e-6), 0.0, 1.0));
}
// Thick segment between points ii and ii+1, expanded in pixel space.
@vertex
fn vs_line(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VsOut {
let a = pts[ii];
let b = pts[ii + 1u];
if (!(finite(a.y) && finite(b.y))) {
if (!(finite(a.y) && finite(pts[ii + 1u].y)) || a.x - u.rect.z >= u.anim.z) {
return degenerate();
}
let b = traced(a, pts[ii + 1u]);
let pa = to_px(a);
let pb = to_px(b);
var d = pb - pa;
@@ -36,7 +46,7 @@ fn vs_line(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) ->
let p = along + n * (c.y * 2.0 - 1.0);
var out: VsOut;
out.pos = vec4<f32>(px_to_ndc(p), 0.0, 1.0);
out.color = vec4<f32>(u.color.rgb * u.color.a, u.color.a);
out.color = shade(u.color);
return out;
}
@@ -44,19 +54,18 @@ fn vs_line(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) ->
@vertex
fn vs_area(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VsOut {
let a = pts[ii];
let b = pts[ii + 1u];
if (!(finite(a.y) && finite(b.y))) {
if (!(finite(a.y) && finite(pts[ii + 1u].y)) || a.x - u.rect.z >= u.anim.z) {
return degenerate();
}
let b = traced(a, pts[ii + 1u]);
let c = corner(vi);
let x = mix(x_ndc(a.x), x_ndc(b.x), c.x);
let top = mix(y_ndc(a.y), y_ndc(b.y), c.x);
let y = mix(-1.0, top, c.y);
// Fade towards the bottom.
let alpha = u.color.a * mix(0.15, 1.0, c.y);
var out: VsOut;
out.pos = vec4<f32>(x, y, 0.0, 1.0);
out.color = vec4<f32>(u.color.rgb * alpha, alpha);
out.color = shade(vec4<f32>(u.color.rgb, u.color.a * mix(0.15, 1.0, c.y)));
return out;
}
@@ -64,11 +73,45 @@ fn vs_area(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) ->
@vertex
fn vs_hist(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VsOut {
let a = pts[ii];
if (!finite(a.y)) {
let g = grow(a.x);
if (!finite(a.y) || g <= 0.0) {
return degenerate();
}
let color = select(u.down, u.up, a.y >= u.extra.w);
let half = max(u.extra.z * 0.5 / u.view.x * 2.0, px_to_ndc_x(0.5));
let cx = x_ndc(a.x);
return rect(vi, cx - half, cx + half, y_ndc(u.extra.w), y_ndc(a.y), color);
return rect(vi, cx - half, cx + half, y_ndc(u.extra.w), y_ndc(mix(u.extra.w, a.y, g)), color);
}
// Flat dash at point ii's level across the bars of its group (extra.z bars wide, one when
// not zoomed out). The point is its group's last bar, so the dash runs from the group's
// start to that bar; a short final group stays short. Consecutive levels never join, so a
// session's pivot starts afresh instead of stepping up from the last one.
@vertex
fn vs_steps(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VsOut {
let a = pts[ii];
let x1 = a.x + 0.5;
let x0 = floor(a.x / u.extra.z) * u.extra.z;
if (!finite(a.y) || x0 - u.rect.z >= u.anim.z) {
return degenerate();
}
let hy = px_to_ndc_y(u.params.z * 0.5);
let cy = y_ndc(a.y);
return rect(vi, x_ndc(x0), x_ndc(min(x1, u.rect.z + u.anim.z)), cy - hy, cy + hy, u.color);
}
// Square marker centred on point ii (e.g. parabolic SAR), sized from the line thickness.
// Markers grow in behind the reveal front.
@vertex
fn vs_dots(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VsOut {
let a = pts[ii];
let g = grow(a.x);
if (!finite(a.y) || g <= 0.0) {
return degenerate();
}
let hx = px_to_ndc_x(u.params.z * 1.25 * g);
let hy = px_to_ndc_y(u.params.z * 1.25 * g);
let cx = x_ndc(a.x);
let cy = y_ndc(a.y);
return rect(vi, cx - hx, cx + hx, cy - hy, cy + hy, u.color);
}
+128 -1
View File
@@ -1,6 +1,6 @@
//! Renders real frames offscreen (any adapter, including software Vulkan) and checks pixels.
use tr_chart::gpu::{Frame, LineKind, Style};
use tr_chart::gpu::{Frame, LineKind, Reveal, Style};
use tr_chart::headless::Headless;
use tr_chart::scene::{ChartType, Overlay, PricePane, SeriesGpu, line_ref, price_pane, sub_pane};
use tr_chart::viewport::Viewport;
@@ -73,3 +73,130 @@ fn renders_candles_volume_lines_and_hist() {
assert!(count(&px, |p| p[1] > 150 && p[0] < 100) > 200);
assert!(count(&px, |p| p[0] > 200 && p[1] < 100) > 200);
}
#[test]
fn reveal_sweeps_in_from_the_left_and_fades() {
let Some(mut h) = Headless::new(800, 400) else {
eprintln!("no wgpu adapter; skipping");
return;
};
let bars = sample::generate("AAPL", Timeframe::D1, 2_000, 1_790_000_000);
let series = SeriesGpu::build(&bars);
let closes: Vec<f64> = bars.iter().map(|b| b.close).collect();
let overlays = [
Overlay {
line: line_ref(&indicators::sma(&closes, 20)),
color: [0.0, 0.6, 1.0, 1.0],
kind: LineKind::Line,
scale: 1.0,
},
Overlay {
line: line_ref(&indicators::ema(&closes, 50)),
color: [1.0, 0.3, 0.3, 1.0],
kind: LineKind::Dots,
scale: 1.0,
},
];
let v = Viewport::fit_last(bars.len(), 150, 800.0);
let mut draw = |reveal: Reveal, pane: u64| {
let out = price_pane(&PricePane {
series: &series,
chart_type: ChartType::Candles,
show_volume: true,
overlays: &overlays,
viewport: &v,
height_px: 400.0,
log: false,
manual_scale: None,
style: Style { reveal, ..Style::default() },
});
h.render(&mut [Frame { pane, frame_no: pane, cmds: out.cmds }]);
h.read_pixels()
};
// Lit pixels (candles, volume or the SMA) in columns [x0, x1).
let lit = |px: &[u8], x0: usize, x1: usize| {
px.as_chunks::<4>()
.0
.iter()
.enumerate()
.filter(|(i, p)| (x0..x1).contains(&(i % 800)) && p[..3] != [0, 0, 0])
.count()
};
let half = draw(Reveal::sweep(0.45, v.left(), v.visible_bars()), 1);
if let Ok(dir) = std::env::var("TR_DUMP_DIR") {
dump_ppm(&format!("{dir}/reveal.ppm"), 800, 400, &half);
}
let (left, right) = (lit(&half, 0, 250), lit(&half, 450, 800));
eprintln!("half revealed: left {left} right {right}");
assert!(left > 2_000, "bars behind the front are drawn ({left})");
assert_eq!(right, 0, "nothing past the front, lines and dots included");
let full = draw(Reveal::DONE, 2);
assert!(lit(&full, 450, 800) > 2_000);
let faded = draw(Reveal { opacity: 0.0, ..Reveal::DONE }, 3);
assert_eq!(lit(&faded, 0, 800), 0, "opacity 0 draws nothing");
}
#[test]
fn steps_draw_short_sessions_without_joining_them() {
let Some(mut h) = Headless::new(800, 400) else {
eprintln!("no wgpu adapter; skipping");
return;
};
// Two-bar sessions alternating between 25 and 75 (pivots on a 4H equity chart), then a
// session of one bar at 50.
let mut levels: Vec<f64> = (0..39).map(|i| if i / 2 % 2 == 0 { 25.0 } else { 75.0 }).collect();
levels.push(50.0);
let v = Viewport::fit_last(levels.len(), levels.len(), 800.0);
let mut draw = |kind: LineKind, pane: u64| {
let o = [Overlay { line: line_ref(&levels), color: [1.0; 4], kind, scale: 1.0 }];
let out = sub_pane(&o, &v, 400.0, Some((0.0, 100.0)), Style::default());
h.render(&mut [Frame { pane, frame_no: pane, cmds: out.cmds }]);
h.read_pixels()
};
// Lit pixels in rows [y0, y1); 25 and 75 sit on rows 300 and 100, 50 on row 200.
let rows = |px: &[u8], y0: usize, y1: usize| {
px.as_chunks::<4>().0.iter().enumerate().filter(|(i, p)| (y0..y1).contains(&(i / 800)) && p[0] > 0).count()
};
let steps = draw(LineKind::Steps, 1);
if let Ok(dir) = std::env::var("TR_DUMP_DIR") {
dump_ppm(&format!("{dir}/steps.ppm"), 800, 400, &steps);
}
let (high, low, one_bar) = (rows(&steps, 95, 105), rows(&steps, 295, 305), rows(&steps, 195, 205));
eprintln!("steps: 75 {high}, 25 {low}, 50 {one_bar}");
assert!(high > 300 && low > 300, "every session draws its level");
assert!(one_bar > 10, "a one-bar session draws");
assert_eq!(rows(&steps, 110, 190) + rows(&steps, 210, 290), 0, "sessions are not joined");
let lines = draw(LineKind::Line, 2);
assert!(rows(&lines, 110, 190) > 0, "a line would join them");
}
#[test]
fn zoomed_out_steps_stay_inside_their_group() {
let Some(mut h) = Headless::new(800, 400) else {
eprintln!("no wgpu adapter; skipping");
return;
};
// 401 bars at one pixel each draw in groups of two; the last group holds only bar 400,
// which starts a new session at 75.
let mut levels = vec![25.0; 400];
levels.push(75.0);
let v = Viewport { right: 410.0, bar_px: 1.0, width_px: 800.0 };
let o = [Overlay { line: line_ref(&levels), color: [1.0; 4], kind: LineKind::Steps, scale: 1.0 }];
let out = sub_pane(&o, &v, 400.0, Some((0.0, 100.0)), Style::default());
h.render(&mut [Frame { pane: 1, frame_no: 0, cmds: out.cmds }]);
let px = h.read_pixels();
// Bar i spans columns [i + 390, i + 391); 75 sits on row 100.
let lit = |x0: usize, x1: usize| {
px.as_chunks::<4>()
.0
.iter()
.enumerate()
.filter(|(i, p)| (95..105).contains(&(i / 800)) && (x0..x1).contains(&(i % 800)) && p[0] > 0)
.count()
};
assert!(lit(790, 791) > 0, "the new session draws on its own bar");
assert_eq!(lit(0, 790), 0, "and not over the previous session");
}
+6 -3
View File
@@ -1,16 +1,19 @@
[package]
name = "tr-congress"
description = "STOCK Act disclosure ingest (Senate eFD + House Clerk PTRs) for terminal-rs"
description = "STOCK Act disclosure ingest (Senate eFD + House Clerk PTRs) for Terminal"
version.workspace = true
edition.workspace = true
[dependencies]
anyhow.workspace = true
thiserror.workspace = true
chrono.workspace = true
serde.workspace = true
serde_json.workspace = true
regex.workspace = true
# Ingest and storage: desktop only (the browser build has no SQLite and can't reach the sources).
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
thiserror.workspace = true
serde_json.workspace = true
tokio.workspace = true
reqwest.workspace = true
rusqlite.workspace = true
+1 -20
View File
@@ -7,6 +7,7 @@ use anyhow::Result;
use chrono::{NaiveDate, Utc};
use rusqlite::{Connection, OptionalExtension, Row, params};
pub use crate::model::{Alert, PoliticianSummary};
use crate::model::{AmountRange, Chamber, ParsedTrade, Party, Politician, Trade, TxType};
const SCHEMA: &str = r#"
@@ -111,17 +112,6 @@ pub struct FilingRecord {
pub is_amendment: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Alert {
pub id: i64,
pub politician_id: i64,
pub politician: String,
pub filing_id: String,
pub created_at: i64,
pub message: String,
pub seen: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Stats {
pub politicians: i64,
@@ -130,15 +120,6 @@ pub struct Stats {
pub scanned: i64,
}
/// A politician with aggregate trade counts, for list views.
#[derive(Debug, Clone, PartialEq)]
pub struct PoliticianSummary {
pub politician: Politician,
pub trades: i64,
pub last_filed: Option<NaiveDate>,
pub tracked: bool,
}
pub struct Db {
conn: Connection,
}
+60
View File
@@ -0,0 +1,60 @@
//! The browser build's stand-in for the SQLite store: always empty, because the browser
//! can't fetch disclosures yet. It keeps the desktop store's method signatures so callers
//! compile unchanged.
//!
//! TODO(web proxy): fill this from a same-origin proxy for Senate eFD and the House Clerk.
use std::path::Path;
use anyhow::{Result, bail};
pub use crate::model::{Alert, PoliticianSummary};
use crate::model::{Politician, Trade};
pub struct Db;
impl Db {
pub fn open(_path: impl AsRef<Path>) -> Result<Self> {
Ok(Self)
}
pub fn open_in_memory() -> Result<Self> {
Ok(Self)
}
pub fn latest_trades(&self, _limit: usize) -> Result<Vec<Trade>> {
Ok(Vec::new())
}
pub fn trades_for_ticker(&self, _ticker: &str, _limit: usize) -> Result<Vec<Trade>> {
Ok(Vec::new())
}
pub fn trades_for_politician(&self, _politician_id: i64) -> Result<Vec<Trade>> {
Ok(Vec::new())
}
pub fn politician(&self, _id: i64) -> Result<Option<Politician>> {
Ok(None)
}
pub fn search_politicians(&self, _query: &str, _limit: usize) -> Result<Vec<PoliticianSummary>> {
Ok(Vec::new())
}
pub fn set_tracked(&self, _politician_id: i64, _tracked: bool) -> Result<()> {
bail!("tracking needs congressional data, which the browser build can't load yet")
}
pub fn is_tracked(&self, _politician_id: i64) -> Result<bool> {
Ok(false)
}
pub fn alerts(&self, _unseen_only: bool, _limit: usize) -> Result<Vec<Alert>> {
Ok(Vec::new())
}
pub fn mark_alerts_seen(&self) -> Result<()> {
Ok(())
}
}
+11
View File
@@ -1,12 +1,23 @@
//! STOCK Act congressional trade disclosures, ingested directly from the
//! official sources: the Senate eFD system and the House Clerk's PTR filings.
//!
//! The browser build (`wasm32`) has no SQLite and can't reach either site (they don't allow
//! cross-origin requests), so there [`db`] is an empty stand-in and there is no ingest.
#[cfg(not(target_arch = "wasm32"))]
pub mod db;
#[cfg(target_arch = "wasm32")]
#[path = "db_web.rs"]
pub mod db;
#[cfg(not(target_arch = "wasm32"))]
pub mod house;
pub mod model;
pub mod performance;
#[cfg(not(target_arch = "wasm32"))]
pub mod refresh;
#[cfg(not(target_arch = "wasm32"))]
pub mod roster;
#[cfg(not(target_arch = "wasm32"))]
pub mod senate;
pub use db::Db;
+30
View File
@@ -243,6 +243,36 @@ pub fn normalize_ticker(raw: &str) -> Option<String> {
RE.is_match(&t).then_some(t)
}
/// An alert raised when a tracked politician files new trades.
#[derive(Debug, Clone, PartialEq)]
pub struct Alert {
pub id: i64,
pub politician_id: i64,
pub politician: String,
pub filing_id: String,
pub created_at: i64,
pub message: String,
pub seen: bool,
}
/// A politician with aggregate trade counts, for list views.
#[derive(Debug, Clone, PartialEq)]
pub struct PoliticianSummary {
pub politician: Politician,
pub trades: i64,
pub last_filed: Option<NaiveDate>,
pub tracked: bool,
}
/// A new filing by a tracked politician, found during a refresh.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct NewTradeAlert {
pub politician_id: i64,
pub politician: String,
pub filing_id: String,
pub message: String,
}
#[cfg(test)]
mod tests {
use super::*;
+2 -9
View File
@@ -12,6 +12,7 @@ use tracing::{debug, info, warn};
use crate::db::{Db, FilingRecord, FilingStatus, NewPolitician};
use crate::house::{self, IndexEntry, PdfError};
pub use crate::model::NewTradeAlert;
use crate::model::{Chamber, Party};
use crate::roster::{self, Roster};
use crate::senate::{self, ReportKind, ReportRef};
@@ -58,14 +59,6 @@ impl RefreshConfig {
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct NewTradeAlert {
pub politician_id: i64,
pub politician: String,
pub filing_id: String,
pub message: String,
}
#[derive(Debug, Clone, Default)]
pub struct RefreshReport {
pub new_filings: usize,
@@ -394,7 +387,7 @@ impl HttpSource {
pub fn new() -> Result<Self> {
let client = reqwest::Client::builder()
.user_agent(concat!(
"terminal-rs/",
"terminal/",
env!("CARGO_PKG_VERSION"),
" (STOCK Act disclosure reader; +https://github.com/)"
))
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tr-core"
description = "Core market types, timeframes, indicators and sample data for terminal-rs"
description = "Core market types, timeframes, indicators and sample data for Terminal"
version.workspace = true
edition.workspace = true
@@ -10,6 +10,7 @@ serde.workspace = true
[dev-dependencies]
criterion.workspace = true
serde_json.workspace = true
[[bench]]
name = "indicators"
+14
View File
@@ -1,5 +1,6 @@
use criterion::{Criterion, criterion_group, criterion_main};
use std::hint::black_box;
use tr_core::indicators::registry::{self, Input};
use tr_core::{Timeframe, indicators, sample};
fn bench(c: &mut Criterion) {
@@ -14,6 +15,19 @@ fn bench(c: &mut Criterion) {
g.bench_function("bollinger", |b| b.iter(|| indicators::bollinger(black_box(&closes), 20, 2.0)));
g.bench_function("vwap", |b| b.iter(|| indicators::vwap(black_box(&bars))));
g.finish();
// The whole catalogue at default parameters, as the chart worker computes it.
let input = Input::new(&bars[..100_000], Timeframe::M1);
let mut g = c.benchmark_group("catalog_100k_bars");
g.sample_size(10);
g.bench_function("every_indicator", |b| {
b.iter(|| registry::all().iter().map(|d| d.run(black_box(&input), &d.defaults()).len()).sum::<usize>())
});
for code in ["ICHIMOKU", "MAMA", "SUPERTREND", "ZIGZAG", "PIVOT", "YZ"] {
let d = registry::find(code).expect(code);
g.bench_function(code, |b| b.iter(|| d.run(black_box(&input), &d.defaults())));
}
g.finish();
}
criterion_group!(benches, bench);
-289
View File
@@ -1,289 +0,0 @@
//! Technical indicators. Every function returns a vector aligned with its
//! input; positions without enough history hold `f64::NAN`.
//!
//! Conventions follow the common charting definitions: EMA uses
//! alpha = 2/(n+1) seeded with an SMA, RSI uses Wilder smoothing, Bollinger
//! bands use the population standard deviation, and VWAP resets each session.
use crate::Bar;
/// Simple moving average over `n` values, computed with a running sum.
pub fn sma(values: &[f64], n: usize) -> Vec<f64> {
let mut out = vec![f64::NAN; values.len()];
if n == 0 || values.len() < n {
return out;
}
let mut sum: f64 = values[..n].iter().sum();
out[n - 1] = sum / n as f64;
for i in n..values.len() {
sum += values[i] - values[i - n];
out[i] = sum / n as f64;
}
out
}
/// Exponential smoothing seeded with the SMA of the first `n` values.
fn smooth(values: &[f64], n: usize, alpha: f64) -> Vec<f64> {
let mut out = vec![f64::NAN; values.len()];
if n == 0 || values.len() < n {
return out;
}
let mut prev = values[..n].iter().sum::<f64>() / n as f64;
out[n - 1] = prev;
for i in n..values.len() {
prev = alpha * values[i] + (1.0 - alpha) * prev;
out[i] = prev;
}
out
}
/// Exponential moving average, alpha = 2/(n+1).
pub fn ema(values: &[f64], n: usize) -> Vec<f64> {
smooth(values, n, 2.0 / (n as f64 + 1.0))
}
/// Wilder's moving average (RMA), alpha = 1/n.
pub fn rma(values: &[f64], n: usize) -> Vec<f64> {
smooth(values, n, 1.0 / n as f64)
}
/// Relative Strength Index with Wilder smoothing (first value at index `n`).
/// Single pass with no intermediate buffers: on large series the cost is dominated by
/// first-touch of output memory, so only the output is allocated.
pub fn rsi(closes: &[f64], n: usize) -> Vec<f64> {
let mut out = vec![f64::NAN; closes.len()];
if n == 0 || closes.len() <= n {
return out;
}
let value = |g: f64, l: f64| {
if l == 0.0 { if g == 0.0 { 50.0 } else { 100.0 } } else { 100.0 - 100.0 / (1.0 + g / l) }
};
let (mut ag, mut al) = (0.0, 0.0);
for i in 1..=n {
let d = closes[i] - closes[i - 1];
ag += d.max(0.0);
al += (-d).max(0.0);
}
ag /= n as f64;
al /= n as f64;
out[n] = value(ag, al);
let alpha = 1.0 / n as f64;
for i in (n + 1)..closes.len() {
let d = closes[i] - closes[i - 1];
ag = alpha * d.max(0.0) + (1.0 - alpha) * ag;
al = alpha * (-d).max(0.0) + (1.0 - alpha) * al;
out[i] = value(ag, al);
}
out
}
#[derive(Debug, Clone)]
pub struct Macd {
pub macd: Vec<f64>,
pub signal: Vec<f64>,
pub histogram: Vec<f64>,
}
/// Streaming EMA seeded with the SMA of its first `n` inputs.
struct Ema {
n: usize,
alpha: f64,
seen: usize,
acc: f64,
}
impl Ema {
fn new(n: usize) -> Self {
Self { n, alpha: 2.0 / (n as f64 + 1.0), seen: 0, acc: 0.0 }
}
/// Feed one value; returns the EMA once `n` values have been seen.
fn push(&mut self, v: f64) -> Option<f64> {
self.seen += 1;
if self.seen < self.n {
self.acc += v;
None
} else if self.seen == self.n {
self.acc = (self.acc + v) / self.n as f64;
Some(self.acc)
} else {
self.acc = self.alpha * v + (1.0 - self.alpha) * self.acc;
Some(self.acc)
}
}
}
/// MACD line (fast EMA - slow EMA), its signal EMA and histogram, in one pass.
pub fn macd(closes: &[f64], fast: usize, slow: usize, signal: usize) -> Macd {
let len = closes.len();
let mut line = vec![f64::NAN; len];
let mut sig = vec![f64::NAN; len];
let mut histogram = vec![f64::NAN; len];
if fast == 0 || slow == 0 || signal == 0 {
return Macd { macd: line, signal: sig, histogram };
}
let (mut ef, mut es, mut eg) = (Ema::new(fast), Ema::new(slow), Ema::new(signal));
for (i, &c) in closes.iter().enumerate() {
let (f, s) = (ef.push(c), es.push(c));
let (Some(f), Some(s)) = (f, s) else { continue };
let m = f - s;
line[i] = m;
// The signal EMA starts once the MACD line itself is defined.
if let Some(g) = eg.push(m) {
sig[i] = g;
histogram[i] = m - g;
}
}
Macd { macd: line, signal: sig, histogram }
}
#[derive(Debug, Clone)]
pub struct Bands {
pub middle: Vec<f64>,
pub upper: Vec<f64>,
pub lower: Vec<f64>,
}
/// Bollinger bands: SMA(n) +/- k * population standard deviation.
pub fn bollinger(closes: &[f64], n: usize, k: f64) -> Bands {
let middle = sma(closes, n);
let mut upper = vec![f64::NAN; closes.len()];
let mut lower = vec![f64::NAN; closes.len()];
if n > 0 && closes.len() >= n {
let mut sum_sq: f64 = closes[..n].iter().map(|v| v * v).sum();
for i in (n - 1)..closes.len() {
if i >= n {
sum_sq += closes[i] * closes[i] - closes[i - n] * closes[i - n];
}
let mean = middle[i];
let var = (sum_sq / n as f64 - mean * mean).max(0.0);
let sd = var.sqrt();
upper[i] = mean + k * sd;
lower[i] = mean - k * sd;
}
}
Bands { middle, upper, lower }
}
/// Volume-weighted average price over hlc3, reset whenever `session_of(ts)` changes.
pub fn vwap_by(bars: &[Bar], session_of: impl Fn(i64) -> i64) -> Vec<f64> {
let mut out = Vec::with_capacity(bars.len());
let (mut pv, mut vol) = (0.0, 0.0);
let mut session = None;
for b in bars {
let s = session_of(b.ts);
if session != Some(s) {
session = Some(s);
pv = 0.0;
vol = 0.0;
}
pv += b.hlc3() * b.volume;
vol += b.volume;
out.push(if vol > 0.0 { pv / vol } else { b.hlc3() });
}
out
}
/// Session VWAP resetting each UTC day. For daily and longer bars this
/// degenerates to hlc3, so callers typically anchor it by month instead.
pub fn vwap(bars: &[Bar]) -> Vec<f64> {
vwap_by(bars, |ts| ts.div_euclid(86_400))
}
#[cfg(test)]
mod tests {
use super::*;
fn close_to(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
fn assert_series(got: &[f64], want: &[f64]) {
assert_eq!(got.len(), want.len());
for (i, (g, w)) in got.iter().zip(want).enumerate() {
assert!((g.is_nan() && w.is_nan()) || close_to(*g, *w), "index {i}: got {g}, want {w}");
}
}
const N: f64 = f64::NAN;
#[test]
fn sma_basic() {
assert_series(&sma(&[1.0, 2.0, 3.0, 4.0, 5.0], 3), &[N, N, 2.0, 3.0, 4.0]);
assert!(sma(&[1.0], 3)[0].is_nan());
assert!(sma(&[1.0, 2.0], 0).iter().all(|v| v.is_nan()));
}
#[test]
fn ema_seeded_with_sma() {
// alpha = 0.5; seed = mean(1,2,3) = 2; then 0.5*4+0.5*2 = 3; 0.5*5+0.5*3 = 4.
assert_series(&ema(&[1.0, 2.0, 3.0, 4.0, 5.0], 3), &[N, N, 2.0, 3.0, 4.0]);
// Constant input stays constant.
assert!(close_to(ema(&[7.0; 50], 10)[49], 7.0));
}
#[test]
fn rsi_reference_values() {
// Wilder's original 14-period example (New Concepts in Technical Trading Systems).
let closes = [
44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08, 45.89, 46.03, 45.61, 46.28, 46.28,
46.00, 46.03, 46.41, 46.22, 45.64,
];
let r = rsi(&closes, 14);
assert!(r[13].is_nan());
assert!((r[14] - 70.46).abs() < 0.01, "rsi[14] = {}", r[14]);
assert!((r[15] - 66.25).abs() < 0.01, "rsi[15] = {}", r[15]);
assert!((r[19] - 57.92).abs() < 0.05, "rsi[19] = {}", r[19]);
}
#[test]
fn rsi_extremes() {
let up: Vec<f64> = (0..30).map(|i| i as f64).collect();
assert_eq!(rsi(&up, 14)[29], 100.0);
assert_eq!(rsi(&[5.0; 30], 14)[29], 50.0);
}
#[test]
fn macd_matches_manual_emas() {
let closes: Vec<f64> = (0..60).map(|i| 100.0 + (i as f64 * 0.3).sin() * 5.0 + i as f64 * 0.1).collect();
let m = macd(&closes, 12, 26, 9);
let f = ema(&closes, 12);
let s = ema(&closes, 26);
assert!(m.macd[24].is_nan());
assert!(close_to(m.macd[25], f[25] - s[25]));
// Signal needs 9 MACD values: first at 25 + 8.
assert!(m.signal[32].is_nan());
let seed: f64 = m.macd[25..34].iter().sum::<f64>() / 9.0;
assert!(close_to(m.signal[33], seed));
assert!(close_to(m.histogram[40], m.macd[40] - m.signal[40]));
}
#[test]
fn bollinger_population_sd() {
let b = bollinger(&[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0], 8, 2.0);
// mean 5, population sd 2.
assert!(close_to(b.middle[7], 5.0));
assert!(close_to(b.upper[7], 9.0));
assert!(close_to(b.lower[7], 1.0));
assert!(b.upper[6].is_nan());
// Rolling window stays numerically consistent with a fresh computation.
let xs: Vec<f64> = (0..200).map(|i| ((i * 37) % 101) as f64).collect();
let roll = bollinger(&xs, 20, 2.0);
let fresh = bollinger(&xs[180..], 20, 2.0);
assert!(close_to(roll.upper[199], fresh.upper[19]));
}
#[test]
fn vwap_resets_per_session() {
let day = 86_400;
let bars = [
Bar::new(0, 0.0, 12.0, 8.0, 10.0, 100.0),
Bar::new(60, 0.0, 22.0, 18.0, 20.0, 300.0),
Bar::new(day, 0.0, 6.0, 4.0, 5.0, 10.0),
];
let v = vwap(&bars);
assert!(close_to(v[0], 10.0));
assert!(close_to(v[1], (10.0 * 100.0 + 20.0 * 300.0) / 400.0));
assert!(close_to(v[2], 5.0));
}
}
+176
View File
@@ -0,0 +1,176 @@
//! Price levels: session keys and pivot points.
use chrono::Datelike;
use crate::Timeframe;
use super::{NAN, Ohlcv};
/// The session a bar at `ts` belongs to on `tf`, for VWAP and pivots: the UTC day
/// intraday, the month on daily bars, the year above that.
pub fn session_key(tf: Timeframe) -> impl Fn(i64) -> i64 {
move |ts| {
let d = chrono::DateTime::from_timestamp(ts, 0).unwrap_or_default();
match tf {
t if t.is_intraday() => ts.div_euclid(86_400),
Timeframe::D1 => (d.year() * 12 + d.month() as i32) as i64,
_ => d.year() as i64,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PivotKind {
Classic,
Fibonacci,
Camarilla,
Woodie,
Demark,
}
/// Open, high, low and close of one session.
#[derive(Debug, Clone, Copy)]
struct Session {
open: f64,
high: f64,
low: f64,
close: f64,
}
impl PivotKind {
/// Level names, in the order [`pivots`] returns them.
pub fn names(self) -> &'static [&'static str] {
match self {
PivotKind::Classic | PivotKind::Fibonacci | PivotKind::Woodie => &["P", "R1", "S1", "R2", "S2", "R3", "S3"],
PivotKind::Camarilla => &["P", "R1", "S1", "R2", "S2", "R3", "S3", "R4", "S4"],
PivotKind::Demark => &["P", "R1", "S1"],
}
}
/// Levels from the previous session `s`, given the current session's `open`.
fn levels(self, s: Session, open: f64) -> Vec<f64> {
let Session { open: o, high: h, low: l, close: c } = s;
let range = h - l;
let classic =
|p: f64| vec![p, 2.0 * p - l, 2.0 * p - h, p + range, p - range, h + 2.0 * (p - l), l - 2.0 * (h - p)];
match self {
PivotKind::Classic => classic((h + l + c) / 3.0),
PivotKind::Woodie => classic((h + l + 2.0 * open) / 4.0),
PivotKind::Fibonacci => {
let p = (h + l + c) / 3.0;
vec![
p,
p + 0.382 * range,
p - 0.382 * range,
p + 0.618 * range,
p - 0.618 * range,
p + range,
p - range,
]
}
PivotKind::Camarilla => {
let p = (h + l + c) / 3.0;
let k = 1.1 * range;
vec![
p,
c + k / 12.0,
c - k / 12.0,
c + k / 6.0,
c - k / 6.0,
c + k / 4.0,
c - k / 4.0,
c + k / 2.0,
c - k / 2.0,
]
}
PivotKind::Demark => {
let x = if c < o {
h + 2.0 * l + c
} else if c > o {
2.0 * h + l + c
} else {
h + l + 2.0 * c
};
vec![x / 4.0, x / 2.0 - l, x / 2.0 - h]
}
}
}
}
/// Pivot levels for each bar from the previous session's prices (`session_of` maps a
/// timestamp to its session); the first session has none.
pub fn pivots(o: &Ohlcv, session_of: impl Fn(i64) -> i64, kind: PivotKind) -> Vec<(&'static str, Vec<f64>)> {
let names = kind.names();
let len = o.len();
let mut out = vec![vec![NAN; len]; names.len()];
let keys: Vec<i64> = o.ts.iter().map(|&t| session_of(t)).collect();
let mut cur: Option<Session> = None;
let mut levels: Option<Vec<f64>> = None;
for i in 0..len {
let bar = Session { open: o.open[i], high: o.high[i], low: o.low[i], close: o.close[i] };
match cur.as_mut() {
Some(s) if keys[i] == keys[i - 1] => {
s.high = s.high.max(bar.high);
s.low = s.low.min(bar.low);
s.close = bar.close;
}
prev => {
levels = prev.map(|p| kind.levels(*p, bar.open));
cur = Some(bar);
}
}
if let Some(l) = &levels {
for (series, v) in out.iter_mut().zip(l) {
series[i] = *v;
}
}
}
names.iter().copied().zip(out).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Bar;
#[test]
fn session_keys() {
let day = 86_400;
let k = session_key(Timeframe::M5);
assert_eq!(k(10), k(day - 1));
assert_ne!(k(day - 1), k(day));
// 2026-01-31 and 2026-02-01 fall in different months on daily bars.
let m = session_key(Timeframe::D1);
assert_ne!(m(1_769_817_600), m(1_769_904_000));
assert_eq!(session_key(Timeframe::W1)(1_769_817_600), 2026);
}
#[test]
fn classic_pivots_from_the_previous_session() {
let day = 86_400;
let bars = [
Bar::new(0, 10.0, 12.0, 8.0, 11.0, 1.0),
Bar::new(60, 11.0, 15.0, 9.0, 13.0, 1.0),
Bar::new(day, 13.0, 14.0, 12.0, 13.5, 1.0),
Bar::new(day + 60, 13.5, 14.5, 12.5, 14.0, 1.0),
Bar::new(2 * day, 14.0, 15.0, 13.0, 14.0, 1.0),
];
let o = Ohlcv::new(&bars);
let p = pivots(&o, |ts| ts.div_euclid(day), PivotKind::Classic);
let names: Vec<&str> = p.iter().map(|(n, _)| *n).collect();
assert_eq!(names, ["P", "R1", "S1", "R2", "S2", "R3", "S3"]);
// Day 1: H 15, L 8, C 13 → P = 12, R1 = 16, S1 = 9.
let (pp, r1, s1) = (&p[0].1, &p[1].1, &p[2].1);
assert!(pp[0].is_nan() && pp[1].is_nan());
assert_eq!((pp[2], r1[2], s1[2]), (12.0, 16.0, 9.0));
// Every bar of day 2 carries its levels; day 3 uses H 14.5, L 12, C 14.
assert_eq!((pp[3], r1[3], s1[3]), (12.0, 16.0, 9.0));
assert!((pp[4] - 40.5 / 3.0).abs() < 1e-12);
let dm = pivots(&o, |ts| ts.div_euclid(day), PivotKind::Demark);
// Day 1 closed above its open: X = 2H + L + C = 51.
assert_eq!((dm[0].1[2], dm[1].1[2], dm[2].1[2]), (12.75, 17.5, 10.5));
let w = pivots(&o, |ts| ts.div_euclid(day), PivotKind::Woodie);
assert_eq!(w[0].1[2], (15.0 + 8.0 + 2.0 * 13.0) / 4.0);
assert_eq!(pivots(&o, |ts| ts.div_euclid(day), PivotKind::Camarilla).len(), 9);
}
}
+491
View File
@@ -0,0 +1,491 @@
//! Moving averages and adaptive filters, including John Ehlers' DSP smoothers.
use std::f64::consts::PI;
use super::stats::{change, highest, lowest, sum, windows};
use super::{NAN, Ohlcv, from_first, ratio, zip};
/// Simple moving average over `n` values.
pub fn sma(values: &[f64], n: usize) -> Vec<f64> {
let k = n as f64;
sum(values, n).into_iter().map(|s| s / k).collect()
}
/// Exponential smoothing with factor `alpha`, seeded with the SMA of the first `n` finite
/// values. A non-finite input yields NaN at that bar and leaves the state untouched.
pub(super) fn smooth(values: &[f64], n: usize, alpha: f64) -> Vec<f64> {
let mut out = vec![NAN; values.len()];
if n == 0 {
return out;
}
let (mut seen, mut acc) = (0usize, 0.0);
for (i, &v) in values.iter().enumerate() {
if !v.is_finite() {
continue;
}
if seen < n {
acc += v;
seen += 1;
if seen < n {
continue;
}
acc /= n as f64;
} else {
acc = alpha * v + (1.0 - alpha) * acc;
}
out[i] = acc;
}
out
}
/// Exponential moving average, alpha = 2/(n+1).
pub fn ema(values: &[f64], n: usize) -> Vec<f64> {
smooth(values, n, 2.0 / (n as f64 + 1.0))
}
/// Wilder's moving average (RMA, SMMA), alpha = 1/n.
pub fn rma(values: &[f64], n: usize) -> Vec<f64> {
smooth(values, n, 1.0 / n as f64)
}
/// Linearly weighted moving average (newest weight n, oldest 1), O(1) per bar.
pub fn wma(values: &[f64], n: usize) -> Vec<f64> {
let mut out = vec![NAN; values.len()];
let denom = (n * (n + 1)) as f64 / 2.0;
windows(values, n, |i, r, _, w, _| out[i] = w / denom + r);
out
}
/// Hull moving average: WMA(2·WMA(n/2) − WMA(n), √n).
pub fn hma(values: &[f64], n: usize) -> Vec<f64> {
if n < 2 {
return wma(values, n);
}
let diff = zip(&wma(values, n / 2), &wma(values, n), |a, b| 2.0 * a - b);
wma(&diff, ((n as f64).sqrt() as usize).max(1))
}
/// Double EMA: 2·EMA − EMA(EMA).
pub fn dema(values: &[f64], n: usize) -> Vec<f64> {
let e1 = ema(values, n);
let e2 = ema(&e1, n);
zip(&e1, &e2, |a, b| 2.0 * a - b)
}
/// Triple EMA: 3·E1 − 3·E2 + E3.
pub fn tema(values: &[f64], n: usize) -> Vec<f64> {
let e1 = ema(values, n);
let e2 = ema(&e1, n);
let e3 = ema(&e2, n);
(0..values.len()).map(|i| 3.0 * e1[i] - 3.0 * e2[i] + e3[i]).collect()
}
/// Triangular moving average: an SMA of an SMA spanning `n` bars in total.
pub fn trima(values: &[f64], n: usize) -> Vec<f64> {
if n == 0 {
return vec![NAN; values.len()];
}
let (a, b) = if n % 2 == 1 { (n.div_ceil(2), n.div_ceil(2)) } else { (n / 2, n / 2 + 1) };
sma(&sma(values, a), b)
}
/// Kaufman adaptive moving average: the smoothing constant moves between the `fast` and
/// `slow` EMA constants with the efficiency ratio over `n` bars.
pub fn kama(values: &[f64], n: usize, fast: usize, slow: usize) -> Vec<f64> {
from_first(values, |v| {
let mut out = vec![NAN; v.len()];
if n == 0 || v.len() < n {
return out;
}
let (fsc, ssc) = (2.0 / (fast as f64 + 1.0), 2.0 / (slow as f64 + 1.0));
let mut k = v[n - 1];
out[n - 1] = k;
let mut vol: f64 = (1..n.min(v.len())).map(|j| (v[j] - v[j - 1]).abs()).sum();
for i in n..v.len() {
vol += (v[i] - v[i - 1]).abs();
if i > n {
vol -= (v[i - n] - v[i - n - 1]).abs();
}
let er = ratio((v[i] - v[i - n]).abs(), vol, 0.0).clamp(0.0, 1.0);
let sc = (er * (fsc - ssc) + ssc).powi(2);
k += sc * (v[i] - k);
out[i] = k;
}
out
})
}
/// Zero-lag EMA: an EMA of `2·x − x[lag]` with lag (n−1)/2.
pub fn zlema(values: &[f64], n: usize) -> Vec<f64> {
let lag = n.saturating_sub(1) / 2;
let adj: Vec<f64> =
(0..values.len()).map(|i| if i >= lag { 2.0 * values[i] - values[i - lag] } else { NAN }).collect();
ema(&adj, n)
}
/// Arnaud Legoux moving average: Gaussian weights centred at `offset` (0..1) across the
/// window, width n/`sigma`.
pub fn alma(values: &[f64], n: usize, offset: f64, sigma: f64) -> Vec<f64> {
let mut out = vec![NAN; values.len()];
if n == 0 || sigma <= 0.0 {
return out;
}
let m = offset * (n - 1) as f64;
let s = n as f64 / sigma;
let w: Vec<f64> = (0..n).map(|j| (-(j as f64 - m).powi(2) / (2.0 * s * s)).exp()).collect();
let norm: f64 = w.iter().sum();
for i in n - 1..values.len() {
let win = &values[i + 1 - n..=i];
if win.iter().all(|x| x.is_finite()) {
out[i] = win.iter().zip(&w).map(|(x, w)| x * w).sum::<f64>() / norm;
}
}
out
}
/// Volume-weighted moving average (falls back to the SMA where the window has no volume).
pub fn vwma(values: &[f64], volume: &[f64], n: usize) -> Vec<f64> {
let pv = zip(values, volume, |c, v| c * v);
let (spv, sv, sc) = (sum(&pv, n), sum(volume, n), sma(values, n));
(0..values.len()).map(|i| if sv[i] > 0.0 { spv[i] / sv[i] } else { sc[i] }).collect()
}
/// Elastic volume-weighted moving average: each bar moves the average by its share of the
/// last `n` bars' volume.
pub fn evwma(values: &[f64], volume: &[f64], n: usize) -> Vec<f64> {
let sv = sum(volume, n);
let mut out = vec![NAN; values.len()];
let mut prev = NAN;
for i in 0..values.len() {
if !sv[i].is_finite() || !values[i].is_finite() {
continue;
}
prev = if prev.is_nan() {
values[i]
} else if sv[i] > 0.0 {
((sv[i] - volume[i]) * prev + volume[i] * values[i]) / sv[i]
} else {
prev
};
out[i] = prev;
}
out
}
/// Tillson T3: six chained EMAs combined with volume factor `v`.
pub fn t3(values: &[f64], n: usize, v: f64) -> Vec<f64> {
let e1 = ema(values, n);
let e2 = ema(&e1, n);
let e3 = ema(&e2, n);
let e4 = ema(&e3, n);
let e5 = ema(&e4, n);
let e6 = ema(&e5, n);
let (v2, v3) = (v * v, v * v * v);
let (c1, c2, c3, c4) = (-v3, 3.0 * v2 + 3.0 * v3, -6.0 * v2 - 3.0 * v - 3.0 * v3, 1.0 + 3.0 * v + v3 + 3.0 * v2);
(0..values.len()).map(|i| c1 * e6[i] + c2 * e5[i] + c3 * e4[i] + c4 * e3[i]).collect()
}
/// McGinley Dynamic: an EMA-seeded average whose speed adapts to (price/average)^4. The
/// step is capped at the full distance so it never overshoots.
pub fn mcginley(values: &[f64], n: usize) -> Vec<f64> {
let seed = ema(values, n);
let mut out = vec![NAN; values.len()];
let mut md = NAN;
for i in 0..values.len() {
let x = values[i];
if md.is_nan() {
md = seed[i];
} else if x.is_finite() {
let d = n as f64 * (x / md).powi(4);
md = if md != 0.0 && d.is_finite() { md + (x - md) / d.max(1.0) } else { x };
}
out[i] = md;
}
out
}
/// Chande's variable index dynamic average: an EMA whose factor scales with |CMO(`cmo_n`)|.
pub fn vidya(values: &[f64], n: usize, cmo_n: usize) -> Vec<f64> {
let ch = change(values, 1);
let up: Vec<f64> = ch.iter().map(|c| if c.is_nan() { NAN } else { c.max(0.0) }).collect();
let dn: Vec<f64> = ch.iter().map(|c| if c.is_nan() { NAN } else { (-c).max(0.0) }).collect();
let (su, sd) = (sum(&up, cmo_n), sum(&dn, cmo_n));
let alpha = 2.0 / (n as f64 + 1.0);
let mut out = vec![NAN; values.len()];
let mut v = NAN;
for i in 0..values.len() {
if !su[i].is_finite() || !sd[i].is_finite() || !values[i].is_finite() {
continue;
}
let k = alpha * ratio(su[i] - sd[i], su[i] + sd[i], 0.0).abs();
v = if v.is_nan() { values[i] } else { k * values[i] + (1.0 - k) * v };
out[i] = v;
}
out
}
/// Ehlers' fractal adaptive moving average of the median price over `n` (even) bars.
pub fn frama(d: &Ohlcv, n: usize) -> Vec<f64> {
let n = n.max(2).next_multiple_of(2);
let h = n / 2;
let price = d.hl2();
let (hh1, ll1) = (highest(&d.high, h), lowest(&d.low, h));
let (hh, ll) = (highest(&d.high, n), lowest(&d.low, n));
let mut out = vec![NAN; d.len()];
let (mut dim, mut filt) = (1.0, NAN);
for i in n - 1..d.len() {
let n1 = (hh1[i] - ll1[i]) / h as f64;
let n2 = (hh1[i - h] - ll1[i - h]) / h as f64;
let n3 = (hh[i] - ll[i]) / n as f64;
if !(n1.is_finite() && n2.is_finite() && n3.is_finite() && price[i].is_finite()) {
continue;
}
if n1 > 0.0 && n2 > 0.0 && n3 > 0.0 {
dim = ((n1 + n2).ln() - n3.ln()) / 2f64.ln();
}
let alpha = (-4.6 * (dim - 1.0)).exp().clamp(0.01, 1.0);
filt = if filt.is_nan() { price[i] } else { alpha * price[i] + (1.0 - alpha) * filt };
out[i] = filt;
}
out
}
/// Ehlers' two-pole super smoother with critical period `n`.
pub fn super_smoother(values: &[f64], n: usize) -> Vec<f64> {
if n == 0 {
return vec![NAN; values.len()];
}
from_first(values, |v| {
let a1 = (-1.414 * PI / n as f64).exp();
let c2 = 2.0 * a1 * (1.414 * PI / n as f64).cos();
let c3 = -a1 * a1;
let c1 = 1.0 - c2 - c3;
let mut out = v.to_vec();
for i in 2..v.len() {
out[i] = c1 * (v[i] + v[i - 1]) / 2.0 + c2 * out[i - 1] + c3 * out[i - 2];
}
out
})
}
/// Ehlers' simple decycler: the series minus its two-pole high-pass component, which
/// removes cycles shorter than `n` bars with no lag. Cutoffs under 3 bars put the
/// high-pass pole outside the unit circle, so they are blank.
pub fn decycler(values: &[f64], n: usize) -> Vec<f64> {
if n < 3 {
return vec![NAN; values.len()];
}
from_first(values, |v| {
let w = 0.707 * 2.0 * PI / n as f64;
let a = (w.cos() + w.sin() - 1.0) / w.cos();
let (k1, k2, k3) = ((1.0 - a / 2.0).powi(2), 2.0 * (1.0 - a), (1.0 - a).powi(2));
let mut hp = vec![0.0; v.len()];
for i in 2..v.len() {
hp[i] = k1 * (v[i] - 2.0 * v[i - 1] + v[i - 2]) + k2 * hp[i - 1] - k3 * hp[i - 2];
}
zip(v, &hp, |x, h| x - h)
})
}
fn at(v: &[f64], i: usize, k: usize) -> f64 {
if i >= k { v[i - k] } else { 0.0 }
}
/// MESA adaptive moving average and its following average.
#[derive(Debug, Clone)]
pub struct Mama {
pub mama: Vec<f64>,
pub fama: Vec<f64>,
}
/// Ehlers' MESA adaptive moving average: a Hilbert-transform phase rate sets the EMA
/// factor between `slow` and `fast` limits.
pub fn mama(values: &[f64], fast: f64, slow: f64) -> Mama {
let len = values.len();
let mut out = Mama { mama: vec![NAN; len], fama: vec![NAN; len] };
let Some(start) = values.iter().position(|v| v.is_finite()) else { return out };
let p = &values[start..];
let m = p.len();
let [mut smooth, mut detr, mut i1, mut q1, mut i2, mut q2, mut re, mut im, mut period, mut phase] =
std::array::from_fn(|_| vec![0.0; m]);
let (mut ma, mut fa) = (p[0], p[0]);
for i in 0..m {
let pr = |k: usize| if i >= k { p[i - k] } else { p[0] };
smooth[i] = (4.0 * pr(0) + 3.0 * pr(1) + 2.0 * pr(2) + pr(3)) / 10.0;
let adj = 0.075 * at(&period, i, 1) + 0.54;
let hilbert =
|v: &[f64]| (0.0962 * v[i] + 0.5769 * at(v, i, 2) - 0.5769 * at(v, i, 4) - 0.0962 * at(v, i, 6)) * adj;
detr[i] = hilbert(&smooth);
q1[i] = hilbert(&detr);
i1[i] = at(&detr, i, 3);
let (ji, jq) = (hilbert(&i1), hilbert(&q1));
i2[i] = 0.2 * (i1[i] - jq) + 0.8 * at(&i2, i, 1);
q2[i] = 0.2 * (q1[i] + ji) + 0.8 * at(&q2, i, 1);
let r = i2[i] * at(&i2, i, 1) + q2[i] * at(&q2, i, 1);
let j = i2[i] * at(&q2, i, 1) - q2[i] * at(&i2, i, 1);
re[i] = 0.2 * r + 0.8 * at(&re, i, 1);
im[i] = 0.2 * j + 0.8 * at(&im, i, 1);
let prev = at(&period, i, 1);
let mut per = prev;
if im[i] != 0.0 && re[i] != 0.0 {
per = 2.0 * PI / (im[i] / re[i]).atan();
}
per = per.min(1.5 * prev).max(0.67 * prev).clamp(6.0, 50.0);
period[i] = 0.2 * per + 0.8 * prev;
phase[i] = if i1[i] != 0.0 { (q1[i] / i1[i]).atan().to_degrees() } else { at(&phase, i, 1) };
let delta = (at(&phase, i, 1) - phase[i]).max(1.0);
let alpha = (fast / delta).max(slow);
ma = alpha * p[i] + (1.0 - alpha) * ma;
fa = 0.5 * alpha * ma + (1.0 - 0.5 * alpha) * fa;
if i >= 6 {
out.mama[start + i] = ma;
out.fama[start + i] = fa;
}
}
out
}
/// Ehlers' instantaneous trendline and its trigger (2·IT − IT[2]).
pub fn itrend(values: &[f64], alpha: f64) -> (Vec<f64>, Vec<f64>) {
let it = from_first(values, |p| {
let mut it = vec![0.0; p.len()];
let a = alpha;
for i in 0..p.len() {
let pr = |k: usize| if i >= k { p[i - k] } else { p[0] };
it[i] = if i < 7 {
(pr(0) + 2.0 * pr(1) + pr(2)) / 4.0
} else {
(a - a * a / 4.0) * p[i] + 0.5 * a * a * p[i - 1] - (a - 0.75 * a * a) * p[i - 2]
+ 2.0 * (1.0 - a) * it[i - 1]
- (1.0 - a).powi(2) * it[i - 2]
};
}
it
});
let trigger = (0..it.len()).map(|i| if i >= 2 { 2.0 * it[i] - it[i - 2] } else { NAN }).collect();
(it, trigger)
}
/// Symmetrically weighted moving average over 4 bars (weights 1, 2, 2, 1).
pub(super) fn swma(values: &[f64]) -> Vec<f64> {
(0..values.len())
.map(
|i| {
if i < 3 { NAN } else { (values[i] + 2.0 * values[i - 1] + 2.0 * values[i - 2] + values[i - 3]) / 6.0 }
},
)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9 * (1.0 + b.abs())
}
fn wave(n: usize) -> Vec<f64> {
(0..n)
.map(|i| 100.0 + (i as f64 * 0.21).sin() * 8.0 + (i as f64 * 0.05).cos() * 3.0 + i as f64 * 0.04)
.collect()
}
#[test]
fn wma_hand_computed_and_rolling() {
// (1·1 + 2·2 + 3·3) / 6 = 14/6; (2 + 6 + 12) / 6 = 20/6.
let w = wma(&[1.0, 2.0, 3.0, 4.0], 3);
assert!(w[1].is_nan() && near(w[2], 14.0 / 6.0) && near(w[3], 20.0 / 6.0));
let xs = wave(300);
let got = wma(&xs, 9);
for i in 8..300 {
let want = (0..9).map(|k| (k + 1) as f64 * xs[i - 8 + k]).sum::<f64>() / 45.0;
assert!(near(got[i], want), "{i}");
}
}
#[test]
fn compositions_follow_their_definitions() {
let xs = wave(400);
let (e1, e2) = (ema(&xs, 10), ema(&ema(&xs, 10), 10));
let d = dema(&xs, 10);
assert!(near(d[300], 2.0 * e1[300] - e2[300]));
let h = hma(&xs, 16);
let diff = zip(&wma(&xs, 8), &wma(&xs, 16), |a, b| 2.0 * a - b);
assert!(near(h[300], wma(&diff, 4)[300]));
// TRIMA spans n bars: its first value is at n - 1.
let t = trima(&xs, 10);
assert!(t[8].is_nan() && t[9].is_finite());
let t = trima(&xs, 9);
assert!(t[7].is_nan() && t[8].is_finite());
// T3 coefficients sum to 1, so a constant stays constant.
assert!(near(t3(&[5.0; 200], 5, 0.7)[199], 5.0));
}
#[test]
fn adaptive_averages_track_a_constant_and_a_trend() {
let flat = vec![42.0; 300];
for v in [
kama(&flat, 10, 2, 30),
zlema(&flat, 20),
alma(&flat, 9, 0.85, 6.0),
mcginley(&flat, 14),
vidya(&flat, 20, 9),
super_smoother(&flat, 10),
decycler(&flat, 60),
mama(&flat, 0.5, 0.05).mama,
itrend(&flat, 0.07).0,
] {
assert!(near(v[299], 42.0), "{}", v[299]);
}
// With a perfectly efficient trend KAMA uses the fast constant: k += (2/3)^2 (x - k).
let line: Vec<f64> = (0..50).map(|i| i as f64).collect();
let k = kama(&line, 10, 2, 30);
let fsc: f64 = (2.0f64 / 3.0).powi(2);
assert!(near(k[10], 9.0 + fsc * (10.0 - 9.0)));
// ALMA weights sum to 1 and lean to the recent end with offset 0.85.
let a = alma(&line, 9, 0.85, 6.0);
assert!(a[20] > sma(&line, 9)[20] && a[20] < 20.0);
}
#[test]
fn volume_weighting() {
let c = [10.0, 20.0, 30.0];
let v = [1.0, 1.0, 2.0];
assert!(near(vwma(&c, &v, 3)[2], (10.0 + 20.0 + 60.0) / 4.0));
assert!(near(vwma(&c, &[0.0; 3], 3)[2], 20.0), "no volume falls back to the SMA");
let e = evwma(&c, &v, 2);
assert!(e[0].is_nan() && near(e[1], 20.0));
assert!(near(e[2], ((3.0 - 2.0) * 20.0 + 2.0 * 30.0) / 3.0));
}
#[test]
fn frama_moves_between_bounds() {
let bars: Vec<crate::Bar> = wave(200)
.into_iter()
.enumerate()
.map(|(i, c)| crate::Bar::new(i as i64, c, c + 1.0, c - 1.0, c, 1.0))
.collect();
let f = frama(&Ohlcv::new(&bars), 16);
assert!(f[14].is_nan() && f[15].is_finite());
// A running blend of prices never leaves the range seen so far.
let price: Vec<f64> = bars.iter().map(|b| (b.high + b.low) / 2.0).collect();
for i in 15..f.len() {
let seen = &price[15..=i];
let (lo, hi) = seen.iter().fold((f64::MAX, f64::MIN), |(l, h), v| (l.min(*v), h.max(*v)));
assert!(f[i] >= lo - 1e-9 && f[i] <= hi + 1e-9, "frama[{i}] = {} outside {lo}..{hi}", f[i]);
}
}
#[test]
fn decycler_is_stable_from_its_shortest_cutoff() {
let xs: Vec<f64> = (0..2000).map(|i| 100.0 + (i as f64 * 1.7).sin()).collect();
for n in 3..=10 {
let d = decycler(&xs, n);
assert!(d.iter().all(|v| (90.0..110.0).contains(v)), "cutoff {n} runs away");
}
for n in 0..3 {
assert!(decycler(&xs, n).iter().all(|v| v.is_nan()), "cutoff {n} is unstable");
}
}
}
+209
View File
@@ -0,0 +1,209 @@
//! Technical indicators. Every function returns a vector aligned with its input; positions
//! without enough history hold `f64::NAN`. Inputs may themselves start with NaN (the output
//! of another indicator): smoothing starts at the first defined value, and a gap later on
//! blanks only the windows that contain it.
//!
//! Conventions follow the common charting definitions: EMA uses alpha = 2/(n+1) seeded with
//! an SMA, RSI, ATR and ADX use Wilder smoothing, standard deviations are population ones,
//! and VWAP and pivot levels reset each session (a day intraday, a month on daily bars, a
//! year above that).
//!
//! The functions are grouped by family; [`registry`] lists every indicator with its
//! parameters, placement and outputs, and is what the app's command line, picker and chart
//! are driven from.
use crate::Bar;
mod levels;
mod ma;
mod momentum;
pub mod registry;
mod stats;
mod trend;
mod volatility;
mod volume;
pub use levels::*;
pub use ma::*;
pub use momentum::*;
pub use stats::*;
pub use trend::*;
pub use volatility::*;
pub use volume::*;
const NAN: f64 = f64::NAN;
/// Bars split into columns: the input of the bar-based indicators.
#[derive(Debug, Clone, Default)]
pub struct Ohlcv {
pub ts: Vec<i64>,
pub open: Vec<f64>,
pub high: Vec<f64>,
pub low: Vec<f64>,
pub close: Vec<f64>,
pub volume: Vec<f64>,
}
impl Ohlcv {
pub fn new(bars: &[Bar]) -> Self {
Self {
ts: bars.iter().map(|b| b.ts).collect(),
open: bars.iter().map(|b| b.open).collect(),
high: bars.iter().map(|b| b.high).collect(),
low: bars.iter().map(|b| b.low).collect(),
close: bars.iter().map(|b| b.close).collect(),
volume: bars.iter().map(|b| b.volume).collect(),
}
}
pub fn len(&self) -> usize {
self.close.len()
}
pub fn is_empty(&self) -> bool {
self.close.is_empty()
}
/// Median price, (high + low) / 2.
pub fn hl2(&self) -> Vec<f64> {
zip(&self.high, &self.low, |h, l| (h + l) / 2.0)
}
/// Typical price, (high + low + close) / 3.
pub fn hlc3(&self) -> Vec<f64> {
(0..self.len()).map(|i| (self.high[i] + self.low[i] + self.close[i]) / 3.0).collect()
}
/// Average price, (open + high + low + close) / 4.
pub fn ohlc4(&self) -> Vec<f64> {
(0..self.len()).map(|i| (self.open[i] + self.high[i] + self.low[i] + self.close[i]) / 4.0).collect()
}
/// Weighted close, (high + low + 2 × close) / 4.
pub fn hlcc4(&self) -> Vec<f64> {
(0..self.len()).map(|i| (self.high[i] + self.low[i] + 2.0 * self.close[i]) / 4.0).collect()
}
}
/// Run `f` on `values` from the first finite one on; leading NaNs stay NaN.
fn from_first(values: &[f64], f: impl FnOnce(&[f64]) -> Vec<f64>) -> Vec<f64> {
let start = values.iter().position(|v| v.is_finite()).unwrap_or(values.len());
let mut out = vec![NAN; start];
out.extend(f(&values[start..]));
debug_assert_eq!(out.len(), values.len());
out
}
fn zip(a: &[f64], b: &[f64], f: impl Fn(f64, f64) -> f64) -> Vec<f64> {
a.iter().zip(b).map(|(x, y)| f(*x, *y)).collect()
}
/// `a / b`, or `zero` when `b` is 0.
fn ratio(a: f64, b: f64, zero: f64) -> f64 {
if b == 0.0 { zero } else { a / b }
}
/// `values[i - k]`, NaN before the start.
fn ago(values: &[f64], i: usize, k: usize) -> f64 {
if i >= k { values[i - k] } else { NAN }
}
#[cfg(test)]
mod tests {
use super::*;
fn close_to(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
fn assert_series(got: &[f64], want: &[f64]) {
assert_eq!(got.len(), want.len());
for (i, (g, w)) in got.iter().zip(want).enumerate() {
assert!((g.is_nan() && w.is_nan()) || close_to(*g, *w), "index {i}: got {g}, want {w}");
}
}
const N: f64 = f64::NAN;
#[test]
fn sma_basic() {
assert_series(&sma(&[1.0, 2.0, 3.0, 4.0, 5.0], 3), &[N, N, 2.0, 3.0, 4.0]);
assert!(sma(&[1.0], 3)[0].is_nan());
assert!(sma(&[1.0, 2.0], 0).iter().all(|v| v.is_nan()));
}
#[test]
fn ema_seeded_with_sma() {
// alpha = 0.5; seed = mean(1,2,3) = 2; then 0.5*4+0.5*2 = 3; 0.5*5+0.5*3 = 4.
assert_series(&ema(&[1.0, 2.0, 3.0, 4.0, 5.0], 3), &[N, N, 2.0, 3.0, 4.0]);
// Constant input stays constant.
assert!(close_to(ema(&[7.0; 50], 10)[49], 7.0));
// Leading NaNs (another indicator's warm-up) delay the seed instead of poisoning it.
assert_series(&ema(&[N, N, 1.0, 2.0, 3.0, 4.0], 3), &[N, N, N, N, 2.0, 3.0]);
}
#[test]
fn rsi_reference_values() {
// Wilder's original 14-period example (New Concepts in Technical Trading Systems).
let closes = [
44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08, 45.89, 46.03, 45.61, 46.28, 46.28,
46.00, 46.03, 46.41, 46.22, 45.64,
];
let r = rsi(&closes, 14);
assert!(r[13].is_nan());
assert!((r[14] - 70.46).abs() < 0.01, "rsi[14] = {}", r[14]);
assert!((r[15] - 66.25).abs() < 0.01, "rsi[15] = {}", r[15]);
assert!((r[19] - 57.92).abs() < 0.05, "rsi[19] = {}", r[19]);
}
#[test]
fn rsi_extremes() {
let up: Vec<f64> = (0..30).map(|i| i as f64).collect();
assert_eq!(rsi(&up, 14)[29], 100.0);
assert_eq!(rsi(&[5.0; 30], 14)[29], 50.0);
}
#[test]
fn macd_matches_manual_emas() {
let closes: Vec<f64> = (0..60).map(|i| 100.0 + (i as f64 * 0.3).sin() * 5.0 + i as f64 * 0.1).collect();
let m = macd(&closes, 12, 26, 9);
let f = ema(&closes, 12);
let s = ema(&closes, 26);
assert!(m.macd[24].is_nan());
assert!(close_to(m.macd[25], f[25] - s[25]));
// Signal needs 9 MACD values: first at 25 + 8.
assert!(m.signal[32].is_nan());
let seed: f64 = m.macd[25..34].iter().sum::<f64>() / 9.0;
assert!(close_to(m.signal[33], seed));
assert!(close_to(m.histogram[40], m.macd[40] - m.signal[40]));
}
#[test]
fn bollinger_population_sd() {
let b = bollinger(&[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0], 8, 2.0);
// mean 5, population sd 2.
assert!(close_to(b.middle[7], 5.0));
assert!(close_to(b.upper[7], 9.0));
assert!(close_to(b.lower[7], 1.0));
assert!(b.upper[6].is_nan());
// Rolling window stays numerically consistent with a fresh computation.
let xs: Vec<f64> = (0..200).map(|i| ((i * 37) % 101) as f64).collect();
let roll = bollinger(&xs, 20, 2.0);
let fresh = bollinger(&xs[180..], 20, 2.0);
assert!(close_to(roll.upper[199], fresh.upper[19]));
}
#[test]
fn vwap_resets_per_session() {
let day = 86_400;
let bars = [
Bar::new(0, 0.0, 12.0, 8.0, 10.0, 100.0),
Bar::new(60, 0.0, 22.0, 18.0, 20.0, 300.0),
Bar::new(day, 0.0, 6.0, 4.0, 5.0, 10.0),
];
let v = vwap(&bars);
assert!(close_to(v[0], 10.0));
assert!(close_to(v[1], (10.0 * 100.0 + 20.0 * 300.0) / 400.0));
assert!(close_to(v[2], 5.0));
}
}
+655
View File
@@ -0,0 +1,655 @@
//! Oscillators and momentum: RSI and its variants, stochastics, MACD-style differences,
//! rate of change, and the cycle oscillators.
use super::ma::{ema, rma, sma, smooth, swma, wma};
use super::stats::{change, highest, linreg, lowest, percent_rank, sum};
use super::volatility::true_range;
use super::{NAN, Ohlcv, ago, from_first, ratio, zip};
/// Relative Strength Index with Wilder smoothing (first value at index `n`).
/// Single pass with no intermediate buffers: on large series the cost is dominated by
/// first-touch of output memory, so only the output is allocated.
pub fn rsi(closes: &[f64], n: usize) -> Vec<f64> {
from_first(closes, |closes| {
let mut out = vec![NAN; closes.len()];
if n == 0 || closes.len() <= n {
return out;
}
let value = |g: f64, l: f64| {
if l == 0.0 { if g == 0.0 { 50.0 } else { 100.0 } } else { 100.0 - 100.0 / (1.0 + g / l) }
};
let (mut ag, mut al) = (0.0, 0.0);
for i in 1..=n {
let d = closes[i] - closes[i - 1];
ag += d.max(0.0);
al += (-d).max(0.0);
}
ag /= n as f64;
al /= n as f64;
out[n] = value(ag, al);
let alpha = 1.0 / n as f64;
for i in (n + 1)..closes.len() {
let d = closes[i] - closes[i - 1];
ag = alpha * d.max(0.0) + (1.0 - alpha) * ag;
al = alpha * (-d).max(0.0) + (1.0 - alpha) * al;
out[i] = value(ag, al);
}
out
})
}
#[derive(Debug, Clone)]
pub struct Macd {
pub macd: Vec<f64>,
pub signal: Vec<f64>,
pub histogram: Vec<f64>,
}
/// Streaming EMA seeded with the SMA of its first `n` inputs.
struct Ema {
n: usize,
alpha: f64,
seen: usize,
acc: f64,
}
impl Ema {
fn new(n: usize) -> Self {
Self { n, alpha: 2.0 / (n as f64 + 1.0), seen: 0, acc: 0.0 }
}
/// Feed one value; returns the EMA once `n` values have been seen.
fn push(&mut self, v: f64) -> Option<f64> {
self.seen += 1;
if self.seen < self.n {
self.acc += v;
None
} else if self.seen == self.n {
self.acc = (self.acc + v) / self.n as f64;
Some(self.acc)
} else {
self.acc = self.alpha * v + (1.0 - self.alpha) * self.acc;
Some(self.acc)
}
}
}
/// MACD line (fast EMA - slow EMA), its signal EMA and histogram, in one pass.
pub fn macd(closes: &[f64], fast: usize, slow: usize, signal: usize) -> Macd {
let len = closes.len();
let mut line = vec![f64::NAN; len];
let mut sig = vec![f64::NAN; len];
let mut histogram = vec![f64::NAN; len];
if fast == 0 || slow == 0 || signal == 0 {
return Macd { macd: line, signal: sig, histogram };
}
let (mut ef, mut es, mut eg) = (Ema::new(fast), Ema::new(slow), Ema::new(signal));
for (i, &c) in closes.iter().enumerate() {
let (f, s) = (ef.push(c), es.push(c));
let (Some(f), Some(s)) = (f, s) else { continue };
let m = f - s;
line[i] = m;
// The signal EMA starts once the MACD line itself is defined.
if let Some(g) = eg.push(m) {
sig[i] = g;
histogram[i] = m - g;
}
}
Macd { macd: line, signal: sig, histogram }
}
/// A line with an EMA signal and their difference.
fn with_signal(line: Vec<f64>, n: usize) -> Macd {
let signal = ema(&line, n);
let histogram = zip(&line, &signal, |a, b| a - b);
Macd { macd: line, signal, histogram }
}
/// Percentage price oscillator: (fast EMA − slow EMA) / slow EMA × 100, with signal.
pub fn ppo(values: &[f64], fast: usize, slow: usize, signal: usize) -> Macd {
let line = zip(&ema(values, fast), &ema(values, slow), |f, s| ratio(f - s, s, 0.0) * 100.0);
with_signal(line, signal)
}
/// Absolute price oscillator: fast EMA − slow EMA.
pub fn apo(values: &[f64], fast: usize, slow: usize) -> Vec<f64> {
zip(&ema(values, fast), &ema(values, slow), |f, s| f - s)
}
/// Rate of change in percent over `n` bars.
pub fn roc(values: &[f64], n: usize) -> Vec<f64> {
(0..values.len())
.map(|i| if i >= n { ratio(values[i] - values[i - n], values[i - n], NAN) * 100.0 } else { NAN })
.collect()
}
/// Momentum: the change over `n` bars.
pub fn momentum(values: &[f64], n: usize) -> Vec<f64> {
change(values, n)
}
/// %K and %D lines of a stochastic.
#[derive(Debug, Clone)]
pub struct Stoch {
pub k: Vec<f64>,
pub d: Vec<f64>,
}
/// Raw stochastic: where `close` sits in the `n`-bar high/low range, 0..100 (50 when flat).
fn stoch_raw(high: &[f64], low: &[f64], close: &[f64], n: usize) -> Vec<f64> {
let (hh, ll) = (highest(high, n), lowest(low, n));
(0..close.len()).map(|i| 100.0 * ratio(close[i] - ll[i], hh[i] - ll[i], 0.5)).collect()
}
/// Stochastic oscillator: %K = SMA(raw, `smooth_k`), %D = SMA(%K, `d`).
pub fn stochastic(o: &Ohlcv, n: usize, smooth_k: usize, d: usize) -> Stoch {
let k = sma(&stoch_raw(&o.high, &o.low, &o.close, n), smooth_k);
let d = sma(&k, d);
Stoch { k, d }
}
/// Stochastic RSI: a stochastic of RSI(`rsi_n`) over `stoch_n` bars.
pub fn stoch_rsi(values: &[f64], rsi_n: usize, stoch_n: usize, smooth_k: usize, d: usize) -> Stoch {
let r = rsi(values, rsi_n);
let k = sma(&stoch_raw(&r, &r, &r, stoch_n), smooth_k);
let d = sma(&k, d);
Stoch { k, d }
}
/// Commodity channel index of `values` (usually the typical price) over `n` bars.
pub fn cci(values: &[f64], n: usize) -> Vec<f64> {
let m = sma(values, n);
(0..values.len())
.map(|i| {
if m[i].is_nan() {
return NAN;
}
let md = values[i + 1 - n..=i].iter().map(|v| (v - m[i]).abs()).sum::<f64>() / n as f64;
ratio(values[i] - m[i], 0.015 * md, 0.0)
})
.collect()
}
/// Williams %R: −100 (at the `n`-bar low) to 0 (at the high).
pub fn williams_r(o: &Ohlcv, n: usize) -> Vec<f64> {
stoch_raw(&o.high, &o.low, &o.close, n).into_iter().map(|k| k - 100.0).collect()
}
/// Ultimate oscillator: buying pressure over true range on three horizons, weighted 4:2:1.
pub fn ultimate(o: &Ohlcv, a: usize, b: usize, c: usize) -> Vec<f64> {
let prev = |i: usize| ago(&o.close, i, 1);
let bp: Vec<f64> = (0..o.len()).map(|i| o.close[i] - o.low[i].min(prev(i))).collect();
let tr: Vec<f64> = (0..o.len()).map(|i| o.high[i].max(prev(i)) - o.low[i].min(prev(i))).collect();
let avg = |n| zip(&sum(&bp, n), &sum(&tr, n), |p, t| ratio(p, t, 0.0));
let (x, y, z) = (avg(a), avg(b), avg(c));
(0..o.len()).map(|i| 100.0 * (4.0 * x[i] + 2.0 * y[i] + z[i]) / 7.0).collect()
}
/// Awesome oscillator: SMA(median price, `fast`) − SMA(median price, `slow`).
pub fn awesome(o: &Ohlcv, fast: usize, slow: usize) -> Vec<f64> {
let hl2 = o.hl2();
zip(&sma(&hl2, fast), &sma(&hl2, slow), |a, b| a - b)
}
/// Accelerator oscillator: AO − SMA(AO, `n`).
pub fn accelerator(o: &Ohlcv, fast: usize, slow: usize, n: usize) -> Vec<f64> {
let ao = awesome(o, fast, slow);
zip(&ao, &sma(&ao, n), |a, b| a - b)
}
/// True strength index: double-smoothed momentum over double-smoothed |momentum|, × 100.
pub fn tsi(values: &[f64], long: usize, short: usize) -> Vec<f64> {
let pc = change(values, 1);
let apc: Vec<f64> = pc.iter().map(|v| v.abs()).collect();
let num = ema(&ema(&pc, long), short);
let den = ema(&ema(&apc, long), short);
zip(&num, &den, |a, b| ratio(a, b, 0.0) * 100.0)
}
/// TRIX: one-bar percent change of a triple-smoothed EMA.
pub fn trix(values: &[f64], n: usize) -> Vec<f64> {
let e3 = ema(&ema(&ema(values, n), n), n);
roc(&e3, 1)
}
/// Pring's Know Sure Thing: weighted sum of four smoothed rates of change, with a signal.
pub fn kst(values: &[f64], roc_n: [usize; 4], sma_n: [usize; 4], signal: usize) -> Macd {
let mut line = vec![0.0; values.len()];
for (w, (r, s)) in roc_n.iter().zip(sma_n).enumerate() {
let part = sma(&roc(values, *r), s);
for (l, p) in line.iter_mut().zip(&part) {
*l += (w + 1) as f64 * p;
}
}
let signal = sma(&line, signal);
let histogram = zip(&line, &signal, |a, b| a - b);
Macd { macd: line, signal, histogram }
}
/// Up and down moves (`max(Δ, 0)`, `max(−Δ, 0)`) of `values` over `k` bars.
fn moves(values: &[f64], k: usize) -> (Vec<f64>, Vec<f64>) {
let ch = change(values, k);
let up = ch.iter().map(|c| if c.is_nan() { NAN } else { c.max(0.0) }).collect();
let dn = ch.iter().map(|c| if c.is_nan() { NAN } else { (-c).max(0.0) }).collect();
(up, dn)
}
/// Chande momentum oscillator, −100..100.
pub fn cmo(values: &[f64], n: usize) -> Vec<f64> {
let (up, dn) = moves(values, 1);
zip(&sum(&up, n), &sum(&dn, n), |u, d| 100.0 * ratio(u - d, u + d, 0.0))
}
/// Detrended price oscillator: close − SMA(n) from n/2 + 1 bars ago.
pub fn dpo(values: &[f64], n: usize) -> Vec<f64> {
let m = sma(values, n);
let back = n / 2 + 1;
(0..values.len()).map(|i| values[i] - ago(&m, i, back)).collect()
}
/// Coppock curve: WMA(ROC(long) + ROC(short), `n`).
pub fn coppock(values: &[f64], n: usize, long: usize, short: usize) -> Vec<f64> {
wma(&zip(&roc(values, long), &roc(values, short), |a, b| a + b), n)
}
/// Relative vigor index: close − open over high − low, summed over `n`, with an SWMA signal.
pub fn rvgi(o: &Ohlcv, n: usize) -> Macd {
let num = swma(&zip(&o.close, &o.open, |c, op| c - op));
let den = swma(&zip(&o.high, &o.low, |h, l| h - l));
let line = zip(&sum(&num, n), &sum(&den, n), |a, b| ratio(a, b, 0.0));
let signal = swma(&line);
let histogram = zip(&line, &signal, |a, b| a - b);
Macd { macd: line, signal, histogram }
}
/// Ehlers' Fisher transform of the median price's position in its `n`-bar range, with the
/// previous value as trigger.
pub fn fisher(o: &Ohlcv, n: usize) -> (Vec<f64>, Vec<f64>) {
let hl2 = o.hl2();
let (hh, ll) = (highest(&hl2, n), lowest(&hl2, n));
let mut fish = vec![NAN; o.len()];
let (mut v, mut f) = (0.0, 0.0);
for i in 0..o.len() {
if hh[i].is_nan() {
continue;
}
v = (0.66 * (ratio(hl2[i] - ll[i], hh[i] - ll[i], 0.5) - 0.5) + 0.67 * v).clamp(-0.999, 0.999);
f = 0.5 * ((1.0 + v) / (1.0 - v)).ln() + 0.5 * f;
fish[i] = f;
}
let trigger = (0..fish.len()).map(|i| ago(&fish, i, 1)).collect();
(fish, trigger)
}
/// Blau's stochastic momentum index: distance from the midpoint of the `n`-bar range,
/// double-smoothed over `d`, as a percentage of half the range; with an EMA signal.
pub fn smi(o: &Ohlcv, n: usize, d: usize, signal: usize) -> Macd {
let (hh, ll) = (highest(&o.high, n), lowest(&o.low, n));
let rel: Vec<f64> = (0..o.len()).map(|i| o.close[i] - (hh[i] + ll[i]) / 2.0).collect();
let range = zip(&hh, &ll, |h, l| h - l);
let num = ema(&ema(&rel, d), d);
let den = ema(&ema(&range, d), d);
with_signal(zip(&num, &den, |a, b| ratio(a, b / 2.0, 0.0) * 100.0), signal)
}
/// SMI ergodic indicator: TSI(`long`, `short`) with an EMA signal and their difference.
pub fn smi_ergodic(values: &[f64], short: usize, long: usize, signal: usize) -> Macd {
with_signal(tsi(values, long, short), signal)
}
/// Connors RSI: the mean of RSI(`rsi_n`), RSI(`streak_n`) of the up/down streak length, and
/// the percent rank of the one-bar change over `rank_n` bars.
pub fn connors_rsi(values: &[f64], rsi_n: usize, streak_n: usize, rank_n: usize) -> Vec<f64> {
let mut streak = vec![0.0; values.len()];
for i in 1..values.len() {
let s = streak[i - 1];
streak[i] = if values[i] > values[i - 1] {
if s > 0.0 { s + 1.0 } else { 1.0 }
} else if values[i] < values[i - 1] {
if s < 0.0 { s - 1.0 } else { -1.0 }
} else {
0.0
};
}
let (a, b, c) = (rsi(values, rsi_n), rsi(&streak, streak_n), percent_rank(&roc(values, 1), rank_n));
(0..values.len()).map(|i| (a[i] + b[i] + c[i]) / 3.0).collect()
}
/// Ehlers' Laguerre RSI with damping `gamma`, 0..1.
pub fn laguerre_rsi(values: &[f64], gamma: f64) -> Vec<f64> {
from_first(values, |p| {
let g = gamma;
let mut l = [p.first().copied().unwrap_or_default(); 4];
p.iter()
.map(|&x| {
let prev = l;
l[0] = (1.0 - g) * x + g * prev[0];
l[1] = -g * l[0] + prev[0] + g * prev[1];
l[2] = -g * l[1] + prev[1] + g * prev[2];
l[3] = -g * l[2] + prev[2] + g * prev[3];
let (mut cu, mut cd) = (0.0, 0.0);
for w in l.windows(2) {
let d = w[0] - w[1];
if d >= 0.0 { cu += d } else { cd -= d }
}
ratio(cu, cu + cd, 0.0)
})
.collect()
})
}
/// Relative momentum index: an RSI of the change over `m` bars, Wilder-smoothed over `n`.
pub fn rmi(values: &[f64], n: usize, m: usize) -> Vec<f64> {
let (up, dn) = moves(values, m);
zip(&rma(&up, n), &rma(&dn, n), |u, d| 100.0 * ratio(u, u + d, 0.5))
}
/// DecisionPoint price momentum oscillator: the one-bar ROC smoothed twice with the custom
/// 2/n factor (×10 after the first), with an EMA signal.
pub fn pmo(values: &[f64], a: usize, b: usize, signal: usize) -> Macd {
let r = roc(values, 1);
let s1: Vec<f64> = smooth(&r, a, 2.0 / a as f64).into_iter().map(|v| v * 10.0).collect();
with_signal(smooth(&s1, b, 2.0 / b as f64), signal)
}
/// Balance of power: SMA of (close − open) / (high − low).
pub fn bop(o: &Ohlcv, n: usize) -> Vec<f64> {
let raw: Vec<f64> = (0..o.len()).map(|i| ratio(o.close[i] - o.open[i], o.high[i] - o.low[i], 0.0)).collect();
sma(&raw, n)
}
/// Elder ray: bull power (high − EMA) and bear power (low − EMA).
pub fn elder_ray(o: &Ohlcv, n: usize) -> (Vec<f64>, Vec<f64>) {
let e = ema(&o.close, n);
(zip(&o.high, &e, |h, e| h - e), zip(&o.low, &e, |l, e| l - e))
}
/// Bull bear power: (high − EMA) + (low − EMA).
pub fn bull_bear_power(o: &Ohlcv, n: usize) -> Vec<f64> {
let (bull, bear) = elder_ray(o, n);
zip(&bull, &bear, |a, b| a + b)
}
/// Chande's Qstick: SMA of close − open.
pub fn qstick(o: &Ohlcv, n: usize) -> Vec<f64> {
sma(&zip(&o.close, &o.open, |c, op| c - op), n)
}
/// Psychological line: the percentage of up closes over `n` bars.
pub fn psychological(values: &[f64], n: usize) -> Vec<f64> {
let up: Vec<f64> =
(0..values.len()).map(|i| if i == 0 { NAN } else { f64::from(values[i] > values[i - 1]) }).collect();
sum(&up, n).into_iter().map(|s| 100.0 * s / n as f64).collect()
}
/// Chande forecast oscillator: percent distance of the close from its `n`-bar regression.
pub fn cfo(values: &[f64], n: usize) -> Vec<f64> {
zip(values, &linreg(values, n), |c, l| 100.0 * ratio(c - l, c, 0.0))
}
/// Intraday momentum index: an RSI of open-to-close moves, 0..100.
pub fn imi(o: &Ohlcv, n: usize) -> Vec<f64> {
let gains: Vec<f64> = (0..o.len()).map(|i| (o.close[i] - o.open[i]).max(0.0)).collect();
let losses: Vec<f64> = (0..o.len()).map(|i| (o.open[i] - o.close[i]).max(0.0)).collect();
zip(&sum(&gains, n), &sum(&losses, n), |g, l| 100.0 * ratio(g, g + l, 0.5))
}
/// Schaff trend cycle: a twice-stochasticised, half-smoothed MACD(`fast`, `slow`) over a
/// `cycle`-bar window, 0..100.
pub fn stc(values: &[f64], cycle: usize, fast: usize, slow: usize) -> Vec<f64> {
let m = apo(values, fast, slow);
let stage = |x: &[f64]| {
let (lo, hi) = (lowest(x, cycle), highest(x, cycle));
let mut out = vec![NAN; x.len()];
let (mut raw, mut sm) = (NAN, NAN);
for i in 0..x.len() {
if lo[i].is_nan() {
continue;
}
let range = hi[i] - lo[i];
if range > 0.0 {
raw = (x[i] - lo[i]) / range * 100.0;
} else if raw.is_nan() {
raw = 0.0;
}
sm = if sm.is_nan() { raw } else { sm + 0.5 * (raw - sm) };
out[i] = sm;
}
out
};
stage(&stage(&m))
}
/// Ehlers' center of gravity over `n` bars, with the previous value as signal.
pub fn center_of_gravity(values: &[f64], n: usize) -> (Vec<f64>, Vec<f64>) {
let mut cg = vec![NAN; values.len()];
for i in (n.max(1) - 1..values.len()).filter(|_| n > 0) {
let (mut num, mut den) = (0.0, 0.0);
for k in 0..n {
num += (k + 1) as f64 * values[i - k];
den += values[i - k];
}
cg[i] = if den != 0.0 { -num / den + (n as f64 + 1.0) / 2.0 } else { 0.0 };
}
let signal = (0..cg.len()).map(|i| ago(&cg, i, 1)).collect();
(cg, signal)
}
/// Inverse Fisher transform of RSI(`rsi_n`), WMA-smoothed over `n`, −1..1.
pub fn ift_rsi(values: &[f64], rsi_n: usize, n: usize) -> Vec<f64> {
let v: Vec<f64> = rsi(values, rsi_n).into_iter().map(|r| 0.1 * (r - 50.0)).collect();
wma(&v, n).into_iter().map(f64::tanh).collect()
}
/// Pretty good oscillator: distance from the SMA in units of the average true range.
pub fn pgo(o: &Ohlcv, n: usize) -> Vec<f64> {
let m = sma(&o.close, n);
let atr = ema(&true_range(o), n);
(0..o.len()).map(|i| ratio(o.close[i] - m[i], atr[i], 0.0)).collect()
}
/// QQE: RSI smoothed over `smooth` bars and a trailing level `factor` times its smoothed
/// volatility away, flipping sides when crossed.
pub fn qqe(values: &[f64], rsi_n: usize, smooth: usize, factor: f64) -> (Vec<f64>, Vec<f64>) {
let line = ema(&rsi(values, rsi_n), smooth);
let wilders = (2 * rsi_n).saturating_sub(1).max(1);
let atr: Vec<f64> = (0..line.len()).map(|i| (line[i] - ago(&line, i, 1)).abs()).collect();
let dar: Vec<f64> = ema(&ema(&atr, wilders), wilders).into_iter().map(|v| v * factor).collect();
let mut trail = vec![NAN; line.len()];
let (mut long, mut short, mut trend) = (NAN, NAN, 1);
let mut prev = NAN;
for i in 0..line.len() {
let (r, d) = (line[i], dar[i]);
if r.is_nan() || d.is_nan() {
continue;
}
let (nl, ns) = (r - d, r + d);
let (pl, ps) = (long, short);
long = if !pl.is_nan() && prev > pl && r > pl { pl.max(nl) } else { nl };
short = if !ps.is_nan() && prev < ps && r < ps { ps.min(ns) } else { ns };
if !ps.is_nan() && prev <= ps && r > ps {
trend = 1;
} else if !pl.is_nan() && prev >= pl && r < pl {
trend = -1;
}
trail[i] = if trend == 1 { long } else { short };
prev = r;
}
(line, trail)
}
/// WaveTrend (LazyBear): a CCI-like channel index of the typical price, EMA-smoothed, with
/// a 4-bar SMA signal.
pub fn wavetrend(o: &Ohlcv, n1: usize, n2: usize) -> (Vec<f64>, Vec<f64>) {
let ap = o.hlc3();
let esa = ema(&ap, n1);
let dev = ema(&zip(&ap, &esa, |a, e| (a - e).abs()), n1);
let ci: Vec<f64> = (0..ap.len()).map(|i| ratio(ap[i] - esa[i], 0.015 * dev[i], 0.0)).collect();
let wt1 = ema(&ci, n2);
let wt2 = sma(&wt1, 4);
(wt1, wt2)
}
/// Ehlers' cyber cycle with smoothing `alpha`, and its previous value as trigger.
pub fn cyber_cycle(values: &[f64], alpha: f64) -> (Vec<f64>, Vec<f64>) {
let cycle = from_first(values, |p| {
let a = alpha;
let pr = |i: usize, k: usize| if i >= k { p[i - k] } else { p[0] };
let sm: Vec<f64> =
(0..p.len()).map(|i| (pr(i, 0) + 2.0 * pr(i, 1) + 2.0 * pr(i, 2) + pr(i, 3)) / 6.0).collect();
let mut c = vec![0.0; p.len()];
for i in 0..p.len() {
c[i] = if i < 7 {
(pr(i, 0) - 2.0 * pr(i, 1) + pr(i, 2)) / 4.0
} else {
(1.0 - 0.5 * a).powi(2) * (sm[i] - 2.0 * sm[i - 1] + sm[i - 2]) + 2.0 * (1.0 - a) * c[i - 1]
- (1.0 - a).powi(2) * c[i - 2]
};
}
c
});
let trigger = (0..cycle.len()).map(|i| ago(&cycle, i, 1)).collect();
(cycle, trigger)
}
/// Disparity index: percent distance of the close from its `n`-bar SMA.
pub fn disparity(values: &[f64], n: usize) -> Vec<f64> {
zip(values, &sma(values, n), |c, m| 100.0 * ratio(c - m, m, 0.0))
}
/// Polarized fractal efficiency: straight-line distance over path length across `n` bars,
/// signed by direction and EMA-smoothed, −100..100.
pub fn pfe(values: &[f64], n: usize, smoothing: usize) -> Vec<f64> {
let k = n as f64;
let step: Vec<f64> = (0..values.len())
.map(|i| if i == 0 { NAN } else { ((values[i] - values[i - 1]).powi(2) + 1.0).sqrt() })
.collect();
let path = sum(&step, n);
let raw: Vec<f64> = (0..values.len())
.map(|i| {
if i < n || path[i].is_nan() {
return NAN;
}
let d = values[i] - values[i - n];
let p = 100.0 * (d * d + k * k).sqrt() / path[i];
if d > 0.0 { p } else { -p }
})
.collect();
ema(&raw, smoothing)
}
/// Kaufman's efficiency ratio: |net change| over the sum of |bar changes|, 0..1.
pub fn efficiency_ratio(values: &[f64], n: usize) -> Vec<f64> {
let step: Vec<f64> = change(values, 1).into_iter().map(f64::abs).collect();
let path = sum(&step, n);
(0..values.len())
.map(|i| if i >= n { ratio((values[i] - values[i - n]).abs(), path[i], 0.0) } else { NAN })
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Bar;
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9 * (1.0 + b.abs())
}
fn ohlcv(rows: &[(f64, f64, f64, f64)]) -> Ohlcv {
let bars: Vec<Bar> =
rows.iter().enumerate().map(|(i, r)| Bar::new(i as i64, r.0, r.1, r.2, r.3, 100.0)).collect();
Ohlcv::new(&bars)
}
fn wave(n: usize) -> Ohlcv {
let rows: Vec<(f64, f64, f64, f64)> = (0..n)
.map(|i| {
let c = 100.0 + (i as f64 * 0.17).sin() * 6.0 + i as f64 * 0.02;
(c - 0.3, c + 1.0 + (i % 3) as f64 * 0.2, c - 1.1, c)
})
.collect();
ohlcv(&rows)
}
#[test]
fn stochastic_and_williams_hand_computed() {
// 3-bar window: HH 12, LL 8, close 11 → raw 75, %R −25.
let o = ohlcv(&[(9.0, 10.0, 8.0, 9.0), (10.0, 12.0, 9.0, 11.0), (11.0, 11.5, 10.0, 11.0)]);
let s = stochastic(&o, 3, 1, 1);
assert!(near(s.k[2], 75.0) && near(s.d[2], 75.0));
assert!(near(williams_r(&o, 3)[2], -25.0));
// A flat window sits in the middle instead of dividing by zero.
let flat = ohlcv(&[(5.0, 5.0, 5.0, 5.0); 4]);
assert_eq!(stochastic(&flat, 3, 1, 1).k[3], 50.0);
}
#[test]
fn cci_hand_computed() {
// Typical prices 1, 2, 3: mean 2, mean deviation 2/3 → (3 − 2) / (0.015 · 2/3) = 100.
assert!(near(cci(&[1.0, 2.0, 3.0], 3)[2], 100.0));
}
#[test]
fn differences_match_their_parts() {
let o = wave(300);
let c = &o.close;
let p = ppo(c, 12, 26, 9);
let (f, s) = (ema(c, 12), ema(c, 26));
assert!(near(p.macd[200], (f[200] - s[200]) / s[200] * 100.0));
assert!(near(apo(c, 12, 26)[200], f[200] - s[200]));
assert!(near(roc(c, 9)[50], (c[50] / c[41] - 1.0) * 100.0));
assert!(near(momentum(c, 10)[50], c[50] - c[40]));
let hl2 = o.hl2();
assert!(near(awesome(&o, 5, 34)[100], sma(&hl2, 5)[100] - sma(&hl2, 34)[100]));
let ao = awesome(&o, 5, 34);
assert!(near(accelerator(&o, 5, 34, 5)[100], ao[100] - sma(&ao, 5)[100]));
let b = bull_bear_power(&o, 13);
let (bull, bear) = elder_ray(&o, 13);
assert!(near(b[90], bull[90] + bear[90]));
}
#[test]
fn bounded_oscillators_stay_in_range() {
let o = wave(500);
let c = &o.close;
let checks: Vec<(Vec<f64>, f64, f64)> = vec![
(cmo(c, 9), -100.0, 100.0),
(stoch_rsi(c, 14, 14, 3, 3).k, 0.0, 100.0),
(ultimate(&o, 7, 14, 28), 0.0, 100.0),
(connors_rsi(c, 3, 2, 100), 0.0, 100.0),
(laguerre_rsi(c, 0.5), 0.0, 1.0),
(rmi(c, 20, 5), 0.0, 100.0),
(imi(&o, 14), 0.0, 100.0),
(psychological(c, 12), 0.0, 100.0),
(stc(c, 12, 26, 50), 0.0, 100.0),
(ift_rsi(c, 5, 9), -1.0, 1.0),
(qqe(c, 14, 5, 4.236).0, 0.0, 100.0),
(pfe(c, 10, 5), -100.0, 100.0),
(efficiency_ratio(c, 10), 0.0, 1.0),
(smi(&o, 10, 3, 10).macd, -100.0, 100.0),
];
for (k, (v, lo, hi)) in checks.iter().enumerate() {
let finite: Vec<f64> = v.iter().copied().filter(|x| x.is_finite()).collect();
assert!(finite.len() > 300, "check {k} has {} values", finite.len());
assert!(finite.iter().all(|x| *x >= lo - 1e-9 && *x <= hi + 1e-9), "check {k} out of range");
}
}
#[test]
fn known_shapes() {
let up: Vec<f64> = (0..60).map(|i| 10.0 + i as f64).collect();
assert_eq!(cmo(&up, 9)[30], 100.0);
assert_eq!(efficiency_ratio(&up, 10)[30], 1.0);
assert_eq!(psychological(&up, 12)[30], 100.0);
assert!(near(tsi(&up, 25, 13)[59], 100.0));
// Streaks: three rises then a fall.
let c = connors_rsi(&[1.0, 2.0, 3.0, 4.0, 3.0], 2, 2, 2);
assert!(c[4].is_finite());
let (cg, sig) = center_of_gravity(&[1.0; 20], 10);
assert!(near(cg[15], -(55.0 / 10.0) + 5.5) && near(sig[16], cg[15]));
// DPO compares with the SMA from n/2 + 1 bars back.
let d = dpo(&up, 10);
assert!(near(d[30], up[30] - sma(&up, 10)[24]));
}
}
File diff suppressed because it is too large. Load diff
+372
View File
@@ -0,0 +1,372 @@
//! Rolling statistics: sums, extremes, dispersion, least-squares fits and ranks. Running
//! sums are recomputed exactly once per window length, so drift stays bounded over
//! millions of bars at O(1) amortised cost per bar.
use std::collections::VecDeque;
use super::NAN;
/// Calls `f(i, r, s, w, s2)` for every full window of `n` values ending at `i` that holds
/// only finite values, where, over `y - r` (`r` = the first finite value, for precision),
/// `s` = Σy, `w` = Σk·y with k = 1 for the oldest … n for the newest, and `s2` = Σy².
pub(super) fn windows(values: &[f64], n: usize, mut f: impl FnMut(usize, f64, f64, f64, f64)) {
if n == 0 {
return;
}
let r = values.iter().copied().find(|v| v.is_finite()).unwrap_or(0.0);
let y = |i: usize| if values[i].is_finite() { values[i] - r } else { 0.0 };
let (mut s, mut w, mut s2, mut bad) = (0.0, 0.0, 0.0, 0usize);
for i in 0..values.len() {
if !values[i].is_finite() {
bad += 1;
}
let v = y(i);
if i >= n {
if !values[i - n].is_finite() {
bad -= 1;
}
let o = y(i - n);
w -= s;
s -= o;
s2 -= o * o;
w += n as f64 * v;
} else {
w += (i + 1) as f64 * v;
}
s += v;
s2 += v * v;
if i + 1 >= n {
if (i + 1) % n == 0 {
(s, w, s2) = (0.0, 0.0, 0.0);
for (k, j) in (i + 1 - n..=i).enumerate() {
let v = y(j);
s += v;
w += (k + 1) as f64 * v;
s2 += v * v;
}
}
if bad == 0 {
f(i, r, s, w, s2);
}
}
}
}
/// Rolling sum of `n` values. Windows holding a non-finite value are NaN.
pub fn sum(values: &[f64], n: usize) -> Vec<f64> {
let mut out = vec![NAN; values.len()];
windows(values, n, |i, r, s, _, _| out[i] = s + r * n as f64);
out
}
/// Population variance over `n` values.
pub fn variance(values: &[f64], n: usize) -> Vec<f64> {
let mut out = vec![NAN; values.len()];
let k = n as f64;
windows(values, n, |i, _, s, _, s2| {
let m = s / k;
out[i] = (s2 / k - m * m).max(0.0);
});
out
}
/// Population standard deviation over `n` values.
pub fn stdev(values: &[f64], n: usize) -> Vec<f64> {
variance(values, n).into_iter().map(f64::sqrt).collect()
}
/// Distance from the `n`-value mean in standard deviations (0 when flat).
pub fn zscore(values: &[f64], n: usize) -> Vec<f64> {
let mut out = vec![NAN; values.len()];
let k = n as f64;
windows(values, n, |i, r, s, _, s2| {
let m = s / k;
let sd = (s2 / k - m * m).max(0.0).sqrt();
out[i] = if sd > 0.0 { (values[i] - r - m) / sd } else { 0.0 };
});
out
}
/// Index of the highest (`max`) or lowest value in each window of `n` (the most recent one
/// on ties), or `usize::MAX` while the window is short or holds a non-finite value.
pub(super) fn extreme_at(values: &[f64], n: usize, max: bool) -> Vec<usize> {
let mut out = vec![usize::MAX; values.len()];
if n == 0 {
return out;
}
let mut dq: VecDeque<usize> = VecDeque::new();
let mut bad = 0usize;
for (i, &v) in values.iter().enumerate() {
if v.is_finite() {
while let Some(&b) = dq.back() {
if (max && values[b] <= v) || (!max && values[b] >= v) {
dq.pop_back();
} else {
break;
}
}
dq.push_back(i);
} else {
bad += 1;
}
if i >= n && !values[i - n].is_finite() {
bad -= 1;
}
while dq.front().is_some_and(|&f| f + n <= i) {
dq.pop_front();
}
if i + 1 >= n
&& bad == 0
&& let Some(&f) = dq.front()
{
out[i] = f;
}
}
out
}
fn pick(values: &[f64], idx: Vec<usize>) -> Vec<f64> {
idx.into_iter().map(|j| if j == usize::MAX { NAN } else { values[j] }).collect()
}
/// Highest value over the last `n` (monotonic deque, O(1) amortised).
pub fn highest(values: &[f64], n: usize) -> Vec<f64> {
pick(values, extreme_at(values, n, true))
}
/// Lowest value over the last `n`.
pub fn lowest(values: &[f64], n: usize) -> Vec<f64> {
pick(values, extreme_at(values, n, false))
}
/// `values[i] - values[i - k]`.
pub fn change(values: &[f64], k: usize) -> Vec<f64> {
(0..values.len()).map(|i| if i >= k { values[i] - values[i - k] } else { NAN }).collect()
}
/// Displace a series by `k` bars: forward (into the future, extending the output past the
/// last bar) when positive, back when negative.
pub fn displace(values: &[f64], k: isize) -> Vec<f64> {
let len = values.len();
if k >= 0 {
let k = k as usize;
let mut out = vec![NAN; len + k];
out[k..].copy_from_slice(values);
out
} else {
let k = k.unsigned_abs().min(len);
let mut out = vec![NAN; len];
out[..len - k].copy_from_slice(&values[k..]);
out
}
}
/// Least-squares line through one window, with x = 0 for the oldest value.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Fit {
pub slope: f64,
pub intercept: f64,
/// Coefficient of determination (0 for a flat window).
pub r2: f64,
/// Standard error of the estimate, sqrt(SSE / (n - 2)).
pub se: f64,
}
impl Fit {
pub fn at(&self, x: f64) -> f64 {
self.intercept + self.slope * x
}
}
/// Map every rolling `n`-value least-squares fit through `f` (O(1) per bar).
pub fn linreg_map(values: &[f64], n: usize, f: impl Fn(Fit) -> f64) -> Vec<f64> {
let mut out = vec![NAN; values.len()];
let k = n as f64;
let sx = k * (k - 1.0) / 2.0;
let sxx = (k - 1.0) * k * (2.0 * k - 1.0) / 6.0;
let den = k * sxx - sx * sx;
windows(values, n, |i, r, s, w, s2| {
let sxy = w - s;
let slope = if den != 0.0 { (k * sxy - sx * s) / den } else { 0.0 };
let b = (s - slope * sx) / k;
let sse = (s2 - b * s - slope * sxy).max(0.0);
let sst = s2 - s * s / k;
let r2 = if sst > 1e-12 * s2.max(1e-300) { (1.0 - sse / sst).clamp(0.0, 1.0) } else { 0.0 };
let se = if n > 2 { (sse / (k - 2.0)).sqrt() } else { 0.0 };
out[i] = f(Fit { slope, intercept: b + r, r2, se });
});
out
}
/// Linear regression value at the newest bar (least squares moving average, LSMA).
pub fn linreg(values: &[f64], n: usize) -> Vec<f64> {
linreg_map(values, n, |f| f.at(n as f64 - 1.0))
}
/// Slope of the rolling regression line, per bar.
pub fn linreg_slope(values: &[f64], n: usize) -> Vec<f64> {
linreg_map(values, n, |f| f.slope)
}
/// R² of the rolling regression line.
pub fn r_squared(values: &[f64], n: usize) -> Vec<f64> {
linreg_map(values, n, |f| f.r2)
}
/// Time series forecast: the regression line extended one bar ahead.
pub fn tsf(values: &[f64], n: usize) -> Vec<f64> {
linreg_map(values, n, |f| f.at(n as f64))
}
/// Percentage of the previous `n` values less than or equal to the current one.
pub fn percent_rank(values: &[f64], n: usize) -> Vec<f64> {
let mut out = vec![NAN; values.len()];
if n == 0 {
return out;
}
for i in n..values.len() {
let v = values[i];
let w = &values[i - n..i];
if v.is_finite() && w.iter().all(|x| x.is_finite()) {
out[i] = 100.0 * w.iter().filter(|&&x| x <= v).count() as f64 / n as f64;
}
}
out
}
/// Rolling median of `n` values (sorted window, O(n) per bar).
pub fn median(values: &[f64], n: usize) -> Vec<f64> {
let mut out = vec![NAN; values.len()];
if n == 0 {
return out;
}
let mut sorted: Vec<f64> = Vec::with_capacity(n);
let mut bad = 0usize;
let pos = |s: &[f64], v: f64| s.partition_point(|x| *x < v);
for i in 0..values.len() {
let v = values[i];
if v.is_finite() {
let p = pos(&sorted, v);
sorted.insert(p, v);
} else {
bad += 1;
}
if i >= n {
let o = values[i - n];
if o.is_finite() {
let p = pos(&sorted, o);
sorted.remove(p);
} else {
bad -= 1;
}
}
if i + 1 >= n && bad == 0 {
out[i] = if n % 2 == 1 { sorted[n / 2] } else { (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0 };
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn noisy(n: usize) -> Vec<f64> {
(0..n).map(|i| 100.0 + ((i * 7919) % 101) as f64 * 0.37 - (i as f64 * 0.11).sin() * 9.0).collect()
}
fn near(a: f64, b: f64) -> bool {
(a.is_nan() && b.is_nan()) || (a - b).abs() <= 1e-7 * (1.0 + b.abs())
}
#[test]
fn rolling_sums_match_brute_force() {
let xs = noisy(500);
for n in [1, 2, 7, 20] {
let s = sum(&xs, n);
let var = variance(&xs, n);
for i in 0..xs.len() {
if i + 1 < n {
assert!(s[i].is_nan());
continue;
}
let w = &xs[i + 1 - n..=i];
let m = w.iter().sum::<f64>() / n as f64;
assert!(near(s[i], w.iter().sum()), "sum n={n} i={i}");
let v = w.iter().map(|x| (x - m).powi(2)).sum::<f64>() / n as f64;
assert!(near(var[i], v), "var n={n} i={i}: {} vs {v}", var[i]);
}
}
}
#[test]
fn gaps_only_blank_the_windows_that_hold_them() {
let mut xs = noisy(40);
xs[10] = NAN;
let s = sum(&xs, 5);
assert!(s[9].is_finite());
assert!((10..15).all(|i| s[i].is_nan()));
assert!(near(s[15], xs[11..16].iter().sum()));
assert!(highest(&xs, 5)[14].is_nan() && highest(&xs, 5)[15].is_finite());
}
#[test]
fn extremes_match_brute_force() {
let xs = noisy(400);
for n in [1, 3, 14, 52] {
let (h, l) = (highest(&xs, n), lowest(&xs, n));
for i in n - 1..xs.len() {
let w = &xs[i + 1 - n..=i];
assert_eq!(h[i], w.iter().cloned().fold(f64::MIN, f64::max));
assert_eq!(l[i], w.iter().cloned().fold(f64::MAX, f64::min));
}
}
// Ties resolve to the most recent bar.
assert_eq!(extreme_at(&[1.0, 3.0, 2.0, 3.0], 4, true)[3], 3);
}
#[test]
fn regression_on_a_line_is_exact() {
let xs: Vec<f64> = (0..50).map(|i| 3.0 + 0.5 * i as f64).collect();
let lr = linreg(&xs, 10);
let sl = linreg_slope(&xs, 10);
let r2 = r_squared(&xs, 10);
let f = tsf(&xs, 10);
for i in 9..50 {
assert!(near(lr[i], xs[i]), "{i}");
assert!(near(sl[i], 0.5));
assert!(near(r2[i], 1.0));
assert!(near(f[i], xs[i] + 0.5));
}
// Noisy data: compare with a direct fit.
let ys = noisy(120);
let n = 25;
let got = linreg_map(&ys, n, |f| f.slope + f.se);
let i = 101;
let w = &ys[i + 1 - n..=i];
let k = n as f64;
let mx = (k - 1.0) / 2.0;
let my = w.iter().sum::<f64>() / k;
let sxy: f64 = w.iter().enumerate().map(|(j, y)| (j as f64 - mx) * (y - my)).sum();
let sxx: f64 = (0..n).map(|j| (j as f64 - mx).powi(2)).sum();
let b = sxy / sxx;
let a = my - b * mx;
let sse: f64 = w.iter().enumerate().map(|(j, y)| (y - a - b * j as f64).powi(2)).sum();
assert!(near(got[i], b + (sse / (k - 2.0)).sqrt()));
}
#[test]
fn rank_median_and_shift() {
let xs = [5.0, 1.0, 4.0, 2.0, 3.0, 6.0];
assert_eq!(percent_rank(&xs, 4)[4], 50.0);
assert_eq!(percent_rank(&xs, 4)[5], 100.0);
assert_eq!(median(&xs, 3)[2], 4.0);
assert_eq!(median(&xs, 4)[5], 3.5);
assert_eq!(displace(&[1.0, 2.0], 2)[3], 2.0);
let back = displace(&[1.0, 2.0, 3.0], -1);
assert_eq!(&back[..2], &[2.0, 3.0]);
assert!(back[2].is_nan());
assert_eq!(change(&[1.0, 4.0, 9.0], 2)[2], 8.0);
assert!(zscore(&[2.0; 10], 5)[9] == 0.0);
}
}
+521
View File
@@ -0,0 +1,521 @@
//! Trend: directional movement, trailing stops, Ichimoku, Bill Williams' tools, zigzag and
//! trend-strength filters.
use super::ma::{rma, sma};
use super::stats::{change, displace, extreme_at, highest, linreg, lowest, sum};
use super::volatility::{atr, bollinger, true_range};
use super::{NAN, Ohlcv, ago, ratio, zip};
/// Wilder's directional movement index.
#[derive(Debug, Clone)]
pub struct Dmi {
pub plus: Vec<f64>,
pub minus: Vec<f64>,
pub adx: Vec<f64>,
}
/// +DI and −DI over `n` bars and ADX smoothed over `adx_n`, all Wilder-smoothed.
pub fn dmi(o: &Ohlcv, n: usize, adx_n: usize) -> Dmi {
let len = o.len();
let (up, down) = (change(&o.high, 1), change(&o.low, 1));
let plus_dm: Vec<f64> = (0..len)
.map(|i| {
if up[i].is_nan() {
NAN
} else if up[i] > -down[i] && up[i] > 0.0 {
up[i]
} else {
0.0
}
})
.collect();
let minus_dm: Vec<f64> = (0..len)
.map(|i| {
if down[i].is_nan() {
NAN
} else if -down[i] > up[i] && -down[i] > 0.0 {
-down[i]
} else {
0.0
}
})
.collect();
let mut tr = true_range(o);
if let Some(t) = tr.first_mut() {
*t = NAN;
}
let tr = rma(&tr, n);
let plus = zip(&rma(&plus_dm, n), &tr, |d, t| 100.0 * ratio(d, t, 0.0));
let minus = zip(&rma(&minus_dm, n), &tr, |d, t| 100.0 * ratio(d, t, 0.0));
let dx: Vec<f64> = (0..len).map(|i| ratio((plus[i] - minus[i]).abs(), plus[i] + minus[i], 0.0)).collect();
let adx = rma(&dx, adx_n).into_iter().map(|v| 100.0 * v).collect();
Dmi { plus, minus, adx }
}
/// Aroon up and down: how recently the `n`-bar high and low were set, 0..100.
pub fn aroon(o: &Ohlcv, n: usize) -> (Vec<f64>, Vec<f64>) {
let k = n as f64;
let since = |idx: Vec<usize>| -> Vec<f64> {
idx.into_iter()
.enumerate()
.map(|(i, j)| if j == usize::MAX { NAN } else { 100.0 * (k - (i - j) as f64) / k })
.collect()
};
(since(extreme_at(&o.high, n + 1, true)), since(extreme_at(&o.low, n + 1, false)))
}
/// Vortex indicator: +VI and −VI over `n` bars.
pub fn vortex(o: &Ohlcv, n: usize) -> (Vec<f64>, Vec<f64>) {
let vp: Vec<f64> = (0..o.len()).map(|i| (o.high[i] - ago(&o.low, i, 1)).abs()).collect();
let vm: Vec<f64> = (0..o.len()).map(|i| (o.low[i] - ago(&o.high, i, 1)).abs()).collect();
let tr = sum(&true_range(o), n);
(zip(&sum(&vp, n), &tr, |v, t| ratio(v, t, 0.0)), zip(&sum(&vm, n), &tr, |v, t| ratio(v, t, 0.0)))
}
/// A stop-and-reverse level and, per bar, whether it trails an uptrend (below price).
#[derive(Debug, Clone)]
pub struct Trail {
pub value: Vec<f64>,
pub up: Vec<bool>,
}
impl Trail {
fn new(len: usize) -> Self {
Trail { value: vec![NAN; len], up: vec![false; len] }
}
/// The level split into its uptrend and downtrend stretches (NaN elsewhere), so each
/// side draws as its own broken line.
pub fn split(&self) -> (Vec<f64>, Vec<f64>) {
let side = |want: bool| -> Vec<f64> {
self.value.iter().zip(&self.up).map(|(v, u)| if *u == want { *v } else { NAN }).collect()
};
(side(true), side(false))
}
}
/// Wilder's parabolic SAR with acceleration `start`, step `inc` and cap `max`.
pub fn psar(o: &Ohlcv, start: f64, inc: f64, max: f64) -> Trail {
let len = o.len();
let mut t = Trail::new(len);
if len < 2 {
return t;
}
let (h, l, c) = (&o.high, &o.low, &o.close);
let mut below = c[1] > c[0];
let (mut ext, mut sar) = if below { (h[1], l[0]) } else { (l[1], h[0]) };
let mut af = start;
for i in 1..len {
let mut fresh = i == 1;
sar += af * (ext - sar);
if below && sar > l[i] {
(fresh, below, sar, ext, af) = (true, false, h[i].max(ext), l[i], start);
} else if !below && sar < h[i] {
(fresh, below, sar, ext, af) = (true, true, l[i].min(ext), h[i], start);
}
if !fresh && ((below && h[i] > ext) || (!below && l[i] < ext)) {
ext = if below { h[i] } else { l[i] };
af = (af + inc).min(max);
}
if below {
sar = sar.min(l[i - 1]);
if i > 1 {
sar = sar.min(l[i - 2]);
}
} else {
sar = sar.max(h[i - 1]);
if i > 1 {
sar = sar.max(h[i - 2]);
}
}
t.value[i] = sar;
t.up[i] = below;
}
t
}
/// SuperTrend: median price ± `factor` × ATR(`n`), ratcheting with the trend.
pub fn supertrend(o: &Ohlcv, n: usize, factor: f64) -> Trail {
let (a, mid) = (atr(o, n), o.hl2());
let mut t = Trail::new(o.len());
let (mut lb_prev, mut ub_prev, mut was_up) = (NAN, NAN, None);
for i in 0..o.len() {
if a[i].is_nan() {
continue;
}
let (mut lb, mut ub) = (mid[i] - factor * a[i], mid[i] + factor * a[i]);
let pc = ago(&o.close, i, 1);
if !lb_prev.is_nan() && !(lb > lb_prev || pc < lb_prev) {
lb = lb_prev;
}
if !ub_prev.is_nan() && !(ub < ub_prev || pc > ub_prev) {
ub = ub_prev;
}
let up = match was_up {
None => false,
Some(false) => o.close[i] > ub,
Some(true) => o.close[i] >= lb,
};
t.value[i] = if up { lb } else { ub };
t.up[i] = up;
(lb_prev, ub_prev, was_up) = (lb, ub, Some(up));
}
t
}
/// Volatility stop: a close-based trailing stop `factor` × ATR(`n`) from the extreme close
/// of the current swing.
pub fn volatility_stop(o: &Ohlcv, n: usize, factor: f64) -> Trail {
let (a, tr) = (atr(o, n), true_range(o));
let c = &o.close;
let mut t = Trail::new(c.len());
let Some(&first) = c.first() else { return t };
let (mut hi, mut lo, mut up, mut stop) = (first, first, true, 0.0f64);
for i in 0..c.len() {
let src = c[i];
let m = if a[i].is_nan() { tr[i] } else { a[i] * factor };
hi = hi.max(src);
lo = lo.min(src);
stop = if up { stop.max(hi - m) } else { stop.min(lo + m) };
if stop.is_nan() {
stop = src;
}
let now_up = src - stop >= 0.0;
if now_up != up {
(hi, lo) = (src, src);
stop = if now_up { hi - m } else { lo + m };
}
up = now_up;
t.value[i] = stop;
t.up[i] = up;
}
t
}
/// Gann HiLo activator: the SMA of lows under an uptrend, of highs over a downtrend; the
/// trend flips when the close crosses the other side's previous value.
pub fn gann_hilo(o: &Ohlcv, high_n: usize, low_n: usize) -> Trail {
let (sh, sl) = (sma(&o.high, high_n), sma(&o.low, low_n));
let mut t = Trail::new(o.len());
let mut up = None;
for i in 0..o.len() {
if o.close[i] > ago(&sh, i, 1) {
up = Some(true);
} else if o.close[i] < ago(&sl, i, 1) {
up = Some(false);
}
if let Some(u) = up {
t.value[i] = if u { sl[i] } else { sh[i] };
t.up[i] = u;
}
}
t
}
#[derive(Debug, Clone)]
pub struct Ichimoku {
pub conversion: Vec<f64>,
pub base: Vec<f64>,
/// Leading spans, displaced into the future (longer than the input).
pub span_a: Vec<f64>,
pub span_b: Vec<f64>,
/// The close displaced into the past.
pub lagging: Vec<f64>,
}
/// Ichimoku Kinko Hyo: Tenkan-sen, Kijun-sen, Senkou spans A and B and Chikou span.
pub fn ichimoku(o: &Ohlcv, conversion: usize, base: usize, span_b: usize, displacement: usize) -> Ichimoku {
let mid = |n| zip(&highest(&o.high, n), &lowest(&o.low, n), |h, l| (h + l) / 2.0);
let (conv, base) = (mid(conversion), mid(base));
let shift = displacement.saturating_sub(1) as isize;
let span_a = displace(&zip(&conv, &base, |a, b| (a + b) / 2.0), shift);
let span_b = displace(&mid(span_b), shift);
let lagging = displace(&o.close, -shift);
Ichimoku { conversion: conv, base, span_a, span_b, lagging }
}
#[derive(Debug, Clone)]
pub struct Alligator {
pub jaw: Vec<f64>,
pub teeth: Vec<f64>,
pub lips: Vec<f64>,
}
/// Bill Williams' alligator: smoothed (Wilder) median price over `lengths`, each displaced
/// forward by its `offsets`.
pub fn alligator(o: &Ohlcv, lengths: [usize; 3], offsets: [usize; 3]) -> Alligator {
let hl2 = o.hl2();
let line = |k: usize| displace(&rma(&hl2, lengths[k]), offsets[k] as isize);
Alligator { jaw: line(0), teeth: line(1), lips: line(2) }
}
/// Gator oscillator: |jaw − teeth| above zero and −|teeth − lips| below, at each bar.
pub fn gator(o: &Ohlcv, lengths: [usize; 3], offsets: [usize; 3]) -> (Vec<f64>, Vec<f64>) {
let a = alligator(o, lengths, offsets);
let len = o.len();
let upper = zip(&a.jaw[..len], &a.teeth[..len], |j, t| (j - t).abs());
let lower = zip(&a.teeth[..len], &a.lips[..len], |t, l| -(t - l).abs());
(upper, lower)
}
/// Bill Williams' fractals: bars whose high (low) is strictly beyond the `n` bars on each
/// side, marked at that high (low). A fractal is only known `n` bars later.
pub fn fractals(o: &Ohlcv, n: usize) -> (Vec<f64>, Vec<f64>) {
let len = o.len();
let (mut up, mut down) = (vec![NAN; len], vec![NAN; len]);
for i in n..len.saturating_sub(n) {
let mut around = (i - n..i).chain(i + 1..=i + n);
if around.clone().all(|j| o.high[j] < o.high[i]) {
up[i] = o.high[i];
}
if around.all(|j| o.low[j] > o.low[i]) {
down[i] = o.low[i];
}
}
(up, down)
}
/// Zigzag through swing highs and lows that reverse by at least `deviation` percent,
/// interpolated linearly between pivots. The last leg runs to the latest extreme and
/// repaints until the reversal is confirmed.
pub fn zigzag(o: &Ohlcv, deviation: f64) -> Vec<f64> {
let len = o.len();
let mut out = vec![NAN; len];
if len == 0 {
return out;
}
let (h, l) = (&o.high, &o.low);
let (up_by, down_by) = (1.0 + deviation / 100.0, 1.0 - deviation / 100.0);
let mut pivots: Vec<(usize, f64)> = Vec::new();
let (mut dir, mut hi, mut lo) = (0i8, 0usize, 0usize);
for i in 1..len {
match dir {
1 if h[i] >= h[hi] => hi = i,
1 if l[i] <= h[hi] * down_by => {
pivots.push((hi, h[hi]));
(dir, lo) = (-1, i);
}
-1 if l[i] <= l[lo] => lo = i,
-1 if h[i] >= l[lo] * up_by => {
pivots.push((lo, l[lo]));
(dir, hi) = (1, i);
}
0 => {
if h[i] > h[hi] {
hi = i;
}
if l[i] < l[lo] {
lo = i;
}
if hi > lo && h[hi] >= l[lo] * up_by {
pivots.push((lo, l[lo]));
dir = 1;
} else if lo > hi && l[lo] <= h[hi] * down_by {
pivots.push((hi, h[hi]));
dir = -1;
}
}
_ => {}
}
}
match dir {
1 => pivots.push((hi, h[hi])),
-1 => pivots.push((lo, l[lo])),
_ => {}
}
for w in pivots.windows(2) {
let ((i0, v0), (i1, v1)) = (w[0], w[1]);
for (k, slot) in out.iter_mut().enumerate().take(i1 + 1).skip(i0) {
*slot = v0 + (v1 - v0) * (k - i0) as f64 / (i1 - i0).max(1) as f64;
}
}
out
}
/// Choppiness index: 100 × log10(ΣTR / (highest high − lowest low)) / log10(n). High values
/// mean a sideways market, low values a trend.
pub fn choppiness(o: &Ohlcv, n: usize) -> Vec<f64> {
let tr = sum(&true_range(o), n);
let (hh, ll) = (highest(&o.high, n), lowest(&o.low, n));
let k = (n as f64).log10();
(0..o.len()).map(|i| 100.0 * ratio(tr[i], hh[i] - ll[i], n as f64).log10() / k).collect()
}
/// Vertical horizontal filter: the `n`-bar close range over the sum of bar-to-bar moves.
pub fn vhf(values: &[f64], n: usize) -> Vec<f64> {
let (hh, ll) = (highest(values, n), lowest(values, n));
let path = sum(&change(values, 1).into_iter().map(f64::abs).collect::<Vec<_>>(), n);
(0..values.len()).map(|i| ratio(hh[i] - ll[i], path[i], 0.0)).collect()
}
/// Random walk index: the `n`-bar move against the range a random walk would cover.
pub fn rwi(o: &Ohlcv, n: usize) -> (Vec<f64>, Vec<f64>) {
let a = atr(o, n);
let scale = (n as f64).sqrt();
let high = (0..o.len()).map(|i| ratio(o.high[i] - ago(&o.low, i, n), a[i] * scale, 0.0)).collect();
let low = (0..o.len()).map(|i| ratio(ago(&o.high, i, n) - o.low[i], a[i] * scale, 0.0)).collect();
(high, low)
}
/// Trend intensity index: the share of positive deviations from SMA(`ma_n`) over the last
/// `n` bars, 0..100.
pub fn tii(values: &[f64], ma_n: usize, n: usize) -> Vec<f64> {
let dev = zip(values, &sma(values, ma_n), |c, m| c - m);
let pos: Vec<f64> = dev.iter().map(|d| if d.is_nan() { NAN } else { d.max(0.0) }).collect();
let neg: Vec<f64> = dev.iter().map(|d| if d.is_nan() { NAN } else { (-d).max(0.0) }).collect();
zip(&sum(&pos, n), &sum(&neg, n), |p, q| 100.0 * ratio(p, p + q, 0.5))
}
/// Chande Kroll stop: ATR-offset extremes over `p` bars, then their extremes over `q`.
/// Returns (stop for shorts, stop for longs).
pub fn chande_kroll(o: &Ohlcv, p: usize, x: f64, q: usize) -> (Vec<f64>, Vec<f64>) {
let a = atr(o, p);
let first_high = zip(&highest(&o.high, p), &a, |h, a| h - x * a);
let first_low = zip(&lowest(&o.low, p), &a, |l, a| l + x * a);
(highest(&first_high, q), lowest(&first_low, q))
}
/// Chandelier exits: `mult` × ATR(`n`) below the `n`-bar high (longs) and above the low
/// (shorts).
pub fn chandelier(o: &Ohlcv, n: usize, mult: f64) -> (Vec<f64>, Vec<f64>) {
let a = atr(o, n);
(zip(&highest(&o.high, n), &a, |h, a| h - mult * a), zip(&lowest(&o.low, n), &a, |l, a| l + mult * a))
}
#[derive(Debug, Clone)]
pub struct Squeeze {
/// Linear-regression momentum of the close against its channel midpoint.
pub momentum: Vec<f64>,
/// Bollinger bands inside the Keltner channels.
pub on: Vec<bool>,
/// Bollinger bands outside the Keltner channels.
pub off: Vec<bool>,
}
/// TTM-style squeeze momentum: Bollinger(`bb_n`, `bb_mult`) against Keltner(`kc_n`,
/// `kc_mult`, SMA of true range), with linear-regression momentum.
pub fn squeeze(o: &Ohlcv, bb_n: usize, bb_mult: f64, kc_n: usize, kc_mult: f64) -> Squeeze {
let bb = bollinger(&o.close, bb_n, bb_mult);
let (ma, range) = (sma(&o.close, kc_n), sma(&true_range(o), kc_n));
let len = o.len();
let (mut on, mut off) = (vec![false; len], vec![false; len]);
for i in 0..len {
let (ku, kl) = (ma[i] + kc_mult * range[i], ma[i] - kc_mult * range[i]);
on[i] = bb.lower[i] > kl && bb.upper[i] < ku;
off[i] = bb.lower[i] < kl && bb.upper[i] > ku;
}
let (hh, ll) = (highest(&o.high, kc_n), lowest(&o.low, kc_n));
let rel: Vec<f64> = (0..len).map(|i| o.close[i] - ((hh[i] + ll[i]) / 2.0 + ma[i]) / 2.0).collect();
Squeeze { momentum: linreg(&rel, kc_n), on, off }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Bar;
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9 * (1.0 + b.abs())
}
fn ohlcv(rows: &[(f64, f64, f64, f64)]) -> Ohlcv {
let bars: Vec<Bar> =
rows.iter().enumerate().map(|(i, r)| Bar::new(i as i64, r.0, r.1, r.2, r.3, 100.0)).collect();
Ohlcv::new(&bars)
}
fn line(n: usize, slope: f64) -> Ohlcv {
let rows: Vec<(f64, f64, f64, f64)> =
(0..n).map(|i| 100.0 + slope * i as f64).map(|c| (c - slope / 2.0, c + 0.5, c - 0.5, c)).collect();
ohlcv(&rows)
}
#[test]
fn dmi_in_a_steady_uptrend() {
let d = dmi(&line(100, 1.0), 14, 14);
assert!(d.plus[13].is_nan() && d.plus[14].is_finite());
assert!(d.minus[99] == 0.0 && d.plus[99] > 0.0);
assert!(near(d.adx[99], 100.0));
}
#[test]
fn aroon_counts_bars_since_extremes() {
let o = line(30, 1.0);
let (up, down) = aroon(&o, 10);
assert!(up[9].is_nan());
assert_eq!((up[20], down[20]), (100.0, 0.0));
}
#[test]
fn trails_follow_the_trend() {
let o = line(80, 1.0);
for t in
[psar(&o, 0.02, 0.02, 0.2), supertrend(&o, 10, 3.0), volatility_stop(&o, 20, 2.0), gann_hilo(&o, 13, 21)]
{
assert!(t.up[79], "should trail an uptrend");
assert!(t.value[79] < o.low[79]);
let (u, d) = t.split();
assert!(u[79].is_finite() && d[79].is_nan());
}
let falling = line(80, -1.0);
let st = supertrend(&falling, 10, 3.0);
assert!(!st.up[79] && st.value[79] > falling.high[79]);
}
#[test]
fn psar_flips_on_reversal() {
let mut rows: Vec<(f64, f64, f64, f64)> =
(0..20).map(|i| 100.0 + i as f64).map(|c| (c, c + 0.5, c - 0.5, c)).collect();
rows.extend((0..20).map(|i| 119.0 - 2.0 * i as f64).map(|c| (c, c + 0.5, c - 0.5, c)));
let p = psar(&ohlcv(&rows), 0.02, 0.02, 0.2);
assert!(p.up[15] && !p.up[39]);
assert!(p.value[39] > rows[39].1);
}
#[test]
fn ichimoku_displacement() {
let o = line(100, 1.0);
let k = ichimoku(&o, 9, 26, 52, 26);
assert_eq!(k.span_a.len(), 125);
assert!(near(k.span_a[124], (k.conversion[99] + k.base[99]) / 2.0));
assert!(near(k.lagging[0], o.close[25]));
assert!(k.lagging[99].is_nan());
let a = alligator(&o, [13, 8, 5], [8, 5, 3]);
assert_eq!(a.jaw.len(), 108);
let (up, low) = gator(&o, [13, 8, 5], [8, 5, 3]);
assert!(up[99] > 0.0 && low[99] < 0.0);
}
#[test]
fn fractals_and_zigzag_find_swings() {
// Up 10%, down 10%, up again.
let path = [100.0, 104.0, 110.0, 105.0, 99.0, 103.0, 108.0];
let rows: Vec<(f64, f64, f64, f64)> = path.iter().map(|&c| (c, c, c, c)).collect();
let o = ohlcv(&rows);
let (up, down) = fractals(&o, 2);
assert_eq!(up[2], 110.0);
assert_eq!(down[4], 99.0);
assert!(up[3].is_nan());
let z = zigzag(&o, 5.0);
assert_eq!((z[0], z[2], z[4], z[6]), (100.0, 110.0, 99.0, 108.0));
assert!(near(z[1], 105.0) && near(z[3], 104.5));
}
#[test]
fn trend_filters() {
let o = line(100, 1.0);
assert!(near(vhf(&o.close, 28)[99], 27.0 / 28.0));
assert!(near(tii(&o.close, 60, 30)[99], 100.0));
let c = choppiness(&o, 14)[99];
assert!(c > 0.0 && c < 50.0, "trending market is not choppy: {c}");
let (hi, lo) = rwi(&o, 14);
assert!(hi[99] > lo[99]);
let (sp_short, sp_long) = chande_kroll(&o, 10, 1.0, 9);
assert!(sp_short[99].is_finite() && sp_long[99].is_finite());
let (long_exit, short_exit) = chandelier(&o, 22, 3.0);
assert!(near(long_exit[99], o.high[99] - 3.0 * atr(&o, 22)[99]));
assert!(near(short_exit[99], lowest(&o.low, 22)[99] + 3.0 * atr(&o, 22)[99]));
let s = squeeze(&o, 20, 2.0, 20, 1.5);
assert!(s.momentum[99] > 0.0);
}
}
+307
View File
@@ -0,0 +1,307 @@
//! Ranges, bands and channels, and volatility estimators.
use crate::Timeframe;
use super::ma::{ema, rma, sma};
use super::momentum::roc;
use super::stats::{change, highest, linreg_map, lowest, stdev, sum, variance};
use super::{NAN, Ohlcv, ago, ratio, zip};
/// True range: the bar's range extended to the previous close (high − low on the first bar).
pub fn true_range(o: &Ohlcv) -> Vec<f64> {
(0..o.len())
.map(|i| {
let p = ago(&o.close, i, 1);
if p.is_nan() { o.high[i] - o.low[i] } else { o.high[i].max(p) - o.low[i].min(p) }
})
.collect()
}
/// Average true range (Wilder).
pub fn atr(o: &Ohlcv, n: usize) -> Vec<f64> {
rma(&true_range(o), n)
}
/// ATR as a percentage of the close.
pub fn natr(o: &Ohlcv, n: usize) -> Vec<f64> {
zip(&atr(o, n), &o.close, |a, c| 100.0 * ratio(a, c, NAN))
}
#[derive(Debug, Clone)]
pub struct Bands {
pub upper: Vec<f64>,
pub middle: Vec<f64>,
pub lower: Vec<f64>,
}
impl Bands {
/// `middle ± width`.
fn around(middle: Vec<f64>, width: &[f64]) -> Self {
let upper = zip(&middle, width, |m, w| m + w);
let lower = zip(&middle, width, |m, w| m - w);
Bands { upper, middle, lower }
}
}
/// Bollinger bands: SMA ± `k` population standard deviations.
pub fn bollinger(closes: &[f64], n: usize, k: f64) -> Bands {
let sd: Vec<f64> = stdev(closes, n).into_iter().map(|s| k * s).collect();
Bands::around(sma(closes, n), &sd)
}
/// Bollinger %B: where the close sits between the bands (0 at the lower, 1 at the upper).
pub fn percent_b(closes: &[f64], n: usize, k: f64) -> Vec<f64> {
let b = bollinger(closes, n, k);
(0..closes.len()).map(|i| ratio(closes[i] - b.lower[i], b.upper[i] - b.lower[i], 0.5)).collect()
}
/// Bollinger bandwidth: band width as a percentage of the middle band.
pub fn bandwidth(closes: &[f64], n: usize, k: f64) -> Vec<f64> {
let b = bollinger(closes, n, k);
(0..closes.len()).map(|i| 100.0 * ratio(b.upper[i] - b.lower[i], b.middle[i], 0.0)).collect()
}
/// Keltner channels: EMA(close, `n`) ± `mult` × ATR(`atr_n`).
pub fn keltner(o: &Ohlcv, n: usize, mult: f64, atr_n: usize) -> Bands {
let w: Vec<f64> = atr(o, atr_n).into_iter().map(|a| mult * a).collect();
Bands::around(ema(&o.close, n), &w)
}
/// Donchian channels: the `n`-bar highest high and lowest low, and their midpoint.
pub fn donchian(o: &Ohlcv, n: usize) -> Bands {
let (upper, lower) = (highest(&o.high, n), lowest(&o.low, n));
let middle = zip(&upper, &lower, |h, l| (h + l) / 2.0);
Bands { upper, middle, lower }
}
/// Moving average envelopes: SMA ± `pct` percent.
pub fn envelopes(values: &[f64], n: usize, pct: f64) -> Bands {
let middle = sma(values, n);
let w: Vec<f64> = middle.iter().map(|m| m * pct / 100.0).collect();
Bands::around(middle, &w)
}
/// Headley's acceleration bands: SMAs of the high and low pushed out by `factor` times the
/// bar's relative range.
pub fn acceleration_bands(o: &Ohlcv, n: usize, factor: f64) -> Bands {
let rel: Vec<f64> = (0..o.len()).map(|i| ratio(o.high[i] - o.low[i], o.high[i] + o.low[i], 0.0)).collect();
let up: Vec<f64> = (0..o.len()).map(|i| o.high[i] * (1.0 + factor * rel[i])).collect();
let dn: Vec<f64> = (0..o.len()).map(|i| o.low[i] * (1.0 - factor * rel[i])).collect();
Bands { upper: sma(&up, n), middle: sma(&o.close, n), lower: sma(&dn, n) }
}
/// STARC bands: SMA(close, `n`) ± `mult` × ATR(`atr_n`).
pub fn starc(o: &Ohlcv, n: usize, atr_n: usize, mult: f64) -> Bands {
let w: Vec<f64> = atr(o, atr_n).into_iter().map(|a| mult * a).collect();
Bands::around(sma(&o.close, n), &w)
}
/// Standard error bands: the `n`-bar regression value ± `k` standard errors, both smoothed
/// with an SMA over `smooth` bars.
pub fn standard_error_bands(values: &[f64], n: usize, smooth: usize, k: f64) -> Bands {
let mid = sma(&linreg_map(values, n, |f| f.at(n as f64 - 1.0)), smooth);
let se: Vec<f64> = sma(&linreg_map(values, n, |f| f.se), smooth).into_iter().map(|s| k * s).collect();
Bands::around(mid, &se)
}
/// Bars per year on `tf`, for annualising volatility (US equity hours intraday).
pub fn periods_per_year(tf: Timeframe) -> f64 {
match tf {
Timeframe::D1 => 252.0,
Timeframe::W1 => 52.0,
Timeframe::MN1 => 12.0,
intraday => 252.0 * 23_400.0 / intraday.seconds() as f64,
}
}
/// `ln(a / b)` for positive prices, NaN otherwise.
fn ln_ratio(a: f64, b: f64) -> f64 {
if a > 0.0 && b > 0.0 { (a / b).ln() } else { NAN }
}
/// Annualised percentage volatility from a per-bar variance; rounding can leave tiny
/// negative variances, which read as zero. Missing variances stay NaN.
fn annualised(variance: f64, ppy: f64) -> f64 {
if variance.is_finite() { 100.0 * (variance.max(0.0) * ppy).sqrt() } else { NAN }
}
/// Annualised percentage volatility from a rolling mean of per-bar variance terms.
fn annualise(terms: &[f64], n: usize, ppy: f64) -> Vec<f64> {
sma(terms, n).into_iter().map(|v| annualised(v, ppy)).collect()
}
/// Historical (close-to-close) volatility: annualised standard deviation of log returns, %.
pub fn historical_volatility(values: &[f64], n: usize, ppy: f64) -> Vec<f64> {
let r: Vec<f64> = (0..values.len()).map(|i| ln_ratio(values[i], ago(values, i, 1))).collect();
stdev(&r, n).into_iter().map(|s| 100.0 * s * ppy.sqrt()).collect()
}
/// Parkinson's high-low volatility estimator, annualised %.
pub fn parkinson(o: &Ohlcv, n: usize, ppy: f64) -> Vec<f64> {
let k = 1.0 / (4.0 * std::f64::consts::LN_2);
let terms: Vec<f64> = (0..o.len()).map(|i| k * ln_ratio(o.high[i], o.low[i]).powi(2)).collect();
annualise(&terms, n, ppy)
}
/// Garman-Klass OHLC volatility estimator, annualised %.
pub fn garman_klass(o: &Ohlcv, n: usize, ppy: f64) -> Vec<f64> {
let c = 2.0 * std::f64::consts::LN_2 - 1.0;
let terms: Vec<f64> = (0..o.len())
.map(|i| 0.5 * ln_ratio(o.high[i], o.low[i]).powi(2) - c * ln_ratio(o.close[i], o.open[i]).powi(2))
.collect();
annualise(&terms, n, ppy)
}
/// Per-bar Rogers-Satchell variance term (drift independent).
fn rs_terms(o: &Ohlcv) -> Vec<f64> {
(0..o.len())
.map(|i| {
let (h, l, op, c) = (o.high[i], o.low[i], o.open[i], o.close[i]);
ln_ratio(h, c) * ln_ratio(h, op) + ln_ratio(l, c) * ln_ratio(l, op)
})
.collect()
}
/// Rogers-Satchell volatility estimator, annualised %.
pub fn rogers_satchell(o: &Ohlcv, n: usize, ppy: f64) -> Vec<f64> {
annualise(&rs_terms(o), n, ppy)
}
/// Yang-Zhang volatility: overnight, open-to-close and Rogers-Satchell variances combined
/// to minimise estimator variance, annualised %.
pub fn yang_zhang(o: &Ohlcv, n: usize, ppy: f64) -> Vec<f64> {
let overnight: Vec<f64> = (0..o.len()).map(|i| ln_ratio(o.open[i], ago(&o.close, i, 1))).collect();
let intraday: Vec<f64> = (0..o.len()).map(|i| ln_ratio(o.close[i], o.open[i])).collect();
let k = n as f64;
let sample = if n > 1 { k / (k - 1.0) } else { 1.0 };
let w = 0.34 / (1.34 + (k + 1.0) / (k - 1.0).max(1.0));
let (vo, vc, rs) = (variance(&overnight, n), variance(&intraday, n), sma(&rs_terms(o), n));
(0..o.len()).map(|i| annualised(sample * vo[i] + w * sample * vc[i] + (1.0 - w) * rs[i], ppy)).collect()
}
/// Ulcer index: root mean square of the percentage drawdown from the `n`-bar high.
pub fn ulcer(values: &[f64], n: usize) -> Vec<f64> {
let hh = highest(values, n);
let dd: Vec<f64> = (0..values.len()).map(|i| (100.0 * ratio(values[i] - hh[i], hh[i], 0.0)).powi(2)).collect();
sma(&dd, n).into_iter().map(f64::sqrt).collect()
}
/// Chaikin volatility: the `roc_n`-bar rate of change of the EMA of the high-low range.
pub fn chaikin_volatility(o: &Ohlcv, n: usize, roc_n: usize) -> Vec<f64> {
roc(&ema(&zip(&o.high, &o.low, |h, l| h - l), n), roc_n)
}
/// Dorsey's mass index: the `sum_n`-bar sum of EMA(range) / EMA(EMA(range)).
pub fn mass_index(o: &Ohlcv, n: usize, sum_n: usize) -> Vec<f64> {
let e1 = ema(&zip(&o.high, &o.low, |h, l| h - l), n);
let e2 = ema(&e1, n);
sum(&zip(&e1, &e2, |a, b| ratio(a, b, 1.0)), sum_n)
}
/// Dorsey's relative volatility index: an RSI of the `n`-bar standard deviation, split by
/// the direction of the close, EMA-smoothed over `smooth`.
pub fn relative_volatility_index(values: &[f64], n: usize, smooth: usize) -> Vec<f64> {
let sd = stdev(values, n);
let ch = change(values, 1);
let side = |up: bool| -> Vec<f64> {
(0..values.len()).map(|i| if sd[i].is_nan() || (ch[i] > 0.0) == up { sd[i] } else { 0.0 }).collect()
};
zip(&ema(&side(true), smooth), &ema(&side(false), smooth), |u, d| 100.0 * ratio(u, u + d, 0.5))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Bar;
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9 * (1.0 + b.abs())
}
fn ohlcv(rows: &[(f64, f64, f64, f64)]) -> Ohlcv {
let bars: Vec<Bar> =
rows.iter().enumerate().map(|(i, r)| Bar::new(i as i64, r.0, r.1, r.2, r.3, 100.0)).collect();
Ohlcv::new(&bars)
}
#[test]
fn true_range_uses_previous_close() {
let o = ohlcv(&[(10.0, 11.0, 9.0, 10.0), (12.0, 13.0, 12.0, 12.5), (9.0, 10.0, 8.0, 9.0)]);
assert_eq!(true_range(&o), vec![2.0, 3.0, 4.5]);
let a = atr(&o, 2);
assert!(a[0].is_nan() && near(a[1], 2.5) && near(a[2], 0.5 * 4.5 + 0.5 * 2.5));
}
#[test]
fn bands_hand_computed() {
let o = ohlcv(&[(1.0, 2.0, 0.0, 1.0), (2.0, 4.0, 1.0, 3.0), (3.0, 3.5, 2.5, 3.0)]);
let d = donchian(&o, 2);
assert!(near(d.upper[2], 4.0) && near(d.lower[2], 1.0) && near(d.middle[2], 2.5));
let e = envelopes(&o.close, 2, 10.0);
assert!(near(e.upper[2], 3.3) && near(e.lower[2], 2.7));
let b = bollinger(&[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0], 8, 2.0);
assert!(near(percent_b(&[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0], 8, 2.0)[7], (9.0 - 1.0) / 8.0));
assert!(near(bandwidth(&[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0], 8, 2.0)[7], 800.0 / 5.0));
assert!(near(b.middle[7], 5.0));
// Flat prices: zero-width bands put %B in the middle.
assert_eq!(percent_b(&[3.0; 10], 5, 2.0)[9], 0.5);
}
#[test]
fn estimators_agree_on_constant_ranges() {
// Every bar spans a fixed log range and opens/closes at the extremes alternately:
// the range-based estimators then all see the same per-bar variance.
let rows: Vec<(f64, f64, f64, f64)> = (0..200)
.map(|i| {
let lo = 100.0 * (1.0 + 0.001 * (i % 7) as f64);
let hi = lo * 1.02;
if i % 2 == 0 { (lo, hi, lo, hi) } else { (hi, hi, lo, lo) }
})
.collect();
let o = ohlcv(&rows);
let r = (1.02f64).ln();
let ppy = 252.0;
// Rogers-Satchell is zero when bars open and close at their extremes.
assert!(near(rogers_satchell(&o, 20, ppy)[100], 0.0));
let p = parkinson(&o, 20, ppy)[100];
assert!(near(p, 100.0 * (r * r / (4.0 * std::f64::consts::LN_2) * ppy).sqrt()));
let gk = garman_klass(&o, 20, ppy)[100];
let c = 2.0 * std::f64::consts::LN_2 - 1.0;
assert!(near(gk, 100.0 * ((0.5 * r * r - c * r * r) * ppy).sqrt()));
assert!(yang_zhang(&o, 20, ppy)[100].is_finite());
}
#[test]
fn estimators_are_blank_until_their_window_fills() {
let rows: Vec<(f64, f64, f64, f64)> = (0..40).map(|i| (100.0, 101.0 + (i % 3) as f64, 99.0, 100.5)).collect();
let o = ohlcv(&rows);
for (name, v, first) in [
("parkinson", parkinson(&o, 20, 252.0), 19),
("garman-klass", garman_klass(&o, 20, 252.0), 19),
("rogers-satchell", rogers_satchell(&o, 20, 252.0), 19),
("yang-zhang", yang_zhang(&o, 20, 252.0), 20),
] {
assert!(v[..first].iter().all(|x| x.is_nan()), "{name} reads before its window fills");
assert!(v[first..].iter().all(|x| x.is_finite() && *x > 0.0), "{name} is blank after warm-up");
}
// A bar with a non-positive price has no log range; its windows stay blank.
let mut rows = rows;
rows[30].2 = 0.0;
assert!(parkinson(&ohlcv(&rows), 20, 252.0)[30..].iter().all(|x| x.is_nan()));
}
#[test]
fn historical_volatility_of_constant_growth_is_zero() {
let g: Vec<f64> = (0..50).map(|i| 100.0 * 1.01f64.powi(i)).collect();
assert!(historical_volatility(&g, 20, 252.0)[49].abs() < 1e-6);
assert!(ulcer(&g, 14)[49].abs() < 1e-9);
assert_eq!(periods_per_year(Timeframe::H1), 252.0 * 6.5);
}
#[test]
fn mass_index_of_constant_range() {
let rows: Vec<(f64, f64, f64, f64)> =
(0..100).map(|i| (i as f64, i as f64 + 2.0, i as f64, i as f64 + 1.0)).collect();
assert!(near(mass_index(&ohlcv(&rows), 9, 25)[99], 25.0));
}
}
+395
View File
@@ -0,0 +1,395 @@
//! Volume: cumulative flows, money flow, volume oscillators and session VWAP.
use crate::Bar;
use super::ma::{ema, rma, sma};
use super::momentum::{Macd, ppo, roc};
use super::stats::{change, sum};
use super::volatility::Bands;
use super::{NAN, Ohlcv, ago, ratio, zip};
/// VWAP resetting whenever `session_of(ts)` changes; falls back to the typical price
/// while the session has no volume.
pub fn vwap_by(bars: &[Bar], session_of: impl Fn(i64) -> i64) -> Vec<f64> {
let mut out = Vec::with_capacity(bars.len());
let (mut pv, mut vol) = (0.0, 0.0);
let mut session = None;
for b in bars {
let s = session_of(b.ts);
if session != Some(s) {
session = Some(s);
pv = 0.0;
vol = 0.0;
}
pv += b.hlc3() * b.volume;
vol += b.volume;
out.push(if vol > 0.0 { pv / vol } else { b.hlc3() });
}
out
}
/// Session VWAP resetting each UTC day. For daily and longer bars this
/// degenerates to hlc3, so callers typically anchor it by month instead.
pub fn vwap(bars: &[Bar]) -> Vec<f64> {
vwap_by(bars, |ts| ts.div_euclid(86_400))
}
/// Session VWAP with bands `k` volume-weighted standard deviations of the typical price
/// away.
pub fn vwap_bands(o: &Ohlcv, session_of: impl Fn(i64) -> i64, k: f64) -> Bands {
let tp = o.hlc3();
let len = o.len();
let (mut middle, mut width) = (vec![NAN; len], vec![NAN; len]);
let (mut pv, mut pv2, mut vol, mut session) = (0.0, 0.0, 0.0, None);
for i in 0..len {
let s = session_of(o.ts[i]);
if session != Some(s) {
(pv, pv2, vol, session) = (0.0, 0.0, 0.0, Some(s));
}
let v = o.volume[i];
pv += tp[i] * v;
pv2 += tp[i] * tp[i] * v;
vol += v;
if vol > 0.0 {
let m = pv / vol;
middle[i] = m;
width[i] = k * (pv2 / vol - m * m).max(0.0).sqrt();
} else {
(middle[i], width[i]) = (tp[i], 0.0);
}
}
let upper = zip(&middle, &width, |m, w| m + w);
let lower = zip(&middle, &width, |m, w| m - w);
Bands { upper, middle, lower }
}
/// Running total of `f(i)` from the first bar.
fn cumulative(len: usize, mut f: impl FnMut(usize) -> f64) -> Vec<f64> {
let mut acc = 0.0;
(0..len)
.map(|i| {
acc += f(i);
acc
})
.collect()
}
/// `values[i] - values[i - 1]` with 0 on the first bar.
fn step(values: &[f64], i: usize) -> f64 {
if i == 0 { 0.0 } else { values[i] - values[i - 1] }
}
/// On-balance volume: volume added on up closes, subtracted on down closes.
pub fn obv(o: &Ohlcv) -> Vec<f64> {
cumulative(o.len(), |i| {
let d = step(&o.close, i);
if d > 0.0 {
o.volume[i]
} else if d < 0.0 {
-o.volume[i]
} else {
0.0
}
})
}
/// Close location value × volume: the money flow volume of each bar.
fn money_flow_volume(o: &Ohlcv) -> Vec<f64> {
(0..o.len())
.map(|i| {
let (h, l, c) = (o.high[i], o.low[i], o.close[i]);
ratio((c - l) - (h - c), h - l, 0.0) * o.volume[i]
})
.collect()
}
/// Chaikin's accumulation/distribution line.
pub fn adl(o: &Ohlcv) -> Vec<f64> {
let mfv = money_flow_volume(o);
cumulative(o.len(), |i| mfv[i])
}
/// Chaikin money flow: money flow volume over volume across `n` bars, −1..1.
pub fn cmf(o: &Ohlcv, n: usize) -> Vec<f64> {
zip(&sum(&money_flow_volume(o), n), &sum(&o.volume, n), |m, v| ratio(m, v, 0.0))
}
/// Chaikin oscillator: EMA(ADL, `fast`) − EMA(ADL, `slow`).
pub fn chaikin_osc(o: &Ohlcv, fast: usize, slow: usize) -> Vec<f64> {
let a = adl(o);
zip(&ema(&a, fast), &ema(&a, slow), |f, s| f - s)
}
/// Money flow index: a volume-weighted RSI of the typical price, 0..100.
pub fn mfi(o: &Ohlcv, n: usize) -> Vec<f64> {
let tp = o.hlc3();
let flow = |up: bool| -> Vec<f64> {
(0..o.len())
.map(|i| {
let d = tp[i] - ago(&tp, i, 1);
if d.is_nan() {
NAN
} else if (up && d > 0.0) || (!up && d < 0.0) {
tp[i] * o.volume[i]
} else {
0.0
}
})
.collect()
};
zip(&sum(&flow(true), n), &sum(&flow(false), n), |p, q| 100.0 * ratio(p, p + q, 0.5))
}
/// Elder's force index: EMA of the close change × volume.
pub fn force_index(o: &Ohlcv, n: usize) -> Vec<f64> {
ema(&zip(&change(&o.close, 1), &o.volume, |d, v| d * v), n)
}
/// Arms' ease of movement: SMA of the median-price move per unit of volume, scaled by
/// `divisor`. Bars without volume contribute nothing.
pub fn ease_of_movement(o: &Ohlcv, n: usize, divisor: f64) -> Vec<f64> {
let hl2 = o.hl2();
let raw: Vec<f64> = (0..o.len())
.map(|i| ratio(divisor * (hl2[i] - ago(&hl2, i, 1)) * (o.high[i] - o.low[i]), o.volume[i], 0.0))
.collect();
sma(&raw, n)
}
/// Price volume trend: cumulative volume × percentage close change.
pub fn pvt(o: &Ohlcv) -> Vec<f64> {
cumulative(o.len(), |i| if i == 0 { 0.0 } else { ratio(step(&o.close, i), o.close[i - 1], 0.0) * o.volume[i] })
}
/// A volume index starting at 1000 that compounds the close's percentage change only on
/// bars where `pick(volume, previous volume)` holds.
fn volume_index(o: &Ohlcv, pick: impl Fn(f64, f64) -> bool) -> Vec<f64> {
let mut v = 1000.0;
(0..o.len())
.map(|i| {
if i > 0 && pick(o.volume[i], o.volume[i - 1]) {
v *= 1.0 + ratio(step(&o.close, i), o.close[i - 1], 0.0);
}
v
})
.collect()
}
/// Negative volume index (moves on volume declines) with an EMA signal.
pub fn nvi(o: &Ohlcv, signal: usize) -> (Vec<f64>, Vec<f64>) {
let line = volume_index(o, |v, p| v < p);
let sig = ema(&line, signal);
(line, sig)
}
/// Positive volume index (moves on volume increases) with an EMA signal.
pub fn pvi(o: &Ohlcv, signal: usize) -> (Vec<f64>, Vec<f64>) {
let line = volume_index(o, |v, p| v > p);
let sig = ema(&line, signal);
(line, sig)
}
/// Klinger volume oscillator (simplified): EMA difference of volume signed by the
/// typical-price trend, with an EMA signal.
pub fn klinger(o: &Ohlcv, fast: usize, slow: usize, signal: usize) -> Macd {
let tp = o.hlc3();
let sv: Vec<f64> = (0..o.len())
.map(|i| {
let d = tp[i] - ago(&tp, i, 1);
if d.is_nan() {
NAN
} else if d >= 0.0 {
o.volume[i]
} else {
-o.volume[i]
}
})
.collect();
let line = zip(&ema(&sv, fast), &ema(&sv, slow), |f, s| f - s);
let signal = ema(&line, signal);
let histogram = zip(&line, &signal, |a, b| a - b);
Macd { macd: line, signal, histogram }
}
/// Volume oscillator: percentage difference of fast and slow volume EMAs.
pub fn volume_osc(o: &Ohlcv, fast: usize, slow: usize) -> Vec<f64> {
zip(&ema(&o.volume, fast), &ema(&o.volume, slow), |f, s| 100.0 * ratio(f - s, s, 0.0))
}
/// Percentage volume oscillator: PPO of volume, with signal.
pub fn pvo(o: &Ohlcv, fast: usize, slow: usize, signal: usize) -> Macd {
ppo(&o.volume, fast, slow, signal)
}
/// Volume rate of change in percent over `n` bars.
pub fn vroc(o: &Ohlcv, n: usize) -> Vec<f64> {
roc(&o.volume, n)
}
/// Relative volume: this bar's volume over the average of the previous `n` bars.
pub fn relative_volume(o: &Ohlcv, n: usize) -> Vec<f64> {
let avg = sma(&o.volume, n);
(0..o.len()).map(|i| ratio(o.volume[i], ago(&avg, i, 1), NAN)).collect()
}
/// Twiggs money flow: Wilder-smoothed true-range money flow volume over smoothed volume.
pub fn twiggs_mf(o: &Ohlcv, n: usize) -> Vec<f64> {
let adv: Vec<f64> = (0..o.len())
.map(|i| {
let p = ago(&o.close, i, 1);
let (th, tl) = (o.high[i].max(p), o.low[i].min(p));
ratio((o.close[i] - tl) - (th - o.close[i]), th - tl, 0.0) * o.volume[i]
})
.collect();
zip(&rma(&adv, n), &rma(&o.volume, n), |a, v| ratio(a, v, 0.0))
}
/// Williams accumulation/distribution: cumulative close moves measured from the true range
/// extreme on the opposite side.
pub fn williams_ad(o: &Ohlcv) -> Vec<f64> {
cumulative(o.len(), |i| {
if i == 0 {
return 0.0;
}
let (p, c) = (o.close[i - 1], o.close[i]);
if c > p {
c - o.low[i].min(p)
} else if c < p {
c - o.high[i].max(p)
} else {
0.0
}
})
}
/// Wilder's accumulative swing index with limit move `limit`.
pub fn asi(o: &Ohlcv, limit: f64) -> Vec<f64> {
cumulative(o.len(), |i| {
if i == 0 {
return 0.0;
}
let (h, l, c, op) = (o.high[i], o.low[i], o.close[i], o.open[i]);
let (pc, po) = (o.close[i - 1], o.open[i - 1]);
let (a, b, cc, d) = ((h - pc).abs(), (l - pc).abs(), h - l, (pc - po).abs());
let r = if a >= b && a >= cc {
a - 0.5 * b + 0.25 * d
} else if b >= a && b >= cc {
b - 0.5 * a + 0.25 * d
} else {
cc + 0.25 * d
};
let k = a.max(b);
ratio(50.0 * (c - pc + 0.5 * (c - op) + 0.25 * (pc - po)) * k, r * limit, 0.0)
})
}
/// Bill Williams' market facilitation index: range per unit of volume.
pub fn bw_mfi(o: &Ohlcv) -> Vec<f64> {
(0..o.len()).map(|i| ratio(o.high[i] - o.low[i], o.volume[i], 0.0)).collect()
}
/// Net volume: volume signed by the close's direction.
pub fn net_volume(o: &Ohlcv) -> Vec<f64> {
(0..o.len())
.map(|i| {
let d = step(&o.close, i);
if d > 0.0 {
o.volume[i]
} else if d < 0.0 {
-o.volume[i]
} else {
0.0
}
})
.collect()
}
/// Volume zone oscillator: EMA of direction-signed volume over EMA of volume, −100..100.
pub fn vzo(o: &Ohlcv, n: usize) -> Vec<f64> {
zip(&ema(&net_volume(o), n), &ema(&o.volume, n), |s, v| 100.0 * ratio(s, v, 0.0))
}
/// Time segmented volume: the `n`-bar sum of close change × volume.
pub fn tsv(o: &Ohlcv, n: usize) -> Vec<f64> {
sum(&zip(&change(&o.close, 1), &o.volume, |d, v| d * v), n)
}
#[cfg(test)]
mod tests {
use super::*;
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9 * (1.0 + b.abs())
}
fn bars(rows: &[(f64, f64, f64, f64, f64)]) -> Ohlcv {
let b: Vec<Bar> =
rows.iter().enumerate().map(|(i, r)| Bar::new(i as i64 * 60, r.0, r.1, r.2, r.3, r.4)).collect();
Ohlcv::new(&b)
}
#[test]
fn cumulative_flows_hand_computed() {
let o = bars(&[
(10.0, 12.0, 8.0, 11.0, 100.0),
(11.0, 13.0, 10.0, 10.0, 200.0),
(10.0, 11.0, 9.0, 10.0, 50.0),
(10.0, 12.0, 10.0, 12.0, 300.0),
]);
assert_eq!(obv(&o), vec![0.0, -200.0, -200.0, 100.0]);
// Close location: (11−8 − (12−11))/4 = 0.5, (0 − 3)/3 = −1, 0, 1.
assert_eq!(adl(&o), vec![50.0, -150.0, -150.0, 150.0]);
assert!(near(cmf(&o, 4)[3], 150.0 / 650.0));
assert!(near(pvt(&o)[1], -200.0 / 11.0));
assert_eq!(net_volume(&o), vec![0.0, -200.0, 0.0, 300.0]);
assert_eq!(williams_ad(&o), vec![0.0, 0.0 + (10.0 - 13.0), -3.0, -3.0 + (12.0 - 10.0)]);
let (n, _) = nvi(&o, 3);
let (p, _) = pvi(&o, 3);
assert!(near(n[2], 1000.0) && near(p[1], 1000.0 * (1.0 - 1.0 / 11.0)));
assert!(near(n[3], 1000.0) && near(p[3], p[1] * 1.2));
}
#[test]
fn money_flow_index_bounds() {
let rising: Vec<(f64, f64, f64, f64, f64)> =
(0..30).map(|i| i as f64 + 10.0).map(|c| (c, c + 1.0, c - 1.0, c, 1000.0)).collect();
assert_eq!(mfi(&bars(&rising), 14)[29], 100.0);
let flat: Vec<(f64, f64, f64, f64, f64)> = (0..30).map(|_| (5.0, 5.0, 5.0, 5.0, 0.0)).collect();
let o = bars(&flat);
assert_eq!(mfi(&o, 14)[29], 50.0);
assert_eq!(ease_of_movement(&o, 14, 10_000.0)[29], 0.0);
assert_eq!(cmf(&o, 20)[29], 0.0);
assert!(relative_volume(&o, 20)[29].is_nan());
}
#[test]
fn vwap_bands_weight_by_volume() {
let raw = [Bar::new(0, 0.0, 10.0, 10.0, 10.0, 1.0), Bar::new(60, 0.0, 20.0, 20.0, 20.0, 3.0)];
let b = vwap_bands(&Ohlcv::new(&raw), |_| 0, 1.0);
// Mean 17.5; variance (1·56.25 + 3·6.25) / 4 = 18.75.
assert!(near(b.middle[1], 17.5));
assert!(near(b.upper[1] - b.middle[1], 18.75f64.sqrt()));
assert_eq!(b.upper[0], b.lower[0]);
assert!(near(vwap_by(&raw, |_| 0)[1], b.middle[1]));
}
#[test]
fn oscillators_relate_to_their_parts() {
let rows: Vec<(f64, f64, f64, f64, f64)> = (0..120)
.map(|i| {
let c = 50.0 + (i as f64 * 0.2).sin() * 4.0;
(c - 0.2, c + 0.6, c - 0.7, c, 1000.0 + (i % 5) as f64 * 150.0)
})
.collect();
let o = bars(&rows);
let v = &o.volume;
assert!(near(volume_osc(&o, 5, 10)[60], 100.0 * (ema(v, 5)[60] - ema(v, 10)[60]) / ema(v, 10)[60]));
assert!(near(relative_volume(&o, 20)[60], v[60] / sma(v, 20)[59]));
let z = vzo(&o, 14);
assert!(z.iter().filter(|x| x.is_finite()).all(|x| x.abs() <= 100.0));
let k = klinger(&o, 34, 55, 13);
assert!(near(k.histogram[100], k.macd[100] - k.signal[100]));
assert!(twiggs_mf(&o, 21)[100].abs() <= 1.0);
assert!(asi(&o, 10_000.0)[119].is_finite());
assert!(near(tsv(&o, 13)[50], (38..=50).map(|i| (o.close[i] - o.close[i - 1]) * v[i]).sum::<f64>()));
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
//! Core market types shared across terminal-rs: bars, timeframes, securities and
//! Core market types shared across Terminal: bars, timeframes, securities and
//! market keys, the security catalog, technical indicators and sample data.
pub mod bar;
+16 -2
View File
@@ -10,15 +10,29 @@ anyhow.workspace = true
chrono.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
reqwest.workspace = true
rusqlite.workspace = true
tracing.workspace = true
async-trait = "0.1"
futures = "0.3"
web-time = "1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tokio.workspace = true
rusqlite.workspace = true
tokio-tungstenite = { version = "0.30", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] }
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std"] }
# The browser build: no threads, SQLite or raw sockets. Fetch and WebSocket come from the
# browser, and chrono reads the clock through JavaScript.
[target.'cfg(target_arch = "wasm32")'.dependencies]
tokio = { version = "1", default-features = false, features = ["sync", "macros"] }
chrono = { workspace = true, features = ["wasmbind"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "WebSocket", "MessageEvent", "CloseEvent", "Event", "BinaryType"] }
send_wrapper = { version = "0.6", features = ["futures"] }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std"] }
+36 -32
View File
@@ -6,13 +6,12 @@ use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail};
use chrono::DateTime;
use futures::{SinkExt, StreamExt};
use serde_json::Value;
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
use tr_core::{Bar, Security, Timeframe, catalog};
use crate::{LiveFeed, Quote};
use crate::ws::Socket;
use crate::{LiveFeed, Quote, rt};
const BINANCE_REST: [&str; 2] = ["https://data-api.binance.vision", "https://api.binance.com"];
const BINANCE_WS: [&str; 2] = ["wss://data-stream.binance.vision/ws", "wss://stream.binance.com:9443/ws"];
@@ -186,25 +185,37 @@ pub fn parse_coinbase_book(json: &str, symbol: &str, depth: usize) -> Result<cra
Ok(b)
}
const TIMEOUT: Duration = Duration::from_secs(20);
pub struct CryptoProvider {
client: reqwest::Client,
}
impl CryptoProvider {
pub fn new() -> Result<Self> {
let client = reqwest::Client::builder()
.user_agent(concat!("terminal-rs/", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(20))
.build()?;
Ok(Self { client })
let client = reqwest::Client::builder();
// Browsers set their own User-Agent, and fetch takes its timeout per request.
#[cfg(not(target_arch = "wasm32"))]
let client = client.user_agent(concat!("terminal/", env!("CARGO_PKG_VERSION"))).timeout(TIMEOUT);
Ok(Self { client: client.build()? })
}
async fn get(&self, url: &str, query: &[(&str, String)]) -> Result<String> {
let resp = self.client.get(url).query(query).send().await?;
if !resp.status().is_success() {
bail!("GET {url}: HTTP {}", resp.status());
}
Ok(resp.text().await?)
let req = self.client.get(url).query(query);
#[cfg(target_arch = "wasm32")]
let req = req.timeout(TIMEOUT);
let fetch = async move {
let resp = req.send().await?;
if !resp.status().is_success() {
bail!("GET {url}: HTTP {}", resp.status());
}
Ok(resp.text().await?)
};
// A browser fetch holds JavaScript values, which are not `Send`. The web build has
// one thread, so asserting it is sound and keeps the provider traits shared.
#[cfg(target_arch = "wasm32")]
let fetch = send_wrapper::SendWrapper::new(fetch);
fetch.await
}
async fn binance_history(
@@ -363,7 +374,7 @@ impl CryptoProvider {
let (tx, rx) = mpsc::channel(256);
let stream = format!("{}@kline_{}", binance_symbol(sec).to_ascii_lowercase(), binance_interval(tf));
let product = coinbase_product(sec);
tokio::spawn(async move {
rt::spawn(async move {
let mut backoff = 1;
let mut attempt = 0usize;
while !tx.is_closed() {
@@ -378,7 +389,7 @@ impl CryptoProvider {
tracing::debug!("live feed {stream}: {e:#}");
}
attempt += 1;
tokio::time::sleep(Duration::from_secs(backoff)).await;
rt::sleep(Duration::from_secs(backoff)).await;
backoff = (backoff * 2).min(30);
}
});
@@ -387,32 +398,25 @@ impl CryptoProvider {
}
async fn binance_ws(url: &str, tx: &mpsc::Sender<Bar>) -> Result<()> {
let (mut ws, _) = tokio_tungstenite::connect_async(url).await.context("binance websocket")?;
while let Some(msg) = ws.next().await {
match msg? {
Message::Text(t) => {
if let Some(bar) = parse_binance_ws(t.as_str())
&& tx.send(bar).await.is_err()
{
return Ok(());
}
}
Message::Close(_) => break,
_ => {}
let mut ws = Socket::connect(url).await.context("binance websocket")?;
while let Some(text) = ws.next().await {
if let Some(bar) = parse_binance_ws(&text?)
&& tx.send(bar).await.is_err()
{
return Ok(());
}
}
bail!("binance websocket closed")
}
async fn coinbase_ws(product: &str, tf: Timeframe, tx: &mpsc::Sender<Bar>) -> Result<()> {
let (mut ws, _) = tokio_tungstenite::connect_async(COINBASE_WS).await.context("coinbase websocket")?;
let mut ws = Socket::connect(COINBASE_WS).await.context("coinbase websocket")?;
let sub = serde_json::json!({"type": "subscribe", "product_ids": [product], "channels": ["ticker"]});
ws.send(Message::Text(sub.to_string().into())).await?;
ws.send(sub.to_string()).await?;
// Aggregate ticks into the current bar; the chart merges partial bars into history.
let mut bar: Option<Bar> = None;
while let Some(msg) = ws.next().await {
let Message::Text(t) = msg? else { continue };
let Some((ts, px, size)) = parse_coinbase_ws(t.as_str()) else { continue };
while let Some(text) = ws.next().await {
let Some((ts, px, size)) = parse_coinbase_ws(&text?) else { continue };
let bucket = tf.bucket_start(ts);
let b = match bar.as_mut() {
Some(b) if b.ts == bucket => {
+26
View File
@@ -11,14 +11,26 @@
//!
//! The router is wrapped in [`Fallback`], which serves sample data if a source is
//! unreachable, so the terminal always has something to draw.
//!
//! The browser build (`wasm32`) can only reach sources that allow cross-origin requests:
//! [`for_browser`] serves live crypto through [`web::WebMarkets`] and sample data for the
//! rest. It has no SQLite bar cache.
#[cfg(not(target_arch = "wasm32"))]
pub mod alpaca;
#[cfg(not(target_arch = "wasm32"))]
pub mod cache;
pub mod crypto;
#[cfg(not(target_arch = "wasm32"))]
pub mod markets;
pub mod rss;
pub mod rt;
pub mod sample;
#[cfg(not(target_arch = "wasm32"))]
pub mod treasury;
pub mod web;
mod ws;
#[cfg(not(target_arch = "wasm32"))]
pub mod yahoo;
use std::sync::Arc;
@@ -29,6 +41,7 @@ use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use tr_core::{AssetClass, Bar, Security, Timeframe, catalog};
#[cfg(not(target_arch = "wasm32"))]
pub use cache::BarCache;
/// Latest quote for a symbol.
@@ -384,6 +397,7 @@ pub fn security(symbol: &str) -> Security {
}
/// Build the provider described in the crate docs from environment variables.
#[cfg(not(target_arch = "wasm32"))]
pub fn from_env() -> Provider {
// reqwest and the websocket client both use rustls; pin one crypto provider.
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
@@ -404,6 +418,18 @@ pub fn from_env() -> Provider {
}
}
/// The browser build's provider: sample data only when `offline`, otherwise live crypto
/// with sample data for everything else.
pub fn for_browser(offline: bool) -> Provider {
if !offline {
match web::WebMarkets::new() {
Ok(m) => return Arc::new(Fallback::new(m)),
Err(e) => tracing::warn!("crypto sources unavailable, using sample data: {e:#}"),
}
}
Arc::new(sample::SampleProvider::default().with_live_ticks())
}
/// Keep at most `limit` bars ending at or before `end`.
pub(crate) fn clip(mut bars: Vec<Bar>, end: Option<i64>, limit: usize) -> Vec<Bar> {
if let Some(end) = end {
+89
View File
@@ -0,0 +1,89 @@
//! Background tasks and timers on either target: tokio on the desktop, the browser's event
//! loop on the web (`wasm32`), where there is one thread and no tokio runtime.
use std::future::Future;
use std::time::Duration;
use web_time::Instant;
/// Run `fut` in the background.
#[cfg(not(target_arch = "wasm32"))]
pub fn spawn<F: Future<Output = ()> + Send + 'static>(fut: F) {
tokio::spawn(fut);
}
/// Run `fut` in the background.
#[cfg(target_arch = "wasm32")]
pub fn spawn<F: Future<Output = ()> + 'static>(fut: F) {
wasm_bindgen_futures::spawn_local(fut);
}
/// Run CPU-bound work off the async workers; `None` if it panicked. The web has one thread,
/// so there it runs inline.
#[cfg(not(target_arch = "wasm32"))]
pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Option<T> {
tokio::task::spawn_blocking(f).await.ok()
}
/// Run CPU-bound work off the async workers; `None` if it panicked. The web has one thread,
/// so there it runs inline.
#[cfg(target_arch = "wasm32")]
pub async fn blocking<T>(f: impl FnOnce() -> T) -> Option<T> {
Some(f())
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn sleep(d: Duration) {
tokio::time::sleep(d).await;
}
/// Resolves after `d`, through `setTimeout`.
#[cfg(target_arch = "wasm32")]
pub async fn sleep(d: Duration) {
let ms = d.as_millis().min(i32::MAX as u128) as i32;
let promise = js_sys::Promise::new(&mut |resolve, _| {
if let Some(w) = web_sys::window() {
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
}
});
let _ = wasm_bindgen_futures::JsFuture::from(promise).await;
}
/// A periodic timer: the first tick completes at once, later ones every `period`. A late
/// tick fires immediately and the next one is a full period after it.
pub struct Interval {
period: Duration,
next: Instant,
}
pub fn interval(period: Duration) -> Interval {
Interval { period, next: Instant::now() }
}
impl Interval {
/// Wait for the next tick. Dropping the future part-way (in `select!`) loses nothing.
pub async fn tick(&mut self) {
let now = Instant::now();
if self.next > now {
sleep(self.next - now).await;
}
self.next = self.next.max(Instant::now()) + self.period;
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
#[tokio::test]
async fn interval_spaces_ticks() {
let mut iv = interval(Duration::from_millis(20));
let t0 = Instant::now();
iv.tick().await;
assert!(t0.elapsed() < Duration::from_millis(15), "first tick is immediate");
iv.tick().await;
iv.tick().await;
assert!(t0.elapsed() >= Duration::from_millis(40));
assert_eq!(blocking(|| 6 * 7).await, Some(42));
}
}
+3 -3
View File
@@ -5,7 +5,7 @@ use async_trait::async_trait;
use tokio::sync::mpsc;
use tr_core::{Bar, Timeframe, sample};
use crate::{LiveFeed, MarketData, OptionChain, OrderBook, security};
use crate::{LiveFeed, MarketData, OptionChain, OrderBook, rt, security};
/// Deterministic synthetic data anchored at a fixed "now" per process, so paging is
/// consistent: page N+1 is exactly the bars preceding page N. Levels and volatility
@@ -74,8 +74,8 @@ impl MarketData for SampleProvider {
let (tx, rx) = mpsc::channel(64);
let tick_vol = sec.class.sample_vol() * (0.4 / (365.0 * 86_400.0f64)).sqrt() * 8.0;
let mut rng = sample::Rng::new(sample::seed_for(symbol) ^ 0x5eed);
tokio::spawn(async move {
let mut iv = tokio::time::interval(Duration::from_millis(400));
rt::spawn(async move {
let mut iv = rt::interval(Duration::from_millis(400));
loop {
iv.tick().await;
let now = chrono::Utc::now().timestamp();
+1 -1
View File
@@ -113,7 +113,7 @@ pub struct TreasuryProvider {
impl TreasuryProvider {
pub fn new() -> Result<Self> {
let client = reqwest::Client::builder()
.user_agent("Mozilla/5.0 (compatible; terminal-rs)")
.user_agent("Mozilla/5.0 (compatible; terminal)")
.timeout(Duration::from_secs(30))
.build()?;
Ok(Self { client, years: Mutex::new(HashMap::new()) })
+96
View File
@@ -0,0 +1,96 @@
//! Market data for the browser build.
//!
//! A page can only read sources that allow cross-origin requests. Binance and Coinbase do,
//! so crypto is live: REST history, quotes and order books, and websocket bars. Yahoo,
//! Treasury.gov, Alpaca and CoinDesk do not, so every other asset class comes from the
//! sample generator and there are no headlines or network search.
//!
//! TODO(web proxy): route equities, FX, indices, futures, Treasuries and news through a
//! same-origin proxy to those sources, then use [`crate::markets::Markets`] here instead.
use anyhow::Result;
use async_trait::async_trait;
use tr_core::{AssetClass, Bar, Timeframe};
use crate::crypto::CryptoProvider;
use crate::sample::SampleProvider;
use crate::{LiveFeed, MarketData, OrderBook, Quote, YieldCurves, security};
pub struct WebMarkets {
crypto: CryptoProvider,
sample: SampleProvider,
}
impl WebMarkets {
pub fn new() -> Result<Self> {
Ok(Self { crypto: CryptoProvider::new()?, sample: SampleProvider::default() })
}
}
fn is_crypto(symbol: &str) -> bool {
security(symbol).class == AssetClass::Crypto
}
#[async_trait]
impl MarketData for WebMarkets {
fn name(&self) -> &str {
"binance+sample"
}
async fn history(&self, symbol: &str, tf: Timeframe, end: Option<i64>, limit: usize) -> Result<Vec<Bar>> {
if is_crypto(symbol) {
self.crypto.history(&security(symbol), tf, end, limit).await
} else {
self.sample.history(symbol, tf, end, limit).await
}
}
async fn quotes(&self, symbols: &[String]) -> Result<Vec<Quote>> {
let (crypto, other): (Vec<String>, Vec<String>) = symbols.iter().cloned().partition(|s| is_crypto(s));
let mut out = self.sample.quotes(&other).await?;
if !crypto.is_empty() {
let secs: Vec<_> = crypto.iter().map(|k| (k.clone(), security(k))).collect();
match self.crypto.quotes(&secs).await {
Ok(q) => out.extend(q),
Err(e) if out.is_empty() => return Err(e),
Err(e) => tracing::warn!("crypto quotes: {e:#}"),
}
}
Ok(out)
}
fn live(&self, symbol: &str, tf: Timeframe) -> Option<LiveFeed> {
is_crypto(symbol).then(|| CryptoProvider::live(&security(symbol), tf))
}
async fn order_book(&self, symbol: &str, depth: usize) -> Result<OrderBook> {
let sec = security(symbol);
if sec.class == AssetClass::Crypto {
self.crypto.order_book(symbol, &sec, depth).await
} else {
anyhow::bail!("no public order book for {} in the browser", sec.key())
}
}
async fn yield_curves(&self) -> Result<YieldCurves> {
self.sample.yield_curves().await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn non_crypto_comes_from_sample_data() {
let m = WebMarkets::new().unwrap();
let bars = m.history("AAPL US Equity", Timeframe::D1, None, 50).await.unwrap();
assert_eq!(bars.len(), 50);
let q = m.quotes(&["AAPL US Equity".into(), "EURUSD Curncy".into()]).await.unwrap();
assert_eq!(q.len(), 2);
assert!(m.live("AAPL US Equity", Timeframe::M1).is_none());
assert!(m.order_book("AAPL US Equity", 10).await.is_err());
assert_eq!(m.yield_curves().await.unwrap().current.points.len(), 11);
assert!(is_crypto("BTCUSD Curncy") && !is_crypto("US10Y Govt"));
}
}
+125
View File
@@ -0,0 +1,125 @@
//! A text-message websocket client: tokio-tungstenite on the desktop, the browser's
//! `WebSocket` on the web.
#[cfg(not(target_arch = "wasm32"))]
pub use native::Socket;
#[cfg(target_arch = "wasm32")]
pub use web::Socket;
#[cfg(not(target_arch = "wasm32"))]
mod native {
use anyhow::{Context, Result};
use futures::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
pub struct Socket(WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>);
impl Socket {
pub async fn connect(url: &str) -> Result<Self> {
let (ws, _) = tokio_tungstenite::connect_async(url).await.with_context(|| format!("websocket {url}"))?;
Ok(Self(ws))
}
pub async fn send(&mut self, text: String) -> Result<()> {
Ok(self.0.send(Message::Text(text.into())).await?)
}
/// The next text message; `None` once the server closes the connection.
pub async fn next(&mut self) -> Option<Result<String>> {
while let Some(msg) = self.0.next().await {
match msg {
Ok(Message::Text(t)) => return Some(Ok(t.as_str().to_owned())),
Ok(Message::Close(_)) => return None,
Ok(_) => {}
Err(e) => return Some(Err(e.into())),
}
}
None
}
}
}
#[cfg(target_arch = "wasm32")]
mod web {
use anyhow::{Result, anyhow, bail};
use tokio::sync::mpsc;
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use web_sys::{CloseEvent, MessageEvent, WebSocket};
enum Event {
Open,
Text(String),
Error,
Closed,
}
/// Browser websocket; its callbacks feed a channel. Closed when dropped.
pub struct Socket {
ws: WebSocket,
rx: mpsc::UnboundedReceiver<Event>,
_on_open: Closure<dyn FnMut()>,
_on_message: Closure<dyn FnMut(MessageEvent)>,
_on_error: Closure<dyn FnMut()>,
_on_close: Closure<dyn FnMut(CloseEvent)>,
}
impl Socket {
pub async fn connect(url: &str) -> Result<Self> {
let ws = WebSocket::new(url).map_err(|e| anyhow!("websocket {url}: {e:?}"))?;
let (tx, rx) = mpsc::unbounded_channel();
let t = tx.clone();
let on_open = Closure::<dyn FnMut()>::new(move || {
let _ = t.send(Event::Open);
});
let t = tx.clone();
let on_message = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
if let Some(text) = e.data().as_string() {
let _ = t.send(Event::Text(text));
}
});
let t = tx.clone();
let on_error = Closure::<dyn FnMut()>::new(move || {
let _ = t.send(Event::Error);
});
let on_close = Closure::<dyn FnMut(CloseEvent)>::new(move |_| {
let _ = tx.send(Event::Closed);
});
ws.set_onopen(Some(on_open.as_ref().unchecked_ref()));
ws.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
ws.set_onerror(Some(on_error.as_ref().unchecked_ref()));
ws.set_onclose(Some(on_close.as_ref().unchecked_ref()));
let mut socket =
Self { ws, rx, _on_open: on_open, _on_message: on_message, _on_error: on_error, _on_close: on_close };
match socket.rx.recv().await {
Some(Event::Open) => Ok(socket),
_ => bail!("websocket {url}: connection failed"),
}
}
pub async fn send(&mut self, text: String) -> Result<()> {
self.ws.send_with_str(&text).map_err(|e| anyhow!("websocket send: {e:?}"))
}
/// The next text message; `None` once the server closes the connection.
pub async fn next(&mut self) -> Option<Result<String>> {
match self.rx.recv().await? {
Event::Text(t) => Some(Ok(t)),
Event::Error => Some(Err(anyhow!("websocket error"))),
Event::Open | Event::Closed => None,
}
}
}
impl Drop for Socket {
fn drop(&mut self) {
// Detach the callbacks before their closures are freed.
self.ws.set_onopen(None);
self.ws.set_onmessage(None);
self.ws.set_onerror(None);
self.ws.set_onclose(None);
let _ = self.ws.close();
}
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ impl YahooProvider {
pub fn new() -> Result<Self> {
let client = reqwest::Client::builder()
// Yahoo rejects requests without a browser-like user agent.
.user_agent("Mozilla/5.0 (compatible; terminal-rs)")
.user_agent("Mozilla/5.0 (compatible; terminal)")
.timeout(Duration::from_secs(20))
.cookie_store(true)
.build()?;
+7 -2
View File
@@ -1,12 +1,12 @@
[package]
name = "tr-macos"
description = "macOS window chrome for terminal-rs: unified toolbar, vibrancy, accent colour"
description = "macOS window chrome for Terminal: unified toolbar, vibrancy, accent colour, updates"
version.workspace = true
edition.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSGeometry", "NSObject"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSGeometry", "NSObject", "NSBundle"] }
objc2-app-kit = { version = "0.3", default-features = false, features = [
"std",
"NSResponder",
@@ -20,5 +20,10 @@ objc2-app-kit = { version = "0.3", default-features = false, features = [
"NSAppearance",
"NSButton",
"NSControl",
"NSWorkspace",
"NSAccessibility",
"NSApplication",
"NSMenu",
"NSMenuItem",
"objc2-core-foundation",
] }
+21 -2
View File
@@ -6,6 +6,9 @@
//! - [`chrome_metrics`]: the live height of that title bar region and the width taken by
//! the traffic lights, so app content never sits under the system's drag region.
//! - [`accent_color`]: the user's system accent colour (`NSColor.controlAccentColor`).
//! - [`updater`]: update checks and installs through Sparkle.
pub mod updater;
use std::ffi::c_void;
@@ -30,7 +33,7 @@ mod imp {
use objc2_app_kit::{
NSAutoresizingMaskOptions, NSColor, NSColorSpace, NSToolbar, NSView, NSVisualEffectBlendingMode,
NSVisualEffectMaterial, NSVisualEffectState, NSVisualEffectView, NSWindowButton, NSWindowOrderingMode,
NSWindowTitleVisibility, NSWindowToolbarStyle,
NSWindowTitleVisibility, NSWindowToolbarStyle, NSWorkspace,
};
use objc2_foundation::NSString;
@@ -47,7 +50,7 @@ mod imp {
let Some(window) = view.window() else { return false };
window.setTitleVisibility(NSWindowTitleVisibility::Hidden);
window.setTitlebarAppearsTransparent(true);
let toolbar = NSToolbar::initWithIdentifier(NSToolbar::alloc(mtm), &NSString::from_str("terminal-rs.toolbar"));
let toolbar = NSToolbar::initWithIdentifier(NSToolbar::alloc(mtm), &NSString::from_str("terminal.toolbar"));
window.setToolbar(Some(&toolbar));
// Compact unified toolbar: one slim row shared with the traffic lights.
window.setToolbarStyle(NSWindowToolbarStyle::UnifiedCompact);
@@ -105,6 +108,10 @@ mod imp {
let to = |v: f64| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
Some([to(srgb.redComponent()), to(srgb.greenComponent()), to(srgb.blueComponent())])
}
pub fn reduce_motion() -> bool {
NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion()
}
}
/// Apply native chrome to the window owning `ns_view`. Returns whether it was applied.
@@ -153,6 +160,18 @@ pub fn accent_color() -> Option<[u8; 3]> {
}
}
/// Whether the user turned on Reduce Motion in the system accessibility settings.
pub fn reduce_motion() -> bool {
#[cfg(target_os = "macos")]
{
imp::reduce_motion()
}
#[cfg(not(target_os = "macos"))]
{
false
}
}
pub fn is_macos() -> bool {
cfg!(target_os = "macos")
}
+103
View File
@@ -0,0 +1,103 @@
//! Updating the installed app with Sparkle.
//!
//! `scripts/bundle-macos.sh` embeds Sparkle.framework, and the bundle's Info.plist names the
//! update feed and the key every update must be signed with. Sparkle is loaded at run time rather
//! than linked, so builds without it (`cargo run`, other platforms) run as before and
//! [`check_for_updates`] reports that it couldn't check.
/// The project's releases on GitHub.
pub const RELEASES: &str = "https://github.com/michaelshimeles/terminal/releases";
/// Starts Sparkle, which checks the feed once a day and shows its update window when a newer
/// release is out, and adds "Check for Updates…" to the app menu. Main thread; later calls do
/// nothing.
pub fn start() {
#[cfg(target_os = "macos")]
imp::with(|_| ());
}
/// Checks for updates now, in Sparkle's window. Returns false when Sparkle isn't available.
pub fn check_for_updates() -> bool {
#[cfg(target_os = "macos")]
{
// SAFETY: `-[SPUStandardUpdaterController checkForUpdates:]` takes a nullable sender and
// returns nothing.
imp::with(|controller| unsafe {
let () = objc2::msg_send![controller, checkForUpdates: None::<&objc2::runtime::AnyObject>];
})
.is_some()
}
#[cfg(not(target_os = "macos"))]
{
false
}
}
#[cfg(target_os = "macos")]
mod imp {
use std::cell::OnceCell;
use objc2::rc::{Allocated, Retained};
use objc2::runtime::{AnyClass, AnyObject, Bool};
use objc2::{MainThreadMarker, MainThreadOnly, msg_send, sel};
use objc2_app_kit::{NSApplication, NSMenuItem};
use objc2_foundation::{NSBundle, NSString};
thread_local! {
/// The `SPUStandardUpdaterController`, kept for the life of the app; `None` without Sparkle.
static CONTROLLER: OnceCell<Option<Retained<AnyObject>>> = const { OnceCell::new() };
}
/// Runs `f` with the updater controller, loading Sparkle on first use. Main thread only.
pub fn with<R>(f: impl FnOnce(&AnyObject) -> R) -> Option<R> {
MainThreadMarker::new()?;
CONTROLLER.with(|c| c.get_or_init(load).as_deref().map(f))
}
fn load() -> Option<Retained<AnyObject>> {
let mtm = MainThreadMarker::new()?;
let frameworks = NSBundle::mainBundle().privateFrameworksPath()?;
let bundle = NSBundle::bundleWithPath(&NSString::from_str(&format!("{frameworks}/Sparkle.framework")))?;
// SAFETY: the framework is sealed in this app's signature; loading it registers its
// classes. The hardened runtime refuses it unless it is signed by the app's team.
if !unsafe { bundle.load() } {
return None;
}
let class = AnyClass::get(c"SPUStandardUpdaterController")?;
// SAFETY: `initWithStartingUpdater:updaterDelegate:userDriverDelegate:` takes a BOOL and two
// nullable delegates, and returns the initialised controller.
let controller: Retained<AnyObject> = unsafe {
let controller: Allocated<AnyObject> = msg_send![class, alloc];
msg_send![
controller,
initWithStartingUpdater: Bool::YES,
updaterDelegate: None::<&AnyObject>,
userDriverDelegate: None::<&AnyObject>
]
};
add_menu_item(mtm, &controller);
Some(controller)
}
/// "Check for Updates…" under "About Terminal" in the app menu. The controller is the item's
/// target, so Sparkle also greys it out while a check is running.
fn add_menu_item(mtm: MainThreadMarker, controller: &AnyObject) {
let Some(app_menu) =
NSApplication::sharedApplication(mtm).mainMenu().and_then(|m| m.itemAtIndex(0)).and_then(|i| i.submenu())
else {
return;
};
// SAFETY: `checkForUpdates:` is the controller's action method; the menu holds its target
// weakly, and `CONTROLLER` keeps it alive for the life of the app.
unsafe {
let item = NSMenuItem::initWithTitle_action_keyEquivalent(
NSMenuItem::alloc(mtm),
&NSString::from_str("Check for Updates…"),
Some(sel!(checkForUpdates:)),
&NSString::from_str(""),
);
item.setTarget(Some(controller));
app_menu.insertItem_atIndex(&item, 1);
}
}
}
+1
View File
@@ -23,6 +23,7 @@ Tests for the parsers and indicators, frame-time benchmarks, and a README noting
## Progress
- [x] Command line, docking workspace (splits, tabs, drag and drop, pop-outs), saved layouts, link groups, rebindable hotkeys.
- [x] wgpu charts: candles/bars/line/area, 1m–1M, SMA/EMA/VWAP/RSI/MACD/Bollinger, drawings, compare, log scale.
- [x] Indicator library: 151 studies across moving averages, bands, momentum, trend, volatility, volume, statistics and pivots, from one registry driving the command line, `IND` picker, chart menu, help, panes/levels and saved layouts.
- [x] Pluggable data providers (Yahoo, Binance/Coinbase, sample mode); multi-asset (equities, FX, crypto, rates, commodities, indices).
- [x] Congress ingest from Senate eFD and House Clerk (PDF/XML), feed, politician profiles, per-ticker panel, chart markers with hover card, tracking alerts, freshness indicator.
- [x] Native macOS look (unified toolbar, vibrancy sidebar on either side, SF fonts), options chains (OMON) with greeks.
+22
View File
@@ -32,3 +32,25 @@
sub-pane heights are a fraction of the chart rect and share the x-transform with the price pane.
- Indicators computed locally in `tr-core::indicators` (all O(n), ~ms for 1M bars) and cached per
(series version, params); recomputed off the UI thread when data changes.
## As built
- One registry (`tr-core::indicators::registry`) describes all 151 indicators: code and aliases,
name, category, parameters (default, bounds, integer or real), overlay or pane, a fixed range and
reference levels for bounded oscillators, and a compute function returning named, styled outputs
(line, histogram, dots, or per-bar steps for session levels such as pivots; main, signal,
up/down or extra series colours). The command line, the
`IND` picker (the ⌘K palette scoped to indicators, staying open to toggle several), the chart's
right-click menu (one submenu per category), help, the worker and saved layouts all read it, so
adding an indicator is one registry entry.
- `CODE p1 p2…` toggles an indicator; missing parameters take defaults, out-of-range ones are
clamped. Repeating the same command removes it; new parameters replace the instance on the
chart, except for moving averages, where `SMA 50` and `SMA 200` coexist.
- Codes never shadow a listed ticker or an existing command (both are tested); where a common
abbreviation would, the indicator takes a longer code (`STOCHMI` for SMI, `EFI` for force index,
`VOSC` for the volume oscillator, `TRANGE` for true range).
- Layouts store each indicator as its label (`"BB 20 2"`); the older tagged forms (`{"Sma":20}`,
`"Vwap"`…) still load, and unknown codes round-trip unchanged.
- Conventions: outputs align with the bars and are NaN during warm-up; EMA is seeded with the SMA;
RSI, ATR and ADX use Wilder smoothing; standard deviations are population; sessions (VWAP,
pivots) are days for intraday bars, months for daily and years above.
+771
View File
@@ -0,0 +1,771 @@
# Terminal data server: design
Status: **draft for review**. This PR adds no server code.
Author: Grok Bot, for Micky. Research date: 2026-10-29. Times are UTC unless marked.
Terminal today fetches all of its own data: Yahoo, Binance and Coinbase websockets, Treasury.gov, Senate eFD and the House Clerk, and SnapTrade. Each desktop process opens its own connections. The browser build serves sample data wherever a source blocks cross-origin requests (`TODO(web proxy)` in `crates/data/src/web.rs` and `crates/congress/src/db_web.rs`). This doc proposes the replacement Micky asked for: **one Rust server that every client (desktop, web, later mobile) connects to, so market data and user state sync in real time**. Hyperliquid is the first market-data source, and the design leaves a clean provider interface for licensed equities and options later.
**Decision (Micky, 2026-10-29): Terminal runs on our own Hyperliquid non-validator node(s) and our own systems from day one.** Live data comes only from the node's output files and its local info server: books, trades, candles, mids, marks, oracle prices and funding. The public Hyperliquid API is used for nothing live. At most, it serves a one-time history backfill (§5.6).
How facts are marked:
- **[doc: …]** is a fact from official documentation. Sources are listed in [References](#references).
- **[observed]** is a fact from read-only calls I made against `api.hyperliquid.xyz` on 2026-10-29 around 17:35 UTC, or from reading the public `order_book_server` source. These are snapshots, not guarantees.
- **[unverified]** is an assumption or estimate that nobody has checked yet. It needs confirming before we build on it.
- **[target]** is a goal we set for ourselves. Nobody has measured it yet.
---
## 1. Goals and non-goals
### Goals
1. **Our own node, our own systems.** We run Hyperliquid non-validator nodes in Tokyo (§6). Our `tr-ingest` builds every order book, trade tape, candle and price from the node's outputs (§5). There are no live dependencies on `api.hyperliquid.xyz`, so its rate limits don't apply to live data.
2. **One source, many clients.** Ingest computes everything once. The gateway fans it out, so cost scales with instruments, not with clients (§5.9).
3. **The same data everywhere.** Desktop, web (WASM) and mobile see the same quotes, bars, books and user documents, with live updates.
4. **Hyperliquid first:** core perps, spot, and HIP-3 builder dexes (trade.xyz `xyz:` stocks, indices, FX and commodities, and the other dexes).
5. **A provider interface clean enough** that a licensed equities and options vendor (for example Intrinio, Twelve Data or Massive) slots in later with no protocol change. Each provider also carries a display-rights policy (§3.1).
6. **User sync:** accounts, layouts, watchlists, drawings, preferences and the demo (paper) portfolio, with explicit conflict rules.
7. **The web build gets real data.** This removes the `TODO(web proxy)` stubs.
8. **Candles at every interval with no history cap,** built from trades on our node (§5.3).
9. **The terminal stays snappy.** The protocol is binary and conflated, and the UI thread never waits on the network. This keeps the existing worker model in `app/src/worker.rs`.
### Non-goals (for this design)
- **No trading through the server.** The server is data only. It never signs, routes or places orders on Hyperliquid or anywhere else. The SnapTrade order paths in `crates/broker/src/trading.rs` stay client-side on the desktop, with the user's local credentials. They are unchanged and out of scope (§9). The reasons are the legal flag in §13 (Hyperliquid's Terms of Use) and Micky's steer. The demo account is paper trading against our own quotes and never touches a venue.
- No licensed stock or options data yet. Only the interface for it is in scope (§3.1).
- No public API for third parties. The protocol is for our own clients.
- No running of a Hyperliquid **validator**. We run non-validators only (§6).
- No live use of the public Hyperliquid API (the one-time backfill in §5.6 is the only exception).
- No multi-region active-active for v1.
---
## 2. Current data layer (what we map onto)
| Area | Today | Code |
|---|---|---|
| Provider trait | `MarketData` (async): `history`, `quotes`, `live`, `search`, `news`, `options`, `order_book`, `yield_curves`. Wrapped in `Fallback`, which serves sample data on error. | `crates/data/src/lib.rs` |
| Keys | Bloomberg-style market keys from `Security { ticker, class }`: `AAPL US Equity`, `BTCUSD Curncy`, `SPX Index`, `CL1 Comdty`, `US10Y Govt`. `AssetClass` has Equity, Etf, Index, Fx, Crypto, Govt and Cmdty. The yellow keys are Equity, Index, Curncy, Govt and Comdty. | `crates/core/src/security.rs`, `catalog.rs` |
| Data types | `Bar {ts, open, high, low, close, volume}` (ts is the open time in Unix seconds), `Series`, `Timeframe` (M1 to MN1), `Quote {symbol,last,prev_close,ts,spark}`, `OrderBook {bids,asks,source,simulated}`, `OptionChain`, `YieldCurves`, `NewsItem`, `SearchHit`. All of them derive serde. | `crates/core/src/bar.rs`, `timeframe.rs`, `crates/data/src/lib.rs` |
| Live data | `LiveFeed = mpsc::Receiver<Bar>` holds the latest state of the forming bar. Crypto comes from Binance kline websockets, with Coinbase as the fallback. | `crates/data/src/crypto.rs`, `ws.rs` |
| Runtime | tokio on the desktop. On wasm32, `rt::spawn` runs tasks on the browser event loop. | `crates/data/src/rt.rs` |
| Worker | The UI sends `Request` (History, MoreHistory, Quotes, OrderBook, Options, News, Search, Congress queries, Portfolio, Broker*) and drains `Response`, so it never blocks. | `app/src/worker.rs` |
| Bar cache | SQLite `BarCache` (desktop only). | `crates/data/src/cache.rs` |
| Congress | Senate eFD and House Clerk ingest into SQLite, refreshed every 30 minutes (`CONGRESS_REFRESH_MIN`). The web build gets an empty stub. | `crates/congress/` |
| Brokerage | SnapTrade, read-only. The partner keys and the per-user secret live in the Keychain or a 0600 file. | `crates/broker/src/snaptrade.rs`, `secrets.rs` |
| Demo | `DemoBook` starts with $100k (`STARTING_CASH`) and is saved as local JSON. Orders fill at the terminal's live quote. | `crates/broker/src/demo.rs` |
| User docs | Layouts (`LayoutDoc` JSON, which holds the panes, their drawings and the watchlist panes), prefs, themes and palette history. They are files on the desktop and `localStorage` in the browser. | `app/src/storage.rs`, `layouts.rs`, `prefs.rs` |
The plan keeps `MarketData` as the *client-side* trait. A new `RemoteMarkets` implementation forwards each call to the server (§4.6), so the panes, the worker and the chart code don't change. On the server, a new, richer `Provider` trait (§3.1) sits behind the ingest adapters.
---
## 3. Components
```
Tokyo (node hosts, x2 for failover) Gateway host(s)
+----------------------------------------------+ +----------------------------------------------+
| hl-visor -> hl-node (non-validator) | | tr-gateway |
| ~/hl/data/node_fills_by_block/... | | hot store (watch/broadcast), refcounts |
| ~/hl/data/node_order_statuses_by_block/... | internal | auth, subs, conflation, backpressure |--> desktop
| ~/hl/data/node_raw_book_diffs_by_block/... | stream | user sync (docs, drawings, demo book) |--> web (WASM)
| ~/hl/data/hip3_oracle_updates/..., misc_.. |--------->| Congress, SnapTrade (read-only), others |--> mobile
| localhost:3001/info (subset, fileSnapshot) | tr-proto | persistence: Postgres/SQLite |
| tr-ingest (same host) | over +----------------------------------------------+
| file tailers -> book builder (L4->L2/BBO) | WG/mTLS
| trade tape -> candle builder (all TFs) |
| prices: mid, last, oracle, mark, funding |
| candle store (no cap), checkpoints |
+----------------------------------------------+
```
`tr-ingest` has to run **on the node host**, because it reads the node's local files and its `localhost` info server. The reference order-book server is also meant to run on the same machine as the node [doc: OBS]. `tr-gateway` can share the host or sit on a separate small machine (§11).
### 3.1 Ingest adapters behind a `Provider` trait
```rust
/// Server-side source of market data. One instance per upstream (Hyperliquid, a licensed
/// equities vendor, ...). The registry asks each provider what it can serve.
#[async_trait]
pub trait Provider: Send + Sync + 'static {
fn id(&self) -> ProviderId; // "hyperliquid", "intrinio", ...
fn capabilities(&self) -> Capabilities; // channels × asset classes it serves
/// Display rights for data from this provider. Enforced by the gateway, not the client.
fn display_policy(&self) -> DisplayPolicy;
/// Instruments this provider lists right now (refreshed periodically; delistings appear here).
async fn instruments(&self) -> Result<Vec<InstrumentDef>>;
/// Historical bars, newest `limit` before `end` (same contract as MarketData::history).
async fn history(&self, inst: &InstrumentId, tf: Timeframe, end: Option<i64>, limit: usize)
-> Result<Vec<Bar>>;
/// Start an upstream stream; events go to `sink` until the handle is dropped.
async fn subscribe(&self, ch: Channel, inst: &InstrumentId, sink: UpstreamSink)
-> Result<UpstreamHandle>;
}
pub struct DisplayPolicy {
/// May we show this data to the general public (any signed-in or anonymous user)?
pub public_display: bool,
/// If not public: which entitlement unlocks it (e.g. a licensed seat, a BYO key, staff).
pub entitlement: Option<Entitlement>,
/// Required delay before display to non-entitled users (licensed vendors often have one).
pub delay: Option<Duration>,
/// Attribution string the UI must show ("Data: Hyperliquid", "trade.xyz oracle", ...).
pub attribution: &'static str,
}
```
Rules:
- **Secondary crypto exchanges are off by default for public display.** Coinbase and OKX ban public display without written consent, and Kraken requires permission (provider research from the parent agent, which I have not re-checked here). Binance's terms are not reviewed [unverified]. So the existing `CryptoProvider` (Binance and Coinbase) moves server-side with `public_display: false`. It is only served to entitled users (staff, for example) until each venue's rights are confirmed in writing.
- Yahoo Finance and the RSS news sources get `public_display: false` until reviewed [unverified]. Treasury.gov is US government data and is *likely* fine [unverified].
- The gateway checks `DisplayPolicy` on every subscribe (§4). A client can't opt itself in.
- Licensed stocks and options (Intrinio, Twelve Data, Massive or another vendor) arrive later as one more `Provider`. Their entitlement and delay map straight onto `DisplayPolicy`.
Adapters at launch:
| Adapter | Source | Notes |
|---|---|---|
| `HlNodeProvider` | Our node's output files plus its local info server (`localhost:3001/info`) | §5 and §6. The only live Hyperliquid source. |
| `HlBackfill` (one-time job) | S3 node archives and/or public `candleSnapshot` | §5.6. Not a live provider. |
| `CongressProvider` | Senate eFD and House Clerk | Reuses `tr-congress` unchanged (§9) |
| `SnapTradeProvider` | SnapTrade, read-only server mode | Per-user and private, so it is not a market-data topic. Desktop local mode is unchanged (§9). |
| `CryptoProvider` (existing) | Binance and Coinbase | Off for public display |
| `TreasuryProvider` (existing) | Treasury.gov, with FRED as fallback | Yield curves |
### 3.2 Normalizer
This converts provider payloads into the shared types in `tr-core` and `tr-data`, plus a few new fields:
- **Numbers.** Node outputs carry prices and sizes as decimal **strings** (for example `"px": "51.367"`) [doc: L1]. The public API does the same, including websocket candles, which the docs type as numbers [observed]. Parsers accept either. The normalizer parses to `f64` for the existing types. It keeps the original string only where exact decimals matter: tick or lot rounding in the demo book.
- **Time.** Node events carry `time` as an ISO-like string (for example `"2024-07-26T08:26:25.899"`) [doc: L1]. Batched output adds `block_time` and `block_number` [doc: NODE]. We treat these as UTC [unverified: no zone suffix in the examples]. `Bar.ts` is the bar's open time in seconds, as today.
- **Intervals.** We build every app `Timeframe` (M1, M5, M15, M30, H1, H4, D1, W1, MN1) ourselves, plus any extra interval we choose (§5.3). Hyperliquid's own intervals (`1m` to `1M` [doc: INFO]) only matter for the one-time backfill.
- **Books.** Our L2 aggregation (§5.2) becomes `OrderBook { bids, asks, source: "Hyperliquid (own node)", simulated: false }`. L4 (per-order) views get a new type.
- **Price basis.** This is new and required for the perp-as-proxy mapping (§7). Every quote carries `basis: Mid | Mark | Oracle | Last`, `venue`, `instrument_kind: Spot | Perp | Equity | …`, plus `session: Regular | Extended | Closed | Internal24x7` when the provider knows it.
### 3.3 In-memory hot store with fan-out
- The store is a **topic table** keyed by `Topic` (for example `Quote(MarketKey)`, `Bars(MarketKey, Timeframe)`, `Book(MarketKey, depth, group)`, `Trades(MarketKey)`, `Ctx(MarketKey)`, `Congress(Latest)`, `Doc(UserId, DocId)`).
- **State topics** (quote, book, ctx) use `tokio::sync::watch` style "latest value wins". A slow reader skips intermediate values, which conflates naturally.
- **Bars are a hybrid, not a pure state topic.** Updates to the *forming* bar may be conflated, but only within the same bar `ts`. When the bar `ts` advances, the server first emits a non-conflatable `BarClosed { bar }` event with the previous bar's final OHLCV, then the new forming bar. `BarClosed` goes through the event ring like trades, so a slow client can never skip a closed bar's final values. If it overflows, the client gets `Resync` and a fresh history snapshot (§4.3).
- **Event topics** (trades, congress alerts, document ops) use a bounded `broadcast` ring with a per-topic sequence number. A reader that falls behind the ring gets `Resync` (§4.3).
- Each topic keeps a **refcount** of client subscribers. Ingest maintains *every* book, tape and candle regardless of demand, because it has to apply every diff to stay correct. The gateway's refcount only decides which per-coin topics it materialises and serialises for clients. Topics are dropped after a **linger** period (start at 60 s [target]) once the count returns to 0.
- Snapshots are served from memory, so a new client gets the current book or quote immediately.
### 3.4 Persistence (candles and history)
- **Candles:** built by ingest from node trades (§5.3) and stored with **no cap**. We keep 1m bars forever, plus materialised higher intervals. Each bar records its provenance: `node:<host>`, `backfill:s3`, `backfill:api` or `repaired`.
- **Storage:** v1 uses SQLite (WAL) on the node host's NVMe for the candle store, reusing the `BarCache` schema ideas, and replicates closed bars to the gateway's database. Postgres or Timescale can come once there are several gateway instances. Size: ~1,000 active instruments × 1,440 1m bars/day ≈ 1.4M rows/day, roughly **70–150 MB/day or 25–55 GB/year** at 50–100 bytes per row [unverified, rough; this is an upper bound because quiet minutes write no row].
- **Trade tape:** we keep the deduplicated trades (§5.3) for 30 days [target] for the tape and for rebuilding bars. Raw node files are rotated away (§6.4).
- **Also persisted** (gateway side): congress trades, user documents, demo books and audit logs (§8).
### 3.5 Client gateway
- **Transport:** WebSocket over TLS (axum with tokio-tungstenite on the server). The web client uses the browser `WebSocket` that `crates/data/src/ws.rs` already wraps.
- **Per-connection task:** auth handshake, a subscription set, a bounded outbound queue, a conflation timer and heartbeats.
- **Enforcement:** auth, per-user limits (subscriptions per connection, messages per second), and `DisplayPolicy`.
- **HTTP endpoints:** `/healthz`, `/readyz` and `/metrics` (internal), plus OAuth callback routes (§8).
---
## 4. Client protocol
### 4.1 Shared crate: `crates/proto` (`tr-proto`)
The protocol is one crate, used by the server, the desktop app and the WASM build. It depends only on `serde` and `tr-core`, so it compiles for `wasm32-unknown-unknown` (CI checks this). Every message type lives here, and the server and clients cannot drift apart because they compile the same definitions.
### 4.2 Message schema
```rust
pub const PROTOCOL_VERSION: u16 = 1;
/// Client → server
pub enum ClientMsg {
Hello { version: u16, encoding: Encoding, client: ClientInfo, token: Option<String>, resume: Option<ResumeToken> },
Subscribe { id: SubId, topic: TopicReq }, // client-chosen id, unique per connection
Unsubscribe { id: SubId },
Resync { id: SubId }, // client detected a gap; ask for a fresh snapshot
Request { id: ReqId, req: Rpc }, // one-shot: History{key,tf,end,limit}, Search, Options…
DocOp { id: ReqId, op_id: OpId, doc: DocId, base_rev: u64, op: DocOperation }, // user-sync writes (§8); op_id is a client UUID, stable across retries
Ping { t: u64 },
}
/// Server → client
pub enum ServerMsg {
Welcome { version: u16, session: SessionId, user: Option<UserInfo>, server_time_ms: i64, limits: Limits },
Snapshot { id: SubId, seq: u64, data: TopicData }, // full state; resets the subscription's seq
Delta { id: SubId, seq: u64, data: TopicDelta }, // seq = previous + 1
Resync { id: SubId, reason: ResyncReason }, // server dropped deltas; a Snapshot follows
Reply { id: ReqId, result: Result<RpcResult, ApiError> },
DocAck { id: ReqId, doc: DocId, rev: u64 } | DocConflict { id: ReqId, doc: DocId, current: DocSnapshot },
Status { id: SubId, status: TopicStatus }, // Stale{since}, Delisted, Unavailable{reason}, DisplayRestricted
Pong { t: u64, server_time_ms: i64 },
Error { code: ErrorCode, message: String },
}
pub enum TopicReq {
Quotes { keys: Vec<MarketKey>, max_hz: u8 }, // watchlists / tickers: one sub, many keys
Bars { key: MarketKey, tf: Timeframe, history: usize },// snapshot = last `history` bars, deltas = forming bar / new bar
Book { key: MarketKey, depth: u8, group: Option<f64> },
Trades { key: MarketKey },
Ctx { key: MarketKey }, // funding, OI, mark/oracle (perps), supply (spot)
Congress(CongressTopic), Portfolio, Docs { kinds: Vec<DocKind> }, Alerts,
}
```
### 4.3 Snapshot + delta, sequencing and resync
- Every subscription has its own `seq`. A `Snapshot` sets it, and each `Delta` increments it by 1.
- **The client checks continuity.** On a gap, or on a `Delta` before its `Snapshot`, the client sends `Resync`. The server answers with a new `Snapshot`.
- **The server sends `Resync` itself** when an event topic's ring overflowed for that client (§5.8), or after ingest recovers from a gap or a node failover (§5.5, §6.6), because the server's own state may have jumped.
- **For state topics a delta is just the new value** (a quote or a book), so a lost delta is harmless and those topics never need a resync on overflow. Bars deltas are `Forming(bar)` (conflatable within one `ts`) or `Closed(bar)` (never conflated, always delivered before the first `Forming` of the next `ts`). The client applies `Closed` by *replacing* the bar with that `ts`, which repairs any partial bar it had. Today's `Series::apply_live` only appends a newer bar, so `RemoteMarkets` must apply `Closed` with a replace before forwarding to the existing `LiveFeed` consumer. A test in phase 5 covers a slow reader across a boundary.
- **Reconnect:** `Hello.resume` holds the previous session's resume token. The server re-attaches the subscriptions and sends a fresh `Snapshot` for each one. Clients never replay old deltas.
### 4.4 Encoding
| Option | Pros | Cons |
|---|---|---|
| **postcard** (serde, compact varint) | Smallest frames. no_std-friendly, so it builds fine for WASM. Same serde derives as today. | Not self-describing: fields can't be added to an existing struct without a version bump. |
| bincode 2 | Fast and mature | Larger than postcard for small numbers. Same evolution caveat. |
| JSON (serde_json) | Debuggable, browser devtools-friendly | 3–5× larger, and parse cost on the WASM main thread [unverified ratio] |
| MessagePack (rmp-serde) | Self-describing and compact-ish | Middle ground. Another dependency. |
**Recommendation: postcard over binary WebSocket frames for every client, with JSON as a negotiated debug encoding** (`Hello.encoding`). The web client is Rust compiled to WASM, so there is *no JavaScript consumer*: the usual "JSON for the web" argument doesn't apply here. Evolution rules: append-only enum variants, a new message variant (never a changed struct) for new fields, and `PROTOCOL_VERSION` checked in `Hello`/`Welcome`. The server supports N and N−1.
Compression: the browser and tungstenite support for permessage-deflate needs checking [unverified]. postcard plus conflation may make it unnecessary. We measure in phase 6.
### 4.5 Latency targets
| Path | Target |
|---|---|
| Node writes a block's events → ingest has applied them (book, tape, bar) | **< 5 ms / < 20 ms** p50/p99 [target], using `--disable-output-file-buffering` [doc: LAT] |
| Ingest event → written to client sockets by the gateway (same region, p50 / p99) | **< 5 ms / < 25 ms** [target] |
| Subscribe → Snapshot for a hot topic (already in memory) | **< 50 ms** plus RTT [target] |
| Subscribe → Snapshot for a cold topic | **< 100 ms** plus RTT [target]. Ingest already holds every book. |
| Quote fan-out cadence to UI | Default 4 Hz per watchlist. The visible chart's bar and book stream at block speed. [target] |
For comparison, the public `l2Book` feed is pushed "on each block that is at least 0.5 since last push" [doc: WS-SUB]. Our own builder emits on every block. The docs say node outputs are "faster and more granular data than the API" [doc: LAT], but give no numbers [unverified until we measure]. For North American users, the Tokyo-to-client network hop dominates end to end [unverified RTT; to measure].
### 4.6 Client integration
- **`tr-data::remote::RemoteMarkets`** implements `MarketData` over one shared connection. `history` maps to an RPC, `quotes` to a short-lived or shared `Quotes` subscription, and `live` to a `Bars` subscription bridged into the existing `mpsc::Receiver<Bar>`.
- **Selection:** `TR_SERVER_URL` (or a settings toggle) picks `RemoteMarkets`. The direct providers stay as the offline and dev fallback (`TR_PROVIDER=sample` keeps working).
- **Web build:** `for_browser()` switches to `RemoteMarkets`. This closes the `TODO(web proxy)` stubs in `crates/data/src/web.rs` and `crates/congress/src/db_web.rs`.
---
## 5. Ingest from our node
Everything live comes from our own non-validator node (§6) through `tr-ingest`, which runs on the node host.
### 5.1 What the node writes, and the flags we run
`hl-visor run-non-validator` writes to `~/hl/data`. The output we use is opt-in via flags [doc: NODE]:
| Flag | Output | We use it for |
|---|---|---|
| `--write-fills` | `node_fills` (API fills format). It "overrides `--write-trades` if both are set" and also writes TWAP statuses. HIP-3 fills include `deployerFee`. | The trade tape and candles (§5.3). Fees and user fields are dropped before anything leaves ingest. |
| `--write-order-statuses` | `node_order_statuses`, every order status update, "a substantial amount of data" | L4 order metadata (tif, cloid, timestamp) and order lifecycle (§5.2) |
| `--write-raw-book-diffs` | `node_raw_book_diffs`: per-order `new {sz}`, `update {origSz, newSz}` or `remove` with `user, oid, coin, side, px` [doc: L1] | Applying changes to the L4 book (§5.2) |
| `--write-hip3-oracle-updates` | `hip3_oracle_updates`: every HIP-3 deployer `setOracle` | HIP-3 oracle and mark inputs (§5.4) |
| `--write-misc-events` | `misc_events`, including a `Funding` event `{coin, usdc, szi, fundingRate, nSamples}` and ledger updates [doc: L1] | Realised funding (§5.4) |
| `--batch-by-block` | Writes `*_by_block` files, one line per block: `{local_time, block_time, block_number, events}` | Block-aligned processing, gap detection, deduplication across two nodes |
| `--disable-output-file-buffering` | Flushes each line as soon as it's written | Latency [doc: LAT] |
| `--serve-info` | Local info server at `localhost:3001/info` with a subset of info requests, plus `fileSnapshot` | The registry, L4 snapshots, freshness checks, marks (§5.4) |
| `--replica-cmds-style actions-and-responses` (optional) | `replica_cmds`: every L1 transaction | Core oracle derivation, if needed (§5.4). Off unless phase 1 shows we need it, because it is large [unverified size]. |
Every output is hourly-partitioned under `{dir}/hourly/{date}/{hour}` [doc: NODE]. Ingest tails the current file with a checkpoint `(file, byte offset, last block_number)`, written after the derived state has been persisted. So a restart resumes exactly where it stopped. The docs say batching by block costs "a few milliseconds" against the streaming mode [doc: OBS, LAT]. v1 keeps `--batch-by-block` for correctness, and we revisit it once we measure.
### 5.2 Order books: can we rebuild full L2 and L4? Verdict: yes, with caveats
**Yes for core perps.** The node's own docs describe this exact use:
- L4 snapshots hold "the entire order book with full information about the orders for each level. This can be used as a checkpoint upon which the order statuses stream may be applied, allowing users to stream an L4 book in realtime" [doc: L1].
- The latency guide says node outputs let us "construct the book" locally, with "faster and more granular data than the API" [doc: LAT].
- The reference [`order_book_server`](https://github.com/hyperliquid-dex/order_book_server) does it in Rust. It serves `l2Book` (up to 100 levels), `trades` and a new `l4Book` over a websocket, built from the `*_by_block` files for fills, order statuses and raw book diffs [doc: OBS].
**How the rebuild works** (the reference server's approach, which we copy) [doc: OBS; observed in source]:
1. Checkpoint: POST `{"type":"fileSnapshot","request":{"type":"l4Snapshots","includeUsers":true,"includeTriggerOrders":false},"outPath":…,"includeHeightInOutput":true}` to the local info server. It writes the current L4 book for every coin, at a known height, and "uses the latest state instead of an older snapshot file" [doc: NODE]. As an offline alternative, `hl-node compute-l4-snapshots` can build one from a `periodic_abci_states` file (written every 10,000 blocks) [doc: NODE].
2. Apply every raw book diff and order status from the `*_by_block` files with `block_number` above the snapshot height, in order. `new` inserts an order (`oid`, side, `px`, size) at the back of its price level. `update` changes its size. `remove` deletes it. Order statuses give the metadata the diffs lack (timestamp, tif, cloid, order type).
3. L2 is an aggregation of L4: Σ size and order count `n` per price. We do any number of levels and our own price grouping, without the API's 20-level cap [doc: INFO]. BBO and mid come out of L2.
4. Self-check: the reference server periodically takes a fresh snapshot, compares it with its in-memory book, and exits on a mismatch. It also exits if no events arrive for 5 s [observed in source]. We do the same, but rebuild that coin instead of exiting.
**Caveats and gaps (stated plainly):**
- **HIP-3 coverage is likely but not documented.** Nothing in the node docs says explicitly whether HIP-3 dex books appear in `node_raw_book_diffs`, `node_order_statuses` or `l4Snapshots`. The evidence points to yes:
- the fills docs mention `deployerFee` "for HIP-3 fills" [doc: NODE];
- HIP-3 markets trade on HyperCore's matching engine [doc: HIP3];
- the reference server takes its coin universe from the snapshot and has no dex filter [observed in source].
**[unverified]**. It's the phase 1 acceptance test (§12): grep a day of diffs and a snapshot for `xyz:`, `flx:` and so on, and compare one `xyz:` book against the public `l2Book` once. If HIP-3 books are missing, that is a real gap. We'd have no source for HIP-3 books except the public API, which goes against the decision. We'd come back to Micky before going further.
- **Spot:** the reference README says it "does not support spot order books", but its code has `is_spot` handling (`@…` and `PURR/USDC`) and an `ignore_spot` switch [doc: OBS; observed in source]. Spot diffs should be in the same files [unverified]. Our builder treats spot like perps and verifies it the same way.
- **Trigger orders** (stop and take-profit) aren't resting liquidity. The reference server leaves them out [doc: OBS]. Snapshots can include them with `isTrigger`, at the end of each level [doc: L1]. We leave them out of L2 too.
- **Queue position:** "ALO priority fees or other edge cases" can break naive time priority within a level [doc: LAT]. L2 is unaffected. L4 order position within a level is best-effort [unverified]. The snapshot self-check catches drift.
- **The reference code is unmaintained.** It is "not actively maintained" [doc: LAT], "not written by the core team" and provided as-is [doc: OBS]; its last commit was 2026-07-15 [observed]. So we write our own `tr-book` crate, using it as a reference, and we test it against recorded node files.
### 5.3 Trades and candles at every interval, no cap
- **Tape:** with `--write-fills`, each match shows up as two fills, one per side. Ingest dedupes them into one public trade, keyed by `(coin, tid)` [unverified that node fills carry `tid`; the trades schema carries `hash` plus buyer and seller `oid`s, which works as a fallback key, doc: L1]. The aggressor side comes from the taker fill.
- **1m bars** are built from the deduplicated trades by UTC minute: OHLC from trade prices, volume Σ sz (base), notional Σ px·sz, and trade count `n`. A bar closes when the first block with a `block_time` at or after the minute boundary has been applied. That makes closes deterministic and identical on both nodes.
- **Higher intervals** are aggregated from 1m. OHLCV aggregation is exact, so 5m, 15m, 1h, 4h, 1d, 1w and 1M (and any custom interval) are consistent by construction. Weeks start Monday 00:00 UTC and months are calendar months [target; Hyperliquid's own boundary convention is undocumented, unverified].
- **Minutes with no trades** produce no bar row. Clients render the gap, which matches how sparse HIP-3 markets look today.
- **No cap:** every bar since our node started is kept (§3.4). This replaces the public API's "most recent 5000 candles" limit [doc: INFO].
- Live bars reach clients as `Forming` and `Closed` (§4.3).
### 5.4 Mids, marks, oracle prices and funding
| Value | Where it comes from | Confidence |
|---|---|---|
| **Mid** | Our book: `(best bid + best ask) / 2`. One-sided or empty books publish no mid, and clients fall back to mark with a label. | Derived. Exact for two-sided books. |
| **Last trade** | Our tape (§5.3) | Exact |
| **HIP-3 oracle and mark inputs** | `--write-hip3-oracle-updates` records each deployer `setOracle`, which carries `oraclePxs`, `markPxs` and `externalPerpPxs` per coin [doc: NODE, HIP3-ACT]. The oracle price is used as-is. | Exact for the oracle |
| **HIP-3 mark** | The protocol forms the final mark from the deployer's `markPxs` with documented rules: the mark is clamped to move at most 1% per update, and stale marks fall back to the local mark after 10 s [doc: HIP3-ACT]. We implement those rules and reconcile the result against `activeAssetData` (below). | The final combination is [unverified]. To validate in phase 1. |
| **Core perp oracle** | Validators publish oracle prices about every 3 s. Each is a weighted median of CEX spot prices (Binance 3, OKX 2, Bybit 2, Kraken, Kucoin, Gate, MEXC and Hyperliquid spot 1 each), and the final price is a stake-weighted median of the validators' submissions [doc: ORACLE]. On the node, these validator actions should be in `replica_cmds` [unverified; the action schema is undocumented]. | Derivable [unverified]. Fallback: the abci state (below). |
| **Core perp mark** | The median of (a) oracle + a 150 s EMA of (Hyperliquid mid − oracle), (b) the median of best bid, best ask and last trade, and (c) a weighted median of Binance, OKX, Bybit, Gate and MEXC perp mids [doc: MARK]. We can compute (a) and (b), but **not (c)** from node data. So we **read** mark rather than derive it. The local info server supports `activeAssetData`, which returns `markPx` per coin [doc: NODE, PERP]. Ingest polls it locally (no rate limit) every 1 s for watched coins and every 3 s for the rest [target]. Mark only updates with the oracle, about every 3 s [doc: MARK]. | [unverified that the local server returns a live `markPx`]. Fallback: a computed approximation from (a) and (b), labelled "est.". |
| **Funding (realised)** | Hourly `Funding` events in `misc_events` carry `coin` and `fundingRate` [doc: L1] | Exact |
| **Funding (predicted)** | Computed with the documented formula: the hourly rate is ⅛ of the 8 h rate, F = average premium + clamp(interest − premium, ±0.0005), interest is 0.01% per 8 h, the premium is sampled every 5 s from impact prices against the oracle, and funding is capped at 4% per hour [doc: FUNDING]. Impact prices come from our own book. HIP-3 dexes can set their own multipliers and interest rates [doc: HIP3-ACT] [unverified where these show up in node output]. | Derived |
| **Open interest, 24h volume, prev-day price** | Volume and the previous day's price come from our candles. Open interest isn't in the documented outputs. We'd derive it from the `periodic_abci_states` state, translated with `translate-abci-state` [doc: NODE], at that file's cadence, or track it live from fills (`start_pos`) [unverified; later]. | Partial |
Reconciliation: every 10,000 blocks, a job translates the `periodic_abci_states` file and checks our oracle, mark, funding and open interest against it [unverified that every field is present]. Mismatches raise an alert.
### 5.5 Catch-up, gaps and failover inside ingest
- **Restart:** ingest resumes from its checkpoint (§5.1). Books are never trusted across a restart: it takes a fresh L4 snapshot (§5.2) and replays diffs after that height.
- **Gaps:** `block_number` must increase by exactly 1. A gap or a stall (no new block for 5 s [target], the same threshold the reference server uses) marks every topic `Stale`. Ingest re-snapshots the books, and asks the peer node's ingest for the missing blocks' trades so bars are complete (§6.6). If both nodes miss a range, a repair job fills the trades later from the S3 archive (§5.6), and the affected bars are flagged `repaired`.
- **Node restart:** see §6.5.
### 5.6 The one-time history backfill (the only public data use)
Node-built bars cover everything from the first full minute after our node is live. History before that is a one-time job, run once and then never again:
1. **Primary: S3 node archives, rebuilt into trades and then candles with the same code as live.** The bucket `hl-mainnet-node-data` holds `node_fills_by_block` (written with `--write-fills --batch-by-block`), older `node_fills` (API format) and `node_trades` (a different, older format), plus `misc_events_by_block` (funding and more), `explorer_blocks` and `replica_cmds` [doc: HIST]. The archives are **requester-pays**: we pay the S3 transfer, which needs our own AWS account [doc: HIST]. The **start date and total size are not documented** [unverified]. Listing the bucket needs AWS credentials, which I don't have, so phase 1 measures them first. This route gives true 1m (and finer) history and is consistent with live bars.
2. **Secondary: one public `candleSnapshot` sweep**, for instruments or ranges the archive lacks. It returns only the most recent 5000 candles per interval [doc: INFO]: 1m ≈ 3.5 days, 1h ≈ 208 days, 1d back to listing (BTC 1d starts 2020-08-19) [observed]. The weight math for a one-off sweep: ~900 instruments (552 perp entries including delisted, plus 347 spot pairs [observed, §5.7]) × 5 intervals (1m, 15m, 1h, 4h, 1d) × ~104 weight (20 + 5000/60) ≈ 468k weight. That is ≈ 7.8 hours at a 1000/min self-limit, under the 1200/min IP limit [doc: RL]. It runs once, from one IP, with no evasion of limits.
3. **Optional: `hyperliquid-archive/asset_ctxs/[date].csv.lz4`** for daily historical funding, open interest and mark context. It's uploaded about monthly, "with no guarantee", and has no candles or spot asset data [doc: HIST].
**Stitching:** for each `(instrument, interval)`, node-built bars win for every bar at or after node start. Backfill bars fill the earlier range, and each bar keeps its provenance. Where S3-rebuilt and `candleSnapshot` bars overlap, we compare them as a test of our aggregation. Any differences are logged, and the S3 bars win.
### 5.7 Addressing (HIP-3, spot and outcomes)
- `perpDexs` returns `[null, {name, fullName, deployer, oracleUpdater, feeRecipient, assetToStreamingOiCap, …}, …]`. `null` is the first (core) perp dex [doc: PERP]. I observed 11 entries: `null`, `xyz` (XYZ), `flx` (Felix Exchange), `vntl` (Ventuals), `hyna` (HyENA), `km` (Markets by Kinetiq), `abcd` (ABCDEx), `cash` (dreamcash), `para` (Paragon), `mkts` (Markets By Kinetiq) and `io` (EntropyIO). The objects also carried `subDeployers`, `assetToFundingMultiplier`, `assetToFundingInterestRate` and `assetToFundingClamp`, which are not in the doc example [observed].
- `meta`, `metaAndAssetCtxs`, `allMids`, `clearinghouseState` and others take `"dex": "<name>"`. The empty string means the first perp dex [doc: PERP, INFO]. `allPerpMetas` returns every dex at once [doc: PERP].
- HIP-3 coin names are always `{dex}:{coin}`, for example `xyz:TSLA` [doc: ASSET, INFO]. `candleSnapshot` needs the prefix: "For HIP-3, you need to prefix with the dex name, e.g. "xyz:XYZ100"" [doc: INFO]. The WS `allMids` with `dex:"xyz"` returned only `xyz:`-prefixed keys plus a `dex` field [observed].
- Spot coins are `PURR/USDC` or `@{index}` (for example, HYPE is `@107`). "Some assets may be remapped on user interfaces", for example BTC/USDC in the app is UBTC/USDC on HyperCore [doc: INFO].
- Outcome (HIP-4) markets show up as `#<encoding>` keys [doc: ASSET]. I observed 762 `#` keys and 401 `@` keys among the 1399 keys of the default `allMids` [observed]. **Outcomes are out of scope** and are filtered at ingest. They also carry separate legal restrictions [doc: TOU §1.7].
- Snapshot on 2026-10-29 [observed]: the core `meta` had 235 entries, 56 flagged `isDelisted`. The `xyz` `meta` had 141 entries, 21 flagged `isDelisted`. `spotMeta` had 347 pairs and 511 tokens. `allPerpMetas` listed per-dex universe sizes of core 235, `xyz` 141, `para` 39, `hyna` 25, `mkts` 24, `km` 23, `cash` 17, `flx` 16, `io` 16, `vntl` 15 and `abcd` 1, for 552 perp entries in total.
- **In our system** the registry comes from the node's local info server, which supports `perpDexs`, `meta` and `spotMeta` [doc: NODE]. The public-API observations above are from research and are only for scale.
### 5.8 Backpressure
- Each client has a **bounded outbound queue** (start at 256 frames or 1 MB [target]).
- **State topics** are conflated per client. On each flush tick (or immediately, for the active chart), the server writes the latest value of every dirty topic, so a slow client gets fewer updates but never stale ones.
- **Event topics:** if a client's cursor falls behind the ring, the server drops that subscription's backlog and sends `Resync` plus a `Snapshot`. For trades, the snapshot is the last N trades.
- If the queue stays full for more than 10 s [target], the server closes the connection with a "slow consumer" code, and the client reconnects with `resume`.
- **Ingest is never blocked by a client.** The node-file tailers only write into the hot store. Fan-out runs in per-connection tasks.
### 5.9 Scale without Hyperliquid rate limits
- Ingest work is proportional to market activity (all coins, every block), not to clients. The gateway's work is proportional to client subscriptions. Neither touches a Hyperliquid rate limit.
- The public API limits (1200 weight/min, 1000 WS subscriptions, 10 connections per IP [doc: RL]) apply only to the one-time backfill (§5.6). That is the sanctioned way to get unlimited data: the ToU forbids bots that "bypass rate limits" [doc: TOU §3.1.8], and a node avoids the question entirely.
- The internal ingest-to-gateway stream carries per-block batches of book changes, trades, bars and prices for every coin. Its bandwidth is unmeasured [unverified; phase 2 measures it]. Gateways can subscribe only to the topics their clients use.
---
## 6. Node operations
### 6.1 Hosting choice and region
- **Region: Tokyo.** The README says "for lowest latency, run the node in Tokyo, Japan" [doc: NODE], and the Foundation's own node is in AWS `apne1-az1` [doc: FND].
- **We are likely ineligible for the Foundation node** (it requires 10k staked HYPE and Tier 1 maker volume, and excludes "certain activities involving the U.S. or Ontario") [doc: FND]. So we run our own.
- **Provider:** single-tenant bare metal in Tokyo from two different providers, so a single provider outage doesn't take out both (§6.6). The comparison, prices and recommendation are in §6.3: Vultr for node A and Cherry Servers for node B.
- **Ruled out:**
- Freestyle (freestyle.sh): VMs too small (64 GiB / 256 GB max), and no raw inbound TCP for gossip [doc: FREESTYLE].
- OVHcloud: no Japan region [doc: OVH-REGIONS].
- Equinix Metal: discontinued [doc: EQX-EOL].
- **Peers:** mainnet requires at least one root peer in `~/override_gossip_config.json`. Community root peers are mostly in Japan, and a `gossipRootIps` info request lists recent ones [doc: NODE]. The Foundation also lists paid infra providers that offer reliable peering [doc: INFRA-PEERS]. Running "against a reliable peer" is the first latency recommendation [doc: LAT]. Peer addresses live in the host's config, never in this repo or its docs.
### 6.2 Machine spec and OS
- **Minimum (non-validator):** 16 vCPU, 128 GB RAM, 500 GB SSD [doc: NODE]. "Currently only Ubuntu 24.04 is supported" [doc: NODE].
- **For low latency:** at least 32 logical cores, 128 GB RAM and 500 MB/s disk [doc: LAT].
- **Our target per node:** bare metal with 16+ physical cores (32+ threads), 128 GB+, **≥ 2 TB local NVMe**. Disk is sized for ~1–2 days of raw output (§6.4) plus abci states and the candle store. Ubuntu 24.04 LTS. Ingest, and in v1 the gateway, share the box (§6.3, "Colocating our Rust server") [unverified until measured].
- **Network:** gossip ports 4001 and 4002 open publicly [doc: NODE]. Everything else, including the info server and ingest's internal stream, is firewalled, with the internal stream over WireGuard or mTLS. Node bandwidth is undocumented [unverified; measure in week 1].
- **Install:** `hl-visor` binaries are signed. Verify them with the published GPG key, and run `hl-visor run-non-validator` with `~/visor.json` set to `{"chain": "Mainnet"}` [doc: NODE]. Run both visor and ingest under systemd. `hl-visor` auto-updates `hl-node` [doc: NODE], so we alert on version changes and re-verify ingest parsers after each one.
### 6.3 Where to host it: providers checked, with published prices
Requirements (§6.2):
- 16+ physical cores (32+ logical) and 128 GB+ RAM;
- 500 GB+ local NVMe, ideally 2 TB;
- Ubuntu 24.04;
- **raw public TCP on 4001–4002**;
- high bandwidth;
- in or near Tokyo.
Prices are list prices, checked 2026-10-29, excluding tax. "Stock" is what the provider's inventory showed that day. None of these are quotes.
| Provider and plan (Tokyo unless noted) | Hardware | Monthly price | Bandwidth | Stock | Source |
|---|---|---|---|---|---|
| **Vultr bare metal `vbm-24c-256gb-amd`** | EPYC 7443P, 24 cores / 48 threads, 256 GB. The product page also lists 2 × 1.92 TB NVMe; the public plans API lists only the 2 × 480 GB SSDs [confirm at order]. 25 Gbps. | **$725** (hourly $0.993) | 10 TB included, then $0.01/GB | Offered in Tokyo (`nrt`) and Osaka (`itm`) | [doc: VULTR-API, VULTR-BM, VULTR-PRICE] |
| Vultr bare metal `vbm-24c-384gb-amd5` | EPYC 9255, 24c/48t, 384 GB, NVMe | $995 | 10 TB | Tokyo and Osaka | [doc: VULTR-API] |
| Vultr bare metal `vbm-8c-132gb` | E-2288G, **8 cores**, 128 GB, 2 × 1.92 TB NVMe | $350 | 10 TB | Tokyo | **Fails** the 16-core minimum [doc: VULTR-API] |
| **Cherry Servers AMD EPYC 9275F** | 24c/48t at 4.1 GHz, 384 GB, 2 × 1 TB + 2 × 2 TB NVMe, 10 Gbps, Equinix TY15 | **$874.17** | 100 TB free egress | Listed as deployable in Tokyo | [doc: CHERRY-TYO] |
| Cherry Servers AMD Ryzen 9950X | 16c/32t, 128 GB (non-ECC), 2 × 1 TB NVMe, 10 Gbps | €439 (€373 on a 12-month term) | 100 TB | **Sold out in Tokyo** | [doc: CHERRY-9950X] (third-party tracker) |
| Latitude.sh `m4.metal.medium` | EPYC 9124, 16c/32t, 128 GB, 2 × 480 GB + 2 × 1.9 TB NVMe, 2 × 10 Gbps | $522 Tokyo ($365 on a 12-month term); $456 US list | 20 TB | **Sold out** in TYO3 and TYO4 | [doc: LATITUDE, LAT-M4] (Tokyo price from a third-party tracker) |
| Latitude.sh `f4.metal.medium` | EPYC 4564P, 16c/32t at 4.5 GHz, 128 GB, same disks | $715 Tokyo ($501 on term); $555 US list | 20 TB | **Sold out** in Tokyo | [doc: LATITUDE, LAT-F4] |
| AWS `i4i.4xlarge` (on demand) | 16 vCPU (8 cores), 128 GiB, 1 × 3,750 GB local NVMe | $1.61/h ≈ **$1,175** | Egress billed separately | `ap-northeast-1` | [doc: AWS-PRICE] |
| AWS `r6i.4xlarge` + 2 TB gp3 | 16 vCPU, 128 GiB, EBS only | $1.216/h ≈ $888, plus ≈ $215 EBS ≈ **$1,100** | Egress billed separately | `ap-northeast-1` | [doc: AWS-PRICE]; EBS from earlier third-party summaries |
| OVHcloud | — | — | — | **No Japan region.** Its APAC dedicated servers are in Singapore, Sydney and Mumbai only, with metered traffic that is throttled at the quota. | [doc: OVH-REGIONS, OVH-APAC] |
| Equinix Metal | — | — | — | **Discontinued.** Sunset on 2026-06-30. | [doc: EQX-EOL] |
| Freestyle (freestyle.sh) VMs | Pro tops out at 32 vCPU / **64 GiB** / **256 GB** per VM | — | — | **Ruled out:** below the 128 GB RAM and 500 GB disk minimums, and inbound traffic only reaches a VM through the TLS/SNI edge on 443, so there's no raw TCP on 4001–4002 for gossip | [doc: FREESTYLE] |
All three bare-metal providers above are single-tenant with full root, so we open 4001–4002 and firewall the rest ourselves with nftables. Ubuntu 24.04 is in Vultr's OS list [doc: VULTR-API] and Cherry's Tokyo FAQ [doc: CHERRY-TYO]. Node bandwidth needs are undocumented [unverified]. Vultr's 10 TB is the tightest allowance, and its overage ($0.01/GB) is cheap.
**Recommendation:**
- **Node A: Vultr `vbm-24c-256gb-amd` in Tokyo, $725/month.**
- 24 cores / 48 threads meets the 32-logical-core latency advice [doc: LAT].
- 256 GB of RAM leaves headroom for ingest and the gateway on the same box.
- It's orderable today.
- Confirm the NVMe drives at order time.
- **Node B: Cherry Servers EPYC 9275F in Tokyo (Equinix TY15), $874.17/month.**
- A different provider and building, so one provider outage doesn't take out both (§6.6).
- 100 TB egress, and faster cores.
- Alternatively, a second Vultr box in Osaka (`itm`): a different city, but the same provider.
- **Two-node total ≈ $1,600/month.** Budget path: one node at ≈ $725, or the 16-core Latitude and Cherry boxes ($365–522) once Tokyo stock returns.
- **AWS works but costs more for less:** 16 vCPU is 8 physical cores, and egress is extra. It mainly helps the S3 backfill (§5.6), which can run from a short-lived AWS instance instead.
**Colocating our Rust server:**
- **`tr-ingest` runs on the node box** (required: it tails local files and calls `localhost:3001`).
- **v1: `tr-gateway` on the same box as well.** The node gets most of the cores via systemd `CPUAffinity`/cgroup limits, and ingest plus the gateway get a reserved slice (for example 8 of 48 threads) [target; tune after measuring]. That makes the internal stream loopback, with no extra machine.
- **When client load grows,** move `tr-gateway` to a small box on the **same provider's private LAN** in the same city (Vultr VPC or Cherry private VLAN [doc: CHERRY-TYO]), so the ingest-to-gateway hop stays sub-millisecond and off the public internet [unverified latency; measure]. Never put the gateway across the Pacific from ingest. The data starts in Tokyo either way (§11).
**Cost summary (per month, list):**
- **One node:** ≈ **$725** (Vultr bare metal). A 16-core Latitude or Cherry box would be ≈ $365–522 when Tokyo stock returns; AWS is ≈ $1,100–1,175.
- **Two nodes (recommended, §6.6):** ≈ **$1,600** (Vultr + Cherry). The range is ≈ $1,450–2,050, depending on which pair we use.
- These are list prices, not quotes. Third-party tracker figures are labelled as such in the table.
### 6.4 Disk and log rotation (~100 GB/day)
- The node writes "around 100 GB of logs per day", which should be archived or deleted [doc: NODE]. Order statuses and raw book diffs add "a substantial amount" on top [doc: NODE]. We size for **100–200 GB/day** [unverified; measure in week 1].
- **Pruner** (a systemd timer, hourly):
- Delete hourly output directories older than **24 h** [target], *only* when ingest's committed checkpoint is past them. The pruner never deletes a file that ingest hasn't committed.
- Keep the last 3 `periodic_abci_states` files.
- Keep `replica_cmds` only if we enable it, with `--replica-cmds-style recent-actions` where possible [doc: NODE].
- Optionally compress `node_fills_by_block` to object storage (zstd), as our own archive for repairs.
- **Alerts:** disk use above 70% (warn) and 85% (page). If the disk fills, the node stops writing and ingest goes stale.
### 6.5 Monitoring and restart catch-up
- **Liveness:**
- `applied block X` log lines show the node is live [doc: NODE]. We export the block height and blocks per second.
- Ping the local `exchangeStatus` to compare the L1 timestamp with the local timestamp [doc: NODE]. We alert when it's behind by more than 2 s [target].
- Compare the heights of the two nodes.
- **Crashes:** `visor_child_stderr` and `node_logs/status` [doc: NODE] are shipped to logs, with an alert on new stderr content and on restarts.
- **Restart catch-up:** after a restart, the node "may take a while as the node navigates the network to find an appropriate peer" [doc: NODE]. How long catch-up takes, and whether output files are written for the blocks it replays, isn't documented [unverified; we test it with a planned restart in phase 1]. Ingest treats the node as unusable until `exchangeStatus` is fresh, then resumes from its checkpoint (§5.5). Any block range without output is filled from the other node.
### 6.6 Failover with a second node
- **Two nodes, A and B,** in Tokyo on different providers or availability zones. Each runs its own ingest and builds its own books and bars.
- **Primary and standby:** the gateway consumes both streams, tagged with `block_number`. One is primary. The gateway switches when the primary is behind by more than 20 blocks or 2 s [target], or stops sending. On a switch, it sends `Resync` for the book topics (books are per node), and uses `block_number` to drop duplicate or out-of-order trades and bar updates.
- **Bars stay identical** on both nodes because bar closes are block-time based (§5.3). The standby's candle store doubles as a hot replica for repairing gaps on the primary.
- **Upgrades:** `hl-visor` auto-upgrades [doc: NODE]. Both nodes upgrading at once is a correlated failure we can't fully prevent [unverified whether upgrades can be staggered]. We alert on it.
- **Single node (budget fallback):** one node plus the replay and repair path (S3, §5.6). Outages then mean stale data until the node catches up, shown as `Stale` in the UI.
## 7. Symbol mapping to Bloomberg-style keys
### 7.1 Principles
1. **A perp is never silently shown as a stock.** If `AAPL US Equity` is backed by `xyz:AAPL`, every surface shows a **`PERP·xyz`** badge, the attribution "trade.xyz perp on Hyperliquid", and the price basis (`mid` or `mark`). Tooltips explain that this is a perpetual futures price, not the exchange-listed share price.
2. **Venue-native keys always work.** `xyz:AAPL <GO>`, `BTC-PERP`, `@107` and `HYPE/USDC` resolve directly.
3. **The server owns the mapping table** (a versioned config, not code), so we can fix it without a client release.
### 7.2 Model changes (in `tr-core`)
```rust
pub enum InstrumentKind { Spot, Perp, Equity, Etf, Index, Fx, Future, Govt }
pub struct VenueRef { provider: ProviderId, venue: String /* "hyperliquid", "xyz" */, symbol: String /* "xyz:AAPL" */ }
pub struct Resolution {
pub key: MarketKey, // what the user typed / the pane is linked to: "AAPL US Equity"
pub source: VenueRef, // what actually backs it: hyperliquid / xyz / "xyz:AAPL"
pub kind: InstrumentKind, // Perp
pub proxy: bool, // true: the source is a different instrument than the key
pub basis: PriceBasis, // Mid | Mark | Oracle | Last
pub session: Option<SessionCalendar>, // underlying's hours (for "underlying closed" labels)
}
```
`Security { ticker, class }` stays as it is for back-compat. `Resolution` travels alongside it in `Quote`, `Snapshot` and `SearchHit`.
### 7.3 Initial mapping table (sketch; every row needs checking against the trade.xyz spec index)
| App key | Backed by | Kind | Notes |
|---|---|---|---|
| `AAPL US Equity`, `TSLA`, `NVDA`, `MSFT`, … | `xyz:<TICKER>` | Perp (proxy) | Only for tickers present and not `isDelisted` in `meta{dex:"xyz"}` |
| `SPX Index` | `xyz:SP500` | Perp (proxy) | Whether it references the S&P 500 index exactly is [unverified] |
| `NDX Index` | `xyz:XYZ100` | Perp (proxy) | XYZ100's exact reference is [unverified] |
| `NKY Index`, `KOSPI2 Index` | `xyz:JP225`, `xyz:KR200` | Perp (proxy) | [unverified] references |
| `GC1 Comdty`, `SI1`, `HG1`, `CL1`, `CO1`, `NG1` | `xyz:GOLD`, `SILVER`, `COPPER`, `CL`, `BRENTOIL`, `NATGAS` | Perp (proxy) | The futures roll vs spot reference is [unverified] |
| `EURUSD Curncy`, `GBPUSD`, `USDJPY` | `xyz:EUR`, `xyz:GBP`, `xyz:JPY` | Perp (proxy) | **The quote direction for JPY is [unverified]**. An inversion could be needed. |
| `BTCUSD Curncy`, `ETHUSD`, … | core perp `BTC`, `ETH` (mid) | Perp | An alternative is HL spot (`UBTC/USDC`, remapped) [doc: INFO]. Default open question (§15). |
| Other dexes (`flx:`, `km:`, `para:`, …) | venue keys only | Perp | Not mapped to Bloomberg keys in v1. They overlap with xyz (for example `flx:NVDA`) [observed]. |
`perpConciseAnnotations` returns categories such as `stocks` and `commodities` for HIP-3 coins [doc: PERP; observed]. We use it to suggest new rows, never to auto-map them.
### 7.4 Off-hours labelling
trade.xyz US stock perps take external prices 24/5, Sunday 8:05 PM ET to Friday 8:00 PM ET. That spans pre-market, regular, post-market and an overnight session from Blue Ocean ATS [doc: XYZ-US]. Outside external coverage, the oracle advances through an internal EMA of order-book impact prices, with τ = 30 min [doc: XYZ-ORACLE; changelog XYZ-TAU]. Mark price is bounded to ±(1/max leverage) around a reference price, which is the last external price over weekends [doc: XYZ-BOUNDS]. So the UI shows the session state (`Regular`, `Extended`, `Overnight`, `Closed: perp-only price discovery`) next to proxy quotes. A snapshot of the gap, from `xyz:AAPL` during US hours: `markPx 349.72` vs `oraclePx 349.8` [observed].
### 7.5 Delistings and churn
The registry refreshes `perpDexs`, `meta` (per dex) and `spotMeta` from the node's local info server every minute [target]. These calls are local, so there's no rate limit [doc: NODE]. If an instrument turns `isDelisted`, or disappears, the server marks the topic `Delisted`, stops live streams, keeps its history, and notifies users who have it in watchlists or layouts. HIP-3 deployers can also halt and settle a market with `haltTrading`, which can later resume and "recycle" the asset [doc: HIP3]. That means the *same name may come back as a different contract*. History is therefore keyed by `(dex, coin, listing_epoch)`, and a relisting starts a new epoch [target].
---
## 8. User sync: accounts, documents and conflicts
### 8.1 Accounts and auth
- **Sign-in:** OAuth (Apple, Google, GitHub) plus email magic link. Desktop uses the system browser with a loopback redirect (RFC 8252 style). The web uses a same-site HTTP-only session cookie. Mobile uses the platform's auth session.
- **Tokens:** short-lived access tokens (JWT, 15 min [target]) plus refresh tokens, rotated and revocable. The WS `Hello` carries the access token. When it expires, the client re-authenticates in place without dropping subscriptions.
- **Anonymous mode:** public market data with no sync, which matches today's experience.
- **Build or buy:** a hosted identity provider (Clerk, WorkOS, Auth0 or Supabase Auth) vs a small self-hosted OAuth layer in Rust. See the open question in §15.
- **Secrets:** SnapTrade per-user secrets are encrypted at rest (envelope encryption with a KMS or age key). They are never logged and never sent back to clients.
### 8.2 Documents
Each file in `storage.rs` becomes a server document `(user_id, kind, name)` with a server-assigned, monotonically increasing `rev`:
| Kind | Today | Server model | Conflict rule |
|---|---|---|---|
| Layouts | `LayoutDoc` JSON | Whole-document, optimistic concurrency (`base_rev`) | If `base_rev` doesn't match, the server keeps both and saves the losing write as "Name (conflict, Device, time)". This mirrors how people expect files to behave and never loses work. |
| Watchlists | Watchlist panes or prefs | Ordered set of items with stable ids | Per-item ops (`Add`, `Remove`, `Move{after}`), last-writer-wins per item, with tombstones. Concurrent adds from two devices both survive. |
| Drawings | `Vec<Drawing>` inside chart panes | Stored separately per `(user, market_key)`, each drawing with a **new stable `id`** (the `Drawing` struct needs one) | Last-writer-wins per drawing (`rev` and `updated_at`). Delete wins over a concurrent edit. Drawings follow the symbol across layouts. |
| Prefs, themes, hotkeys, palette history | TOML/JSON files | Key-level merge | Last-writer-wins per key |
| Demo portfolio | `DemoBook` JSON | **Server-authoritative.** Clients send commands, and the server fills against its own hot quotes and broadcasts the book. | No merge needed: one writer. Paper only; it never routes to a venue. |
Offline behaviour: clients keep today's local files as a cache and an outbox. Each queued `DocOp` carries an `op_id` (a UUID generated once, when the edit is made, and persisted with the outbox entry). The server records `(user_id, op_id) → result (rev or conflict outcome)` **in the same transaction as the write**, and keeps it for at least 30 days [target]. On reconnect, clients replay queued `DocOp`s with their original `op_id` and `base_rev`. If the server has already applied that `op_id`, it returns the original `DocAck` or `DocConflict` instead of re-evaluating `base_rev`. So a lost acknowledgement never creates a spurious conflict copy. Genuine conflicts resolve as above. The first sign-in uploads the local documents. If the server already has documents, the user chooses "merge" or "keep server".
The demo book fills at the server's quotes, which come from Hyperliquid perp prices (§7). So the demo account must show the same `PERP` labelling, and its fills can happen off-hours, a behaviour change from today [unverified UX decision].
---
## 9. Moving the existing feeds server-side
| Feed | Today | Server |
|---|---|---|
| Congress (Senate eFD, House Clerk) | Each desktop ingests every 30 min. The web gets nothing. | One `CongressProvider` runs `tr_congress::refresh` on schedule into the server DB. Topics: `Congress(Latest)`, `Congress(Ticker)`, `Congress(Politician)`. Tracking alerts are per-user pushes. The web gets the full feature. **Legal note:** the reports carry statutory limits on commercial use (Ethics in Government Act) [unverified for our use; needs legal review]. |
| SnapTrade | Partner keys (`clientId`, `consumerKey`) and the user secret sit on each machine. The desktop uses them for reads *and* for order preview, place and cancel (`crates/broker/src/trading.rs`). | **Two modes.** (a) *Server mode* (web, mobile, and opt-in desktop): partner keys live only on the server. Each Terminal user maps to a SnapTrade `userId` and an encrypted `userSecret`, and the server proxies only the read-only endpoints (accounts, positions, orders, activities) plus the Connection Portal link. No trading in this mode. (b) *Local mode* (desktop, today's behaviour, unchanged): the local credentials stay in the Keychain or the 0600 file, and the desktop keeps calling SnapTrade directly for reads and for its existing order paths. This design does not migrate or remove local credentials, so existing desktop trading keeps working. Moving trading server-side is out of scope (§1) and would need its own design. |
| Yahoo, Treasury, FRED, Alpaca, RSS | Client-side; the web gets sample data | Server-side adapters with `DisplayPolicy` (§3.1). Yahoo and RSS are off for public display until reviewed [unverified]. |
| Binance and Coinbase crypto | Client-side websockets | Server-side, `public_display: false` (§3.1). Hyperliquid core perps and spot become the default crypto source. |
---
## 10. Observability
- **Tracing:** `tracing` with JSON logs and OpenTelemetry export. Logs carry no secrets, tokens or raw IPs (hash client IPs), and no user document contents.
- **Prometheus metrics:**
- **Node (per host):** block height, blocks per second, the `exchangeStatus` lag between L1 time and local time, the `hl-node` version, restarts, and new `visor_child_stderr` lines (§6.5). Also disk use and free space, output bytes per hour, network in/out, and the gap in heights between node A and node B.
- **Ingest:** file-tail lag (block written → applied), last applied `block_number`, block gaps, snapshot self-check mismatches per coin, book rebuilds, trade dedupe counts, bars closed and repaired, and the age of the latest oracle and mark per coin.
- **Hot store:** topics, refcounts, lingering topics, evictions, and staleness per instrument (p50/p99 and the worst 10).
- **Gateway:** connections, subscriptions per connection, outbound queue depth, conflation drops, resyncs (by reason), slow-consumer disconnects, the server-added latency histogram (ingest event to socket write), and the active node (A or B) with failover count.
- **Sync:** doc ops per second, conflicts by kind, and outbox replays.
- **Jobs:** backfill progress (one-time), pruner runs and bytes freed, gap repairs, and congress refresh duration and result.
- **Health:** `/healthz` (process up) and `/readyz` (a fresh node stream on at least one node, registry loaded, DB writable).
- **Alerts:**
- Node: `exchangeStatus` lag above 2 s, no new block for 5 s, disk above 70% (warn) or 85% (page), crash loops, or a version change.
- Ingest: a snapshot mismatch, a block gap, or a failover.
- General: staleness p99 above 10 s during the underlying's open hours, and the error rate.
- **Dashboards:** Grafana, or a hosted equivalent.
---
## 11. Deployment and hosting cost
**v1 topology (colocated, §6.3):**
- **Node host A** (Vultr bare metal, Tokyo) runs `hl-visor`, `hl-node`, `tr-ingest` **and the primary `tr-gateway` plus its database**. Ingest-to-gateway traffic stays on loopback.
- **Node host B** (Cherry Servers, Tokyo, Equinix TY15) runs `hl-visor`, `hl-node`, `tr-ingest` **and a warm-standby `tr-gateway`**:
- B's ingest streams to A's gateway over WireGuard/mTLS across providers (a short in-city hop [unverified; measure]).
- **Market data fails over automatically.** B builds its own books and bars (§6.6). If A's health check fails, DNS points clients at B, and they resync the market topics.
- **User writes have exactly one writer, with synchronous replication and an epoch fence:**
- **One writer.** Document ops, their `(user_id, op_id)` results (§8) and demo-book commands are accepted by the **writer** gateway only. The writer is A in v1, holding writer epoch `e`. B's gateway rejects writes while it's a standby; clients keep them in their outbox (§8).
- **Synchronous ack.** The writer sends `DocAck`/`DocConflict` only after the change **and its `op_id` result** are committed locally **and** confirmed durable on the other host, tagged with epoch `e`. So an acknowledged edit always exists on both hosts, and a client never drops from its outbox an edit the survivor lacks.
- **If the other host can't confirm, the writer stops acking.** Clients queue edits offline (§8) until replication returns. Market data is unaffected. We trade write availability for never losing acknowledged edits [target; revisit after measuring].
- **Promotion is operator-initiated in v1** (a runbook step, not a health check):
1. The operator promotes B to epoch `e+1`.
2. From then on, B refuses replication with any epoch `≤ e`.
3. A can no longer get confirmations, so it **cannot ack any further write** even if some clients can still reach it. When A reconnects, it sees the higher epoch and demotes itself to standby. That prevents split-brain divergence of documents and the demo book.
- Clients replay their outbox against the new writer, and `op_id` dedupe returns the original result for edits that had been acked (§8).
- **Bars and other derived data** are rebuilt per host from node output, so they need no write fence (§3.4, §6.6).
- **The gateway runs on the node boxes, not a separate cloud VM.** Node A has 24 cores / 48 threads and 256 GB, enough headroom for both [unverified until measured]. The node gets most of the cores via cgroup/`CPUAffinity` limits, so client load can't starve block processing.
- If measurement shows contention, the gateway moves to a small box on the **same provider's private LAN** (Vultr VPC, Cherry private VLAN) in Tokyo. It never goes to another region.
- **Market data starts in Tokyo,** so the hop to North American users is the same whether the gateway is in Tokyo or North America. The cost is a longer RTT for document sync from North America, ~150 ms [unverified; to measure], which is fine for that traffic. North American edge gateways can come later, if measurement shows it matters.
| Item | Monthly cost (list, §6.3) |
|---|---|
| Node A: Vultr `vbm-24c-256gb-amd`, Tokyo (node + ingest + gateway) | **$725** |
| Node B: Cherry Servers EPYC 9275F, Tokyo (node + ingest + standby gateway) | **$874.17** |
| Extra bandwidth beyond Vultr's 10 TB, at $0.01/GB | ≈ $0–50 [unverified; depends on §5.9 measurements] |
| Object storage for our own fills archive (optional, §6.4) | ≈ $10–30 [unverified] |
| One-time S3 backfill, requester-pays transfer (§5.6) | One-off; unknown until the bucket size is measured [unverified] |
| **Total (two nodes, gateway colocated)** | **≈ $1,610–1,680** |
| Total (single-node budget fallback, §6.6) | ≈ $735–805 (Vultr only); ≈ $375–600 on a 16-core Latitude or Cherry box once Tokyo stock returns |
Egress estimate (per always-on client): a 50-symbol watchlist at 4 Hz × ~30 bytes per postcard update ≈ 6 KB/s ≈ **15 GB/month** worst case [unverified, rough]. Most clients are idle or backgrounded most of the time. We measure real numbers in phase 6.
**Recommendation:**
- Two bare-metal nodes on two providers in Tokyo (Vultr + Cherry Servers), with `tr-ingest` and `tr-gateway` colocated on the node boxes. Primary on A, warm standby on B.
- Containerise ingest and the gateway (one Dockerfile each). The node itself runs on the host under systemd, as the README expects.
- CI deploys from `master` tags, alongside the existing `deploy-site.yml`.
---
## 12. Phased plan (node first; small PRs, each proving one thing)
| # | PR / step | Proves |
|---|---|---|
| 0 | **This doc** | Agreement on scope, protocol, node plan and risks |
| 1 | **Node A live in Tokyo** (ops, not app code): an Ubuntu 24.04 host per §6.2, GPG-verified `hl-visor`, the flags in §5.1, the pruner, and node metrics and alerts. A runbook goes in `docs/ops/node.md` (no addresses or secrets). | The node syncs and `exchangeStatus` stays fresh. We measure GB/day, bandwidth and catch-up after a planned restart. **Acceptance tests for the §5.2 verdict:** `xyz:` and other HIP-3 coins present in raw book diffs and `l4Snapshots`, spot present, one book compared once against the public `l2Book`, local `activeAssetData.markPx` working, and the oracle actions located in `replica_cmds`. |
| 2 | `crates/hl-node`: parsers for node files (fills, order statuses, raw book diffs, HIP-3 oracle updates, misc events, batched blocks), from **recorded node fixtures**, plus the checkpointed file tailer | Our parsers match the real shapes. Resume-from-checkpoint is exact. |
| 3 | `crates/book` (`tr-book`): L4 from snapshot plus diffs, L2 and BBO aggregation, and a self-check against `fileSnapshot` | Books match fresh snapshots for 24 h with zero unrepaired mismatches, across core, HIP-3 and spot. |
| 4 | `tr-ingest`: tape dedupe, the candle builder (all intervals, no cap), the candle store, and prices (mid, last, oracle, mark, funding, §5.4) | Bars are deterministic and identical when built twice from the same files. Prices reconcile against the abci state. |
| 5 | `crates/proto` (`tr-proto`): message types, postcard and JSON codecs, round-trip and size tests, a `wasm32` build in CI | The schema compiles everywhere. Frame sizes are known. |
| 6 | `server/` (`tr-gateway`) skeleton: the ingest → gateway stream, axum WS, `Hello`/`Welcome`, ping, and the `Quotes` topic. A CLI client and a 1k-client load test. | **One ingest fans out to N clients.** Server-added latency, internal-stream bandwidth and egress are measured. |
| 7 | Symbol registry (from the local info server) and mapping (§7), `Resolution` in quotes, and `RemoteMarkets` in `tr-data` behind `TR_SERVER_URL`. The web build uses it. | The web build shows live `xyz:` and core prices with `PERP` labelling. The `TODO(web proxy)` stubs are gone for quotes. |
| 8 | The `history` RPC and live `Bars` with `BarClosed`, plus `Book` (L2 and L4), `Trades` and `Ctx` topics, linger and eviction, and slow-client backpressure tests | Charts and books work through the server. Slow clients don't hurt others. |
| 9 | **One-time backfill** (§5.6): measure the S3 bucket, rebuild from `node_fills_by_block`, run the `candleSnapshot` sweep for what's left, and stitch with provenance | History before node start exists. Overlap comparisons pass. |
| 10 | **Node B and failover** (§6.6) | Killing node A mid-session causes one `Resync` and no lost closed bars. |
| 11 | Auth (sign-in) and accounts. Document sync for layouts, prefs and watchlists, with the conflict rules. | Two devices stay in sync, and conflicts never lose work. |
| 12 | Drawings (stable ids) and a server-authoritative demo book | Real-time cross-device drawings. One paper book. |
| 13 | `CongressProvider` server-side. The web gets the Congress panes. | Congress data reaches the web and the stub is deleted. |
| 14 | `SnapTradeProvider` server-side (read-only server mode). Desktop local mode is left untouched. | Web and mobile get portfolios with no partner secrets in those clients. Desktop trading still works. |
| 15 | Staging and prod deploys for the gateway, plus dashboards and the full alert set | It runs unattended and we can see when it doesn't. |
| 16 | (Later) a licensed equities and options `Provider` (Intrinio, Twelve Data or Massive) | The provider interface holds up against a second real source. |
---
## 13. Risks
1. **Legal: Hyperliquid's Terms of Use.** The Terms (last updated June 15, 2026) define "Restricted Persons" to include persons and entities that "reside in, are located in, are incorporated in, or have a registered office in the United States of America or Ontario, Canada", and forbid them from using the Interface. They also prohibit circumventing restrictions, for example with VPNs [doc: TOU §1.6, §3.1.5]. The Terms cover the website *Interface*. They contain **no explicit license or prohibition for redistributing or displaying API data** [doc: TOU; I found no data-licensing clause]. Goshen Labs is in Toronto, and many of Terminal's users will be in the US. **This needs legal review before launch**, and it is the main reason the server is data-only. The Foundation node also excludes "certain activities involving the U.S. or Ontario" [doc: FND]. Running our own node doesn't change this analysis. It's a question about displaying Hyperliquid data, not about how we fetch it.
2. **Perp ≠ stock.** HIP-3 equity perps follow the underlying through an oracle during external sessions. Off-hours (weekends, holidays, and anything outside 24/5 coverage) the oracle follows trade.xyz's own order flow through an EMA, and mark is bounded around the last external price [doc: XYZ-ORACLE, XYZ-BOUNDS]. Prices *will* diverge from the listed stock: at the open, around earnings, and on corporate actions (splits and dividends handling [unverified]). Funding also moves the perp relative to spot. Mitigations: mandatory labelling (§7.1), session badges (§7.4), and never computing "stock" analytics (for example the Congress performance estimates) from perp prices without labelling them.
3. **Builder delistings and settlements.** HIP-3 deployers can halt and settle markets at any time, and can recycle names [doc: HIP3]. Validators can delist validator-operated perps [doc: DELIST]. Snapshot counts: 21 of 141 `xyz` markets and 56 of 235 core markets flagged delisted [observed]. Mitigations: epochs (§7.5), notifications, and keeping history.
4. **Dependence on one venue and one software stack.** Everything in v1 comes from one chain, and from `hl-node` binaries we can't audit. Output formats can change without notice. Mitigations: the `Provider` trait, staleness UI, recorded fixtures with parser tests in CI, and alerts on version changes.
5. **The node is a single point of failure** if we run only one. Mitigation: two nodes on two providers (§6.6). With one node, outages show as stale data until catch-up (§6.5).
6. **HIP-3 (and spot) book coverage is undocumented** (§5.2). If node book outputs leave out HIP-3 dexes, our core use case, HIP-3 equity perps, has no book source except the public API. Mitigation: the phase 1 acceptance test, run before any app code is built on top of it.
7. **The reference book code is unmaintained** [doc: LAT, OBS], and queue-priority edge cases exist [doc: LAT]. Mitigations: our own `tr-book` with snapshot self-checks, per-coin rebuilds and recorded-day replay tests.
8. **Core mark can't be fully derived from node data** (it uses external CEX perp mids [doc: MARK]). We rely on the local `activeAssetData` [unverified], with a labelled estimate as the fallback (§5.4).
9. **Disk and I/O.** Writing ~100–200 GB/day [doc: NODE; unverified total] can fill a disk within days if the pruner fails. Mitigations: checkpoint-gated pruning, 70%/85% alerts, and NVMe sized for headroom.
10. **Peers and auto-upgrades.** Startup depends on finding a good peer [doc: NODE]. `hl-visor` auto-upgrades `hl-node`, which can change output schemas or restart both nodes at once. Mitigations: reliable peers (§6.1), version-change alerts, and parser tests against fresh fixtures after every upgrade.
11. **Unknown backfill size and cost.** The S3 archive range and size are undocumented, and transfer is requester-pays [doc: HIST]. Mitigation: measure first. The `candleSnapshot` sweep is the cheap fallback.
12. **Display rights for other feeds.** Coinbase, OKX and Kraken (per the parent's research), Yahoo, and Congress data commercial-use limits. Mitigation: `DisplayPolicy` defaults to off.
13. **Thin HIP-3 liquidity.** Some markets are small (`abcd` had 1 asset [observed]). Mid prices can be noisy or missing when a book is empty [unverified for specific markets]. We fall back to mark price, with the basis label.
14. **A single stateful gateway.** Restarting it drops the hot store. Clients reconnect and resync, but it is visible. Mitigation: fast startup from the registry and SQLite, plus a warm-standby gateway on node host B (§11). Market data fails over automatically. User writes need synchronous replication with an epoch fence, and promotion is operator-initiated, so acknowledged edits are never lost and the two hosts never diverge. The cost is that edits queue in client outboxes while replication or promotion is pending. Later, a two-instance setup with sticky sessions. Colocation means a node-host outage also takes out that host's gateway; that's why the standby sits on the other provider.
15. **Protocol evolution with postcard.** A breaking change needs a version bump. Mitigations: the rules in §4.4 and support for N/N−1.
---
## 14. Hyperliquid facts referenced (summary)
| Fact | Value | Source |
|---|---|---|
| Node spec | Non-validator: 16 vCPU, 128 GB RAM, 500 GB SSD. Ubuntu 24.04 only. Ports 4001–4002 public. Tokyo for lowest latency. | [doc: NODE] |
| Latency spec | ≥ 32 logical cores, 128 GB RAM, 500 MB/s disk, a reliable peer, `--disable-output-file-buffering` | [doc: LAT] |
| Output volume | "Around 100 GB of logs per day" (more with order statuses and book diffs) | [doc: NODE] |
| Book outputs | `--write-raw-book-diffs`, `--write-order-statuses`, `--write-fills`, `--batch-by-block` | [doc: NODE, L1] |
| L4 checkpoint | `fileSnapshot` → `l4Snapshots` on the local info server (latest state), or `compute-l4-snapshots` from `periodic_abci_states` (every 10,000 blocks) | [doc: NODE] |
| L4 rebuild | Snapshot + apply order statuses = realtime L4 book | [doc: L1] |
| Reference builder | `order_book_server`: l2Book (≤ 100 levels), trades, l4Book. Unmaintained, no spot (per README), no trigger orders. | [doc: OBS, LAT] |
| Local info server | Subset only (`meta`, `spotMeta`, `perpDexs`, `exchangeStatus`, `activeAssetData`, …). No time series, no WS, no `l2Book`. | [doc: NODE] |
| Oracle | Validators publish about every 3 s. Weighted CEX spot median, then a stake-weighted median across validators. | [doc: ORACLE] |
| Mark | Median of oracle+EMA, the book/last-trade median, and CEX perp mids | [doc: MARK] |
| Funding | Hourly. Premium sampled every 5 s. Interest 0.01%/8h. Cap 4%/h. | [doc: FUNDING] |
| HIP-3 oracle | `setOracle` about every 3 s with `oraclePxs`, `markPxs` and `externalPerpPxs`. Node flag `--write-hip3-oracle-updates`. | [doc: HIP3-ACT, NODE] |
| S3 archives | Requester-pays. `hl-mainnet-node-data` (fills, trades, misc events, blocks). `hyperliquid-archive` (l2Book snapshots, asset_ctxs). No candles. | [doc: HIST] |
| Public candle depth (backfill only) | Most recent 5000 candles per interval | [doc: INFO] |
| Public REST weight (backfill only) | 1200/min per IP. `candleSnapshot` costs 20 plus 1 per 60 items. | [doc: RL] |
| HIP-3 naming | `{dex}:{coin}`. Asset id `100000 + dex_index*10000 + index` | [doc: ASSET, PERP] |
---
## 15. Open questions for Micky
1. **Legal:** who reviews the Hyperliquid ToS (US/Ontario "Restricted Persons") for a Toronto company displaying Hyperliquid data to US and Canadian users? Is it OK to bring the node and server up before that review, given that it's data-only?
2. **Node budget:** two nodes (≈ $1,600/month, list) or one node to start (≈ $725)? Is there a monthly ceiling?
3. **Hosting providers:** Vultr bare metal + Cherry Servers in Tokyo as the two-provider pair (§6.3), or wait for cheaper 16-core Latitude or Cherry stock in Tokyo? Freestyle, OVHcloud and Equinix Metal are ruled out.
4. **AWS account for the backfill:** OK to open or use one for requester-pays S3 transfer, and what's the cap on the one-time backfill spend?
5. **Gateway placement:** colocated on the node boxes in Tokyo (recommended for v1, §11), or on a separate same-LAN box from the start?
6. **Mark price:** OK to show the node-read mark (`activeAssetData`) and fall back to a labelled estimate if that doesn't work?
7. **If HIP-3 books are missing from node output** (the phase 1 test): pause, or allow the public `l2Book` for HIP-3 only, as a stopgap?
8. **Default crypto source:** core perp mid (`BTC`) or HL spot (`UBTC/USDC`), and how prominently do we label "perp" for crypto?
9. **Proxy mapping:** should `AAPL US Equity` resolve to `xyz:AAPL` by default (with the `PERP` badge), or only when the user types `xyz:AAPL`? And should other dexes (`flx:`, `km:`, …) be mapped when xyz lacks a ticker?
10. **Off-hours:** show the perp price over weekends as the "last" price, or freeze the equity quote at the Friday close and show the perp separately?
11. **Auth:** a hosted identity provider (Clerk, WorkOS, Supabase) or self-hosted OAuth in Rust? Which sign-in methods first?
12. **Anonymous use:** can signed-out users get live data (today's behaviour), or is sign-in required for the web build?
13. **Demo account:** OK to make it server-authoritative and let it fill off-hours at perp prices?
14. **Encoding:** OK with postcard only (JSON for debugging), given that every client is Rust?
15. **Secondary feeds:** keep Binance, Coinbase and Yahoo server-side but staff-only, or drop them from the server entirely?
16. **Congress data:** get legal sign-off on the commercial-use limits before serving it publicly from our server?
17. **Licensed vendor:** is there a preferred vendor (Intrinio, Twelve Data, Massive) and a timeline, so the `DisplayPolicy` and entitlement model can be shaped around its contract?
---
## References
- **RL**: Rate limits and user limits. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/rate-limits-and-user-limits
- **INFO**: Info endpoint. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint
- **PERP**: Info endpoint, Perpetuals. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint/perpetuals
- **WS**: Websocket. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/websocket
- **WS-SUB**: Websocket subscriptions. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/websocket/subscriptions
- **HB**: Timeouts and heartbeats. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/websocket/timeouts-and-heartbeats
- **ASSET**: Asset IDs. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/asset-ids
- **HIP3**: HIP-3, builder-deployed perpetuals. https://hyperliquid.gitbook.io/hyperliquid-docs/hyperliquid-improvement-proposals-hips/hip-3-builder-deployed-perpetuals
- **DELIST**: Delisting. https://hyperliquid.gitbook.io/hyperliquid-docs/trading/delisting
- **HIST**: Historical data. https://hyperliquid.gitbook.io/hyperliquid-docs/historical-data
- **LAT**: Optimizing latency. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/optimizing-latency
- **L1**: L1 data schemas. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/nodes/l1-data-schemas
- **FND**: Foundation non-validating node. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/nodes/foundation-non-validating-node
- **NODE**: hyperliquid-dex/node README. https://github.com/hyperliquid-dex/node
- **OBS**: hyperliquid-dex/order_book_server README. https://github.com/hyperliquid-dex/order_book_server
- **TOU**: Hyperliquid Terms of Use (last updated June 15, 2026). https://app.hyperliquid.xyz/terms
- **XYZ-US**: trade[XYZ] US stocks. https://docs.trade.xyz/perpetuals/markets/stocks/us
- **XYZ-ORACLE**: trade[XYZ] oracle price. https://docs.trade.xyz/perpetuals/mechanics/oracle-price
- **XYZ-BOUNDS**: trade[XYZ] discovery bounds. https://docs.trade.xyz/perpetuals/mechanics/discovery-bounds
- **XYZ-TAU**: trade[XYZ] oracle time constant updates. https://docs.trade.xyz/perpetuals/changelog/oracle-time-constant-updates
- **ORACLE**: Oracle. https://hyperliquid.gitbook.io/hyperliquid-docs/hypercore/oracle
- **MARK**: Robust price indices. https://hyperliquid.gitbook.io/hyperliquid-docs/trading/robust-price-indices
- **FUNDING**: Funding. https://hyperliquid.gitbook.io/hyperliquid-docs/trading/funding
- **HIP3-ACT**: HIP-3 deployer actions. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/hip-3-deployer-actions
- **INFRA-PEERS**: Foundation non-validating node for infra providers. https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/nodes/foundation-non-validating-node-for-infra-providers
- **LATITUDE**: Latitude.sh pricing. https://www.latitude.sh/pricing
- **LAT-M4**: Latitude.sh m4.metal.medium (official page; $456 US list) https://www.latitude.sh/pricing/m4-metal-medium. Tokyo price and stock come from a third-party tracker: https://servermarket.dev/providers/latitude/m4-metal-medium
- **LAT-F4**: Latitude.sh f4.metal.medium, Tokyo price and stock (third-party tracker). https://servermarket.dev/providers/latitude/f4-metal-medium
- **VULTR-API**: Vultr public plans API (plans, monthly price and locations; `nrt` is Tokyo, `itm` is Osaka) and OS list, read 2026-10-29. https://api.vultr.com/v2/plans-metal, https://api.vultr.com/v2/plans, https://api.vultr.com/v2/os
- **VULTR-BM**: Vultr bare metal product page. https://www.vultr.com/products/bare-metal/
- **VULTR-PRICE**: Vultr pricing (bandwidth overage). https://www.vultr.com/pricing/
- **CHERRY-TYO**: Cherry Servers Tokyo dedicated servers (prices, Equinix TY15, 100 TB egress, Ubuntu 24.04, private VLANs). https://www.cherryservers.com/bare-metal-dedicated-servers/tokyo. Launch post: https://www.cherryservers.com/blog/cherry-servers-tokyo-location-live
- **CHERRY-9950X**: Cherry Servers Ryzen 9950X price and stock by location (third-party tracker). https://servermarket.dev/providers/cherry/amd-ryzen-9950x. Official page: https://www.cherryservers.com/pricing/dedicated-servers/amd-ryzen-9950x
- **AWS-PRICE**: AWS EC2 on-demand Linux prices for Asia Pacific (Tokyo), from the price data behind the official pricing page, read 2026-10-29. https://aws.amazon.com/ec2/pricing/on-demand/
- **OVH-REGIONS**: OVHcloud dedicated-server regions (APAC: Singapore, Mumbai, Sydney). https://www.ovhcloud.com/en/bare-metal/regions-availability/
- **OVH-APAC**: OVHcloud APAC traffic quotas and throttling. https://support.us.ovhcloud.com/hc/en-us/articles/4404330636819-All-About-APAC-Region-Servers
- **EQX-EOL**: Equinix Metal sunset (June 30, 2026). https://docs.equinix.com/goodbye-metal/
- **FREESTYLE**: Freestyle VM pricing and limits, and inbound TLS. https://www.freestyle.sh/docs/vms/pricing-and-limits, https://www.freestyle.sh/docs/vms/network/tls-inbound
- **AWS prices** (third-party trackers, accessed 2026-10-29, not quotes): r6i.4xlarge Tokyo https://vmprices.com and https://www.usage.ai, r7a.4xlarge https://cloudcostdb.com, EBS gp3 Tokyo summaries. Official structure: https://aws.amazon.com/ec2/pricing/on-demand/ and https://aws.amazon.com/ebs/pricing/
### Observations log (read-only calls, 2026-10-29 ~17:35 UTC)
- `POST /info {"type":"perpDexs"}` returned 11 entries (null plus 10 named dexes).
- `{"type":"meta"}` returned 235 universe entries (56 `isDelisted`). `{"type":"meta","dex":"xyz"}` returned 141 entries (21 `isDelisted`), all names `xyz:`-prefixed.
- `{"type":"spotMeta"}` returned 347 pairs and 511 tokens.
- `{"type":"allMids"}` returned 1399 keys (401 `@…` spot, 762 `#…` outcomes, no `dex:` keys). `{"type":"allMids","dex":"xyz"}` returned `xyz:` keys only.
- `candleSnapshot` (startTime 0) returned:
- BTC 1m: 5104 bars (~3.5 days)
- BTC 1h: 5002 bars (from 2026-04-04)
- BTC 1d: 2263 bars (from 2020-08-19)
- `xyz:AAPL` 1d: 343 bars (from 2025-11-21)
- Numbers came back as strings.
- WS `wss://api.hyperliquid.xyz/ws`, 12 s window, subscriptions to `allMids{dex:xyz}`, `l2Book BTC`, `trades BTC`, `candle xyz:AAPL 1m`, `bbo xyz:AAPL`, `activeAssetCtx xyz:AAPL` and `activeAssetCtx @107`:
- Each got a `subscriptionResponse`.
- Message counts: l2Book 3, trades 14, activeAssetCtx 13, activeSpotAssetCtx 13, bbo 9, allMids 2, candle 2.
- `ping` → `{"channel":"pong"}`.
- Candle OHLCV came as strings.
- WS `allDexsAssetCtxs` returned ~122 KB messages (2 in 8 s). WS `fastAssetCtxs` returned 10 messages in 8 s (1.9–28 KB), with a first snapshot of 2240 coins after base64 and raw-DEFLATE decoding.
- No `exchange` endpoint calls, no orders, no user-specific subscriptions.
- `POST /info {"type":"allPerpMetas"}` returned per-dex universe sizes as listed in §5.7.
- Hosting research (2026-10-29): read-only GETs of the Vultr public plans and OS APIs (no account), plus AWS's public Tokyo on-demand price data. Nothing was ordered or provisioned.
Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Regenerate the app icon from the site's logo (site/assets-src/icon.svg): app/assets/AppIcon.icns
# for the macOS bundle and app/assets/icon.png, which the app sets as its Dock icon at runtime.
# Needs macOS (sips, iconutil) and Google Chrome or Chromium to render the SVG; set CHROME to the
# browser's path or command name if it isn't found.
set -euo pipefail
cd "$(dirname "$0")/.."
if [[ -n ${CHROME:-} ]]; then
candidates=("$CHROME")
else
candidates=(google-chrome google-chrome-stable chromium chromium-browser
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")
fi
chrome=
for candidate in "${candidates[@]}"; do
if chrome=$(command -v "$candidate"); then
break
fi
done
if [[ -z $chrome ]]; then
echo "build-icon.sh: Chrome or Chromium not found; set CHROME to its path or command name." >&2
exit 1
fi
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
"$chrome" --headless=new --disable-gpu --hide-scrollbars --force-device-scale-factor=1 \
--default-background-color=00000000 --window-size=1024,1024 --screenshot="$tmp/icon.png" \
"file://$PWD/site/assets-src/icon.svg" 2>/dev/null
set=$tmp/AppIcon.iconset
mkdir "$set"
for size in 16 32 128 256 512; do
sips -z $size $size "$tmp/icon.png" --out "$set/icon_${size}x${size}.png" >/dev/null
sips -z $((size * 2)) $((size * 2)) "$tmp/icon.png" --out "$set/icon_${size}x${size}@2x.png" >/dev/null
done
mkdir -p app/assets
iconutil -c icns "$set" -o app/assets/AppIcon.icns
cp "$set/[email protected]" app/assets/icon.png
echo "wrote app/assets/AppIcon.icns and app/assets/icon.png"
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# Build the browser version of Terminal (WebAssembly) into a static folder.
#
# scripts/build-web.sh [out-dir] # default: site/static/app, served by the site at /app/
#
# It writes two builds: terminal_bg.wasm (WebGPU) and terminal-webgl_bg.wasm (adds the
# WebGL2 fallback, which is larger and draws no charts). The page loads the second only when
# the browser has no WebGPU adapter.
#
# Each build's JS glue and wasm go in immutable/<hash>/, a folder named after a hash of its
# files, and index.html points at those folders. A new build gets new URLs, so the site can
# cache everything under immutable/ for good (site/vercel.json), and a page can't pair one
# build's JS with another build's wasm.
#
# Needs the wasm32-unknown-unknown target (rustup target add wasm32-unknown-unknown),
# wasm-bindgen-cli at the version pinned in Cargo.lock, and wasm-opt from binaryen.
set -euo pipefail
cd "$(dirname "$0")/.."
out="${1:-site/static/app}"
target=wasm32-unknown-unknown
wasm="target/$target/web/terminal.wasm"
want=$(awk '/^name = "wasm-bindgen"$/ { getline; gsub(/version = |"/, ""); print; exit }' Cargo.lock)
have=$(wasm-bindgen --version 2>/dev/null | awk '{ print $2 }' || true)
if [ "$want" != "$have" ]; then
echo "wasm-bindgen-cli ${have:-is missing}; Cargo.lock wants $want:" >&2
echo " cargo install wasm-bindgen-cli --version $want --locked" >&2
exit 1
fi
command -v wasm-opt >/dev/null || { echo "wasm-opt is missing: install binaryen" >&2; exit 1; }
# The wasm features rustc enables for this target (rustc --print cfg --target wasm32-unknown-unknown).
features=(--enable-bulk-memory --enable-bulk-memory-opt --enable-call-indirect-overlong --enable-multivalue
--enable-mutable-globals --enable-nontrapping-float-to-int --enable-reference-types --enable-sign-ext)
mkdir -p "$out"
rm -rf "$out"/terminal* "$out/immutable" "$out/fonts"
# sed expressions that replace each {name} in index.html with that build's path
paths=()
# SHA-256 of stdin. Older macOS has shasum but no sha256sum.
sha256() { if command -v sha256sum >/dev/null; then sha256sum; else shasum -a 256; fi; }
# build <name> [cargo args...]
build() {
local name=$1 tmp="$out/immutable/.$1" hash
shift
cargo build --profile web --target "$target" -p terminal "$@"
wasm-bindgen --target web --no-typescript --out-dir "$tmp" --out-name "$name" "$wasm"
wasm-opt -Os "${features[@]}" --strip-debug --strip-producers "$tmp/${name}_bg.wasm" -o "$tmp/${name}_bg.wasm"
hash=$(cd "$tmp" && find . -type f | LC_ALL=C sort | xargs cat | sha256 | cut -c 1-16)
mv "$tmp" "$out/immutable/$hash"
paths+=(-e "s#{$name}#immutable/$hash/$name#g")
}
build terminal
build terminal-webgl --features webgl
sed "${paths[@]}" app/web/index.html >"$out/index.html"
if grep -n '{terminal[a-z-]*}' "$out/index.html" >&2; then
echo "index.html names a build that this script doesn't make" >&2
exit 1
fi
# egui's emoji fonts, which the page fetches after start-up (see app/src/web.rs), with their licenses.
fonts=$(cargo metadata --format-version 1 --locked --filter-platform "$target" | python3 -c '
import json, os, sys
pkgs = json.load(sys.stdin)["packages"]
print(next(os.path.dirname(p["manifest_path"]) for p in pkgs if p["name"] == "epaint_default_fonts"))')/fonts
mkdir -p "$out/fonts"
cp "$fonts"/{NotoEmoji-Regular.ttf,OFL.txt,emoji-icon-font.ttf,emoji-icon-font-mit-license.txt} "$out/fonts/"
size() { wc -c <"$1" | tr -d ' '; }
echo "wrote $out"
for f in "$out"/immutable/*/*_bg.wasm; do
gz=$(gzip -9c "$f" | wc -c | tr -d ' ')
br=$(command -v brotli >/dev/null && brotli -c -q 11 "$f" | wc -c | tr -d ' ' || echo "n/a")
echo "${f#"$out"/}: $(size "$f") bytes raw, $gz gzip -9, $br brotli -q 11; $(basename "${f%_bg.wasm}").js: $(size "${f%_bg.wasm}.js") bytes"
done
+38 -8
View File
@@ -1,21 +1,45 @@
#!/usr/bin/env bash
# Wrap the release binary in a minimal macOS .app bundle (retina-capable, dark-mode aware).
# Wrap a release binary in a minimal macOS .app bundle (retina-capable, dark-mode aware), written
# next to the binary as Terminal.app: target/release/terminal unless another path is given.
# The executable inside is named Terminal too, since the app menu's About, Hide and Quit items
# take the process name. The bundle embeds Sparkle, which updates installed copies; it is left
# unsigned, and scripts/release-macos.sh signs it.
set -euo pipefail
cd "$(dirname "$0")/.."
version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
app=target/release/terminal-rs.app
bin=${1:-target/release/terminal}
app=$(dirname "$bin")/Terminal.app
# The bundle ID keeps the project's old name. Released copies have it, and macOS ties the app's
# Keychain access, privacy permissions and preferences (Sparkle's among them) to it, so changing
# it would make the next update a different app as far as macOS is concerned. Nothing shows it.
bundle_id=dev.terminal-rs.app
# Sparkle checks this feed and installs only updates signed with the private half of this key,
# which `generate_keys --account terminal-rs` keeps in the release Mac's login keychain. Every
# release must be signed with the same key.
feed=https://github.com/michaelshimeles/terminal/releases/latest/download/appcast.xml
sparkle_key=Kq1jkRyRLHyfeNeAv3Fl7we7ARPnyuPh+MI/XSZV77U=
sparkle=$(scripts/fetch-sparkle.sh)
rm -rf "$app"
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
cp target/release/terminal-rs "$app/Contents/MacOS/terminal-rs"
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" "$app/Contents/Frameworks"
cp "$bin" "$app/Contents/MacOS/Terminal"
cp app/assets/AppIcon.icns "$app/Contents/Resources/AppIcon.icns"
# The app isn't sandboxed, so Sparkle's XPC services aren't needed; headers and modules are
# build-time only.
framework=$app/Contents/Frameworks/Sparkle.framework
ditto "$sparkle/Sparkle.framework" "$framework"
for part in XPCServices Headers PrivateHeaders Modules; do
rm -rf "${framework:?}/$part" "${framework:?}/Versions/B/$part"
done
cat > "$app/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key><string>terminal-rs</string>
<key>CFBundleDisplayName</key><string>terminal-rs</string>
<key>CFBundleIdentifier</key><string>dev.terminal-rs.app</string>
<key>CFBundleExecutable</key><string>terminal-rs</string>
<key>CFBundleName</key><string>Terminal</string>
<key>CFBundleDisplayName</key><string>Terminal</string>
<key>CFBundleIdentifier</key><string>${bundle_id}</string>
<key>CFBundleExecutable</key><string>Terminal</string>
<key>CFBundleIconFile</key><string>AppIcon</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>${version}</string>
<key>CFBundleVersion</key><string>${version}</string>
@@ -23,7 +47,13 @@ cat > "$app/Contents/Info.plist" <<PLIST
<key>NSHighResolutionCapable</key><true/>
<key>NSSupportsAutomaticGraphicsSwitching</key><true/>
<key>LSApplicationCategoryType</key><string>public.app-category.finance</string>
<key>SUFeedURL</key><string>${feed}</string>
<key>SUPublicEDKey</key><string>${sparkle_key}</string>
<key>SURequireSignedFeed</key><true/>
<key>SUVerifyUpdateBeforeExtraction</key><true/>
<key>SUEnableAutomaticChecks</key><true/>
</dict>
</plist>
PLIST
plutil -lint "$app/Contents/Info.plist" >/dev/null
echo "bundled $app"
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Downloads Sparkle, the framework that updates an installed copy of the app, into
# target/sparkle/<version> (once, checked against the release's SHA-256) and prints that folder.
# It holds Sparkle.framework, which scripts/bundle-macos.sh embeds, and bin/sign_update and
# bin/generate_keys, which scripts/release-macos.sh uses to sign the update and its feed.
set -euo pipefail
VERSION=2.10.0
SHA256=c2bf58aa8387266ac179357b1415d6f2635f044da8be41042af32425dae6da0c
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DIR="$ROOT/target/sparkle/$VERSION"
if [[ ! -d "$DIR/Sparkle.framework" || ! -x "$DIR/bin/sign_update" ]]; then
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
curl -fsSL --retry 3 -o "$TMP/sparkle.tar.xz" \
"https://github.com/sparkle-project/Sparkle/releases/download/$VERSION/Sparkle-$VERSION.tar.xz"
got="$(shasum -a 256 "$TMP/sparkle.tar.xz" | cut -d' ' -f1)"
if [[ "$got" != "$SHA256" ]]; then
echo "Sparkle-$VERSION.tar.xz has SHA-256 $got, expected $SHA256" >&2
exit 1
fi
mkdir -p "$TMP/x"
tar -xf "$TMP/sparkle.tar.xz" -C "$TMP/x" ./Sparkle.framework ./bin/sign_update ./bin/generate_keys ./LICENSE
rm -rf "$DIR"
mkdir -p "$(dirname "$DIR")"
mv "$TMP/x" "$DIR"
fi
echo "$DIR"
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env bash
# Build a universal (Apple Silicon + Intel) Terminal.app, sign it with a Developer ID
# certificate and the hardened runtime, notarize and staple it, then package it as
# target/universal-apple-darwin/release/Terminal.dmg, itself signed, notarized and stapled, with
# the app icon as its volume icon. That file name is the release asset the download page links
# to. Next to it go Terminal-<version>.zip, the app alone, and appcast.xml, the signed update feed
# that points installed copies at that zip. Upload all three to a release tagged v<version>
# (the script prints the command); bump the version in Cargo.toml first, since installed
# copies only offer a version higher than their own.
#
# Notarization credentials come from a notarytool keychain profile. Create it once with an
# app-specific password from account.apple.com and the team ID shown in the certificate name:
#
# xcrun notarytool store-credentials terminal-rs-notary --apple-id [email protected] --team-id TEAMID
#
# Updates are signed with the EdDSA key in the login keychain under the account terminal-rs,
# whose public half is in scripts/bundle-macos.sh. Keep an exported copy somewhere safe:
# installed copies accept no other key, so losing it ends their updates.
#
# target/sparkle/<version>/bin/generate_keys --account terminal-rs -x terminal-rs-sparkle.key
# target/sparkle/<version>/bin/generate_keys --account terminal-rs -f terminal-rs-sparkle.key # on a new Mac
#
# The notary profile and the signing key's account keep the project's old name, terminal-rs,
# because they name items that already exist in the release Mac's keychain.
#
# SIGN_IDENTITY signing identity (default: the first "Developer ID Application" in the keychain)
# NOTARY_PROFILE notarytool keychain profile (default: terminal-rs-notary)
# NOTARY_KEYCHAIN keychain holding that profile (default: the login keychain)
# SPARKLE_ACCOUNT login keychain account of the update signing key (default: terminal-rs)
# NOTARIZE=0 sign and package without notarizing or writing the update feed
set -euo pipefail
cd "$(dirname "$0")/.."
identity=${SIGN_IDENTITY:-$(security find-identity -v -p codesigning | sed -n 's/.*"\(Developer ID Application: .*\)"$/\1/p' | head -1)}
[ -n "$identity" ] || { echo "no Developer ID Application certificate in the keychain; set SIGN_IDENTITY" >&2; exit 1; }
notarize=${NOTARIZE:-1}
notary=(--keychain-profile "${NOTARY_PROFILE:-terminal-rs-notary}")
[ -z "${NOTARY_KEYCHAIN:-}" ] || notary+=(--keychain "$NOTARY_KEYCHAIN")
if [ "$notarize" = 1 ] && ! xcrun notarytool history "${notary[@]}" >/dev/null; then
echo "no notarytool credentials; create the profile described at the top of $0, or set NOTARIZE=0" >&2
exit 1
fi
version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
repo=https://github.com/michaelshimeles/terminal
sparkle=$(scripts/fetch-sparkle.sh)
sparkle_account=${SPARKLE_ACCOUNT:-terminal-rs}
sparkle_key=$(sed -n 's/^sparkle_key=//p' scripts/bundle-macos.sh)
if [ "$notarize" = 1 ]; then
key=$("$sparkle/bin/generate_keys" --account "$sparkle_account" -p 2>/dev/null || true)
if [ "$key" != "$sparkle_key" ]; then
echo "the login keychain's update signing key (account $sparkle_account) is '$key', but the app trusts $sparkle_key." >&2
echo "Import the exported key with $sparkle/bin/generate_keys --account $sparkle_account -f <file>; never generate a new one." >&2
exit 1
fi
fi
tmp=$(mktemp -d)
# A failure while the DMG's writable image is mounted would otherwise leave it attached, and
# rm -rf would delete into it.
cleanup() {
if [ -d "$tmp/mnt" ] && [ "$(stat -f %d "$tmp/mnt")" != "$(stat -f %d "$tmp")" ]; then
hdiutil detach -force "$tmp/mnt" >/dev/null || { echo "left $tmp/rw.dmg mounted at $tmp/mnt" >&2; return; }
fi
rm -rf "$tmp"
}
trap cleanup EXIT
# Submits $1, waits for Apple's verdict, then staples the ticket to $2.
notarize() {
local out id
out=$(xcrun notarytool submit "$1" "${notary[@]}" --wait --output-format json) || true
if [ "$(plutil -extract status raw -o - - <<<"$out" 2>/dev/null)" != Accepted ]; then
echo "notarizing $1 failed: $out" >&2
if id=$(plutil -extract id raw -o - - <<<"$out" 2>/dev/null); then
xcrun notarytool log "$id" "${notary[@]}" >&2
fi
exit 1
fi
xcrun stapler staple "$2"
}
for target in aarch64-apple-darwin x86_64-apple-darwin; do
cargo build --release --locked --target "$target" -p terminal
done
out=target/universal-apple-darwin/release
mkdir -p "$out"
rm -f "$out"/Terminal-*.zip "$out/appcast.xml"
lipo -create -output "$out/terminal" \
target/aarch64-apple-darwin/release/terminal target/x86_64-apple-darwin/release/terminal
scripts/bundle-macos.sh "$out/terminal"
app=$out/Terminal.app
framework=$app/Contents/Frameworks/Sparkle.framework
echo "signing as $identity"
# Inside out, Sparkle's helpers first. The hardened runtime loads Sparkle.framework only when it
# is signed by the app's own team.
for code in "$framework/Versions/B/Autoupdate" "$framework/Versions/B/Updater.app" "$framework" "$app"; do
codesign --force --options runtime --timestamp --sign "$identity" "$code"
done
codesign --verify --deep --strict --verbose=2 "$app"
if [ "$notarize" = 1 ]; then
ditto -c -k --keepParent "$app" "$tmp/Terminal.zip"
notarize "$tmp/Terminal.zip" "$app"
fi
dmg=$out/Terminal.dmg
stage=$tmp/dmg
mkdir "$stage" "$tmp/mnt"
ditto "$app" "$stage/Terminal.app"
ln -s /Applications "$stage/Applications"
cp app/assets/AppIcon.icns "$stage/.VolumeIcon.icns"
# -srcfolder drops the volume's custom-icon flag, so it is set on a writable image that is then
# compressed.
hdiutil create -volname Terminal -srcfolder "$stage" -format UDRW "$tmp/rw.dmg"
hdiutil attach -nobrowse -noautoopen -mountpoint "$tmp/mnt" "$tmp/rw.dmg" >/dev/null
SetFile -a C "$tmp/mnt"
hdiutil detach "$tmp/mnt" >/dev/null
hdiutil convert "$tmp/rw.dmg" -format UDZO -ov -o "$dmg"
codesign --force --timestamp --sign "$identity" "$dmg"
if [ "$notarize" = 1 ]; then
notarize "$dmg" "$dmg"
spctl --assess --type execute --verbose=2 "$app"
spctl --assess --type open --context context:primary-signature --verbose=2 "$dmg"
fi
echo "built $dmg"
# ditto keeps the signature and the stapled ticket; plain zip does not.
zip=$out/Terminal-$version.zip
ditto -c -k --keepParent "$app" "$zip"
echo "built $zip"
[ "$notarize" = 1 ] || exit 0
# Installed copies read releases/latest/download/appcast.xml (SUFeedURL). generate_keys created
# the key's keychain item, so it can export it without a prompt; the export lives in $tmp only
# while signing.
key_file=$tmp/sparkle.key
"$sparkle/bin/generate_keys" --account "$sparkle_account" -x "$key_file" >/dev/null
# sparkle:edSignature="…" length="…"
enclosure=$("$sparkle/bin/sign_update" --ed-key-file "$key_file" "$zip")
appcast=$out/appcast.xml
cat > "$appcast" <<XML
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<title>Terminal</title>
<link>$repo</link>
<item>
<title>Terminal $version</title>
<pubDate>$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000')</pubDate>
<sparkle:version>$version</sparkle:version>
<sparkle:shortVersionString>$version</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>11.0</sparkle:minimumSystemVersion>
<sparkle:fullReleaseNotesLink>$repo/releases/tag/v$version</sparkle:fullReleaseNotesLink>
<enclosure url="$repo/releases/download/v$version/$(basename "$zip")" type="application/octet-stream" $enclosure/>
</item>
</channel>
</rss>
XML
# The app sets SURequireSignedFeed, so the feed carries its own signature as well.
"$sparkle/bin/sign_update" --ed-key-file "$key_file" "$appcast"
"$sparkle/bin/sign_update" --ed-key-file "$key_file" --verify "$appcast"
rm -f "$key_file"
echo "built $appcast"
echo "publish with: gh release create v$version $dmg $zip $appcast"
+29
View File
@@ -0,0 +1,29 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Page screenshots for review
/review
# Browser build output (scripts/build-web.sh)
/static/app
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff. Show more