Compare commits

..
Author SHA1 Message Date
Michael ShimelesandClaude Opus 5.5 2bedb60151 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]>
2026-10-12 20:30:55 -04:00
Michael ShimelesandClaude Opus 5.5 361c4b1eb0 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]>
2026-10-12 20:30:55 -04:00
146 changed files with 1219 additions and 17806 deletions

No files matched your search

+4 -4
View File
@@ -22,7 +22,7 @@ jobs:
arch: x86_64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
@@ -67,7 +67,7 @@ jobs:
grep -q "smoke:" live.log
- name: Bundle .app
run: ./scripts/bundle-macos.sh
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v4
if: always()
with:
name: macos-${{ matrix.arch }}
@@ -75,12 +75,12 @@ jobs:
screenshot-*.png
smoke.log
live.log
target/release/Terminal.app
target/release/terminal-rs.app
linux:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
-121
View File
@@ -1,121 +0,0 @@
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
+7 -33
View File
@@ -463,10 +463,8 @@ 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",
]
@@ -3702,15 +3700,6 @@ 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"
@@ -4098,16 +4087,13 @@ dependencies = [
[[package]]
name = "terminal-rs"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"eframe",
"egui",
"egui-wgpu",
"epaint_default_fonts",
"futures",
"js-sys",
"raw-window-handle",
"serde",
"serde_json",
@@ -4121,11 +4107,6 @@ dependencies = [
"tr-macos",
"tracing",
"tracing-subscriber",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"web-time",
"wgpu",
]
[[package]]
@@ -4424,7 +4405,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tr-broker"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"anyhow",
"base64",
@@ -4443,7 +4424,7 @@ dependencies = [
[[package]]
name = "tr-chart"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"bytemuck",
"chrono",
@@ -4457,7 +4438,7 @@ dependencies = [
[[package]]
name = "tr-congress"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
@@ -4479,42 +4460,35 @@ dependencies = [
[[package]]
name = "tr-core"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"chrono",
"criterion",
"serde",
"serde_json",
]
[[package]]
name = "tr-data"
version = "0.1.1"
version = "0.1.0"
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.1"
version = "0.1.0"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
+1 -11
View File
@@ -4,7 +4,7 @@ members = ["crates/core", "crates/broker", "crates/congress", "crates/data", "cr
default-members = ["app"]
[workspace.package]
version = "0.1.1"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
@@ -36,13 +36,3 @@ 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
+28 -112
View File
@@ -48,21 +48,9 @@ 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; 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.
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).
- **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.
@@ -154,15 +142,17 @@ 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).
- **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 (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
[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
@@ -189,61 +179,8 @@ current and previous year (a few minutes; it runs in the background while you wo
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.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-rs/`.
- 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`.
`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).
## Brokerage setup (SnapTrade)
@@ -287,24 +224,19 @@ are repriced with the terminal's live quotes where a market key exists.
## Trading (opt-in)
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:
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`, a Buy/Sell button, ⇧B / ⇧S), pick the account and order type,
and click **Review**. The broker checks the order through SnapTrade's
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
[order impact](https://docs.snaptrade.com/reference/Trading/Trading_getOrderImpact) endpoint;
nothing is placed.
3. The broker's estimate opens one confirmation dialog. **Send** places exactly the previewed trade
3. **Place Order…** opens the confirmation dialog. **Confirm** 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),
@@ -317,13 +249,8 @@ 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 demo account.
and the app's flows against the simulated portfolio.
## Keyboard shortcuts and commands
@@ -350,7 +277,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` `SMA 200` `BB 20 2.5` · `IND` `IND VOLUME` · `CLEAR` | toggle any of the 151 indicators (optional parameters) · indicator picker · remove all |
| `RSI` `MACD` `BB` `SMA 50` `EMA 20` `VWAP` · `CLEAR` | toggle indicators |
| `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 |
@@ -359,8 +286,7 @@ 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 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 |
| `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) |
| `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) |
@@ -374,14 +300,9 @@ 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)
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/core Bar/Series, timeframes, market keys + security catalog, indicators, 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;
the browser router (crypto live, the rest sampled) and a tokio/browser task shim
Alpaca), websocket live feeds, sample provider with simulated ticks; bar cache
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),
@@ -429,8 +350,7 @@ 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). The whole 151-indicator catalogue at default parameters over 100k one-minute
bars takes ≈ 134 ms on macOS (Ichimoku, the slowest, 6.6 ms).
output memory).
## Tests
@@ -450,11 +370,7 @@ 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) 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.
population-SD Bollinger, session VWAP).
- 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,
+5 -42
View File
@@ -5,11 +5,6 @@ version.workspace = true
edition.workspace = true
default-run = "terminal-rs"
[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
tr-broker.workspace = true
@@ -21,45 +16,13 @@ chrono.workspace = true
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
tracing.workspace = true
futures = "0.3"
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.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"] }
tr-macos.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.

Before

Width:  |  Height:  |  Size: 141 KiB

+2 -134
View File
@@ -1,86 +1,7 @@
//! 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};
//! Spring animations (SwiftUI-like), integrated per frame and keyed by egui `Id`.
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 {
@@ -129,7 +50,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 reduced() || s.settled(target, target) {
if s.settled(target, target) {
s = Spring::at(target);
} else {
s.step(target, dt, response, damping);
@@ -178,59 +99,6 @@ 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();
+6 -87
View File
@@ -6,7 +6,6 @@
mod broker;
mod chrome;
mod demo;
mod hotkeys_view;
mod options_view;
mod palette_view;
@@ -20,7 +19,7 @@ mod workspace;
pub use broker::PosSort;
use std::collections::HashMap;
use std::time::Duration;
use std::time::{Duration, Instant};
use egui::{Color32, Event, Key, Modifiers, Rect, Ui, UiBuilder, pos2, vec2};
use serde::{Deserialize, Serialize};
@@ -28,7 +27,6 @@ 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;
@@ -403,9 +401,6 @@ 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)]
@@ -426,7 +421,6 @@ 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)>,
@@ -502,7 +496,6 @@ 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).
@@ -512,8 +505,6 @@ 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,
}
@@ -569,13 +560,11 @@ impl TerminalApp {
};
}
let settings: Option<Settings> =
crate::storage::read(&settings_path()).ok().and_then(|b| serde_json::from_slice(&b).ok());
std::fs::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(),
@@ -585,7 +574,6 @@ 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(),
@@ -641,7 +629,6 @@ impl TerminalApp {
system_accent,
last_accent_poll: None,
window_styled: false,
updater_started: false,
vibrancy: false,
chrome: None,
last_chrome_poll: None,
@@ -676,23 +663,9 @@ 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(),
@@ -702,7 +675,7 @@ impl TerminalApp {
panes: None,
};
if let Ok(json) = serde_json::to_vec_pretty(&s)
&& let Err(e) = crate::storage::write_atomic(&settings_path(), &json)
&& let Err(e) = crate::layouts::write_atomic(&settings_path(), &json)
{
tracing::warn!("saving settings: {e}");
}
@@ -802,7 +775,6 @@ 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),
@@ -872,15 +844,6 @@ 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))));
@@ -925,13 +888,6 @@ 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);
}
}
}
@@ -996,11 +952,9 @@ 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 } => {
@@ -1098,9 +1052,6 @@ 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()
@@ -1195,26 +1146,19 @@ 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 && !swallow_text {
if !cmd_focused {
// 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.
@@ -1272,12 +1216,6 @@ 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 {
@@ -1305,8 +1243,6 @@ 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,
@@ -1399,13 +1335,11 @@ impl TerminalApp {
self.last_feed = Some(Instant::now());
}
self.save_dirty_themes(false);
// 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 {
if self.last_save.elapsed() > Duration::from_secs(10) {
self.autosave();
self.last_save = Instant::now();
}
self.toasts.retain(|(_, t)| t.elapsed() < Duration::from_secs(TOAST_SECS));
self.toasts.retain(|(_, t)| t.elapsed() < Duration::from_secs(10));
}
/// Save the active layout (only when it changed) and app settings.
@@ -1468,10 +1402,6 @@ 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);
@@ -1485,7 +1415,6 @@ 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();
@@ -1577,13 +1506,6 @@ 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();
@@ -1597,9 +1519,6 @@ impl eframe::App for TerminalApp {
fn on_exit(&mut self) {
self.autosave();
if self.demo.save_error.is_some() {
self.demo.save();
}
}
}
+9 -32
View File
@@ -3,10 +3,9 @@
//! refresh scheduling and the `PORT`/`ACCT`/`POS`/`ORD`/`ACTV` commands. Order entry is in
//! `trading`.
use std::time::Duration;
use std::time::{Duration, Instant};
use tr_broker::{Credentials, Portfolio, Position, SecretStore};
use web_time::Instant;
use super::{PaneKind, TerminalApp};
use crate::command::PortView;
@@ -103,9 +102,7 @@ impl BrokerState {
pub fn load(offline: bool) -> Self {
let store = SecretStore::platform(&data_dir());
let mut message = None;
// 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 {
let stored = match store.load() {
Ok(c) => c.unwrap_or_default(),
Err(e) => {
tracing::warn!("SnapTrade credentials unavailable: {e:#}");
@@ -115,9 +112,7 @@ impl BrokerState {
};
let mut creds = stored.clone();
let from_env = creds.apply_env(|k| std::env::var(k).ok());
let simulate = offline
|| cfg!(target_arch = "wasm32")
|| std::env::var("TR_BROKER").is_ok_and(|v| v.eq_ignore_ascii_case("sample"));
let simulate = offline || std::env::var("TR_BROKER").is_ok_and(|v| v.eq_ignore_ascii_case("sample"));
Self {
store,
saved: stored,
@@ -162,7 +157,6 @@ 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"
@@ -174,22 +168,10 @@ 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 = 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);
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))
}
@@ -216,13 +198,11 @@ 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 };
// 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) {
if !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.shown_portfolio()
if let Some(pf) = &self.broker.portfolio
&& self.broker.last_quotes.is_none_or(|t| t.elapsed() > Duration::from_secs(20))
{
let mut keys: Vec<String> = pf
@@ -441,10 +421,7 @@ impl TerminalApp {
pub(super) fn portfolio_command(&mut self, view: PortView, arg: &str, symbol: Option<String>) {
match arg.trim() {
"REFRESH" | "SYNC" | "RELOAD" => {
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 {
if self.broker.mode() == BrokerMode::Setup {
self.status = "Brokerage not set up: PORT SETUP".into();
} else {
self.refresh_portfolio();
@@ -460,7 +437,7 @@ impl TerminalApp {
let mut pane = PortfolioPane::new(view);
pane.symbol = symbol;
self.open_panel(PaneKind::Portfolio(Box::new(pane)));
if !self.demo_mode() && self.broker.portfolio.is_none() && !self.broker.loading {
if self.broker.portfolio.is_none() && !self.broker.loading {
self.refresh_portfolio();
}
}
+28 -110
View File
@@ -1,14 +1,13 @@
//! 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::Duration;
use std::time::Instant;
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;
@@ -102,38 +101,14 @@ impl TerminalApp {
resp.context_menu(|ui| self.view_menu(ui));
// Data source chip (the window title itself is redundant in a compact toolbar).
let (label, live) = self.source_chip();
let chip_text = p.layout_no_wrap(label.into(), ui_font(11.0), muted());
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 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() + 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;
x = chip.right() + 10.0;
// Right-hand cluster, laid out from the right edge: one-line clock (UTC on hover).
let now = chrono::Local::now();
@@ -282,28 +257,9 @@ 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 == "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)
}
if n.contains("sample") { "Offline · sample data".into() } else { format!("Live · {}", self.provider) }
}
/// The Bloomberg command line, drawn as a rounded macOS search field.
@@ -355,25 +311,17 @@ impl TerminalApp {
Icon::Search,
muted(),
);
// 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.
// A small "GO" key cap at the trailing edge, Bloomberg-style.
let go = Rect::from_center_size(pos2(field.right() - 18.0, field.center().y), vec2(28.0, 15.0));
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.rect_filled(go, 4.0, if self.cmd.trim().is_empty() { theme::control_hover() } else { amber() });
p.text(
cap.center(),
go.center(),
Align2::CENTER_CENTER,
"GO",
mono(10.5),
widgets::lerp_color(muted(), Color32::BLACK, lit),
if self.cmd.trim().is_empty() { muted() } else { Color32::BLACK },
);
let go_clicked = go_resp.clicked();
let go_clicked = ui.interact(go, id.with("go"), Sense::click()).on_hover_text("<GO> (Enter)").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));
@@ -418,10 +366,6 @@ 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),
@@ -429,12 +373,8 @@ impl TerminalApp {
None => {}
}
self.cmd_hist_pos = None;
// 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();
}
// The command line stays live, Bloomberg-style.
self.focus_cmd = true;
} else if had_focus && esc {
self.cmd.clear();
resp.surrender_focus();
@@ -480,12 +420,10 @@ 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 - 6.0 * (1.0 - shown)))
.fixed_pos(below.left_bottom() + vec2(0.0, 6.0))
.show(ctx, |ui| {
ui.multiply_opacity(shown);
egui::Frame::new()
.fill(theme::card())
.stroke(Stroke::new(1.0, theme::border()))
@@ -494,13 +432,10 @@ 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());
if self.suggestion == Some(i) {
glider.on(ui, r);
}
widgets::hover_wash(ui, r, &resp, false);
let selected = self.suggestion == Some(i);
widgets::hover_wash(ui, r, &resp, selected);
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));
@@ -665,7 +600,7 @@ impl TerminalApp {
PaneKind::Portfolio(pp) => Some(pp.view),
_ => None,
});
let pf = self.shown_portfolio();
let pf = self.broker.portfolio.as_deref();
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)
@@ -889,44 +824,27 @@ 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 layer = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("toasts")));
let life = super::TOAST_SECS as f32;
let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("toasts")));
for (k, (msg, at)) in self.toasts.iter().enumerate() {
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);
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),
);
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,
color,
amber(),
);
// 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
@@ -1,284 +0,0 @@
//! 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);
}
}
}
}
+4 -36
View File
@@ -1,10 +1,8 @@
//! 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};
@@ -37,15 +35,13 @@ 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 {
chip(ui, c);
ui.label(
RichText::new(format!(" {c} ")).font(mono(12.0)).color(amber()).background_color(theme::control()),
);
}
});
}
@@ -260,7 +256,6 @@ 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"),
],
);
@@ -302,7 +297,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", "RSI", "IND"], "Indicators (see below)"),
(&["SMA 50", "EMA 20", "BB 20 2", "RSI", "MACD", "VWAP"], "Toggle indicators (CLEAR removes all)"),
(&["CMP MSFT"], "Compare, rebased to the main symbol"),
(&["TL", "HL", "VL", "FIB", "MAGNET"], "Drawing tools and snapping"),
],
@@ -312,33 +307,6 @@ 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,
+1 -10
View File
@@ -6,7 +6,6 @@ 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;
@@ -246,24 +245,16 @@ 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 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 p = ui.painter_at(r);
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()),
+28 -163
View File
@@ -2,12 +2,10 @@
//! 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::command::{self, Function};
use crate::layout::Layout;
use crate::palette::{Item, Kind, Target};
use crate::prefs::Action;
@@ -56,14 +54,19 @@ const COMMANDS: &[(&str, &str, &str, Option<Action>)] = &[
("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),
("RSI", "", "indicator oscillator", None),
("MACD", "", "indicator oscillator", None),
("BB", "", "bollinger bands indicator", None),
("SMA 50", "", "moving average indicator", None),
("SMA 200", "", "moving average indicator", None),
("EMA 20", "", "moving average indicator", None),
("VWAP", "", "indicator volume weighted", None),
("CLEAR", "", "indicators remove", None),
("CMP", "", "compare comparison remove", None),
("TL", "", "trendline draw", Some(Action::DrawTrendline)),
@@ -142,35 +145,6 @@ pub(super) fn static_items() -> Vec<Item> {
)
.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));
}
@@ -200,22 +174,6 @@ pub(super) fn static_items() -> Vec<Item> {
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(
@@ -236,7 +194,6 @@ fn kind_color(k: Kind) -> Color32 {
Kind::Theme => theme::series(4),
Kind::Settings => theme::muted(),
Kind::Action => theme::up(),
Kind::Indicator => theme::series(3),
}
}
@@ -271,64 +228,16 @@ impl TerminalApp {
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"),
);
}
}
}
Some(command::Command::Symbol(key)) => v.push(ticker_item(&key)),
// Kept as typed: arguments such as paths and titles are case-sensitive.
Some(_) if !offered(&v) => {
Some(_) if !v.iter().any(|it| matches!(&it.target, Target::Run(l) if l.eq_ignore_ascii_case(q))) => {
v.push(Item::new(Kind::Command, q, command::hint(q), Target::Run(q.into())));
}
Some(_) => {}
@@ -377,20 +286,16 @@ impl TerminalApp {
(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 top = screen.top() + (screen.height() * 0.14).clamp(dp(16.0), dp(160.0));
let left = screen.center().x - w / 2.0;
let pad = vec2(dp(10.0), dp(6.0));
let field_h = dp(46.0);
@@ -403,11 +308,7 @@ impl TerminalApp {
.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…"
})
.hint_text("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
@@ -426,7 +327,11 @@ impl TerminalApp {
let items = self.palette_items();
let results = self.palette.results(&items);
let recents = self.palette.recent_count(&items);
let recents = if self.palette.query.trim().is_empty() {
crate::palette::live_recents(&items, &self.palette.recents).len()
} else {
0
};
if up {
self.palette.step(-1, results.len());
}
@@ -451,9 +356,8 @@ impl TerminalApp {
}
// Dim everything beneath, then the card, on a layer above the panes and
// below this one.
let mut under =
let 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);
@@ -472,7 +376,6 @@ impl TerminalApp {
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(
@@ -487,7 +390,7 @@ impl TerminalApp {
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) {
if self.palette_row(ui, it, i == self.palette.selected, keyed) {
picked = Some(i);
}
}
@@ -502,11 +405,7 @@ impl TerminalApp {
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"
},
"↑↓ select ↵ open esc close",
mono(11.0),
muted(),
);
@@ -522,12 +421,8 @@ impl TerminalApp {
.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.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.
@@ -542,17 +437,14 @@ impl TerminalApp {
}
}
/// 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 {
/// One result row: kind badge, title, subtitle and shortcut caps. Returns whether it
/// was clicked.
fn palette_row(&self, ui: &mut Ui, it: &Item, selected: bool, 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);
if selected && keyed {
resp.scroll_to_me(None);
}
widgets::hover_wash(ui, r, &resp, false);
widgets::hover_wash(ui, r, &resp, selected);
let p = ui.painter();
let cy = r.center().y;
@@ -623,7 +515,6 @@ mod tests {
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())));
@@ -653,30 +544,4 @@ mod tests {
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}");
}
}
}
+5 -18
View File
@@ -1,6 +1,6 @@
//! Pane content for every panel kind.
use web_time::Instant;
use std::time::Instant;
use egui::{Align2, Color32, Rect, RichText, Sense, Stroke, Ui, UiBuilder, pos2, vec2};
use tr_data::OrderBook;
@@ -81,9 +81,7 @@ impl TerminalApp {
}
}
PaneKind::Portfolio(pp) => {
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) {
for a in super::portfolio_view::portfolio(ui, pp, &self.broker, &self.quotes) {
use super::portfolio_view::PortAction as P;
match a {
P::Core(a) => actions.push(a),
@@ -102,11 +100,6 @@ 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}");
@@ -187,17 +180,11 @@ impl TerminalApp {
p.text(pos2(col(r, 4), r.center().y), Align2::RIGHT_CENTER, "…", mono(12.5), muted());
}
}
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 => {}
if resp.clicked() {
out.push(Action::OpenChart(s.clone()));
}
resp.context_menu(|ui| {
if tradable {
if matches!(sec.class, tr_core::AssetClass::Equity | tr_core::AssetClass::Etf) {
for (label, side) in [("Buy", "BUY"), ("Sell", "SELL")] {
if ui.button(format!("{label} {}…", sec.ticker)).clicked() {
out.push(Action::Command(format!("{side} {s}")));
+16 -104
View File
@@ -31,18 +31,11 @@ pub enum PortAction {
account: Option<String>,
units: Option<f64>,
},
/// Ask to cancel an open order (confirmed in a dialog for live accounts).
/// Ask to cancel an open order (confirmed in a dialog).
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 ---------------------------------------------------------------------------
@@ -286,23 +279,16 @@ 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, demo, &mut out);
toolbar(ui, pane, b, &mut out);
ui.add_space(dp(4.0));
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) {
match (b.mode(), b.portfolio.as_deref()) {
(BrokerMode::Setup, _) => setup_screen(ui, b, &mut out),
(_, None) if b.loading => {
ui.horizontal(|ui| {
@@ -310,18 +296,18 @@ pub fn portfolio(
ui.label(RichText::new("Loading brokerage accounts…").color(muted()));
});
}
(_, None) => match error {
(_, None) => match &b.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) = error {
if let Some(e) = &b.error {
error_card(ui, e, &mut out);
}
empty_state(ui, &mut out);
}
(_, Some(pf)) => {
if let Some(e) = error {
if let Some(e) = &b.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()) {
@@ -356,32 +342,13 @@ pub fn portfolio(
out
}
fn toolbar(
ui: &mut Ui,
pane: &mut PortfolioPane,
b: &BrokerState,
demo: Option<&Portfolio>,
out: &mut Vec<PortAction>,
) {
fn toolbar(ui: &mut Ui, pane: &mut PortfolioPane, b: &BrokerState, 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
{
@@ -454,7 +421,7 @@ fn setup_screen(ui: &mut Ui, b: &BrokerState, out: &mut Vec<PortAction>) {
ui.add_space(dp(4.0));
note(
ui,
"Terminal reads accounts, balances, positions, orders and activity through SnapTrade. Access is read-only \
"terminal-rs 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));
@@ -729,44 +696,12 @@ fn position_row(
(None, _) => resp.on_hover_text("Not available as a terminal market key"),
};
if let Some(k) = &key {
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 => {}
if resp.clicked() {
out.push(PortAction::Core(Action::OpenChart(k.clone())));
}
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)
});
@@ -944,17 +879,7 @@ 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;
}
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
),
);
}
note(ui, format!("Last {} days · right-click an open order to cancel it", tr_broker::snaptrade::ORDER_DAYS));
});
let sel = selected(pane);
let list: Vec<_> = pf
@@ -1027,16 +952,12 @@ fn orders(ui: &mut Ui, pane: &mut PortfolioPane, pf: &Portfolio, out: &mut Vec<P
);
}
let key = o.market_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
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 {
@@ -1181,22 +1102,13 @@ mod tests {
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, None, &quotes);
let _ = portfolio(ui, &mut pane, &b, &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, 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);
let _ = portfolio(ui, &mut pane, &b, &quotes);
});
out.textures_delta.clear();
}
@@ -1204,7 +1116,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, None, &quotes);
let _ = portfolio(ui, &mut pane, &b, &quotes);
});
out.textures_delta.clear();
}
+6 -4
View File
@@ -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 _ = crate::storage::remove(&f);
let _ = std::fs::remove_file(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(to) => format!("Exported {name:?} to {to}"),
Ok(()) => format!("Exported {name:?} to {}", path.display()),
Err(e) => format!("Export failed: {e:#}"),
};
}
@@ -551,7 +551,7 @@ impl TerminalApp {
}
"import" => {
let path = expand(self.theme_io.trim());
match themes::import_file(&path) {
match themes::load_file(&path) {
Ok(mut t) => {
t.name = themes::unique_name(&self.themes, &t.name);
t.source = None;
@@ -566,7 +566,9 @@ impl TerminalApp {
}
}
"folder" => {
if let Err(e) = crate::storage::reveal(&self.theme_store.dir) {
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() {
self.status = format!("Could not open {}: {e}", self.theme_store.dir.display());
}
}
+1 -2
View File
@@ -2,12 +2,11 @@
//! 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;
use std::time::{Duration, Instant};
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.
+367 -1008
View File
File diff suppressed because it is too large. Load diff
+8 -181
View File
@@ -37,109 +37,6 @@ 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 {
@@ -629,19 +526,6 @@ 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;
@@ -954,8 +838,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)))
});
let to = self.store.export(&name, &path)?;
Ok(format!("Exported {name:?} to {to}"))
self.store.export(&name, &path)?;
Ok(format!("Exported {name:?} to {}", path.display()))
}
LayoutOp::Import(path) => {
let name = self.store.import(&path)?;
@@ -964,7 +848,9 @@ impl TerminalApp {
Ok(format!("Imported {} as {name:?}", path.display()))
}
LayoutOp::OpenFolder => {
crate::storage::reveal(&self.store.dir)?;
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()?;
Ok(format!("Opened {}", self.store.dir.display()))
}
})();
@@ -982,13 +868,9 @@ impl TerminalApp {
if self.dock.is_empty() {
self.empty_state(ui, area, &mut cmds);
}
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);
let (groups, dividers) = self.dock.layout(area, gap);
for g in &groups {
self.render_group(ui, g, &mut actions, &mut cmds);
ui.set_opacity(opacity);
}
for d in &dividers {
let hit = d.rect.expand(2.0);
@@ -1248,12 +1130,7 @@ 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()
@@ -1445,7 +1322,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"))
.with_title(format!("{title} — terminal-rs"))
.with_inner_size([geom.w, geom.h])
.with_position([geom.x, geom.y])
.with_min_inner_size([320.0, 220.0]);
@@ -1594,54 +1471,4 @@ 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);
}
}
+78 -327
View File
@@ -8,12 +8,11 @@ use egui::{
vec2,
};
use tr_chart::drawings::{self, Anchor, Drawing, Shape, Tool};
use tr_chart::gpu::{ChartCallback, Frame, LineKind, LineRef, Reveal, Style};
use tr_chart::gpu::{ChartCallback, Frame, LineKind, LineRef, 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};
@@ -30,36 +29,6 @@ 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 {
@@ -86,13 +55,6 @@ 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,
@@ -106,8 +68,8 @@ pub struct ChartState {
hover_marker: Option<usize>,
/// Resolved security for `symbol`.
sec: Security,
last_live: Option<web_time::Instant>,
last_indicator_req: Option<web_time::Instant>,
last_live: Option<std::time::Instant>,
last_indicator_req: Option<std::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.
@@ -132,7 +94,7 @@ impl ChartState {
magnet: false,
show_volume: true,
show_congress: true,
indicators: crate::command::default_indicators(),
indicators: vec![IndicatorSpec::Sma(20), IndicatorSpec::Sma(50)],
drawings: Vec::new(),
compare: None,
bars: Arc::new(Vec::new()),
@@ -140,10 +102,6 @@ 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,
@@ -177,7 +135,6 @@ 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.
@@ -223,23 +180,7 @@ impl ChartState {
self.tf = r.timeframe();
self.load(w);
} else {
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 });
self.apply_range();
}
}
@@ -267,13 +208,6 @@ 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();
@@ -311,54 +245,25 @@ impl ChartState {
}
pub fn reset_view(&mut self) {
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;
});
if let Some(v) = self.viewport.as_mut() {
*v = Viewport::fit_last(self.bars.len(), DEFAULT_VISIBLE, v.width_px);
}
self.manual_scale = None;
}
pub fn pan_bars(&mut self, n: f64) {
self.glide(|s| {
if let Some(v) = s.viewport.as_mut() {
v.pan_bars(n);
v.clamp(s.bars.len());
}
});
if let Some(v) = self.viewport.as_mut() {
v.pan_bars(n);
v.clamp(self.bars.len());
}
}
pub fn zoom(&mut self, factor: f64) {
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 }
if let Some(v) = self.viewport.as_mut() {
let anchor = v.width_px * 0.85;
v.zoom(factor, anchor);
v.clamp(self.bars.len());
}
}
fn request_indicators(&self, w: &Worker) {
@@ -404,20 +309,10 @@ impl ChartState {
return;
}
if live {
self.last_live = Some(web_time::Instant::now());
self.last_live = Some(std::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;
@@ -443,7 +338,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(web_time::Instant::now());
self.last_indicator_req = Some(std::time::Instant::now());
}
if !live {
self.request_compare(w);
@@ -475,9 +370,6 @@ 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;
}
}
@@ -646,25 +538,12 @@ 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(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 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 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);
@@ -687,9 +566,6 @@ 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;
@@ -704,9 +580,6 @@ 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);
@@ -760,18 +633,14 @@ 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 mut pane = PricePane {
let out = scene::price_pane(&PricePane {
series: &gpu,
chart_type: self.chart_type,
show_volume: self.show_volume,
@@ -784,16 +653,7 @@ 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() {
@@ -826,17 +686,20 @@ 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 def = b.spec.def();
let fixed = matches!(b.spec, IndicatorSpec::Rsi(_)).then_some((0.0, 100.0));
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, def.and_then(|d| d.range), s);
let so = scene::sub_pane(&ovs, &v, r.height() as f64, fixed, s);
let p = ui.painter_at(r);
p.hline(r.x_range(), r.top(), Stroke::new(1.0, theme::border()));
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)));
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)),
);
}
}
let frame = Frame { pane: self.id * 8 + 1 + k as u64, frame_no, cmds: so.cmds };
@@ -873,7 +736,7 @@ impl ChartState {
axis_painter.text(
pos2(plot_all.right() + 6.0, y),
Align2::LEFT_CENTER,
scale::format_value(t),
format!("{t:.1}"),
mono(10.5),
muted(),
);
@@ -896,11 +759,8 @@ impl ChartState {
}
}
// 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
{
// Last price line + axis tag.
if let Some(last) = self.bars.last() {
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() };
@@ -909,18 +769,18 @@ impl ChartState {
price_painter.hline(
xx..=(xx + 3.0).min(price_rect.right()),
y,
Stroke::new(1.0, col.gamma_multiply(0.6 * tag_fade)),
Stroke::new(1.0, col.gamma_multiply(0.6)),
);
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.gamma_multiply(tag_fade));
axis_painter.rect_filled(tag, 0.0, col);
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.gamma_multiply(tag_fade),
Color32::BLACK,
);
}
}
@@ -931,7 +791,7 @@ impl ChartState {
// ---- congress markers ----
self.hover_marker = None;
if self.show_congress {
self.paint_markers(ui, price_rect, &v, &scale, hover, &reveal);
self.paint_markers(ui, price_rect, &v, &scale, hover);
}
// ---- crosshair + legend ----
@@ -966,39 +826,11 @@ 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() {
@@ -1014,40 +846,21 @@ impl ChartState {
}
});
ui.menu_button("Indicators", |ui| {
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);
}
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.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();
@@ -1110,12 +923,20 @@ 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
let vals: Vec<String> =
bi.overlays.iter().filter_map(|(_, o)| value_at(&o.line).map(|v| format!("{v:.2}"))).collect();
let c = bi
.overlays
.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));
.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());
p.text(
pos2(price_rect.left() + 6.0, ly),
Align2::LEFT_TOP,
@@ -1125,13 +946,6 @@ 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);
@@ -1159,17 +973,15 @@ impl ChartState {
}
for (r, bi, _) in sub_rects {
let b = &self.built[*bi];
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;
}
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(),
);
}
}
@@ -1287,27 +1099,16 @@ impl ChartState {
}
}
/// 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,
) {
fn paint_markers(&mut self, ui: &Ui, rect: Rect, v: &Viewport, s: &PriceScale, hover: Option<Pos2>) {
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 full_r = (v.bar_px as f32 * 0.45).clamp(4.0, 7.0);
let 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 };
@@ -1320,15 +1121,7 @@ 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 + 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 off = 6.0 + r + *n as f32 * (2.0 * r + 1.0);
let y = if sell {
rect.top() + s.y_of(bar.high) as f32 - off
} else {
@@ -1338,14 +1131,13 @@ impl ChartState {
let c = Pos2::new(x, y);
let col = theme::party_color(t.party);
p.circle_filled(c, r, col);
if full_r >= 5.0 {
if r >= 5.0 {
let letter = match t.tx_type {
TxType::Purchase => "B",
TxType::Sale | TxType::SalePartial => "S",
_ => "E",
};
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));
p.text(c, Align2::CENTER_CENTER, letter, mono(r * 1.6), Color32::WHITE);
} else {
p.circle_stroke(c, r, Stroke::new(1.0, Color32::WHITE));
}
@@ -1364,11 +1156,6 @@ 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 {
@@ -1434,39 +1221,3 @@ 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)));
}
}
+63 -112
View File
@@ -1,16 +1,49 @@
//! Bloomberg-style command line: `<SECURITY> [<FUNCTION> args] <GO>`.
//!
//! 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`.
//! Examples: `AAPL`, `AAPL US EQUITY <GO>`, `5`, `1W`, `RSI`, `SMA 50`, `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};
/// 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()
#[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,
}
}
}
/// Views of the brokerage portfolio pane (`PORT`, `ACCT`, `POS`, `ORD`, `ACTV`).
@@ -64,8 +97,6 @@ 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 {
@@ -105,18 +136,6 @@ 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() {
@@ -210,10 +229,7 @@ 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,
@@ -255,8 +271,6 @@ 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).
@@ -273,8 +287,6 @@ 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> {
@@ -290,13 +302,19 @@ 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),
// `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()),
}
}
"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),
)),
"CLEAR" | "CLR" => match words().first().map(String::as_str) {
Some("DRAW") | Some("DRAWINGS") => Function::ClearDrawings,
_ => Function::ClearIndicators,
@@ -339,7 +357,6 @@ 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,
@@ -358,22 +375,14 @@ 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`),
// then the indicator catalog (`RSI`, `BB 20 2.5`).
// Timeframes win where spellings overlap (`1D`, `1M`); otherwise try a range (`5D`, `YTD`).
_ => {
return head
.parse::<Timeframe>()
.ok()
.map(Function::Timeframe)
.or_else(|| Range::parse(&up).map(Function::Range))
.or_else(|| IndicatorSpec::parse(head, rest).map(Function::Indicator));
.or_else(|| Range::parse(&up).map(Function::Range));
}
})
}
@@ -394,21 +403,6 @@ 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)),
}
}
@@ -444,9 +438,7 @@ fn describe(f: &Function) -> String {
}
),
Function::ChartType(c) => format!("Chart type {}", c.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::Indicator(i) => format!("Toggle {}", i.label()),
Function::ClearIndicators => "Remove all indicators".into(),
Function::Compare(Some(s)) => format!("Compare with {s}"),
Function::Compare(None) => "Remove comparison".into(),
@@ -485,7 +477,6 @@ 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(),
@@ -527,13 +518,8 @@ fn describe(f: &Function) -> String {
if let Some(p) = t.limit {
s += &format!(" limit {p}");
}
if t.all {
s += " (whole position)";
}
s
s + " (preview and confirm before anything is sent)"
}
Function::AccountMode(false) => "Switch to the DEMO account (paper trading)".into(),
Function::AccountMode(true) => "Switch to LIVE brokerage accounts (asks first)".into(),
}
}
@@ -574,19 +560,10 @@ mod tests {
#[test]
fn functions() {
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("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)))));
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))));
@@ -616,7 +593,6 @@ 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)));
@@ -685,15 +661,6 @@ 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]
@@ -723,24 +690,8 @@ mod tests {
#[test]
fn indicator_identity() {
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}");
}
}
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());
}
}
+1 -10
View File
@@ -9,10 +9,6 @@ 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 {
@@ -196,12 +192,7 @@ 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() {
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());
plain(ui, "No disclosures yet. Ingest runs in the background (REFRESH to force).", muted());
}
let row_h = 16.0;
ScrollArea::both().id_salt("feed").auto_shrink([false, false]).show_rows(ui, row_h, rows.len(), |ui, range| {
+28 -17
View File
@@ -16,7 +16,6 @@ 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;
@@ -268,7 +267,7 @@ pub fn chart_settings(symbol: &str, d: &crate::prefs::PanelDefaults) -> PaneSett
tf: d.timeframe,
chart_type: d.chart_type,
log: false,
indicators: crate::command::default_indicators(),
indicators: vec![IndicatorSpec::Sma(20), IndicatorSpec::Sma(50)],
drawings: Vec::new(),
compare: None,
show_volume: d.show_volume,
@@ -277,6 +276,16 @@ 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,
@@ -305,17 +314,19 @@ 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> = storage::list(&self.dir)
let mut names: Vec<String> = std::fs::read_dir(&self.dir)
.into_iter()
.filter(|p| p.extension().is_some_and(|x| x == "json"))
.filter_map(|p| {
let bytes = storage::read(&p).ok()?;
.flatten()
.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
.filter_map(|e| {
let bytes = std::fs::read(e.path()).ok()?;
let doc: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
Some(
doc["name"]
.as_str()
.map(str::to_string)
.unwrap_or_else(|| p.file_stem().unwrap_or_default().to_string_lossy().into_owned()),
.unwrap_or_else(|| e.path().file_stem().unwrap_or_default().to_string_lossy().into_owned()),
)
})
.collect();
@@ -332,12 +343,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 storage::exists(&p) {
if p.exists() {
return Some(p);
}
storage::list(&self.dir).into_iter().find(|p| {
std::fs::read_dir(&self.dir).ok()?.flatten().map(|e| e.path()).find(|p| {
p.extension().is_some_and(|x| x == "json")
&& storage::read(p)
&& std::fs::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)))
@@ -346,7 +357,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(&storage::read(&path)?).with_context(|| format!("{}", path.display()))?;
let mut doc = LayoutDoc::from_json(&std::fs::read(&path)?).with_context(|| format!("{}", path.display()))?;
if doc.name.trim().is_empty() {
doc.name = name.to_string();
}
@@ -355,13 +366,13 @@ impl Store {
pub fn save(&self, doc: &LayoutDoc) -> Result<PathBuf> {
let path = self.path(&doc.name);
storage::write_atomic(&path, &doc.to_json()).with_context(|| format!("writing {}", path.display()))?;
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:?}"))?;
storage::remove(&path)?;
std::fs::remove_file(path)?;
Ok(())
}
@@ -400,16 +411,16 @@ impl Store {
(2..).map(|i| format!("{base} {i}")).find(|n| !self.exists(n)).expect("unbounded")
}
/// 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> {
/// Write a layout to an arbitrary path.
pub fn export(&self, name: &str, to: &Path) -> Result<()> {
let doc = self.load(name)?;
storage::export(to, &doc.to_json()).with_context(|| format!("writing {}", to.display()))
write_atomic(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(&storage::import(from).with_context(|| format!("reading {}", from.display()))?)?;
LayoutDoc::from_json(&std::fs::read(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();
}
+15 -49
View File
@@ -12,58 +12,13 @@ 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 app::TerminalApp;
use web_time::Instant;
use std::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-rs 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()
@@ -80,9 +35,8 @@ 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")
.with_title("terminal-rs")
.with_app_id("terminal-rs")
.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") {
@@ -103,6 +57,18 @@ fn main() -> eframe::Result {
eframe::run_native(
"terminal-rs",
options,
Box::new(move |cc| Ok(Box::new(create_app(cc, tr_data::from_env(), u32::from(msaa), t0)?))),
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)))
}),
)
}
+3 -52
View File
@@ -25,7 +25,6 @@ pub enum Kind {
Theme,
Settings,
Action,
Indicator,
}
impl Kind {
@@ -38,7 +37,6 @@ impl Kind {
Kind::Theme => "THEME",
Kind::Settings => "SETTINGS",
Kind::Action => "ACTION",
Kind::Indicator => "STUDY",
}
}
}
@@ -173,8 +171,6 @@ pub struct Palette {
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>,
@@ -183,7 +179,7 @@ pub struct Palette {
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();
let recents = std::fs::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() }
}
@@ -192,18 +188,6 @@ impl Palette {
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) {
@@ -230,7 +214,7 @@ impl Palette {
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)
&& let Err(e) = crate::layouts::write_atomic(path, &json)
{
tracing::warn!("saving palette recents: {e}");
}
@@ -238,22 +222,7 @@ impl Palette {
/// 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))
rank(items, &self.recents, &self.query)
}
}
@@ -395,24 +364,6 @@ mod tests {
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();
+6 -30
View File
@@ -194,23 +194,11 @@ impl Default for PanelDefaults {
}
}
/// 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.
/// Order entry. Disabled by default; enabling it in Settings › Trading 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>,
@@ -231,8 +219,6 @@ pub enum Action {
Portfolio,
Positions,
Orders,
Buy,
Sell,
NextPane,
PrevPane,
ToggleSidebar,
@@ -276,7 +262,7 @@ pub enum Action {
}
impl Action {
pub const ALL: [Action; 52] = [
pub const ALL: [Action; 50] = [
Action::Help,
Action::Congress,
Action::Politicians,
@@ -287,8 +273,6 @@ impl Action {
Action::Portfolio,
Action::Positions,
Action::Orders,
Action::Buy,
Action::Sell,
Action::NextPane,
Action::PrevPane,
Action::ToggleSidebar,
@@ -343,8 +327,6 @@ 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(),
@@ -406,13 +388,11 @@ impl Action {
| A::ResetView
| A::LogScale
| A::Magnet => "Chart",
A::Buy | A::Sell => "Trading",
_ => "Panels and data",
}
}
pub const CATEGORIES: [&'static str; 6] =
["New panels", "Panels and data", "Trading", "Workspace", "Layouts", "Chart"];
pub const CATEGORIES: [&'static str; 5] = ["New panels", "Panels and data", "Workspace", "Layouts", "Chart"];
/// The panel command word for `New…` actions.
pub fn panel_spec(self) -> Option<&'static str> {
@@ -582,9 +562,6 @@ 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" });
@@ -684,8 +661,7 @@ impl Default for Prefs {
impl Prefs {
pub fn load(path: &std::path::Path) -> Prefs {
let mut p: Prefs =
crate::storage::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
let mut p: Prefs = std::fs::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);
@@ -757,7 +733,7 @@ impl Prefs {
}
pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> {
crate::storage::write_atomic(path, &serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?)
crate::layouts::write_atomic(path, &serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?)
}
}
-173
View File
@@ -1,173 +0,0 @@
//! 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::*;
#[cfg(not(target_arch = "wasm32"))]
mod imp {
use super::*;
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"))
}
fn key(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
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();
}
}
+1 -40
View File
@@ -53,9 +53,6 @@ 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).
@@ -179,49 +176,13 @@ 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 = base_fonts();
let mut fonts = FontDefinitions::default();
let ui_paths: &[&str] = &[
"/System/Library/Fonts/SFNS.ttf",
"/System/Library/Fonts/SFNSText.ttf",
+12 -20
View File
@@ -10,7 +10,6 @@ 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.
@@ -562,8 +561,11 @@ 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> = storage::list(&self.dir)
let mut files: Vec<PathBuf> = std::fs::read_dir(&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();
@@ -604,7 +606,7 @@ impl ThemeStore {
.clone()
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
.unwrap_or_else(|| self.path(&theme.name));
storage::write_atomic(&path, theme.to_toml().as_bytes())
crate::layouts::write_atomic(&path, theme.to_toml().as_bytes())
.with_context(|| format!("writing {}", path.display()))?;
Ok(path)
}
@@ -615,33 +617,23 @@ impl ThemeStore {
bail!("built-in themes cannot be deleted");
}
let path = theme.source.clone().unwrap_or_else(|| self.path(&theme.name));
if !storage::exists(&path) {
if !path.exists() {
bail!("no file for theme {:?}", theme.name);
}
Ok(storage::remove(&path)?)
Ok(std::fs::remove_file(path)?)
}
/// 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> {
/// Write a theme to `to` as JSON (`.json`) or TOML (anything else).
pub fn export(theme: &Theme, to: &Path) -> Result<()> {
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() };
storage::export(to, text.as_bytes()).with_context(|| format!("writing {}", to.display()))
crate::layouts::write_atomic(to, text.as_bytes()).with_context(|| format!("writing {}", to.display()))
}
}
/// Read a TOML or JSON theme file from the themes folder.
/// Read a TOML or JSON theme file.
pub fn load_file(path: &Path) -> Result<Theme> {
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 text = std::fs::read_to_string(path).with_context(|| format!("reading {}", 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
@@ -1,186 +0,0 @@
//! 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,35 +265,6 @@ 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());
@@ -304,57 +275,6 @@ 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::*;
+69 -138
View File
@@ -1,30 +1,22 @@
//! 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, marker_ref};
use tr_chart::scene::{Overlay, SeriesGpu, line_ref};
use tr_congress::db::{Alert, PoliticianSummary};
use tr_congress::model::NewTradeAlert;
use tr_congress::performance::{self, Performance, TradeReturn};
#[cfg(not(target_arch = "wasm32"))]
use tr_congress::refresh::{self, HttpSource, RefreshConfig};
use tr_congress::refresh::{self, HttpSource, NewTradeAlert, RefreshConfig};
use tr_congress::{Db, Politician, Trade};
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 tr_core::{Bar, Security, Series, Timeframe, indicators};
use tr_data::{BarCache, Provider, Quote, SearchHit, YieldCurves};
use crate::command::IndicatorSpec;
use crate::theme;
@@ -156,8 +148,7 @@ pub enum TradeRequest {
creds: tr_broker::Credentials,
dark: bool,
},
/// Which brokerage connections can trade (brokerage support and connection access): one
/// `/authorizations` request, however many accounts there are.
/// Which accounts can trade (brokerage support and connection access).
Capabilities {
creds: tr_broker::Credentials,
},
@@ -179,17 +170,7 @@ pub enum TradeResponse {
result: Result<(), String>,
},
Portal(Result<String, 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>,
Capabilities(Result<std::collections::HashMap<String, tr_broker::trading::Capability>, String>),
}
pub struct BuiltIndicator {
@@ -276,11 +257,8 @@ 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 {
@@ -293,7 +271,6 @@ 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,
}
@@ -305,7 +282,6 @@ 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>,
@@ -319,12 +295,7 @@ impl Shared {
}
}
/// Where saved files live. In the browser this is the key prefix in `localStorage` (see
/// [`crate::storage`]).
pub fn data_dir() -> PathBuf {
if cfg!(target_arch = "wasm32") {
return PathBuf::from("terminal-rs");
}
if let Some(d) = std::env::var_os("TR_DATA_DIR") {
return PathBuf::from(d);
}
@@ -340,7 +311,6 @@ pub fn data_dir() -> PathBuf {
}
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)?;
@@ -395,28 +365,11 @@ 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() {
@@ -435,80 +388,61 @@ async fn congress_refresh(s: &Shared) {
}
}
// 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 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,
}
}
}
#[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 }
Overlay { line: line_ref(values), 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 input = Input::new(bars, tf);
let colors = palette.series.len();
let closes: Vec<f64> = bars.iter().map(|b| b.close).collect();
specs
.iter()
.enumerate()
.map(|(i, spec)| {
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 }
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 }
})
.collect()
}
@@ -551,7 +485,8 @@ 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.
if let Some(bars) = cached_bars(s, &symbol, tf, limit).filter(|b| !b.is_empty()) {
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()) {
let bars = Arc::new(bars);
let gpu = build_gpu(bars.clone()).await;
s.send(Response::History {
@@ -569,7 +504,11 @@ async fn handle(s: &Arc<Shared>, req: Request) {
}
match s.provider.history(&symbol, tf, None, limit).await {
Ok(bars) if !bars.is_empty() => {
cache_bars(s, &symbol, tf, &bars);
if let Ok(mut c) = s.cache.lock()
&& let Some(c) = c.as_mut()
{
let _ = c.store(&symbol, tf, &bars);
}
let exhausted = bars.len() < limit / 2;
let bars = Arc::new(bars);
if !store_series(s, pane, generation, bars.clone()) {
@@ -629,15 +568,17 @@ async fn handle(s: &Arc<Shared>, req: Request) {
}
}
Request::Indicators { pane, generation, bars, tf, specs, palette } => {
let built = rt::blocking(move || build_indicators(&bars, tf, &specs, &palette)).await.unwrap_or_default();
let built = tokio::task::spawn_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 = rt::blocking(move || line_ref(&align_compare(&base, &other))).await;
if let Some(line) = line {
let line = tokio::task::spawn_blocking(move || line_ref(&align_compare(&base, &other))).await;
if let Ok(line) = line {
s.send(Response::Compare { pane, generation, symbol, line });
}
}
@@ -786,11 +727,8 @@ async fn trade(t: TradeRequest) -> TradeResponse {
Err(e) => Err(e),
}),
TradeRequest::Capabilities { creds } => TradeResponse::Capabilities(match client(creds) {
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 }),
Ok(c) => c.capabilities().await.map_err(err),
Err(e) => Err(e),
}),
}
}
@@ -829,8 +767,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 };
rt::spawn(async move {
let mut tick = rt::interval(Duration::from_millis(250));
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_millis(250));
let mut dirty = false;
loop {
tokio::select! {
@@ -872,7 +810,7 @@ fn start_live(s: Arc<Shared>, pane: PaneId, generation: u64, symbol: String, tf:
}
async fn build_gpu(bars: Arc<Vec<Bar>>) -> SeriesGpu {
rt::blocking(move || SeriesGpu::build(&bars)).await.expect("pyramid build task")
tokio::task::spawn_blocking(move || SeriesGpu::build(&bars)).await.expect("pyramid build task")
}
fn resolve_politician(db: &Db, query: &str) -> Option<Politician> {
@@ -955,22 +893,15 @@ mod tests {
#[test]
fn indicators_build_expected_overlays() {
let bars = tr_core::sample::generate("X", Timeframe::D1, 300, 1_790_000_000);
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);
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),
);
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
@@ -1,74 +0,0 @@
<!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,13 +20,6 @@ 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
@@ -1,586 +0,0 @@
//! 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
}
+1 -2
View File
@@ -1,13 +1,12 @@
//! Brokerage integration through [SnapTrade](https://snaptrade.com): a signed API client, a
//! normalised portfolio model (accounts, balances, positions, orders, activity), secure
//! credential storage, an offline simulated portfolio and a local paper-trading [`demo`] account.
//! credential storage and an offline simulated portfolio.
//!
//! 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;
-3
View File
@@ -331,9 +331,6 @@ 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,7 +215,6 @@ pub fn portfolio() -> Portfolio {
fetched: Some(now),
warnings: Vec::new(),
simulated: true,
demo: false,
}
}
+3 -188
View File
@@ -10,8 +10,7 @@
//! 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::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail};
use base64::Engine;
@@ -33,20 +32,6 @@ 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,
@@ -135,49 +120,6 @@ 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 {
@@ -188,18 +130,6 @@ 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 {
@@ -210,6 +140,7 @@ 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()) {
@@ -219,36 +150,6 @@ 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> {
@@ -256,7 +157,6 @@ 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");
@@ -269,14 +169,6 @@ impl SnapTrade {
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
}
@@ -302,9 +194,6 @@ 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,
@@ -312,54 +201,6 @@ 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());
@@ -371,20 +212,9 @@ 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() {
// 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());
return Err(ApiError { status: status.as_u16(), message: error_message(status, &text) }.into());
}
if text.trim().is_empty() {
return Ok(Value::Null);
@@ -570,19 +400,4 @@ 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);
}
}
+9 -25
View File
@@ -427,11 +427,10 @@ impl Capability {
}
}
/// 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
/// 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
.as_array()
.into_iter()
.flatten()
@@ -453,13 +452,7 @@ pub fn parse_connection_capabilities(authorizations: &Value) -> std::collections
},
))
})
.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);
.collect();
accounts
.as_array()
.into_iter()
@@ -505,9 +498,6 @@ 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),
@@ -559,11 +549,10 @@ 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()))?;
// 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 {
match self
.call(Method::POST, &format!("/trade/{id}"), &[], Some(json!({ "wait_to_confirm": true })), true)
.await
{
Ok(v) => Ok(parse_placed(&preview.request.account_id, &v)),
Err(e) => Err(classify_place_error(e)),
}
@@ -577,11 +566,6 @@ 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!(
-272
View File
@@ -1,272 +0,0 @@
//! 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
@@ -1,175 +0,0 @@
//! 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-rs-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 -5
View File
@@ -6,9 +6,8 @@ edition.workspace = true
[dependencies]
tr-core.workspace = true
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 }
egui = "0.36"
egui-wgpu = "0.36"
bytemuck = { version = "1", features = ["derive"] }
chrono.workspace = true
serde.workspace = true
@@ -16,8 +15,6 @@ 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,32 +28,6 @@ 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.
@@ -86,7 +60,6 @@ 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 {
@@ -104,7 +77,6 @@ impl Default for Style {
hist_width_bars: 0.7,
baseline: 0.0,
pixels_per_point: 1.0,
reveal: Reveal::DONE,
}
}
}
@@ -112,9 +84,6 @@ 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 }],
@@ -128,7 +97,6 @@ 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],
}
}
}
@@ -153,10 +121,6 @@ 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)]
@@ -188,8 +152,6 @@ enum Pipe {
Line,
Area,
Hist,
Dots,
Steps,
}
struct Resolved {
@@ -205,8 +167,6 @@ 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,
@@ -317,8 +277,6 @@ 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,
@@ -394,8 +352,6 @@ 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)
}
@@ -420,8 +376,6 @@ 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]);
+1 -1
View File
@@ -2,7 +2,7 @@
//!
//! - [`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, dots)
//! - [`gpu`]: wgpu pipelines (candles, OHLC bars, volume, lines, areas, histograms)
//! and the egui paint-callback adapter.
//! - [`scale`]: axis ticks and labels. [`drawings`]: user drawing model and hit testing.
+2 -29
View File
@@ -133,25 +133,15 @@ 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) {
let p = if j + 1 < n { merge(data[start + j], data[start + j + 1]) } else { data[start + j] };
// 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] };
data.push(p);
}
offsets.push(next_start);
@@ -261,21 +251,4 @@ 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,19 +60,6 @@ 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 {
@@ -165,14 +152,6 @@ 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);
+15 -173
View File
@@ -81,11 +81,6 @@ 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 {
@@ -148,7 +143,7 @@ pub fn visible_extent(
}
_ => p.extent(k, lo, hi)?,
};
for o in overlays.iter().filter(|o| matches!(o.kind, LineKind::Line | LineKind::Dots | LineKind::Steps)) {
for o in overlays.iter().filter(|o| o.kind == LineKind::Line) {
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);
@@ -158,24 +153,6 @@ 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;
@@ -183,7 +160,18 @@ 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(|| autoscale_from(p, ext));
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 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;
@@ -255,9 +243,8 @@ fn overlay_cmds(overlays: &[Overlay], v: &Viewport, t: &PaneTransform, base: &St
}
}
let range = match o.kind {
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;
LineKind::Hist => {
s.hist_width_bars = (base.body_frac as f64 * (1u64 << k) as f64) as f32;
let n = lp.level_len(k);
let off = lp.offsets[k];
let a = (lo >> k).min(n);
@@ -374,68 +361,6 @@ 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();
@@ -446,87 +371,4 @@ 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);
}
}
}
+8 -36
View File
@@ -14,8 +14,6 @@ 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;
@@ -57,24 +55,11 @@ 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 = shade(color);
out.color = vec4<f32>(color.rgb * color.a, color.a);
return out;
}
@@ -110,16 +95,7 @@ struct Bar {
@vertex
fn vs_candle(@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();
}
// 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 color = select(u.down, u.up, b.close >= b.open);
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);
@@ -127,38 +103,34 @@ 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(low), max(y_ndc(high), y_ndc(low) + one_px), color);
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);
}
if (r == 1u) {
let y0 = y_ndc(min(b.open, close));
let y1 = max(y_ndc(max(b.open, close)), y0 + one_px);
let y0 = y_ndc(min(b.open, b.close));
let y1 = max(y_ndc(max(b.open, b.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(low), max(y_ndc(high), y_ndc(low) + one_px), color);
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);
}
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(close);
let y = y_ndc(b.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 * g;
let h = clamp(b.volume / max(u.extra.y, 1e-30), 0.0, 1.0) * u.extra.x * 2.0;
return rect(vi, cx - half, cx + half, -1.0, -1.0 + h, color);
}
+9 -52
View File
@@ -15,24 +15,14 @@ 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];
if (!(finite(a.y) && finite(pts[ii + 1u].y)) || a.x - u.rect.z >= u.anim.z) {
let b = pts[ii + 1u];
if (!(finite(a.y) && finite(b.y))) {
return degenerate();
}
let b = traced(a, pts[ii + 1u]);
let pa = to_px(a);
let pb = to_px(b);
var d = pb - pa;
@@ -46,7 +36,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 = shade(u.color);
out.color = vec4<f32>(u.color.rgb * u.color.a, u.color.a);
return out;
}
@@ -54,18 +44,19 @@ 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];
if (!(finite(a.y) && finite(pts[ii + 1u].y)) || a.x - u.rect.z >= u.anim.z) {
let b = pts[ii + 1u];
if (!(finite(a.y) && finite(b.y))) {
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 = shade(vec4<f32>(u.color.rgb, u.color.a * mix(0.15, 1.0, c.y)));
out.color = vec4<f32>(u.color.rgb * alpha, alpha);
return out;
}
@@ -73,45 +64,11 @@ 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];
let g = grow(a.x);
if (!finite(a.y) || g <= 0.0) {
if (!finite(a.y)) {
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(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);
return rect(vi, cx - half, cx + half, y_ndc(u.extra.w), y_ndc(a.y), color);
}
+1 -128
View File
@@ -1,6 +1,6 @@
//! Renders real frames offscreen (any adapter, including software Vulkan) and checks pixels.
use tr_chart::gpu::{Frame, LineKind, Reveal, Style};
use tr_chart::gpu::{Frame, LineKind, 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,130 +73,3 @@ 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");
}
+2 -5
View File
@@ -6,14 +6,11 @@ edition.workspace = true
[dependencies]
anyhow.workspace = true
thiserror.workspace = true
chrono.workspace = true
serde.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
regex.workspace = true
tokio.workspace = true
reqwest.workspace = true
rusqlite.workspace = true
+20 -1
View File
@@ -7,7 +7,6 @@ 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#"
@@ -112,6 +111,17 @@ 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,
@@ -120,6 +130,15 @@ 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
@@ -1,60 +0,0 @@
//! 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,23 +1,12 @@
//! 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,36 +243,6 @@ 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::*;
+8 -1
View File
@@ -12,7 +12,6 @@ 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};
@@ -59,6 +58,14 @@ 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,
-1
View File
@@ -10,7 +10,6 @@ serde.workspace = true
[dev-dependencies]
criterion.workspace = true
serde_json.workspace = true
[[bench]]
name = "indicators"
-14
View File
@@ -1,6 +1,5 @@
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) {
@@ -15,19 +14,6 @@ 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
@@ -0,0 +1,289 @@
//! 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
@@ -1,176 +0,0 @@
//! 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
@@ -1,491 +0,0 @@
//! 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
@@ -1,209 +0,0 @@
//! 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
@@ -1,655 +0,0 @@
//! 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
@@ -1,372 +0,0 @@
//! 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
@@ -1,521 +0,0 @@
//! 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
@@ -1,307 +0,0 @@
//! 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
@@ -1,395 +0,0 @@
//! 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>()));
}
}
+2 -16
View File
@@ -10,29 +10,15 @@ 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"] }
+32 -36
View File
@@ -6,12 +6,13 @@ 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::ws::Socket;
use crate::{LiveFeed, Quote, rt};
use crate::{LiveFeed, Quote};
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"];
@@ -185,37 +186,25 @@ 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();
// 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-rs/", env!("CARGO_PKG_VERSION"))).timeout(TIMEOUT);
Ok(Self { client: client.build()? })
let client = reqwest::Client::builder()
.user_agent(concat!("terminal-rs/", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(20))
.build()?;
Ok(Self { client })
}
async fn get(&self, url: &str, query: &[(&str, String)]) -> Result<String> {
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
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?)
}
async fn binance_history(
@@ -374,7 +363,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);
rt::spawn(async move {
tokio::spawn(async move {
let mut backoff = 1;
let mut attempt = 0usize;
while !tx.is_closed() {
@@ -389,7 +378,7 @@ impl CryptoProvider {
tracing::debug!("live feed {stream}: {e:#}");
}
attempt += 1;
rt::sleep(Duration::from_secs(backoff)).await;
tokio::time::sleep(Duration::from_secs(backoff)).await;
backoff = (backoff * 2).min(30);
}
});
@@ -398,25 +387,32 @@ impl CryptoProvider {
}
async fn binance_ws(url: &str, tx: &mpsc::Sender<Bar>) -> Result<()> {
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(());
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,
_ => {}
}
}
bail!("binance websocket closed")
}
async fn coinbase_ws(product: &str, tf: Timeframe, tx: &mpsc::Sender<Bar>) -> Result<()> {
let mut ws = Socket::connect(COINBASE_WS).await.context("coinbase websocket")?;
let (mut ws, _) = tokio_tungstenite::connect_async(COINBASE_WS).await.context("coinbase websocket")?;
let sub = serde_json::json!({"type": "subscribe", "product_ids": [product], "channels": ["ticker"]});
ws.send(sub.to_string()).await?;
ws.send(Message::Text(sub.to_string().into())).await?;
// Aggregate ticks into the current bar; the chart merges partial bars into history.
let mut bar: Option<Bar> = None;
while let Some(text) = ws.next().await {
let Some((ts, px, size)) = parse_coinbase_ws(&text?) else { continue };
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 };
let bucket = tf.bucket_start(ts);
let b = match bar.as_mut() {
Some(b) if b.ts == bucket => {
-26
View File
@@ -11,26 +11,14 @@
//!
//! 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;
@@ -41,7 +29,6 @@ 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.
@@ -397,7 +384,6 @@ 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();
@@ -418,18 +404,6 @@ 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
@@ -1,89 +0,0 @@
//! 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, rt, security};
use crate::{LiveFeed, MarketData, OptionChain, OrderBook, 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);
rt::spawn(async move {
let mut iv = rt::interval(Duration::from_millis(400));
tokio::spawn(async move {
let mut iv = tokio::time::interval(Duration::from_millis(400));
loop {
iv.tick().await;
let now = chrono::Utc::now().timestamp();
-96
View File
@@ -1,96 +0,0 @@
//! 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
@@ -1,125 +0,0 @@
//! 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();
}
}
}
+2 -7
View File
@@ -1,12 +1,12 @@
[package]
name = "tr-macos"
description = "macOS window chrome for terminal-rs: unified toolbar, vibrancy, accent colour, updates"
description = "macOS window chrome for terminal-rs: unified toolbar, vibrancy, accent colour"
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", "NSBundle"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSGeometry", "NSObject"] }
objc2-app-kit = { version = "0.3", default-features = false, features = [
"std",
"NSResponder",
@@ -20,10 +20,5 @@ objc2-app-kit = { version = "0.3", default-features = false, features = [
"NSAppearance",
"NSButton",
"NSControl",
"NSWorkspace",
"NSAccessibility",
"NSApplication",
"NSMenu",
"NSMenuItem",
"objc2-core-foundation",
] }
+1 -20
View File
@@ -6,9 +6,6 @@
//! - [`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;
@@ -33,7 +30,7 @@ mod imp {
use objc2_app_kit::{
NSAutoresizingMaskOptions, NSColor, NSColorSpace, NSToolbar, NSView, NSVisualEffectBlendingMode,
NSVisualEffectMaterial, NSVisualEffectState, NSVisualEffectView, NSWindowButton, NSWindowOrderingMode,
NSWindowTitleVisibility, NSWindowToolbarStyle, NSWorkspace,
NSWindowTitleVisibility, NSWindowToolbarStyle,
};
use objc2_foundation::NSString;
@@ -108,10 +105,6 @@ 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.
@@ -160,18 +153,6 @@ 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
@@ -1,103 +0,0 @@
//! 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-rs/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,7 +23,6 @@ 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,25 +32,3 @@
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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

-41
View File
@@ -1,41 +0,0 @@
#!/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
@@ -1,79 +0,0 @@
#!/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-rs.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-rs "$@"
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
+7 -33
View File
@@ -1,41 +1,21 @@
#!/usr/bin/env bash
# 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-rs 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.
# Wrap the release binary in a minimal macOS .app bundle (retina-capable, dark-mode aware).
set -euo pipefail
cd "$(dirname "$0")/.."
version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
bin=${1:-target/release/terminal-rs}
app=$(dirname "$bin")/Terminal.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-rs/releases/latest/download/appcast.xml
sparkle_key=Kq1jkRyRLHyfeNeAv3Fl7we7ARPnyuPh+MI/XSZV77U=
sparkle=$(scripts/fetch-sparkle.sh)
app=target/release/terminal-rs.app
rm -rf "$app"
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
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
cp target/release/terminal-rs "$app/Contents/MacOS/terminal-rs"
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</string>
<key>CFBundleDisplayName</key><string>Terminal</string>
<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</string>
<key>CFBundleIconFile</key><string>AppIcon</string>
<key>CFBundleExecutable</key><string>terminal-rs</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>${version}</string>
<key>CFBundleVersion</key><string>${version}</string>
@@ -43,13 +23,7 @@ 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
@@ -1,29 +0,0 @@
#!/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"
-162
View File
@@ -1,162 +0,0 @@
#!/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
#
# 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-rs
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-rs
done
out=target/universal-apple-darwin/release
mkdir -p "$out"
rm -f "$out"/Terminal-*.zip "$out/appcast.xml"
lipo -create -output "$out/terminal-rs" \
target/aarch64-apple-darwin/release/terminal-rs target/x86_64-apple-darwin/release/terminal-rs
scripts/bundle-macos.sh "$out/terminal-rs"
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
@@ -1,29 +0,0 @@
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
-1
View File
@@ -1 +0,0 @@
engine-strict=true
-117
View File
@@ -1,117 +0,0 @@
# site
The download page for Terminal. It is one static page built with SvelteKit for Vercel, plus a
`/download` route that redirects to the latest release DMG and saves the email visitors give before
downloading.
This folder is its own npm project, separate from the Cargo workspace. Run the commands below from
`site/`, and set the Vercel project's Root Directory to `site`. It needs Node 22.17 or later, the
minimum SvelteKit supports; `.npmrc` turns on `engine-strict`, so `npm install` stops on older
versions.
```sh
cd site
npm install
npm run dev # http://localhost:5173
npm run build # Vercel output in .vercel/output
npm run preview # serve the production build on http://localhost:4173
npm run check
```
## How it fits together
- `src/routes/+page.svelte` is the page. It is prerendered and then hydrated, because the demo and
the feature cards animate in the browser. Without JavaScript the page still reads: the demo
shows the app window and every section is visible.
- `src/lib/demo/` is the hero demo. `script.ts` holds the 20-second timeline: when each command is
typed, the camera moves, the captions and the sound cues. `Scene.svelte` draws one frame for a
given time over the app screenshot, `player.svelte.ts` advances the time, and `sound.ts`
synthesizes the sounds with the Web Audio API, so there are no audio files. The demo plays muted
on a loop while it is on screen; "Play with sound" restarts it once with sound.
- `src/lib/cards/` holds the feature card animations. Each one loops only while it is on screen.
- With reduced motion, the demo and cards hold one still frame and nothing fades in on scroll.
- The theme button in the nav switches the page, the demo and the cards between dark and light,
and remembers the choice. Dark is the default. The page colors are in `src/app.css`, including
the app's own Terminal Dark and Terminal Light colors under `.app`.
- `src/lib/site.ts` holds the version, architecture, minimum macOS, site URL and download URL,
plus the name, title and description used by the page's metadata. Update it with each release.
`static/robots.txt` and `static/sitemap.xml` repeat the site URL.
- `src/routes/download/+server.ts` answers `GET /download` with a 302 to
`https://github.com/michaelshimeles/terminal-rs/releases/latest/download/Terminal.dmg`.
That URL works only once a GitHub release includes an asset named exactly `Terminal.dmg`.
- The download buttons open `src/lib/DownloadDialog.svelte`, which asks for an email, sends it to
`POST /download`, then starts the download with `GET /download`. The browser remembers that it
gave an email, so later downloads skip the dialog. If saving fails or takes more than 3 seconds,
the download starts anyway.
Without JavaScript the buttons link straight to `/download`.
## Deploys
GitHub Actions deploys the site, not Vercel's Git integration, because the browser app at `/app`
is built from Rust and Vercel's build doesn't run Rust. `.github/workflows/deploy-site.yml` runs
`scripts/build-web.sh`, then `vercel build` and `vercel deploy --prebuilt` from the repository
root. Pushes to `master` deploy to production. Pull requests from branches in this repository
get a preview, linked from the pull request; pull requests from forks only build the app.
`vercel.json` turns off Vercel's own Git deployments, so each commit deploys once.
The workflow reads three repository secrets:
- `VERCEL_TOKEN`: a Vercel access token (Account Settings › Tokens) scoped to the goshen-labs team.
- `VERCEL_ORG_ID` and `VERCEL_PROJECT_ID`: `orgId` and `projectId` from `.vercel/project.json`,
which `vercel link --scope goshen-labs --project terminal` writes.
To redeploy without a new commit, run the workflow from the Actions tab. Run on `master`, it
deploys to production.
`vercel.json` also routes the browser app. `/app` redirects once to `/app/`, which serves
`static/app/index.html` without a redirect, so the page's relative URLs resolve under `/app/`.
`scripts/build-web.sh` puts each build's JS and wasm in `static/app/immutable/<hash>/`, named
after a hash of those files, and the browser caches everything under `/app/immutable/` for a
year. A new build gets new URLs, so a page never loads one build's JS with another build's wasm.
`index.html` and the fonts keep Vercel's default `max-age=0, must-revalidate`, so the browser
checks them on every visit. A request under `/app/immutable/` for a file that doesn't exist gets a
404 with `no-store`, so that year-long header never sticks to a 404. `vercel.json` uses `routes`
rather than `headers` and `rewrites` because only `routes` can add that rule after the filesystem
check, the same way the SvelteKit adapter guards `/_app/immutable/`. `npm run dev` doesn't apply
`vercel.json`; open `/app/index.html` there.
## Emails
`POST /download` adds each email to the `emails` sorted set in Upstash Redis, scored by when the
address was first given in milliseconds since the epoch. It reads `KV_REST_API_URL` and
`KV_REST_API_TOKEN`, declared in `src/env.ts`. Without them the site still builds and serves
downloads, but every save fails and logs an error.
To set them up, add Upstash for Redis to the Vercel project, which connects the database and sets
both variables:
```sh
vercel link --scope goshen-labs --project terminal
vercel integration add upstash/upstash-kv --name terminal-emails
vercel env pull .env.local # for npm run dev
```
To list the emails, oldest first, with the read-only token the integration also sets:
```sh
set -a; . ./.env.local; set +a
curl -H "Authorization: Bearer $KV_REST_API_READ_ONLY_TOKEN" "$KV_REST_API_URL/zrange/emails/0/-1"
```
## Images
`assets-src/` holds the sources: two 3200x1920 window captures of the app (`hero-dark.png` and
`hero-light.png`), the icon (`icon.svg`) and the Open Graph card template (`og.html`).
`scripts/build-images.sh` regenerates everything in `static/` from them: the two captures as
full-size WebP for the demo (it zooms into the chart, so they stay at 3200 px), the icon,
favicons, the touch icon and `og.jpg`. It needs ImageMagick, `cwebp`, and Google Chrome or
Chromium. The script finds `google-chrome`, `google-chrome-stable`, `chromium` or
`chromium-browser` on your `PATH`, then Chrome in `/Applications`. To use another browser, set
`CHROME` to its path or command name, such as `CHROME=brave-browser`.
```sh
./scripts/build-images.sh
```
The icon is the candlestick mark from `src/lib/Logo.svelte`. The app bundle doesn't ship an icon yet; if it
gets one, update both.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

-55
View File
@@ -1,55 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0.35" y2="1">
<stop offset="0" stop-color="#303036" />
<stop offset="1" stop-color="#09090b" />
</linearGradient>
<radialGradient id="glow" cx="0.28" cy="0.06" r="0.95">
<stop offset="0" stop-color="#6e9bff" stop-opacity=".32" />
<stop offset="1" stop-color="#6e9bff" stop-opacity="0" />
</radialGradient>
<linearGradient id="sheen" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff" stop-opacity=".16" />
<stop offset="1" stop-color="#ffffff" stop-opacity="0" />
</linearGradient>
<linearGradient id="glass" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff" />
<stop offset="1" stop-color="#ffffff" stop-opacity=".74" />
</linearGradient>
<linearGradient id="rim" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff" stop-opacity=".42" />
<stop offset=".45" stop-color="#ffffff" stop-opacity=".06" />
<stop offset="1" stop-color="#ffffff" stop-opacity=".16" />
</linearGradient>
<clipPath id="tile">
<rect x="100" y="100" width="824" height="824" rx="185" />
</clipPath>
</defs>
<rect x="100" y="100" width="824" height="824" rx="185" fill="url(#bg)" />
<g clip-path="url(#tile)">
<rect x="100" y="100" width="824" height="824" fill="url(#glow)" />
<rect x="100" y="100" width="824" height="379" fill="url(#sheen)" />
</g>
<g transform="translate(100 100) scale(8.24)">
<g transform="translate(0 2.6)" fill="#000000" opacity=".5">
<rect x="24.8" y="44" width="4.4" height="39" rx="2.2" />
<rect x="18" y="53" width="18" height="23" rx="4" />
<rect x="47.8" y="30" width="4.4" height="40" rx="2.2" />
<rect x="41" y="37" width="18" height="25" rx="4" />
<rect x="70.8" y="16" width="4.4" height="40" rx="2.2" />
<rect x="64" y="23" width="18" height="25" rx="4" />
</g>
<g fill="url(#glass)">
<rect x="24.8" y="44" width="4.4" height="39" rx="2.2" />
<rect x="18" y="53" width="18" height="23" rx="4" />
<rect x="47.8" y="30" width="4.4" height="40" rx="2.2" />
<rect x="41" y="37" width="18" height="25" rx="4" />
<rect x="70.8" y="16" width="4.4" height="40" rx="2.2" />
<rect x="64" y="23" width="18" height="25" rx="4" />
</g>
<rect x="21" y="54.3" width="12" height="1.2" rx="0.6" fill="#ffffff" />
<rect x="44" y="38.3" width="12" height="1.2" rx="0.6" fill="#ffffff" />
<rect x="67" y="24.3" width="12" height="1.2" rx="0.6" fill="#ffffff" />
</g>
<rect x="104" y="104" width="816" height="816" rx="181" fill="none" stroke="url(#rim)" stroke-width="8" />
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

-127
View File
@@ -1,127 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<style>
html,
body {
margin: 0;
width: 1200px;
height: 630px;
overflow: hidden;
}
body {
position: relative;
background:
radial-gradient(ellipse 560px 420px at 14% 18%, rgba(110, 155, 255, 0.1), transparent 70%),
#0a0a0b;
color: #f4f4f5;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', system-ui, sans-serif;
}
.copy {
position: absolute;
left: 72px;
top: 72px;
width: 540px;
}
.brand {
display: flex;
align-items: center;
gap: 16px;
font-size: 30px;
font-weight: 600;
letter-spacing: -0.01em;
}
.brand img {
width: 104px;
height: 104px;
margin: -12px -8px -12px -12px;
}
h1 {
margin: 56px 0 20px;
font-size: 56px;
line-height: 1.04;
font-weight: 700;
letter-spacing: -0.035em;
}
p {
margin: 0;
color: #8e8e93;
font-size: 24px;
line-height: 1.4;
}
.pill {
display: inline-flex;
align-items: center;
gap: 10px;
margin-top: 36px;
padding: 14px 24px 14px 22px;
border-radius: 999px;
background: #f5f5f7;
color: #000;
font-size: 20px;
font-weight: 600;
}
.pill svg {
width: 17px;
height: 21px;
}
.shot {
position: absolute;
left: 640px;
top: 92px;
width: 1180px;
border-radius: 14px;
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.1),
0 30px 80px rgba(0, 0, 0, 0.6);
}
.bar {
position: absolute;
left: 700px;
top: 452px;
display: flex;
align-items: center;
gap: 12px;
width: 400px;
height: 64px;
padding: 0 10px 0 22px;
border-radius: 20px;
background: rgba(20, 20, 26, 0.55);
backdrop-filter: blur(16px) saturate(180%);
box-shadow:
inset 0 0.5px 0 rgba(255, 255, 255, 0.35),
inset 0 0 0 0.5px rgba(255, 255, 255, 0.16),
0 20px 50px rgba(0, 0, 0, 0.55);
font: 26px ui-monospace, 'SF Mono', Menlo, monospace;
}
.bar span {
flex: 1;
}
.bar b {
padding: 9px 14px;
border-radius: 12px;
background: linear-gradient(#fff, #e9e9ee);
color: #1c1c1e;
font-size: 18px;
}
</style>
</head>
<body>
<div class="copy">
<div class="brand"><img src="icon.svg" alt="" />Terminal</div>
<h1>The market, one command away.</h1>
<p>A fast, keyboard-driven market terminal for the Mac, written in Rust.</p>
<div class="pill">
<svg viewBox="0 0 17 21">
<path
d="M11.2 4.3c.6-.8 1-1.8.9-2.8-.9 0-2 .6-2.6 1.4-.6.7-1.1 1.7-.9 2.7 1 .1 2-.5 2.6-1.3zM12.1 5.8c-1.4-.1-2.7.8-3.3.8-.7 0-1.7-.8-2.9-.8C4.4 5.9 3 6.8 2.2 8.1c-1.6 2.7-.4 6.8 1.1 9 .8 1.1 1.7 2.3 2.8 2.3 1.1 0 1.6-.7 2.9-.7 1.4 0 1.8.7 2.9.7 1.2 0 2-1.1 2.7-2.2.9-1.3 1.2-2.5 1.2-2.6 0 0-2.4-.9-2.4-3.6 0-2.2 1.8-3.3 1.9-3.4-1-1.5-2.6-1.7-3.2-1.7z"
/>
</svg>
Free for macOS
</div>
</div>
<img class="shot" src="hero-dark.png" alt="" />
<div class="bar"><span>AAPL</span><b>GO</b></div>
</body>
</html>
-1777
View File
File diff suppressed because it is too large. Load diff
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff. Show more