Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bedb60151 | ||
|
|
361c4b1eb0 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
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,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",
|
||||
] }
|
||||
|
Before Width: | Height: | Size: 141 KiB |
@@ -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,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}")));
|
||||
|
||||
@@ -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, "es);
|
||||
let _ = portfolio(ui, &mut pane, &b, "es);
|
||||
});
|
||||
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, "es);
|
||||
});
|
||||
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), "es);
|
||||
let _ = portfolio(ui, &mut pane, &b, "es);
|
||||
});
|
||||
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, "es);
|
||||
let _ = portfolio(ui, &mut pane, &b, "es);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ÷rs {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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| {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)?)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -215,7 +215,6 @@ pub fn portfolio() -> Portfolio {
|
||||
fetched: Some(now),
|
||||
warnings: Vec::new(),
|
||||
simulated: true,
|
||||
demo: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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(>c, 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);
|
||||
}
|
||||
@@ -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}")
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -10,7 +10,6 @@ serde.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
criterion.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[[bench]]
|
||||
name = "indicators"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>()));
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
] }
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
Before Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 121 KiB |
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 +0,0 @@
|
||||
engine-strict=true
|
||||
@@ -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.
|
||||
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
@@ -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 |
@@ -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>
|
||||