mirror of
https://github.com/michaelshimeles/terminal.git
synced 2026-11-08 17:31:10 -10:00
Compare commits
No files matched your search
@@ -22,7 +22,7 @@ jobs:
|
||||
arch: x86_64
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
@@ -40,9 +40,9 @@ jobs:
|
||||
TR_PROVIDER: sample
|
||||
TR_CONGRESS_REFRESH_MIN: "0"
|
||||
TR_SMOKE_SECS: "8"
|
||||
TR_DATA_DIR: ${{ runner.temp }}/terminal-rs
|
||||
TR_DATA_DIR: ${{ runner.temp }}/terminal
|
||||
run: |
|
||||
./target/release/terminal-rs > smoke.log 2>&1 &
|
||||
./target/release/terminal > smoke.log 2>&1 &
|
||||
pid=$!
|
||||
sleep 6
|
||||
screencapture -x screenshot-${{ matrix.arch }}.png || true
|
||||
@@ -56,9 +56,9 @@ jobs:
|
||||
env:
|
||||
TR_SMOKE_SECS: "12"
|
||||
TR_CONGRESS_REFRESH_MIN: "0"
|
||||
TR_DATA_DIR: ${{ runner.temp }}/terminal-rs-live
|
||||
TR_DATA_DIR: ${{ runner.temp }}/terminal-live
|
||||
run: |
|
||||
./target/release/terminal-rs > live.log 2>&1 &
|
||||
./target/release/terminal > live.log 2>&1 &
|
||||
pid=$!
|
||||
sleep 10
|
||||
screencapture -x screenshot-live-${{ matrix.arch }}.png || true
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
grep -q "smoke:" live.log
|
||||
- name: Bundle .app
|
||||
run: ./scripts/bundle-macos.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: macos-${{ matrix.arch }}
|
||||
@@ -75,24 +75,31 @@ jobs:
|
||||
screenshot-*.png
|
||||
smoke.log
|
||||
live.log
|
||||
target/release/terminal-rs.app
|
||||
target/release/Terminal.app
|
||||
|
||||
linux:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: sudo apt-get update && sudo apt-get install -y libxkbcommon-x11-0 libxcb-xkb1 mesa-vulkan-drivers xvfb
|
||||
- run: cargo fmt --all -- --check
|
||||
- run: cargo build --release --workspace --all-targets
|
||||
- run: cargo test --release --workspace
|
||||
- run: cargo clippy --release --workspace --all-targets -- -D warnings
|
||||
- name: The protocol crate builds for the browser
|
||||
run: cargo build -p tr-proto --target wasm32-unknown-unknown
|
||||
- name: The Hyperliquid crate builds for the browser
|
||||
run: cargo build -p tr-hl --target wasm32-unknown-unknown && cargo clippy -p tr-hl --target wasm32-unknown-unknown -- -D warnings
|
||||
- name: The wallet crate builds for the browser
|
||||
run: cargo build -p tr-turnkey --target wasm32-unknown-unknown && cargo clippy -p tr-turnkey --target wasm32-unknown-unknown -- -D warnings
|
||||
- name: Run the app (smoke test)
|
||||
env:
|
||||
TR_PROVIDER: sample
|
||||
TR_CONGRESS_REFRESH_MIN: "0"
|
||||
TR_SMOKE_SECS: "6"
|
||||
run: xvfb-run -a ./target/release/terminal-rs 2>&1 | tee smoke.log && grep -q "smoke:" smoke.log
|
||||
run: xvfb-run -a ./target/release/terminal 2>&1 | tee smoke.log && grep -q "smoke:" smoke.log
|
||||
@@ -0,0 +1,128 @@
|
||||
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"
|
||||
# The repository variable TR_SERVER_URL, when set, picks the server the site connects to,
|
||||
# for example after the gateway moves to a custom domain. Unset, build-web.sh's default holds.
|
||||
- name: Build the browser app
|
||||
env:
|
||||
SERVER_URL: ${{ vars.TR_SERVER_URL }}
|
||||
run: |
|
||||
if [ -n "$SERVER_URL" ]; then export TR_SERVER_URL="$SERVER_URL"; fi
|
||||
scripts/build-web.sh
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: web-app
|
||||
path: site/static/app
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
deploy:
|
||||
name: Deploy to Vercel
|
||||
needs: wasm
|
||||
# Pull requests from forks get no secrets, so they stop after the build.
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-24.04
|
||||
environment:
|
||||
name: ${{ github.ref == 'refs/heads/master' && 'production' || 'preview' }}
|
||||
url: ${{ steps.deploy.outputs.url }}
|
||||
env:
|
||||
TARGET: ${{ github.ref == 'refs/heads/master' && 'production' || 'preview' }}
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
steps:
|
||||
- name: Check the Vercel secrets
|
||||
env:
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||
run: |
|
||||
missing=0
|
||||
for name in VERCEL_TOKEN VERCEL_ORG_ID VERCEL_PROJECT_ID; do
|
||||
if [ -z "${!name}" ]; then
|
||||
echo "::error::The $name repository secret is not set (see site/README.md)"
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
exit $missing
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: web-app
|
||||
path: site/static/app
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: site/package-lock.json
|
||||
- run: npm install --global [email protected]
|
||||
# Run from the repository root, because the Vercel project's Root Directory is site/.
|
||||
- name: Pull the project settings and environment variables
|
||||
env:
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||
run: vercel pull --yes --environment="$TARGET" --token="$VERCEL_TOKEN"
|
||||
# The build gets no token, since it runs the site's npm install and build scripts.
|
||||
- name: Build
|
||||
run: |
|
||||
if [ "$TARGET" = production ]; then vercel build --prod; else vercel build; fi
|
||||
test -f .vercel/output/static/app/index.html
|
||||
- name: Deploy
|
||||
id: deploy
|
||||
env:
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||
run: |
|
||||
if [ "$TARGET" = production ]; then
|
||||
url=$(vercel deploy --prebuilt --prod --token="$VERCEL_TOKEN")
|
||||
else
|
||||
url=$(vercel deploy --prebuilt --token="$VERCEL_TOKEN")
|
||||
fi
|
||||
echo "url=$url" >> "$GITHUB_OUTPUT"
|
||||
echo "Deployed $TARGET: $url ($url/app)" >> "$GITHUB_STEP_SUMMARY"
|
||||
Generated
+2411
-36
File diff suppressed because it is too large.
Load diff
+15
-2
@@ -1,20 +1,23 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/core", "crates/broker", "crates/congress", "crates/data", "crates/chart", "crates/macos", "app"]
|
||||
members = ["crates/core", "crates/broker", "crates/hl", "crates/turnkey", "crates/congress", "crates/data", "crates/chart", "crates/macos", "crates/proto", "app", "server"]
|
||||
default-members = ["app"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
|
||||
[workspace.dependencies]
|
||||
tr-core = { path = "crates/core" }
|
||||
tr-broker = { path = "crates/broker" }
|
||||
tr-hl = { path = "crates/hl" }
|
||||
tr-turnkey = { path = "crates/turnkey" }
|
||||
tr-congress = { path = "crates/congress" }
|
||||
tr-data = { path = "crates/data" }
|
||||
tr-chart = { path = "crates/chart" }
|
||||
tr-macos = { path = "crates/macos" }
|
||||
tr-proto = { path = "crates/proto" }
|
||||
anyhow = "1"
|
||||
thiserror = "2"
|
||||
chrono = { version = "0.4", default-features = false, features = ["std", "clock", "serde"] }
|
||||
@@ -36,3 +39,13 @@ debug = 1
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
# The browser build (scripts/build-web.sh): one fully optimised unit, no debug info, so the
|
||||
# .wasm is as small as it gets before wasm-opt.
|
||||
[profile.web]
|
||||
inherits = "release"
|
||||
opt-level = "s"
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
debug = false
|
||||
strip = true
|
||||
@@ -1,4 +1,4 @@
|
||||
# terminal-rs
|
||||
# Terminal
|
||||
|
||||
A fast, keyboard-driven market terminal in pure Rust: TradingView-class GPU charting with a
|
||||
Bloomberg Terminal look (dense tiled panes, monospace numbers, a command line like `AAPL <GO>`)
|
||||
@@ -9,7 +9,7 @@ systems** and **your own brokerage accounts through [SnapTrade](https://snaptrad
|
||||
to Solarized, and a fully dockable
|
||||
workspace with saved layouts.
|
||||
|
||||

|
||||

|
||||
|
||||
> **Built from reverse-engineering research.** Chart UX was studied behaviourally on
|
||||
> tradingview.com with [REA](https://github.com/morluto/rea) in website mode (passive captures of
|
||||
@@ -48,9 +48,21 @@ gets the native title bar with traffic lights, window vibrancy behind the sideba
|
||||
matches across all asset classes (catalog instantly, plus Yahoo search after a short pause);
|
||||
↑/↓, Tab, Enter; history with ↑/↓. `SECF gold cmdty` opens a full search panel.
|
||||
- **Charts** — GPU-rendered candles, OHLC bars, line and area; 1m, 5m, 15m, 30m, 1H, 4H, 1D, 1W,
|
||||
1M plus quick ranges (1D … ALL); volume; SMA, EMA, VWAP, Bollinger overlays; RSI and MACD
|
||||
sub-panes; `CMP <SYM>` compare; log scale; crosshair with OHLC legend; smooth pan and zoom over
|
||||
a million bars. Live crypto bars from the Binance websocket (Coinbase fallback).
|
||||
1M plus quick ranges (1D … ALL); volume; 151 indicators; `CMP <SYM>` compare; log scale;
|
||||
crosshair with OHLC legend; smooth pan and zoom over a million bars. Live crypto bars from the
|
||||
Binance websocket (Coinbase fallback).
|
||||
- **Indicators** — 151 built-in studies: 23 moving averages (SMA, EMA, WMA, Hull, DEMA/TEMA, KAMA,
|
||||
ALMA, T3, McGinley, VIDYA, FRAMA, MAMA/FAMA, Ehlers filters, GMMA…), 8 bands and channels
|
||||
(Bollinger, Keltner, Donchian, STARC, standard-error and VWAP bands…), 43 momentum oscillators
|
||||
(RSI, MACD, Stochastic, StochRSI, CCI, Williams %R, TSI, KST, Fisher, Schaff, QQE, WaveTrend,
|
||||
Connors RSI…), 22 trend tools (ADX/DMI, Aroon, Vortex, Parabolic SAR, SuperTrend, Ichimoku,
|
||||
Alligator, ZigZag, fractals, Chandelier and Chande Kroll stops…), 16 volatility measures (ATR,
|
||||
close-to-close and Parkinson, Garman-Klass, Rogers-Satchell and Yang-Zhang estimators, Ulcer,
|
||||
Mass index, squeeze momentum…), 23 volume studies (OBV, A/D, CMF, MFI, Klinger, NVI/PVI, VZO…),
|
||||
11 rolling statistics and five pivot-point families. Type a code with optional parameters
|
||||
(`RSI`, `SMA 200`, `BB 20 2.5`), browse and search with `IND`, or right-click the chart.
|
||||
Oscillators get their own panes (up to six) with reference levels; moving averages can be added
|
||||
several times; indicators are saved with the layout.
|
||||
- **Drawings** — trendline (Alt+T), horizontal (Alt+H), vertical (Alt+V) and fib retracement
|
||||
(Alt+F), stored in (time, price) so they survive zoom and timeframe changes; magnet (Alt+M) snaps
|
||||
to OHLC; click to select, Del to remove.
|
||||
@@ -72,6 +84,11 @@ gets the native title bar with traffic lights, window vibrancy behind the sideba
|
||||
| US Treasuries | Treasury.gov daily par yield curve CSV (FRED fallback); Yahoo `^IRX/^FVX/^TNX/^TYX` intraday | Treasury.gov | — |
|
||||
| FX, indices, futures | Yahoo Finance (`EURUSD=X`, `^GSPC`, `CL=F`) | Yahoo spark (batched) | — |
|
||||
| Stocks, ETFs | Alpaca when `APCA_API_KEY_ID`/`APCA_API_SECRET_KEY` are set, else Yahoo | Yahoo spark | — |
|
||||
| Hyperliquid HIP-3 perps (`xyz:AAPL`, `xyz:GOLD`, `xyz:EUR`) | The Terminal data server only (`TR_SERVER_URL`) | Server mids | Server candles, book and trades |
|
||||
|
||||
A HIP-3 perp is its own instrument, labelled `PERP · HL xyz` everywhere. `AAPL US Equity` stays the
|
||||
listed stock. `AAPL PERP` opens the perp linked to it, and `PERP` or ⇧P switches back and forth.
|
||||
A perp has no other source, so it never gets sample data; without the server it shows why.
|
||||
|
||||
No keys are required. `TR_PROVIDER=sample` runs fully offline on deterministic, class-aware sample
|
||||
data (BTCUSD near 65,000, EURUSD near 1.09, US10Y near 4.2%) **with simulated live ticks**, and
|
||||
@@ -110,7 +127,7 @@ gets the native title bar with traffic lights, window vibrancy behind the sideba
|
||||
row in a linked watchlist or world-markets pane loads it into the group.
|
||||
- **Saved layouts**: save, name, rename, duplicate, delete and switch (`LAYOUT <name>`, the
|
||||
sidebar list, ⌥1…⌥9); the active layout autosaves and is restored on launch. Each layout is a
|
||||
JSON file in `~/Library/Application Support/terminal-rs/layouts` (`$XDG_DATA_HOME` on Linux)
|
||||
JSON file in `~/Library/Application Support/terminal/layouts` (`$XDG_DATA_HOME` on Linux)
|
||||
and can be imported or exported (`LAYOUT EXPORT`/`IMPORT`, or Settings › Layouts). Per-pane
|
||||
settings (chart type, indicators, drawings, filters, depth, link, tab title) and, optionally,
|
||||
the theme (`LAYOUT THEME ON`) save with the layout.
|
||||
@@ -142,17 +159,15 @@ gets the native title bar with traffic lights, window vibrancy behind the sideba
|
||||
access. Without credentials the panes show a setup screen; offline (`TR_PROVIDER=sample` or
|
||||
`TR_BROKER=sample`) they show a clearly labelled simulated portfolio. See
|
||||
[Brokerage setup](#brokerage-setup-snaptrade).
|
||||
- **Trading (opt-in)** — off by default; Settings › Trading enables it after an explicit
|
||||
warning. `BUY AAPL 10`, `SELL MSFT 5 LMT 410`, `SELL TSLA 3 STP 250 LMT 245`, `BUY NVDA $500`,
|
||||
`TRADE` (or `AAPL BUY`) open an order ticket, as do Buy/Sell on watchlist quotes and positions.
|
||||
The ticket has an account picker, buy/sell, market, limit, stop and stop-limit, shares or a
|
||||
dollar amount, and Day/GTC/FOK/IOC. **Preview** runs SnapTrade's order-impact check at your
|
||||
broker (estimated price, cost, commission, cash after); only that preview, unchanged and
|
||||
unexpired, can be placed, and only through a confirmation dialog showing account, symbol, side,
|
||||
quantity and estimated cost. Open orders cancel from `ORD` (right-click › Cancel Order, also
|
||||
confirmed). Accounts whose brokerage does not trade through SnapTrade, or whose connection is
|
||||
read-only or disabled, are marked read-only and cannot preview. An optional maximum order value
|
||||
refuses large orders. The sample portfolio simulates fills locally. See
|
||||
- **Demo account and easy trading** — a **DEMO** paper-trading account ($100,000 virtual cash) is
|
||||
selected by default; the toolbar badge shows DEMO or a red **LIVE** and switches between them
|
||||
(switching to live asks first and is remembered). Buy/Sell buttons sit on charts, watchlist rows
|
||||
and positions (plus **Close**), ⇧B / ⇧S trade the security in focus, and `BUY AAPL 10`,
|
||||
`SELL ALL TSLA`, `BUY NVDA $500`, `SELL MSFT 5 LMT 410` work from the command line or ⌘K. One
|
||||
compact ticket: side, shares or dollars, market/limit (stops under More), live estimated cost
|
||||
against buying power. Demo orders fill in one click at the live quote (limits and stops when
|
||||
the quote crosses) and persist in the app data folder; Settings › Trading resets the account.
|
||||
Live orders stay opt-in and go through SnapTrade's preview and a single confirmation. See
|
||||
[Trading](#trading-opt-in).
|
||||
- **Command palette** — ⌘K (Ctrl+K on Linux and Windows) opens a centred, theme-matched palette
|
||||
that fuzzy-searches every function code (`PORT`, `POS`, `ORD`, `OMON`, `THEME`, …), tickers
|
||||
@@ -171,16 +186,76 @@ TR_PROVIDER=sample cargo run --release # offline
|
||||
APCA_API_KEY_ID=… APCA_API_SECRET_KEY=… cargo run --release # Alpaca
|
||||
```
|
||||
|
||||
Data lives in `~/Library/Application Support/terminal-rs` (macOS) or
|
||||
`$XDG_DATA_HOME/terminal-rs` (Linux), or `TR_DATA_DIR`. The first congress ingest covers the
|
||||
current and previous year (a few minutes; it runs in the background while you work).
|
||||
Data lives in `~/Library/Application Support/terminal` (macOS) or
|
||||
`$XDG_DATA_HOME/terminal` (Linux), or `TR_DATA_DIR`. Versions from before the project was renamed
|
||||
used a `terminal-rs` folder in the same place; the first launch moves it to `terminal` and leaves
|
||||
a link at the old path, so builds that still use the old name find the same files. The first
|
||||
congress ingest covers the current and previous year (a few minutes; it runs in the background
|
||||
while you work).
|
||||
`cargo run -p tr-congress --example ingest -- congress.sqlite3` runs one ingest from the CLI.
|
||||
|
||||
Appearance: `TR_THEME=light|dark|<theme name>` overrides the saved theme, `TR_MSAA=1|2|4|8` sets
|
||||
chart anti-aliasing (default 4 on macOS), `TR_VIBRANCY=0` disables the translucent sidebar, and
|
||||
⌃⌘S (Ctrl+Shift+S elsewhere) toggles the sidebar. `scripts/bundle-macos.sh` builds a retina-capable
|
||||
`terminal-rs.app`; `scripts/check-macos-cross.sh` compiles and lints the whole workspace for
|
||||
aarch64-apple-darwin from Linux (zig as the C cross-compiler; linking needs the macOS SDK).
|
||||
`Terminal.app` (icon from `scripts/build-icon.sh`); `scripts/release-macos.sh` builds the universal (Apple Silicon + Intel) app,
|
||||
signs it with a Developer ID certificate, notarizes it and packages `Terminal.dmg`,
|
||||
plus the zip and signed `appcast.xml` that installed copies update from (one-time credential
|
||||
setup is at the top of the script). The app checks that feed once a day through
|
||||
[Sparkle](https://sparkle-project.org) and offers to install a newer release; Terminal › Check
|
||||
for Updates… or `UPDATE` checks at once. `scripts/check-macos-cross.sh` compiles
|
||||
and lints the whole workspace for aarch64-apple-darwin from Linux (zig as the C cross-compiler;
|
||||
linking needs the macOS SDK).
|
||||
|
||||
## In the browser
|
||||
|
||||
The same code also builds for WebAssembly and runs in a browser tab on WebGPU:
|
||||
|
||||
```sh
|
||||
rustup target add wasm32-unknown-unknown
|
||||
cargo install wasm-bindgen-cli --version 0.2.129 --locked # must match Cargo.lock
|
||||
brew install binaryen # wasm-opt
|
||||
scripts/build-web.sh # writes site/static/app
|
||||
cd site && npm run dev # then open http://localhost:5173/app/index.html
|
||||
```
|
||||
|
||||
`scripts/build-web.sh <dir>` writes somewhere else instead; any static file server works. Add
|
||||
`?sample` to the URL to run offline on sample data, or `?fps` for the frame-time readout.
|
||||
Every push to `master` builds it and deploys it with the site to
|
||||
[terminal.rasmic.xyz/app](https://terminal.rasmic.xyz/app/). Pull requests get a preview;
|
||||
[site/README.md](site/README.md#deploys) covers the workflow and the secrets it needs.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
- HIP-3 perps (`xyz:AAPL`) are live through the Terminal data server the page is built with. They
|
||||
never fall back to sample data: while the server is unreachable they say so.
|
||||
- Congress disclosures, SnapTrade accounts and live order entry need the desktop app. The paper
|
||||
trading demo account works.
|
||||
- Layouts, settings, themes and the demo account are saved in `localStorage` under `terminal/`.
|
||||
The first load copies anything saved under the old `terminal-rs/` prefix, without overwriting
|
||||
newer entries.
|
||||
- Charts need WebGPU, since they read bars from storage buffers in the vertex shader. Browsers
|
||||
without a WebGPU adapter get a second, larger build on WebGL2 where everything except the chart
|
||||
series works.
|
||||
|
||||
Measured with Playwright driving Chrome 155 on an Apple M4 Pro:
|
||||
|
||||
| | WebGPU build | WebGL2 build |
|
||||
|---|---|---|
|
||||
| `.wasm` | 5.60 MB (2.26 MB gzip, 1.69 MB brotli) | 7.48 MB (2.99 MB gzip, 2.22 MB brotli) |
|
||||
| JS glue | 115 KB | 169 KB |
|
||||
|
||||
- A cold load from localhost draws its first frame 91 to 97 ms after navigation, the medians of
|
||||
three runs of 9 loads, two headless and one in a Retina window. On a 50 Mbit/s link with 40 ms
|
||||
of latency it takes 446 ms, and 1.64 s on 10 Mbit/s with 80 ms.
|
||||
- Panning and zooming a chart costs 1.3 to 1.5 ms of CPU in the UI code and 2.4 to 3.3 ms for the
|
||||
whole frame (medians across runs), and holds the display's 60 Hz.
|
||||
- egui's two emoji fonts, 740 KB together, load after the first frame instead of inside the `.wasm`.
|
||||
|
||||
## Brokerage setup (SnapTrade)
|
||||
|
||||
@@ -205,8 +280,9 @@ the portal, and **Disconnect** removes a connection and its accounts from SnapTr
|
||||
explicit confirmation.
|
||||
|
||||
**Where credentials live.** On macOS the client ID, consumer key, user ID and user secret are
|
||||
stored together in the **Keychain** (generic password, service `terminal-rs`, account
|
||||
`snaptrade`). Elsewhere they go in `<data dir>/secrets/snaptrade.json` with owner-only permissions
|
||||
stored together in the **Keychain** (generic password, service `terminal`, account
|
||||
`snaptrade`); credentials saved under the old service, `terminal-rs`, move there the first time
|
||||
the app reads them. Elsewhere they go in `<data dir>/secrets/snaptrade.json` with owner-only permissions
|
||||
(0600 in a 0700 directory). They are never written to the repository, layouts, prefs or logs;
|
||||
request errors are scrubbed of the URL (which carries the user secret), and the settings screen
|
||||
masks secrets. **Forget Credentials** removes them. `SNAPTRADE_CLIENT_ID`,
|
||||
@@ -224,19 +300,24 @@ are repriced with the terminal's live quotes where a market key exists.
|
||||
|
||||
## Trading (opt-in)
|
||||
|
||||
Trading is **off** until you enable it in **Settings › Trading**, which asks you to acknowledge
|
||||
that confirmed orders are real. Then:
|
||||
The **demo account** needs no setup: it is a local paper-trading account (`demo-account.json` in
|
||||
the app data folder) with no commissions, no short selling and Day orders expiring at the 4 p.m.
|
||||
New York close. Switch to your brokerage accounts with the toolbar badge, `MODE LIVE`, or
|
||||
Settings › Trading (`DEMO` switches back).
|
||||
|
||||
Live trading is **off** until you enable it in **Settings › Trading**, which asks you to
|
||||
acknowledge that confirmed orders are real. Then:
|
||||
|
||||
1. Make sure the connection allows trading. Connections made with **Connect Brokerage** are
|
||||
read-only; **Reconnect with Trading Access…** in Settings › Trading opens the Connection Portal
|
||||
asking for trading access where the broker supports it (`trade-if-available`). Settings ›
|
||||
Trading lists, per brokerage, whether trading is available, from SnapTrade's
|
||||
`allows_trading` flag and each connection's access type.
|
||||
2. Open a ticket (`BUY AAPL 10`, right-click a position › Sell …), pick the account and order type,
|
||||
and click **Preview**. The broker checks the order through SnapTrade's
|
||||
2. Open a ticket (`BUY AAPL 10`, a Buy/Sell button, ⇧B / ⇧S), pick the account and order type,
|
||||
and click **Review**. The broker checks the order through SnapTrade's
|
||||
[order impact](https://docs.snaptrade.com/reference/Trading/Trading_getOrderImpact) endpoint;
|
||||
nothing is placed.
|
||||
3. **Place Order…** opens the confirmation dialog. **Confirm** places exactly the previewed trade
|
||||
3. The broker's estimate opens one confirmation dialog. **Send** places exactly the previewed trade
|
||||
(`POST /trade/{tradeId}`). Editing the ticket after previewing, or waiting past the preview's
|
||||
validity (SnapTrade expires it after five minutes), requires a new preview.
|
||||
A preview is used up when it is sent. If the answer is lost (network error, timeout, 5xx),
|
||||
@@ -249,8 +330,13 @@ orders at brokers that support notional orders; fractional share quantities are
|
||||
at brokers SnapTrade reports as not supporting them. Settings › Trading also has an optional
|
||||
**maximum order value** and a default account.
|
||||
|
||||
Trading access is read once per brokerage connection (`/authorizations`), cached for an hour (on
|
||||
disk too) with a **Refresh** button. SnapTrade requests are limited to four at a time; a
|
||||
rate-limited read waits for `Retry-After` and retries, and a rate limit is shown as a quiet
|
||||
"retrying" note rather than an error. Orders are never retried automatically.
|
||||
|
||||
Tests never place real orders: the trading client is exercised only against a local mock server,
|
||||
and the app's flows against the simulated portfolio.
|
||||
and the app's flows against the simulated portfolio and the demo account.
|
||||
|
||||
## Keyboard shortcuts and commands
|
||||
|
||||
@@ -277,7 +363,7 @@ Settings › Hotkeys. On Linux and Windows ⌘ is Ctrl and ⌥ is Alt.
|
||||
| `AAPL` · `AAPL US EQUITY <GO>` | load a security in the focused chart |
|
||||
| `BTCUSD <CRNCY>` · `EURUSD <CRNCY>` · `US10Y <GOVT>` · `CL1 <CMDTY>` · `SPX <INDEX>` | other asset classes |
|
||||
| `5` `1H` `D` `1W` `1M` · `YTD` `5Y` `ALL` | interval · quick range |
|
||||
| `RSI` `MACD` `BB` `SMA 50` `EMA 20` `VWAP` · `CLEAR` | toggle indicators |
|
||||
| `RSI` `MACD` `SMA 200` `BB 20 2.5` · `IND` `IND VOLUME` · `CLEAR` | toggle any of the 151 indicators (optional parameters) · indicator picker · remove all |
|
||||
| `CMP MSFT` · `LOG` · `CANDLE` `BAR` `LINE` `AREA` | compare · log scale · chart type |
|
||||
| `AAPL OMON` · `AAPL CN` · `BTCUSD CRNCY OB` · `WEI` · `GC` · `SECF <query>` | options · news · order book · world markets · yield curve · search |
|
||||
| `CONG` · `NVDA CONG` · `POL PELOSI` · `TRACK PELOSI` · `ALERTS` · `REFRESH` | Congress feed · who traded it · profile · track · alerts · refresh |
|
||||
@@ -286,7 +372,8 @@ Settings › Hotkeys. On Linux and Windows ⌘ is Ctrl and ⌥ is Alt.
|
||||
| `THEME BLOOMBERG` · `THEME NEXT` · `THEME SYSTEM` · `THEME EDIT` · `THEME COMPACT` · `LAYOUT THEME ON` | themes |
|
||||
| `PORT` · `ACCT` · `POS` · `ORD` · `ACTV` · `AAPL POS` · `AAPL ORD` | brokerage portfolio · accounts and balances · positions · orders · activity · one security |
|
||||
| `PORT REFRESH` · `PORT CONNECT` · `PORT SETUP` (`BROKER`) | reload accounts · connect a broker in the browser · SnapTrade keys |
|
||||
| `BUY AAPL 10` · `SELL MSFT 5 LMT 410` · `SELL TSLA 3 STP 250 LMT 245` · `BUY NVDA $500` · `TRADE` | order ticket, prefilled (trading must be enabled; preview and confirm before anything is sent) |
|
||||
| `BUY AAPL 10` · `SELL ALL TSLA` · `SELL MSFT 5 LMT 410` · `SELL TSLA 3 STP 250 LMT 245` · `BUY NVDA $500` · `TRADE` | order ticket, prefilled (demo: one click; live: review and confirm) |
|
||||
| `DEMO` · `MODE LIVE` · ⇧B · ⇧S | switch account mode · buy / sell the security in focus |
|
||||
| `SIDEBAR RIGHT` · `SIDEBAR` · `PREFS` · `HELP` | sidebar side or visibility · settings · help |
|
||||
| right-click chart / watchlist / feed row / sidebar toggle | context menu (chart type, indicators, draw, add to watchlist, who traded it, View menu) |
|
||||
|
||||
@@ -300,9 +387,14 @@ app/ eframe shell: command line, docked panes, chart widget, panels
|
||||
themes.rs theme model, built-in themes, TOML/JSON theme files and the themes folder
|
||||
app/ workspace (drag and drop, pop-out windows, link groups), panels, settings, chrome
|
||||
worker.rs tokio runtime: all network, SQLite, pyramid and indicator work (UI never blocks)
|
||||
crates/core Bar/Series, timeframes, market keys + security catalog, indicators, sample data
|
||||
storage.rs saved files: the data folder on desktop, localStorage in the browser
|
||||
web.rs browser entry point (eframe web runner), console logging, frame timings
|
||||
web/ the page that hosts the browser build
|
||||
crates/core Bar/Series, timeframes, market keys + security catalog, indicators and their
|
||||
registry (codes, parameters, panes, levels), sample data
|
||||
crates/data MarketData trait; per-class router (Binance/Coinbase, Treasury.gov/FRED, Yahoo,
|
||||
Alpaca), websocket live feeds, sample provider with simulated ticks; bar cache
|
||||
Alpaca), websocket live feeds, sample provider with simulated ticks; bar cache;
|
||||
the browser router (crypto live, the rest sampled) and a tokio/browser task shim
|
||||
crates/chart wgpu renderer, LOD pyramids, viewport math, axes, drawings, egui paint callback
|
||||
crates/congress Senate eFD + House Clerk parsers, rosters, SQLite store, refresh, alerts, performance
|
||||
crates/broker SnapTrade client (request signing, accounts, balances, positions, orders, activity),
|
||||
@@ -350,7 +442,8 @@ Measured on Linux with Mesa **llvmpipe (a CPU software rasterizer)** — a real
|
||||
is the benchmark above).
|
||||
- **Indicators over 1M bars** (`cargo bench -p tr-core`): SMA 1.8 ms, EMA 2.2 ms, VWAP 7.0 ms,
|
||||
Bollinger 29 ms; RSI and MACD are single-pass (≈ 12 ms and 26 ms including first-touch of their
|
||||
output memory).
|
||||
output memory). The whole 151-indicator catalogue at default parameters over 100k one-minute
|
||||
bars takes ≈ 134 ms on macOS (Ichimoku, the slowest, 6.6 ms).
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -370,7 +463,11 @@ cargo bench -p tr-core && cargo bench -p tr-chart
|
||||
chart/spark/search. Market-key parsing, catalog search and symbol mapping per source are unit
|
||||
tested; opt-in live tests hit the real sources for every asset class.
|
||||
- Indicators are checked against reference values (Wilder's RSI example, hand-computed EMA, MACD,
|
||||
population-SD Bollinger, session VWAP).
|
||||
population-SD Bollinger, session VWAP) and hand-computed windows (stochastic, %R, CCI, WMA,
|
||||
true range, Donchian, envelopes, %B, OBV and A/D, CMF, Aroon, pivots). Every catalogue entry
|
||||
runs on intraday and daily random walks and on degenerate input (no bars, one to three bars,
|
||||
flat zero-volume bars) at its default, minimum and maximum parameters, checking alignment,
|
||||
declared ranges and no panics; layouts saved in the old indicator format still load.
|
||||
- Layouts: the dock tree (insert, split, tab, move, remove/normalise, resize clamping, presets,
|
||||
JSON round trip and repair of hand-edited files), layout documents (every panel kind and its
|
||||
settings, links, popped windows, older formats), the store (save, list, rename, duplicate,
|
||||
|
||||
+53
-6
@@ -1,13 +1,20 @@
|
||||
[package]
|
||||
name = "terminal-rs"
|
||||
name = "terminal"
|
||||
description = "Bloomberg-style trading terminal with GPU charts and congressional trade tracking"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
default-run = "terminal-rs"
|
||||
default-run = "terminal"
|
||||
|
||||
[features]
|
||||
# Browser build only: add wgpu's WebGL2 backend for browsers without WebGPU (about 1 MB more,
|
||||
# and charts stay off there because WebGL2 has no storage buffers).
|
||||
webgl = ["eframe/wgpu"]
|
||||
|
||||
[dependencies]
|
||||
tr-core.workspace = true
|
||||
tr-broker.workspace = true
|
||||
tr-hl.workspace = true
|
||||
tr-turnkey.workspace = true
|
||||
tr-congress.workspace = true
|
||||
tr-data.workspace = true
|
||||
tr-chart.workspace = true
|
||||
@@ -16,13 +23,53 @@ chrono.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
futures = "0.3"
|
||||
egui = "0.36"
|
||||
eframe = { version = "0.36", default-features = false, features = ["wgpu", "default_fonts", "wayland", "x11"] }
|
||||
zeroize = "1"
|
||||
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"
|
||||
# The Deposit window's QR code (no image dependencies).
|
||||
qrcode = { version = "0.14", default-features = false }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
eframe = { version = "0.36", default-features = false, features = ["wgpu", "default_fonts"] }
|
||||
tokio.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
raw-window-handle = "0.6"
|
||||
|
||||
# The browser build (see `src/web.rs` and scripts/build-web.sh): WebGPU only, unless the
|
||||
# `webgl` feature adds the WebGL2 fallback.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wgpu = { version = "30", default-features = false, features = ["webgpu", "wgsl"] }
|
||||
egui-wgpu = { version = "0.36", default-features = false, features = ["fragile-send-sync-non-atomic-wasm"] }
|
||||
# egui's text fonts only; the page fetches its emoji fonts after start-up (see theme::base_fonts).
|
||||
epaint_default_fonts = "0.36"
|
||||
tokio = { version = "1", default-features = false, features = ["sync", "macros"] }
|
||||
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "registry", "std"] }
|
||||
chrono = { workspace = true, features = ["wasmbind"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = [
|
||||
"Blob",
|
||||
"Document",
|
||||
"Element",
|
||||
"HtmlAnchorElement",
|
||||
"HtmlCanvasElement",
|
||||
"Location",
|
||||
"Performance",
|
||||
"Response",
|
||||
"Storage",
|
||||
"Url",
|
||||
"UrlSearchParams",
|
||||
"Window",
|
||||
"console",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = "0.1"
|
||||
tr-turnkey = { workspace = true, features = ["mock"] }
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 141 KiB |
+134
-2
@@ -1,7 +1,86 @@
|
||||
//! Spring animations (SwiftUI-like), integrated per frame and keyed by egui `Id`.
|
||||
//! Spring animations (SwiftUI-like), integrated per frame and keyed by egui `Id`, plus
|
||||
//! timed transitions and easing curves. Everything snaps when Reduce Motion is on.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
use egui::{Context, Id};
|
||||
|
||||
static REDUCED: AtomicBool = AtomicBool::new(false);
|
||||
static KEYS: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
/// A key no other [`progress`] transition has used, so replacing an object (a pane
|
||||
/// reloaded under the same id) never mistakes its transition for a finished one.
|
||||
pub fn fresh_key() -> u64 {
|
||||
KEYS.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Whether the system asks for reduced motion.
|
||||
pub fn reduced() -> bool {
|
||||
REDUCED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn set_reduced(on: bool) {
|
||||
REDUCED.store(on, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn ease_out(t: f32) -> f32 {
|
||||
1.0 - (1.0 - t.clamp(0.0, 1.0)).powi(3)
|
||||
}
|
||||
|
||||
/// Ease out with a slight overshoot past 1 before settling.
|
||||
pub fn ease_out_back(t: f32) -> f32 {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let (c1, c3) = (1.70158, 2.70158);
|
||||
1.0 + c3 * (t - 1.0).powi(3) + c1 * (t - 1.0).powi(2)
|
||||
}
|
||||
|
||||
/// `t` rescaled so `a..b` maps to 0..1 (clamped), for staggering parts of one transition.
|
||||
pub fn span(t: f32, a: f32, b: f32) -> f32 {
|
||||
((t - a) / (b - a)).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Linear progress (0..1) of a `secs`-long transition that restarts whenever `key`
|
||||
/// changes. The first call for an `id` starts it too. Requests repaints until done.
|
||||
pub fn progress(ctx: &Context, id: Id, key: u64, secs: f32) -> f32 {
|
||||
if reduced() {
|
||||
return 1.0;
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
let (k, start) = ctx.data_mut(|d| *d.get_temp_mut_or_insert_with(id, || (key, now)));
|
||||
let start = if k == key {
|
||||
start
|
||||
} else {
|
||||
ctx.data_mut(|d| d.insert_temp(id, (key, now)));
|
||||
now
|
||||
};
|
||||
since(ctx, now - start, secs)
|
||||
}
|
||||
|
||||
/// Entrance progress (0..1) of something drawn while a condition holds (a popover, an
|
||||
/// overlay): it restarts whenever `id` was not shown on the previous frame.
|
||||
pub fn appear(ctx: &Context, id: Id, secs: f32) -> f32 {
|
||||
let frame = ctx.cumulative_frame_nr();
|
||||
let now = ctx.input(|i| i.time);
|
||||
let start = match ctx.data(|d| d.get_temp::<(u64, f64)>(id)) {
|
||||
Some((last, start)) if last + 1 >= frame => start,
|
||||
_ => now,
|
||||
};
|
||||
ctx.data_mut(|d| d.insert_temp(id, (frame, start)));
|
||||
since(ctx, now - start, secs)
|
||||
}
|
||||
|
||||
/// Progress of a transition that began `elapsed` seconds ago.
|
||||
pub fn since(ctx: &Context, elapsed: f64, secs: f32) -> f32 {
|
||||
if reduced() {
|
||||
return 1.0;
|
||||
}
|
||||
let t = (elapsed / secs.max(1e-3) as f64).clamp(0.0, 1.0) as f32;
|
||||
if t < 1.0 {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// A damped spring moving `pos` toward a target.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Spring {
|
||||
@@ -50,7 +129,7 @@ pub fn spring(ctx: &Context, id: Id, target: f32) -> f32 {
|
||||
pub fn spring_with(ctx: &Context, id: Id, target: f32, response: f32, damping: f32) -> f32 {
|
||||
let dt = ctx.input(|i| i.stable_dt).clamp(0.0, 0.1);
|
||||
let mut s = ctx.data_mut(|d| *d.get_temp_mut_or_insert_with(id, || Spring::at(target)));
|
||||
if s.settled(target, target) {
|
||||
if reduced() || s.settled(target, target) {
|
||||
s = Spring::at(target);
|
||||
} else {
|
||||
s.step(target, dt, response, damping);
|
||||
@@ -99,6 +178,59 @@ mod tests {
|
||||
assert!(s.pos.is_finite() && s.pos.abs() < 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn easings_hit_their_ends() {
|
||||
for f in [ease_out, ease_out_back] {
|
||||
assert!(f(0.0).abs() < 1e-6);
|
||||
assert!((f(1.0) - 1.0).abs() < 1e-6);
|
||||
}
|
||||
let peak = (0..=100).map(|i| ease_out_back(i as f32 / 100.0)).fold(0.0, f32::max);
|
||||
assert!(peak > 1.05 && peak < 1.15, "overshoot {peak}");
|
||||
assert!(ease_out(0.5) > 0.5);
|
||||
assert_eq!(span(0.5, 0.25, 0.75), 0.5);
|
||||
assert_eq!(span(0.1, 0.25, 0.75), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_restarts_when_the_key_changes() {
|
||||
let ctx = Context::default();
|
||||
let id = Id::new("p");
|
||||
let frame = |key: u64, t: f64| {
|
||||
let input = egui::RawInput { time: Some(t), ..Default::default() };
|
||||
let mut out = 0.0;
|
||||
ctx.run_ui(input, |ui| out = progress(ui.ctx(), id, key, 0.5)).textures_delta.clear();
|
||||
out
|
||||
};
|
||||
assert_eq!(frame(1, 10.0), 0.0);
|
||||
assert!((frame(1, 10.25) - 0.5).abs() < 1e-4);
|
||||
assert_eq!(frame(1, 11.0), 1.0);
|
||||
assert_eq!(frame(2, 12.0), 0.0);
|
||||
assert!((frame(2, 12.1) - 0.2).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appear_restarts_after_a_hidden_frame() {
|
||||
let ctx = Context::default();
|
||||
let id = Id::new("a");
|
||||
let frame = |t: f64, show: bool| {
|
||||
let input = egui::RawInput { time: Some(t), ..Default::default() };
|
||||
let mut out = None;
|
||||
ctx.run_ui(input, |ui| {
|
||||
if show {
|
||||
out = Some(appear(ui.ctx(), id, 0.2));
|
||||
}
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
out
|
||||
};
|
||||
assert_eq!(frame(1.0, true), Some(0.0));
|
||||
assert!((frame(1.1, true).unwrap() - 0.5).abs() < 1e-4);
|
||||
assert_eq!(frame(1.3, true), Some(1.0));
|
||||
frame(1.4, false);
|
||||
assert_eq!(frame(1.5, true), Some(0.0), "reopened");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_helper_snaps_then_animates() {
|
||||
let ctx = Context::default();
|
||||
|
||||
+248
-19
@@ -6,6 +6,12 @@
|
||||
|
||||
mod broker;
|
||||
mod chrome;
|
||||
mod demo;
|
||||
mod hl;
|
||||
pub(crate) use hl::store_account as hl_store_account;
|
||||
mod hl_view;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod hl_web;
|
||||
mod hotkeys_view;
|
||||
mod options_view;
|
||||
mod palette_view;
|
||||
@@ -14,12 +20,14 @@ mod portfolio_view;
|
||||
mod settings_view;
|
||||
mod theming;
|
||||
mod trading;
|
||||
mod wallet;
|
||||
mod wallet_view;
|
||||
mod workspace;
|
||||
|
||||
pub use broker::PosSort;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
use egui::{Color32, Event, Key, Modifiers, Rect, Ui, UiBuilder, pos2, vec2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -27,6 +35,7 @@ use tr_chart::drawings::Tool;
|
||||
use tr_congress::Trade;
|
||||
use tr_congress::db::{Alert, PoliticianSummary};
|
||||
use tr_data::{NewsItem, OrderBook, Quote, SearchHit, YieldCurves};
|
||||
use web_time::Instant;
|
||||
|
||||
use crate::anim;
|
||||
use crate::chart_view::ChartState;
|
||||
@@ -43,6 +52,9 @@ use crate::worker::{Profile, Request, Response, Worker, data_dir};
|
||||
use chrome::{SIDEBAR_W, STATUS_H, SidebarCmd};
|
||||
use settings_view::SettingsTab;
|
||||
|
||||
/// How often a visible order book pane asks for its book.
|
||||
const BOOK_POLL: Duration = Duration::from_millis(400);
|
||||
|
||||
pub enum PaneKind {
|
||||
Chart(Box<ChartState>),
|
||||
Feed {
|
||||
@@ -153,18 +165,50 @@ pub struct BookPane {
|
||||
pub book: Option<OrderBook>,
|
||||
pub error: Option<String>,
|
||||
pub fetched: Option<Instant>,
|
||||
/// A request is out. A REST book can take longer than [`BOOK_POLL`], and requests run
|
||||
/// concurrently, so the pane waits for the answer before it asks again.
|
||||
pub pending: bool,
|
||||
/// Numbers the pane's asks; only the answer to the latest one clears `pending`.
|
||||
pub asked: u64,
|
||||
/// The latest trades, oldest first, when the book comes from the Terminal server.
|
||||
pub trades: Vec<tr_data::TradePrint>,
|
||||
}
|
||||
|
||||
impl BookPane {
|
||||
pub fn new(symbol: &str, depth: usize, group: f64) -> Self {
|
||||
Self { symbol: symbol.into(), depth, group, book: None, error: None, fetched: None }
|
||||
Self {
|
||||
symbol: symbol.into(),
|
||||
depth,
|
||||
group,
|
||||
book: None,
|
||||
error: None,
|
||||
fetched: None,
|
||||
pending: false,
|
||||
asked: 0,
|
||||
trades: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Time to ask for the book again: [`BOOK_POLL`] since the last ask, once it was answered (or
|
||||
/// went unanswered for 10 s).
|
||||
fn due(&self) -> bool {
|
||||
self.fetched.is_none_or(|t| t.elapsed() > BOOK_POLL && (!self.pending || t.elapsed() > Duration::from_secs(10)))
|
||||
}
|
||||
|
||||
/// Whether the book came through the Terminal server, which also has the market's trades.
|
||||
pub fn served_by_server(&self) -> bool {
|
||||
self.book.as_ref().is_some_and(|b| b.source.ends_with("Terminal server"))
|
||||
}
|
||||
}
|
||||
|
||||
impl PaneKind {
|
||||
/// Short tab title.
|
||||
fn tab_title(&self) -> String {
|
||||
let short = |s: &str| tr_data::security(s).ticker;
|
||||
// A perp's tab says so: `xyz:AAPL PERP`.
|
||||
let short = |s: &str| {
|
||||
let sec = tr_data::security(s);
|
||||
if sec.class == tr_core::AssetClass::Perp { format!("{} PERP", sec.ticker) } else { sec.ticker }
|
||||
};
|
||||
match self {
|
||||
PaneKind::Chart(c) => short(&c.symbol),
|
||||
PaneKind::Feed { .. } => "Congress".into(),
|
||||
@@ -401,6 +445,9 @@ fn migrate_legacy(s: &Settings, defaults: &prefs::PanelDefaults) -> Option<Layou
|
||||
Some(LayoutDoc::preset("Default", s.layout.unwrap_or(Layout::Grid2x2), panes))
|
||||
}
|
||||
|
||||
/// How long an alert toast stays up.
|
||||
const TOAST_SECS: u64 = 10;
|
||||
|
||||
/// A pane drag from its tab or header: pending until the pointer moves past a small
|
||||
/// threshold (so clicks still select tabs), then active until release.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -421,6 +468,7 @@ pub struct TerminalApp {
|
||||
/// Preset last applied, for the toolbar picker (cleared by manual rearranging).
|
||||
preset: Option<Layout>,
|
||||
drag: Option<Drag>,
|
||||
dock_motion: workspace::DockMotion,
|
||||
/// Tab being renamed inline: pane and edit buffer.
|
||||
renaming_tab: Option<(PaneId, String)>,
|
||||
|
||||
@@ -458,6 +506,10 @@ pub struct TerminalApp {
|
||||
wei_updated: Option<String>,
|
||||
/// Latest network search results and the query they answer.
|
||||
net_hits: (String, Vec<SearchHit>),
|
||||
/// HIP-3 perps the Terminal server lists as live (`None` until it has answered once), and
|
||||
/// when they were last asked for.
|
||||
live_perps: Option<Vec<String>>,
|
||||
last_perps: Option<(Instant, bool)>,
|
||||
/// Query typed in the command line awaiting a debounced network search.
|
||||
search_pending: Option<(String, Instant)>,
|
||||
suggestion: Option<usize>,
|
||||
@@ -496,6 +548,7 @@ pub struct TerminalApp {
|
||||
system_accent: Color32,
|
||||
last_accent_poll: Option<Instant>,
|
||||
window_styled: bool,
|
||||
updater_started: bool,
|
||||
/// The window is see-through behind the sidebar (macOS vibrancy).
|
||||
vibrancy: bool,
|
||||
/// Measured macOS title bar geometry (polled; changes with full screen).
|
||||
@@ -505,11 +558,28 @@ 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,
|
||||
/// Hyperliquid (testnet): the account, the agent key and its confirmations.
|
||||
hl: hl::HlState,
|
||||
/// ⌘K command palette.
|
||||
palette: crate::palette::Palette,
|
||||
}
|
||||
|
||||
/// Resolve a run of words (`BTCUSD CRNCY AAPL US EQUITY MSFT`) into market keys.
|
||||
/// Lay fresh quotes over the ones shown, and return the keys that got one. A perp that was
|
||||
/// asked for and left out has no other source, so its old quote goes rather than pass for current.
|
||||
fn merge_quotes(shown: &mut HashMap<String, Quote>, asked: &[String], quotes: Vec<Quote>) -> Vec<String> {
|
||||
let keys: Vec<String> = quotes.iter().map(|q| q.symbol.clone()).collect();
|
||||
for gone in asked.iter().filter(|k| tr_data::is_perp(k) && !keys.contains(k)) {
|
||||
shown.remove(gone);
|
||||
}
|
||||
for q in quotes {
|
||||
shown.insert(q.symbol.clone(), q);
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
fn keys_from_words(words: &[String]) -> Vec<String> {
|
||||
let tokens: Vec<&str> = words.iter().map(String::as_str).collect();
|
||||
let mut out = Vec::new();
|
||||
@@ -560,11 +630,14 @@ impl TerminalApp {
|
||||
};
|
||||
}
|
||||
let settings: Option<Settings> =
|
||||
std::fs::read(settings_path()).ok().and_then(|b| serde_json::from_slice(&b).ok());
|
||||
crate::storage::read(&settings_path()).ok().and_then(|b| serde_json::from_slice(&b).ok());
|
||||
let broker = broker::BrokerState::load(provider.eq_ignore_ascii_case("sample"));
|
||||
let (demo, demo_note) = demo::DemoState::load(demo::demo_path());
|
||||
let mut app = Self {
|
||||
broker,
|
||||
trading: Default::default(),
|
||||
demo,
|
||||
hl: hl::HlState::platform(),
|
||||
worker,
|
||||
provider,
|
||||
panes: Vec::new(),
|
||||
@@ -574,6 +647,7 @@ impl TerminalApp {
|
||||
last_opened: None,
|
||||
preset: None,
|
||||
drag: None,
|
||||
dock_motion: Default::default(),
|
||||
renaming_tab: None,
|
||||
store: Store::new(data_dir().join("layouts")),
|
||||
layout_name: "Default".into(),
|
||||
@@ -600,6 +674,8 @@ impl TerminalApp {
|
||||
last_curve: None,
|
||||
wei_updated: None,
|
||||
net_hits: (String::new(), Vec::new()),
|
||||
live_perps: None,
|
||||
last_perps: None,
|
||||
search_pending: None,
|
||||
suggestion: None,
|
||||
cmd: String::new(),
|
||||
@@ -629,6 +705,7 @@ impl TerminalApp {
|
||||
system_accent,
|
||||
last_accent_poll: None,
|
||||
window_styled: false,
|
||||
updater_started: false,
|
||||
vibrancy: false,
|
||||
chrome: None,
|
||||
last_chrome_poll: None,
|
||||
@@ -660,12 +737,31 @@ impl TerminalApp {
|
||||
app.save_layout();
|
||||
}
|
||||
app.refresh_layout_names();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
app.hl.ctx = Some(ctx.clone());
|
||||
}
|
||||
app.hl_start();
|
||||
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(),
|
||||
@@ -675,7 +771,7 @@ impl TerminalApp {
|
||||
panes: None,
|
||||
};
|
||||
if let Ok(json) = serde_json::to_vec_pretty(&s)
|
||||
&& let Err(e) = crate::layouts::write_atomic(&settings_path(), &json)
|
||||
&& let Err(e) = crate::storage::write_atomic(&settings_path(), &json)
|
||||
{
|
||||
tracing::warn!("saving settings: {e}");
|
||||
}
|
||||
@@ -751,6 +847,7 @@ impl TerminalApp {
|
||||
self.status = format!("{s}: option chain");
|
||||
}
|
||||
Command::SymbolFunction(s, Function::Portfolio(view, arg)) => self.portfolio_command(view, &arg, Some(s)),
|
||||
Command::SymbolFunction(s, Function::Perp) => self.open_perp(Some(s)),
|
||||
Command::SymbolFunction(s, Function::Trade(mut t)) => {
|
||||
t.symbol = Some(s);
|
||||
self.open_ticket(t, None);
|
||||
@@ -775,6 +872,7 @@ impl TerminalApp {
|
||||
Function::Timeframe(tf) => self.with_chart(|c, w| c.set_timeframe(tf, w)),
|
||||
Function::ChartType(t) => self.with_chart(|c, _| c.chart_type = t),
|
||||
Function::Indicator(spec) => self.with_chart(|c, w| c.toggle_indicator(spec, w)),
|
||||
Function::IndicatorPicker(q) => self.palette.open_scoped(crate::palette::Kind::Indicator, &q),
|
||||
Function::ClearIndicators => self.with_chart(|c, _| c.clear_indicators()),
|
||||
Function::Compare(s) => self.with_chart(|c, w| c.set_compare(s, w)),
|
||||
Function::LogScale => self.with_chart(|c, _| c.log = !c.log),
|
||||
@@ -844,6 +942,15 @@ impl TerminalApp {
|
||||
self.open_panel(PaneKind::OrderBook(Box::new(BookPane::new(&sym, depth, 0.0))));
|
||||
}
|
||||
Function::Settings => self.open_panel(PaneKind::Settings),
|
||||
Function::Update => {
|
||||
if !tr_macos::updater::check_for_updates() {
|
||||
let url = format!("{}/latest", tr_macos::updater::RELEASES);
|
||||
self.status = match broker::open_external(&url) {
|
||||
Ok(()) => "Opened the latest release on GitHub".into(),
|
||||
Err(e) => format!("Could not open {url}: {e}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
Function::Options => {
|
||||
let sym = self.optionable_symbol();
|
||||
self.open_panel(PaneKind::Options(Box::new(OptionsPane::new(&sym, 12, true))));
|
||||
@@ -888,6 +995,18 @@ 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);
|
||||
}
|
||||
Function::Kill => self.hl_kill(),
|
||||
Function::Perp => {
|
||||
let key = symbol.or_else(|| self.focused_symbol());
|
||||
self.open_perp(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -913,6 +1032,7 @@ impl TerminalApp {
|
||||
prepended,
|
||||
from_cache,
|
||||
exhausted,
|
||||
full,
|
||||
live,
|
||||
} => {
|
||||
let worker = &self.worker;
|
||||
@@ -920,7 +1040,7 @@ impl TerminalApp {
|
||||
&& c.symbol == symbol
|
||||
&& c.tf == tf
|
||||
{
|
||||
c.on_history(generation, bars, gpu, prepended, from_cache, exhausted, live, worker);
|
||||
c.on_history(generation, bars, gpu, prepended, from_cache, exhausted, full, live, worker);
|
||||
}
|
||||
}
|
||||
Response::HistoryError { pane, generation, error } => {
|
||||
@@ -948,13 +1068,12 @@ impl TerminalApp {
|
||||
c.on_markers(&symbol, trades);
|
||||
}
|
||||
}
|
||||
Response::Quotes(q) => {
|
||||
if q.len() > 20 {
|
||||
Response::Quotes { asked, quotes } => {
|
||||
if quotes.len() > 20 {
|
||||
self.wei_updated = Some(format!("updated {}", chrono::Local::now().format("%H:%M:%S")));
|
||||
}
|
||||
for x in q {
|
||||
self.quotes.insert(x.symbol.clone(), x);
|
||||
}
|
||||
let keys = merge_quotes(&mut self.quotes, &asked, quotes);
|
||||
self.demo_on_quotes(&keys);
|
||||
}
|
||||
Response::Latest(t) => self.latest = t,
|
||||
Response::TickerTrades { pane, symbol, trades } => {
|
||||
@@ -1006,12 +1125,15 @@ impl TerminalApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
Response::OrderBook { pane, symbol, book } => {
|
||||
Response::OrderBook { pane, symbol, ask, book } => {
|
||||
if let Some(p) = self.panes.iter_mut().find(|p| p.id == pane)
|
||||
&& let PaneKind::OrderBook(b) = &mut p.kind
|
||||
&& b.symbol == symbol
|
||||
{
|
||||
if ask == b.asked {
|
||||
b.pending = false;
|
||||
}
|
||||
match book {
|
||||
_ if b.symbol != symbol => {}
|
||||
Ok(book) => {
|
||||
b.book = Some(book);
|
||||
b.error = None;
|
||||
@@ -1020,6 +1142,15 @@ impl TerminalApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
Response::Trades { pane, symbol, trades } => {
|
||||
if let Some(p) = self.panes.iter_mut().find(|p| p.id == pane)
|
||||
&& let PaneKind::OrderBook(b) = &mut p.kind
|
||||
&& b.symbol == symbol
|
||||
{
|
||||
// A failed fetch clears the list rather than leave old trades up.
|
||||
b.trades = trades.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
Response::Portfolio(result) => {
|
||||
let result = self.keep_simulated_trades(result);
|
||||
self.on_portfolio(result)
|
||||
@@ -1039,7 +1170,19 @@ impl TerminalApp {
|
||||
Response::Status(s) => self.status = s,
|
||||
Response::Curves(c) => self.curves = Some(c),
|
||||
Response::SearchHits { query, hits } => self.net_hits = (query, hits),
|
||||
Response::PerpMarkets(result) => {
|
||||
let ok = result.is_ok();
|
||||
match result {
|
||||
Ok(perps) => self.live_perps = Some(perps),
|
||||
// Keep what the server said last; links to perps it listed stay usable.
|
||||
Err(e) => tracing::debug!("live perps: {e}"),
|
||||
}
|
||||
if let Some((_, answered)) = &mut self.last_perps {
|
||||
*answered = ok;
|
||||
}
|
||||
}
|
||||
Response::Trade(r) => self.on_trade(*r),
|
||||
Response::Hl(r) => self.on_hl(*r),
|
||||
Response::CongressRefreshed(summary) => {
|
||||
self.congress_status = summary.to_string();
|
||||
self.congress_checked = Some(chrono::Local::now());
|
||||
@@ -1052,6 +1195,9 @@ impl TerminalApp {
|
||||
|
||||
/// Freshness line for the Congress feed: last check, newest filing, next check.
|
||||
fn congress_freshness(&self) -> String {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return "desktop app only for now".into();
|
||||
}
|
||||
let newest = self.latest.iter().map(|t| t.filed_date).max();
|
||||
let minutes = std::env::var("TR_CONGRESS_REFRESH_MIN")
|
||||
.ok()
|
||||
@@ -1146,19 +1292,26 @@ impl TerminalApp {
|
||||
return;
|
||||
}
|
||||
// Chorded bindings (⌘, Ctrl, Alt) work even while the command line has text.
|
||||
let mut swallow_text = false;
|
||||
for &(key, m) in &presses {
|
||||
let Some(action) = self.prefs.hotkeys.action_for(key, m) else { continue };
|
||||
let chorded = m.command || m.ctrl || m.alt;
|
||||
if !chorded && !self.cmd.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// ⇧B / ⇧S type letters: never while the command line is being typed into.
|
||||
let letter = !chorded && Self::types_text(key);
|
||||
if letter && cmd_focused {
|
||||
continue;
|
||||
}
|
||||
if !self.hotkey_applies(action) {
|
||||
continue;
|
||||
}
|
||||
ctx.input_mut(|i| i.consume_key(m, key));
|
||||
swallow_text |= letter;
|
||||
self.run_hotkey(action);
|
||||
}
|
||||
if !cmd_focused {
|
||||
if !cmd_focused && !swallow_text {
|
||||
// Type-anywhere: capture keystrokes until the command line has focus.
|
||||
// Judge modifiers per key event: the Alt release can arrive in the same frame
|
||||
// as Alt+T, so the frame-level modifier state is unreliable.
|
||||
@@ -1216,6 +1369,12 @@ impl TerminalApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keys that also produce text (a bare or shifted letter, digit or symbol).
|
||||
fn types_text(key: Key) -> bool {
|
||||
let n = key.name();
|
||||
n.len() == 1 || matches!(key, Key::Space | Key::Minus | Key::Plus | Key::Equals | Key::Period | Key::Comma)
|
||||
}
|
||||
|
||||
fn hotkey_applies(&self, a: prefs::Action) -> bool {
|
||||
use prefs::Action as A;
|
||||
match a {
|
||||
@@ -1226,6 +1385,8 @@ impl TerminalApp {
|
||||
| A::ResetView
|
||||
| A::LogScale
|
||||
| A::Magnet => self.focused_is_chart(),
|
||||
// Otherwise ⇧P types a P into the command line.
|
||||
A::TogglePerp => self.focused_symbol().is_some_and(|s| self.switch_target(&s).is_some()),
|
||||
a => a.layout_slot().is_none_or(|i| i < self.layout_names.len()),
|
||||
}
|
||||
}
|
||||
@@ -1243,6 +1404,9 @@ 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::TogglePerp => self.apply(Function::Perp),
|
||||
A::NextPane => self.cycle_focus(1),
|
||||
A::PrevPane => self.cycle_focus(-1),
|
||||
A::ToggleSidebar => self.sidebar_open = !self.sidebar_open,
|
||||
@@ -1294,13 +1458,20 @@ impl TerminalApp {
|
||||
self.worker.send(Request::Quotes(markets_view::overview_keys()));
|
||||
self.last_wei = Some(Instant::now());
|
||||
}
|
||||
// Which perps are live: every 5 minutes, or every minute while the server isn't answering.
|
||||
let every = |ok: bool| Duration::from_secs(if ok { 300 } else { 60 });
|
||||
if self.last_perps.is_none_or(|(t, ok)| t.elapsed() > every(ok)) {
|
||||
self.worker.send(Request::PerpMarkets);
|
||||
self.last_perps = Some((Instant::now(), self.last_perps.is_none_or(|(_, ok)| ok)));
|
||||
}
|
||||
if shown(&|k| matches!(k, PaneKind::Curve))
|
||||
&& self.last_curve.is_none_or(|t| t.elapsed() > Duration::from_secs(600))
|
||||
{
|
||||
self.worker.send(Request::YieldCurves);
|
||||
self.last_curve = Some(Instant::now());
|
||||
}
|
||||
// News every 5 minutes and order books every second, while visible.
|
||||
// News every 5 minutes and order books about twice a second, while visible. The Terminal
|
||||
// server's books change about every 0.5 s; 400 ms puts a poll on every 500 ms repaint tick.
|
||||
for &i in &visible {
|
||||
let p = &mut self.panes[i];
|
||||
match &mut p.kind {
|
||||
@@ -1316,13 +1487,24 @@ impl TerminalApp {
|
||||
expiration: o.expiration,
|
||||
});
|
||||
}
|
||||
PaneKind::OrderBook(b) if b.fetched.is_none_or(|t| t.elapsed() > Duration::from_millis(1000)) => {
|
||||
PaneKind::OrderBook(b) if b.due() => {
|
||||
b.fetched = Some(Instant::now());
|
||||
self.worker.send(Request::OrderBook { pane: p.id, symbol: b.symbol.clone(), depth: b.depth });
|
||||
b.pending = true;
|
||||
b.asked += 1;
|
||||
self.worker.send(Request::OrderBook {
|
||||
pane: p.id,
|
||||
symbol: b.symbol.clone(),
|
||||
depth: b.depth,
|
||||
ask: b.asked,
|
||||
});
|
||||
if b.served_by_server() {
|
||||
self.worker.send(Request::Trades { pane: p.id, symbol: b.symbol.clone() });
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.hl_periodic();
|
||||
self.broker_periodic(&visible);
|
||||
if let Some((q, at)) = &self.search_pending
|
||||
&& at.elapsed() > Duration::from_millis(300)
|
||||
@@ -1335,11 +1517,13 @@ impl TerminalApp {
|
||||
self.last_feed = Some(Instant::now());
|
||||
}
|
||||
self.save_dirty_themes(false);
|
||||
if self.last_save.elapsed() > Duration::from_secs(10) {
|
||||
// A browser tab can close without an exit callback, so save sooner there.
|
||||
let every = Duration::from_secs(if cfg!(target_arch = "wasm32") { 2 } else { 10 });
|
||||
if self.last_save.elapsed() > every {
|
||||
self.autosave();
|
||||
self.last_save = Instant::now();
|
||||
}
|
||||
self.toasts.retain(|(_, t)| t.elapsed() < Duration::from_secs(10));
|
||||
self.toasts.retain(|(_, t)| t.elapsed() < Duration::from_secs(TOAST_SECS));
|
||||
}
|
||||
|
||||
/// Save the active layout (only when it changed) and app settings.
|
||||
@@ -1402,6 +1586,10 @@ impl eframe::App for TerminalApp {
|
||||
if !self.window_styled {
|
||||
self.window_styled = true;
|
||||
self.vibrancy = style_native_window(frame, self.prefs.appearance.vibrancy);
|
||||
} else if !self.updater_started {
|
||||
// From the second frame, so loading Sparkle doesn't delay the first.
|
||||
self.updater_started = true;
|
||||
tr_macos::updater::start();
|
||||
}
|
||||
if self.last_chrome_poll.is_none_or(|t| t.elapsed() > Duration::from_millis(750)) {
|
||||
let c = native_chrome(frame);
|
||||
@@ -1415,6 +1603,7 @@ impl eframe::App for TerminalApp {
|
||||
if let Some([r, g, b]) = tr_macos::accent_color() {
|
||||
self.system_accent = Color32::from_rgb(r, g, b);
|
||||
}
|
||||
anim::set_reduced(tr_macos::reduce_motion());
|
||||
self.last_accent_poll = Some(Instant::now());
|
||||
}
|
||||
self.system_theme = ctx.system_theme();
|
||||
@@ -1506,6 +1695,13 @@ impl eframe::App for TerminalApp {
|
||||
}
|
||||
let cpu = frame_start.elapsed().as_secs_f64() * 1e3;
|
||||
self.perf_cpu_ms = self.perf_cpu_ms * 0.9 + cpu * 0.1;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
crate::web::record_frame(&crate::web::FrameStats {
|
||||
ui_ms: self.perf_cpu_ms,
|
||||
interval_ms: self.perf_dt_ms,
|
||||
last_cpu_ms: frame.info().cpu_usage.map_or(0.0, |s| f64::from(s) * 1e3),
|
||||
frames: ctx.cumulative_frame_nr(),
|
||||
});
|
||||
if self.show_perf {
|
||||
// Measure continuously while the readout is visible.
|
||||
ctx.request_repaint();
|
||||
@@ -1519,6 +1715,9 @@ impl eframe::App for TerminalApp {
|
||||
|
||||
fn on_exit(&mut self) {
|
||||
self.autosave();
|
||||
if self.demo.save_error.is_some() {
|
||||
self.demo.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1526,6 +1725,17 @@ impl eframe::App for TerminalApp {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_book_pane_asks_again_once_answered() {
|
||||
let ago = |ms| Instant::now().checked_sub(Duration::from_millis(ms));
|
||||
let pane = |fetched, pending| BookPane { fetched, pending, ..BookPane::new("BTCUSD Curncy", 20, 0.0) };
|
||||
assert!(pane(None, false).due());
|
||||
assert!(!pane(ago(0), false).due());
|
||||
assert!(pane(ago(450), false).due(), "a poll on every 500 ms repaint tick");
|
||||
assert!(!pane(ago(450), true).due(), "one request out at a time");
|
||||
assert!(pane(ago(11_000), true).due(), "a lost answer doesn't stop the pane");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_settings_migrate_to_a_layout() {
|
||||
let old = r#"{"layout":"MainRight2","watchlist":["AAPL","BTCUSD Curncy"],"sidebar":false,
|
||||
@@ -1544,6 +1754,25 @@ mod tests {
|
||||
assert_eq!(new.active_layout.as_deref(), Some("Desk"));
|
||||
}
|
||||
|
||||
/// When the server can't quote a perp, the watchlist drops its old price instead of showing
|
||||
/// it as current. Listed instruments keep theirs, as before.
|
||||
#[test]
|
||||
fn perps_left_out_of_an_answer_lose_their_old_quote() {
|
||||
let q = |s: &str, last: f64| Quote { symbol: s.into(), last, prev_close: last, ts: 0, spark: vec![] };
|
||||
let mut shown: HashMap<String, Quote> =
|
||||
[q("xyz:AAPL", 336.0), q("xyz:GOLD", 3958.0), q("AAPL US Equity", 336.5)]
|
||||
.map(|x| (x.symbol.clone(), x))
|
||||
.into();
|
||||
let asked: Vec<String> = ["xyz:AAPL", "xyz:GOLD", "AAPL US Equity"].map(String::from).to_vec();
|
||||
let got = merge_quotes(&mut shown, &asked, vec![q("xyz:GOLD", 3960.0)]);
|
||||
assert_eq!(got, ["xyz:GOLD"]);
|
||||
assert!(!shown.contains_key("xyz:AAPL"), "the perp the server didn't quote");
|
||||
assert_eq!(shown["xyz:GOLD"].last, 3960.0);
|
||||
assert_eq!(shown["AAPL US Equity"].last, 336.5, "a listed instrument keeps its last quote");
|
||||
merge_quotes(&mut shown, &["xyz:SP500".into()], vec![]);
|
||||
assert!(shown.contains_key("xyz:GOLD"), "a perp nobody asked about this time stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_settings_round_trip_through_kinds() {
|
||||
let d = prefs::PanelDefaults::default();
|
||||
|
||||
+98
-20
@@ -3,9 +3,10 @@
|
||||
//! refresh scheduling and the `PORT`/`ACCT`/`POS`/`ORD`/`ACTV` commands. Order entry is in
|
||||
//! `trading`.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
use tr_broker::{Credentials, Portfolio, Position, SecretStore};
|
||||
use web_time::Instant;
|
||||
|
||||
use super::{PaneKind, TerminalApp};
|
||||
use crate::command::PortView;
|
||||
@@ -100,9 +101,15 @@ pub struct BrokerState {
|
||||
|
||||
impl BrokerState {
|
||||
pub fn load(offline: bool) -> Self {
|
||||
let store = SecretStore::platform(&data_dir());
|
||||
Self::with_store(SecretStore::platform(&data_dir()), offline)
|
||||
}
|
||||
|
||||
/// [`Self::load`] with credentials from `store` instead of the platform's.
|
||||
pub fn with_store(store: SecretStore, offline: bool) -> Self {
|
||||
let mut message = None;
|
||||
let stored = match store.load() {
|
||||
// The browser build can't reach SnapTrade, and keys don't belong in page storage.
|
||||
let stored = if cfg!(target_arch = "wasm32") { Ok(None) } else { store.load() };
|
||||
let stored = match stored {
|
||||
Ok(c) => c.unwrap_or_default(),
|
||||
Err(e) => {
|
||||
tracing::warn!("SnapTrade credentials unavailable: {e:#}");
|
||||
@@ -112,7 +119,9 @@ impl BrokerState {
|
||||
};
|
||||
let mut creds = stored.clone();
|
||||
let from_env = creds.apply_env(|k| std::env::var(k).ok());
|
||||
let simulate = offline || std::env::var("TR_BROKER").is_ok_and(|v| v.eq_ignore_ascii_case("sample"));
|
||||
let simulate = offline
|
||||
|| cfg!(target_arch = "wasm32")
|
||||
|| std::env::var("TR_BROKER").is_ok_and(|v| v.eq_ignore_ascii_case("sample"));
|
||||
Self {
|
||||
store,
|
||||
saved: stored,
|
||||
@@ -145,18 +154,31 @@ impl BrokerState {
|
||||
BrokerMode::Setup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The terminal's own quote for a position, when it maps onto a market key (options are
|
||||
/// never repriced from their underlying).
|
||||
pub fn live_price(&self, quotes: &std::collections::HashMap<String, tr_data::Quote>, p: &Position) -> Option<f64> {
|
||||
if p.kind == tr_broker::InstrumentKind::Option {
|
||||
return None;
|
||||
}
|
||||
quotes.get(&p.market_key()?).map(|q| q.last).filter(|x| x.is_finite() && *x > 0.0)
|
||||
/// The terminal's own price for a position, from the network the position is on: testnet
|
||||
/// mids for Hyperliquid testnet rows, the Terminal server's (mainnet) quotes for everything
|
||||
/// else. Both use the same market keys (`xyz:AAPL`), so a row never falls back to the other
|
||||
/// map. Options are never repriced from their underlying.
|
||||
pub fn live_price(
|
||||
quotes: &std::collections::HashMap<String, tr_data::Quote>,
|
||||
testnet: &std::collections::HashMap<String, f64>,
|
||||
p: &Position,
|
||||
) -> Option<f64> {
|
||||
if p.kind == tr_broker::InstrumentKind::Option {
|
||||
return None;
|
||||
}
|
||||
let key = p.market_key()?;
|
||||
let px = if super::hl::is_testnet_account(&p.account_id) {
|
||||
testnet.get(&key).copied()
|
||||
} else {
|
||||
quotes.get(&key).map(|q| q.last)
|
||||
};
|
||||
px.filter(|x| x.is_finite() && *x > 0.0)
|
||||
}
|
||||
|
||||
/// Open a URL or path with the system handler.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn open_external(target: &str) -> std::io::Result<()> {
|
||||
let opener = if cfg!(target_os = "macos") {
|
||||
"open"
|
||||
@@ -168,11 +190,23 @@ pub fn open_external(target: &str) -> std::io::Result<()> {
|
||||
std::process::Command::new(opener).arg(target).spawn().map(|_| ())
|
||||
}
|
||||
|
||||
/// Open a URL in a new browser tab.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn open_external(target: &str) -> std::io::Result<()> {
|
||||
let window = web_sys::window().ok_or_else(|| std::io::Error::other("no browser window"))?;
|
||||
window
|
||||
.open_with_url_and_target_and_features(target, "_blank", "noopener")
|
||||
.map(|_| ())
|
||||
.map_err(|_| std::io::Error::other("the browser blocked the new tab"))
|
||||
}
|
||||
|
||||
/// A stable, non-identifying SnapTrade user id for this installation.
|
||||
pub fn new_user_id() -> String {
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
|
||||
let mix = nanos ^ (u128::from(std::process::id()) << 64);
|
||||
format!("terminal-rs-{:016x}", (mix as u64) ^ ((mix >> 64) as u64))
|
||||
let nanos = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
|
||||
// Browsers have no process ids.
|
||||
let pid = if cfg!(target_arch = "wasm32") { 0 } else { std::process::id() };
|
||||
let mix = nanos ^ (u128::from(pid) << 64);
|
||||
format!("terminal-{:016x}", (mix as u64) ^ ((mix >> 64) as u64))
|
||||
}
|
||||
|
||||
impl TerminalApp {
|
||||
@@ -198,17 +232,22 @@ impl TerminalApp {
|
||||
let b = &self.broker;
|
||||
let connecting = b.connecting_since.is_some_and(|t| t.elapsed() < CONNECT_WINDOW);
|
||||
let every = if connecting { CONNECT_POLL } else { REFRESH };
|
||||
if !b.loading && b.mode() != BrokerMode::Setup && b.fetched.is_none_or(|t| t.elapsed() > every) {
|
||||
// The demo account is local: live accounts load only while they are shown.
|
||||
let live = !self.demo_mode();
|
||||
if live && !b.loading && b.mode() != BrokerMode::Setup && b.fetched.is_none_or(|t| t.elapsed() > every) {
|
||||
self.refresh_portfolio();
|
||||
}
|
||||
// Reprice holdings with the terminal's own quotes.
|
||||
if let Some(pf) = &self.broker.portfolio
|
||||
if let Some(pf) = self.shown_portfolio()
|
||||
&& self.broker.last_quotes.is_none_or(|t| t.elapsed() > Duration::from_secs(20))
|
||||
{
|
||||
// Testnet rows price from testnet mids (the Hyperliquid stream), never the server.
|
||||
let mut keys: Vec<String> = pf
|
||||
.positions
|
||||
.iter()
|
||||
.filter(|p| p.kind != tr_broker::InstrumentKind::Option)
|
||||
.filter(|p| {
|
||||
p.kind != tr_broker::InstrumentKind::Option && !super::hl::is_testnet_account(&p.account_id)
|
||||
})
|
||||
.filter_map(Position::market_key)
|
||||
.collect();
|
||||
keys.sort();
|
||||
@@ -421,7 +460,10 @@ impl TerminalApp {
|
||||
pub(super) fn portfolio_command(&mut self, view: PortView, arg: &str, symbol: Option<String>) {
|
||||
match arg.trim() {
|
||||
"REFRESH" | "SYNC" | "RELOAD" => {
|
||||
if self.broker.mode() == BrokerMode::Setup {
|
||||
if self.demo_mode() {
|
||||
self.status =
|
||||
"The demo account is local and always current (MODE LIVE for brokerage accounts)".into();
|
||||
} else if self.broker.mode() == BrokerMode::Setup {
|
||||
self.status = "Brokerage not set up: PORT SETUP".into();
|
||||
} else {
|
||||
self.refresh_portfolio();
|
||||
@@ -437,7 +479,7 @@ impl TerminalApp {
|
||||
let mut pane = PortfolioPane::new(view);
|
||||
pane.symbol = symbol;
|
||||
self.open_panel(PaneKind::Portfolio(Box::new(pane)));
|
||||
if self.broker.portfolio.is_none() && !self.broker.loading {
|
||||
if !self.demo_mode() && self.broker.portfolio.is_none() && !self.broker.loading {
|
||||
self.refresh_portfolio();
|
||||
}
|
||||
}
|
||||
@@ -779,10 +821,46 @@ mod tests {
|
||||
let a = new_user_id();
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
let b = new_user_id();
|
||||
assert!(a.starts_with("terminal-rs-") && a.len() == "terminal-rs-".len() + 16);
|
||||
assert!(a.starts_with("terminal-") && a.len() == "terminal-".len() + 16);
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
/// A Hyperliquid testnet row and a mainnet row on the same key (`xyz:AAPL`) with mainnet
|
||||
/// and testnet updates arriving interleaved: each reads only its own network's price.
|
||||
#[test]
|
||||
fn testnet_rows_never_read_mainnet_quotes() {
|
||||
let quote =
|
||||
|last: f64| tr_data::Quote { symbol: "xyz:AAPL".into(), last, prev_close: last, ts: 0, spark: Vec::new() };
|
||||
let row = |account: &str| Position {
|
||||
account_id: account.into(),
|
||||
symbol: "xyz:AAPL".into(),
|
||||
description: String::new(),
|
||||
kind: tr_broker::InstrumentKind::Future,
|
||||
units: 1.0,
|
||||
price: None,
|
||||
cost_basis: Some(300.0),
|
||||
multiplier: 1.0,
|
||||
currency: "USD".into(),
|
||||
cash_equivalent: false,
|
||||
underlying: None,
|
||||
market: Some("xyz:AAPL".into()),
|
||||
};
|
||||
let testnet = row("hl:testnet:0x00000000000000000000000000000000000f107e");
|
||||
let mainnet = row("mainnet-account");
|
||||
let mut quotes = std::collections::HashMap::new();
|
||||
let mut mids = std::collections::HashMap::new();
|
||||
for (main, test) in [(230.0, 322.0), (231.0, 321.5), (229.5, 323.0)] {
|
||||
quotes.insert("xyz:AAPL".to_string(), quote(main));
|
||||
assert_eq!(live_price("es, &mids, &testnet).is_some(), mids.contains_key("xyz:AAPL"));
|
||||
mids.insert("xyz:AAPL".to_string(), test);
|
||||
assert_eq!(live_price("es, &mids, &testnet), Some(test), "the testnet row reads the testnet mid");
|
||||
assert_eq!(live_price("es, &mids, &mainnet), Some(main), "the mainnet row reads the gateway quote");
|
||||
}
|
||||
// With only the other network's price, neither row borrows it.
|
||||
assert_eq!(live_price("es, &Default::default(), &testnet), None);
|
||||
assert_eq!(live_price(&Default::default(), &mids, &mainnet), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_titles() {
|
||||
assert_eq!(PortfolioPane::new(PortView::Summary).title(), "Portfolio");
|
||||
|
||||
+183
-38
@@ -1,13 +1,14 @@
|
||||
//! Window chrome in the style of a SwiftUI macOS app: a unified toolbar holding the
|
||||
//! command line, a translucent (vibrancy) sidebar, rounded pane cards and a status bar.
|
||||
|
||||
use std::time::Instant;
|
||||
use std::time::Duration;
|
||||
|
||||
use egui::{
|
||||
Align2, Color32, CornerRadius, CursorIcon, Id, Key, Modifiers, Rect, RichText, Sense, Shadow, Stroke, StrokeKind,
|
||||
Ui, UiBuilder, ViewportCommand, pos2, vec2,
|
||||
};
|
||||
use tr_data::SearchHit;
|
||||
use web_time::Instant;
|
||||
|
||||
use super::{PaneKind, TerminalApp};
|
||||
use crate::anim;
|
||||
@@ -101,14 +102,49 @@ impl TerminalApp {
|
||||
resp.context_menu(|ui| self.view_menu(ui));
|
||||
|
||||
// Data source chip (the window title itself is redundant in a compact toolbar).
|
||||
let live = !self.provider.eq_ignore_ascii_case("sample");
|
||||
let chip_text = p.layout_no_wrap(if live { "Live" } else { "Offline" }.into(), ui_font(11.0), muted());
|
||||
let (label, live) = source_chip(&self.provider);
|
||||
let chip_text = p.layout_no_wrap(label.into(), ui_font(11.0), muted());
|
||||
let chip = Rect::from_min_size(pos2(x, cy - 9.0), vec2(chip_text.size().x + 20.0, 18.0));
|
||||
p.rect_filled(chip, 9.0, theme::control());
|
||||
p.circle_filled(pos2(chip.left() + 8.0, cy), 3.0, if live { theme::up() } else { amber() });
|
||||
p.galley(pos2(chip.left() + 14.0, cy - chip_text.size().y / 2.0), chip_text, muted());
|
||||
ui.interact(chip, Id::new("source-chip"), Sense::hover()).on_hover_text(self.provider_label());
|
||||
x = chip.right() + 10.0;
|
||||
ui.interact(chip, Id::new("source-chip"), Sense::hover()).on_hover_text(provider_label(&self.provider));
|
||||
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;
|
||||
// A Hyperliquid account in LIVE is on testnet: say so next to the badge.
|
||||
if live_mode && self.hl_master().is_some() {
|
||||
let t = p.layout_no_wrap("TESTNET".into(), mono(11.0), Color32::BLACK);
|
||||
let pill = Rect::from_min_size(pos2(x - 4.0, cy - 10.0), vec2(t.size().x + 18.0, 20.0));
|
||||
p.rect_filled(pill, 10.0, amber());
|
||||
p.galley(pill.center() - t.size() / 2.0, t, Color32::BLACK);
|
||||
ui.interact(pill, Id::new("hl-testnet-badge"), Sense::hover()).on_hover_text(
|
||||
"The Hyperliquid account in LIVE is on testnet (test funds). This build cannot reach mainnet.",
|
||||
);
|
||||
x = pill.right() + 10.0;
|
||||
}
|
||||
|
||||
// Right-hand cluster, laid out from the right edge: one-line clock (UTC on hover).
|
||||
let now = chrono::Local::now();
|
||||
@@ -257,11 +293,6 @@ impl TerminalApp {
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_label(&self) -> String {
|
||||
let n = self.provider.to_lowercase();
|
||||
if n.contains("sample") { "Offline · sample data".into() } else { format!("Live · {}", self.provider) }
|
||||
}
|
||||
|
||||
/// The Bloomberg command line, drawn as a rounded macOS search field.
|
||||
fn command_field(&mut self, ui: &mut Ui, field: Rect) -> bool {
|
||||
let suggestions = self.suggestions();
|
||||
@@ -311,17 +342,25 @@ impl TerminalApp {
|
||||
Icon::Search,
|
||||
muted(),
|
||||
);
|
||||
// A small "GO" key cap at the trailing edge, Bloomberg-style.
|
||||
// A small "GO" key cap at the trailing edge, Bloomberg-style. It lights up once
|
||||
// there is something to run and presses in on a click or Enter.
|
||||
let go = Rect::from_center_size(pos2(field.right() - 18.0, field.center().y), vec2(28.0, 15.0));
|
||||
p.rect_filled(go, 4.0, if self.cmd.trim().is_empty() { theme::control_hover() } else { amber() });
|
||||
let go_resp = ui.interact(go, id.with("go"), Sense::click()).on_hover_text("<GO> (Enter)");
|
||||
let now = ui.input(|i| i.time);
|
||||
let entered = ui.ctx().data(|d| d.get_temp::<f64>(id.with("go-entered"))).is_some_and(|t| now - t < 0.09);
|
||||
let held = go_resp.is_pointer_button_down_on() || entered;
|
||||
let press = anim::spring_with(ui.ctx(), id.with("go-press"), if held { 1.0 } else { 0.0 }, 0.3, 0.55);
|
||||
let lit = anim::spring_bool(ui.ctx(), id.with("go-lit"), !self.cmd.trim().is_empty());
|
||||
let cap = Rect::from_center_size(go.center(), go.size() * (1.0 - 0.14 * press));
|
||||
p.rect_filled(cap, 4.0, widgets::lerp_color(theme::control_hover(), amber(), lit));
|
||||
p.text(
|
||||
go.center(),
|
||||
cap.center(),
|
||||
Align2::CENTER_CENTER,
|
||||
"GO",
|
||||
mono(10.5),
|
||||
if self.cmd.trim().is_empty() { muted() } else { Color32::BLACK },
|
||||
widgets::lerp_color(muted(), Color32::BLACK, lit),
|
||||
);
|
||||
let go_clicked = ui.interact(go, id.with("go"), Sense::click()).on_hover_text("<GO> (Enter)").clicked();
|
||||
let go_clicked = go_resp.clicked();
|
||||
|
||||
let inner = Rect::from_min_max(pos2(field.left() + 28.0, field.top()), pos2(go.left() - 6.0, field.bottom()));
|
||||
let mut child = ui.new_child(UiBuilder::new().max_rect(inner));
|
||||
@@ -366,6 +405,10 @@ impl TerminalApp {
|
||||
)
|
||||
});
|
||||
if (resp.lost_focus() && enter) || go_clicked {
|
||||
if enter {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(id.with("go-entered"), now));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
let input = std::mem::take(&mut self.cmd);
|
||||
match self.suggestion.take().and_then(|i| suggestions.get(i)) {
|
||||
Some(pick) => self.execute(&pick.key),
|
||||
@@ -373,8 +416,12 @@ impl TerminalApp {
|
||||
None => {}
|
||||
}
|
||||
self.cmd_hist_pos = None;
|
||||
// The command line stays live, Bloomberg-style.
|
||||
self.focus_cmd = true;
|
||||
// The command line stays live, Bloomberg-style, unless the command opened the
|
||||
// order ticket (which takes the keyboard for its quantity) or the palette.
|
||||
self.focus_cmd = !self.palette.open && !self.trading.ticket.as_ref().is_some_and(|t| t.focus_qty > 0);
|
||||
if !self.focus_cmd {
|
||||
resp.surrender_focus();
|
||||
}
|
||||
} else if had_focus && esc {
|
||||
self.cmd.clear();
|
||||
resp.surrender_focus();
|
||||
@@ -408,7 +455,7 @@ impl TerminalApp {
|
||||
return Vec::new();
|
||||
}
|
||||
let net: &[SearchHit] = if self.net_hits.0 == q { &self.net_hits.1 } else { &[] };
|
||||
let mut v = markets_view::merged_hits(q, net);
|
||||
let mut v = markets_view::merged_hits(q, net, &self.perp_candidates());
|
||||
v.truncate(8);
|
||||
v
|
||||
}
|
||||
@@ -420,10 +467,12 @@ impl TerminalApp {
|
||||
}
|
||||
let mut clicked = None;
|
||||
let width = below.width().clamp(420.0, 680.0);
|
||||
let shown = anim::ease_out(anim::appear(ctx, Id::new("cmd-suggestions-in"), 0.18));
|
||||
egui::Area::new(Id::new("cmd-suggestions"))
|
||||
.order(egui::Order::Foreground)
|
||||
.fixed_pos(below.left_bottom() + vec2(0.0, 6.0))
|
||||
.fixed_pos(below.left_bottom() + vec2(0.0, 6.0 - 6.0 * (1.0 - shown)))
|
||||
.show(ctx, |ui| {
|
||||
ui.multiply_opacity(shown);
|
||||
egui::Frame::new()
|
||||
.fill(theme::card())
|
||||
.stroke(Stroke::new(1.0, theme::border()))
|
||||
@@ -432,10 +481,13 @@ impl TerminalApp {
|
||||
.inner_margin(6.0)
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(width);
|
||||
let glider = widgets::Glider::new(ui, Id::new("cmd-suggestions-sel"));
|
||||
for (i, h) in hits.iter().enumerate() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(width, 26.0), Sense::click());
|
||||
let selected = self.suggestion == Some(i);
|
||||
widgets::hover_wash(ui, r, &resp, selected);
|
||||
if self.suggestion == Some(i) {
|
||||
glider.on(ui, r);
|
||||
}
|
||||
widgets::hover_wash(ui, r, &resp, false);
|
||||
let p = ui.painter();
|
||||
let class_c = markets_view::class_color(h.class);
|
||||
let pill = Rect::from_min_size(pos2(r.left() + 6.0, r.center().y - 8.0), vec2(52.0, 16.0));
|
||||
@@ -496,7 +548,7 @@ impl TerminalApp {
|
||||
let head = Rect::from_min_size(rect.min, vec2(rect.width(), self.toolbar_h()));
|
||||
window_drag_region(ui, head, "sidebar");
|
||||
if !cfg!(target_os = "macos") {
|
||||
p.text(head.left_center() + vec2(14.0, 0.0), Align2::LEFT_CENTER, "TERMINAL-RS", mono(12.5), amber());
|
||||
p.text(head.left_center() + vec2(14.0, 0.0), Align2::LEFT_CENTER, "TERMINAL", mono(12.5), amber());
|
||||
}
|
||||
|
||||
let body = Rect::from_min_max(pos2(rect.left(), head.bottom()), rect.max);
|
||||
@@ -514,7 +566,9 @@ impl TerminalApp {
|
||||
let resp = row_frame(ui, false);
|
||||
let r = resp.rect;
|
||||
let pt = ui.painter();
|
||||
pt.text(pos2(r.left() + 8.0, r.center().y), Align2::LEFT_CENTER, &sec.ticker, mono(12.0), text());
|
||||
let perp = sec.class == tr_core::AssetClass::Perp;
|
||||
let tc = if perp { theme::perp() } else { text() };
|
||||
pt.text(pos2(r.left() + 8.0, r.center().y), Align2::LEFT_CENTER, &sec.ticker, mono(12.0), tc);
|
||||
if let Some(q) = self.quotes.get(key) {
|
||||
let up = q.change() >= 0.0;
|
||||
let c = if up { theme::up() } else { theme::down() };
|
||||
@@ -542,7 +596,15 @@ impl TerminalApp {
|
||||
if resp.clicked() {
|
||||
out.push(SidebarCmd::Action(Action::OpenChart(key.clone())));
|
||||
}
|
||||
let resp = resp.on_hover_text(format!("{} — {}", sec.key(), sec.name().unwrap_or(&sec.ticker)));
|
||||
let resp = resp.on_hover_text(match perp {
|
||||
true => format!(
|
||||
"{} — {} · {}",
|
||||
sec.key(),
|
||||
sec.name().unwrap_or("HIP-3 perp"),
|
||||
tr_core::perps::badge(key)
|
||||
),
|
||||
false => format!("{} — {}", sec.key(), sec.name().unwrap_or(&sec.ticker)),
|
||||
});
|
||||
resp.context_menu(|ui| {
|
||||
if ui.button("Open Chart").clicked() {
|
||||
out.push(SidebarCmd::Action(Action::OpenChart(key.clone())));
|
||||
@@ -600,9 +662,9 @@ impl TerminalApp {
|
||||
PaneKind::Portfolio(pp) => Some(pp.view),
|
||||
_ => None,
|
||||
});
|
||||
let pf = self.broker.portfolio.as_deref();
|
||||
let pf = self.shown_portfolio();
|
||||
let value_badge = pf.filter(|p| !p.accounts.is_empty()).map(|p| {
|
||||
let t = p.totals(None, |pos| self.broker.live_price(&self.quotes, pos));
|
||||
let t = p.totals(None, |pos| super::broker::live_price(&self.quotes, &self.hl.mids, pos));
|
||||
compact_money(t.value)
|
||||
});
|
||||
let open_orders = pf.map_or(0, |p| p.orders.iter().filter(|o| o.is_open()).count());
|
||||
@@ -824,27 +886,44 @@ impl TerminalApp {
|
||||
p.text(pos2(rect.right() - 10.0, cy), Align2::RIGHT_CENTER, status, ui_font(11.5), muted());
|
||||
}
|
||||
|
||||
/// Alerts stacked at the top right: each slides in from the edge with a slight
|
||||
/// overshoot, the stack glides up as older ones leave, and they fade out at the end.
|
||||
pub(super) fn toasts(&self, ctx: &egui::Context) {
|
||||
let screen = ctx.content_rect();
|
||||
let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("toasts")));
|
||||
let layer = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("toasts")));
|
||||
let life = super::TOAST_SECS as f32;
|
||||
for (k, (msg, at)) in self.toasts.iter().enumerate() {
|
||||
let slide = anim::spring(ctx, Id::new(("toast", k, *at)), 0.0);
|
||||
let r = Rect::from_min_size(
|
||||
pos2(screen.right() - 432.0 + slide, screen.top() + self.toolbar_h() + 10.0 + k as f32 * 58.0),
|
||||
vec2(420.0, 50.0),
|
||||
);
|
||||
let age = at.elapsed().as_secs_f32();
|
||||
let enter = anim::ease_out_back(anim::since(ctx, age as f64, 0.55));
|
||||
// Reduce Motion skips the fade: the toast stays until it is removed.
|
||||
let fade = if anim::reduced() { 0.0 } else { 0.35 };
|
||||
let leave = if fade > 0.0 { anim::span(age, life - fade, life) } else { 0.0 };
|
||||
// Wake for the fade-out (and the removal at the end of it).
|
||||
ctx.request_repaint_after(Duration::from_secs_f32((life - fade - age).max(0.0)));
|
||||
let y =
|
||||
anim::spring(ctx, Id::new(("toast-y", *at)), screen.top() + self.toolbar_h() + 10.0 + k as f32 * 58.0);
|
||||
let x = screen.right() - 432.0 + (1.0 - enter) * 444.0;
|
||||
let r = Rect::from_min_size(pos2(x, y), vec2(420.0, 50.0));
|
||||
let mut p = layer.clone();
|
||||
p.multiply_opacity(1.0 - leave);
|
||||
widgets::card_on(&p, r, 0.0);
|
||||
// Order entry prefixes its toasts (`+` filled/ok, `!` rejected); everything else
|
||||
// is a Congress alert.
|
||||
let (title, msg, color) = match msg.strip_prefix(super::trading::TOAST_PREFIX) {
|
||||
Some(rest) => match rest.split_at_checked(1) {
|
||||
Some(("+", m)) => ("Trading", m, theme::up()),
|
||||
Some(("!", m)) => ("Trading · not done", m, theme::down()),
|
||||
_ => ("Trading", rest, amber()),
|
||||
},
|
||||
None => ("Congress Alert", msg.as_str(), amber()),
|
||||
};
|
||||
p.rect_filled(Rect::from_min_size(r.min + vec2(1.0, 6.0), vec2(3.0, r.height() - 12.0)), 1.5, color);
|
||||
widgets::draw_icon(
|
||||
&p,
|
||||
Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(18.0, 18.0)),
|
||||
Icon::Bell,
|
||||
amber(),
|
||||
color,
|
||||
);
|
||||
// Order entry prefixes its toasts; everything else is a Congress alert.
|
||||
let (title, msg) = match msg.strip_prefix(super::trading::TOAST_PREFIX) {
|
||||
Some(rest) => ("Trading", rest),
|
||||
None => ("Congress Alert", msg.as_str()),
|
||||
};
|
||||
p.text(pos2(r.left() + 42.0, r.top() + 15.0), Align2::LEFT_CENTER, title, ui_font(12.5), text());
|
||||
p.text(pos2(r.left() + 42.0, r.top() + 33.0), Align2::LEFT_CENTER, msg, ui_font(11.5), muted());
|
||||
}
|
||||
@@ -906,3 +985,69 @@ fn nav_row(ui: &mut Ui, icon: Icon, label: &str, trailing: Option<&str>, selecte
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
/// What the data-source chip says on hover, from the provider's name.
|
||||
/// The data-source chip's label, and whether any of the data is live.
|
||||
fn source_chip(provider: &str) -> (&'static str, bool) {
|
||||
let n = provider.to_lowercase();
|
||||
if n == "sample" {
|
||||
("Offline", false)
|
||||
} else if n.contains("sample") && n.starts_with("server+") {
|
||||
("Crypto + perps live", true)
|
||||
} else if n.contains("sample") {
|
||||
("Crypto live", true)
|
||||
} else {
|
||||
("Live", true)
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_label(provider: &str) -> String {
|
||||
let n = provider.to_lowercase();
|
||||
if n == "sample" {
|
||||
"Offline · sample data".into()
|
||||
} else if let Some(rest) = provider.strip_prefix("server+") {
|
||||
// A Terminal server (tr_data::remote::RemoteMarkets), in front of the browser's sources
|
||||
// (tr_data::web::WebMarkets) or the desktop's.
|
||||
if rest.contains("sample") {
|
||||
"Live crypto from Hyperliquid perps through the Terminal server, or from Binance and Coinbase while \
|
||||
it is unreachable, and HIP-3 perps (xyz:AAPL) from the server only; every other market is sample \
|
||||
data in the browser"
|
||||
.into()
|
||||
} else {
|
||||
format!(
|
||||
"Live · crypto from Hyperliquid perps and HIP-3 perps (xyz:AAPL) through the Terminal server; other \
|
||||
markets from {rest}"
|
||||
)
|
||||
}
|
||||
} 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 · {provider}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{provider_label, source_chip};
|
||||
|
||||
#[test]
|
||||
fn the_source_tooltip_names_the_terminal_server() {
|
||||
assert_eq!(provider_label("sample"), "Offline · sample data");
|
||||
assert!(provider_label("binance+sample").starts_with("Live crypto from Binance"));
|
||||
let web = provider_label("server+binance+sample");
|
||||
assert!(web.contains("Terminal server") && web.contains("sample data in the browser"), "{web}");
|
||||
let desktop = provider_label("server+markets");
|
||||
assert!(desktop.contains("Terminal server") && desktop.ends_with("other markets from markets"), "{desktop}");
|
||||
assert!(web.contains("HIP-3 perps") && desktop.contains("HIP-3 perps"), "{web} / {desktop}");
|
||||
}
|
||||
|
||||
/// With a server in front, the browser build has live perps as well as crypto.
|
||||
#[test]
|
||||
fn the_source_chip_says_what_is_live() {
|
||||
assert_eq!(source_chip("sample"), ("Offline", false));
|
||||
assert_eq!(source_chip("binance+sample"), ("Crypto live", true));
|
||||
assert_eq!(source_chip("server+binance+sample"), ("Crypto + perps live", true));
|
||||
assert_eq!(source_chip("server+yahoo+binance+ust"), ("Live", true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! The demo (paper-trading) account inside the app: loading and saving the book, feeding it
|
||||
//! the terminal's quotes so resting orders fill, and switching between the demo and live
|
||||
//! accounts.
|
||||
//!
|
||||
//! The demo account is selected on first launch and stays selected until the user switches
|
||||
//! to live accounts, which takes a confirmation and is remembered in the preferences. Every
|
||||
//! demo change is saved at once to `demo-account.json` in the app data folder.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use tr_broker::demo::{DemoBook, Event, STARTING_CASH};
|
||||
use tr_broker::trading::OrderRequest;
|
||||
use tr_broker::{InstrumentKind, Order, Portfolio};
|
||||
use web_time::Instant;
|
||||
|
||||
use super::TerminalApp;
|
||||
use crate::prefs::AccountMode;
|
||||
use crate::worker::{Request, data_dir};
|
||||
|
||||
/// How often quotes are requested for demo positions and open demo orders.
|
||||
const QUOTES_EVERY: Duration = Duration::from_secs(10);
|
||||
|
||||
pub struct DemoState {
|
||||
pub book: DemoBook,
|
||||
/// `book.portfolio()`, rebuilt after every change so panes can borrow it.
|
||||
pub pf: Box<Portfolio>,
|
||||
path: PathBuf,
|
||||
last_quotes: Option<Instant>,
|
||||
/// Settings › Trading: the reset confirmation is open, with this starting cash.
|
||||
pub confirm_reset: bool,
|
||||
pub reset_cash: f64,
|
||||
/// The last save failed (disk full, folder unwritable): shown until a save succeeds,
|
||||
/// and retried periodically and on exit.
|
||||
pub save_error: Option<String>,
|
||||
last_save_try: Option<Instant>,
|
||||
}
|
||||
|
||||
pub fn demo_path() -> PathBuf {
|
||||
data_dir().join("demo-account.json")
|
||||
}
|
||||
|
||||
/// Terminal market key for a demo symbol (`AAPL` → `AAPL US Equity`).
|
||||
pub fn market_key(symbol: &str) -> Option<String> {
|
||||
tr_broker::model::market_key(InstrumentKind::Stock, symbol, "USD")
|
||||
}
|
||||
|
||||
impl DemoState {
|
||||
/// Load the saved book. A damaged file is moved aside (never overwritten) and a fresh
|
||||
/// account starts; the returned message says so.
|
||||
pub fn load(path: PathBuf) -> (Self, Option<String>) {
|
||||
let now = chrono::Utc::now();
|
||||
let saved = match crate::storage::read(&path) {
|
||||
Ok(bytes) => DemoBook::from_json(&bytes),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(DemoBook::new(now)),
|
||||
Err(e) => Err(anyhow::Error::new(e).context("reading the demo account")),
|
||||
};
|
||||
let (book, note) = match saved {
|
||||
Ok(b) => (b, None),
|
||||
Err(e) => {
|
||||
let aside = path.with_extension(format!("damaged-{}.json", now.format("%Y%m%d%H%M%S")));
|
||||
let moved = crate::storage::rename(&path, &aside).is_ok();
|
||||
tracing::warn!("demo account: {e:#}");
|
||||
let note = if moved {
|
||||
format!(
|
||||
"The demo account file was damaged and was kept as {}; a new demo account started.",
|
||||
aside.display()
|
||||
)
|
||||
} else {
|
||||
format!("The demo account file could not be read ({e:#}); a new demo account started.")
|
||||
};
|
||||
(DemoBook::new(now), Some(note))
|
||||
}
|
||||
};
|
||||
let pf = Box::new(book.portfolio());
|
||||
(
|
||||
Self {
|
||||
book,
|
||||
pf,
|
||||
path,
|
||||
last_quotes: None,
|
||||
confirm_reset: false,
|
||||
reset_cash: STARTING_CASH,
|
||||
save_error: None,
|
||||
last_save_try: None,
|
||||
},
|
||||
note,
|
||||
)
|
||||
}
|
||||
|
||||
/// The book changed: rebuild the view and save.
|
||||
fn changed(&mut self) {
|
||||
*self.pf = self.book.portfolio();
|
||||
self.save();
|
||||
}
|
||||
|
||||
/// Save now. Returns whether the book is on disk.
|
||||
pub fn save(&mut self) -> bool {
|
||||
self.last_save_try = Some(Instant::now());
|
||||
let saved = self
|
||||
.book
|
||||
.to_json()
|
||||
.and_then(|json| crate::storage::write_atomic(&self.path, &json).context("saving the demo account"));
|
||||
match saved {
|
||||
Ok(()) => {
|
||||
self.save_error = None;
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("saving the demo account: {e:#}");
|
||||
self.save_error = Some(format!("{e:#}"));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retry a failed save every few seconds.
|
||||
fn retry_save(&mut self) {
|
||||
if self.save_error.is_some() && self.last_save_try.is_none_or(|t| t.elapsed() > Duration::from_secs(5)) {
|
||||
self.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalApp {
|
||||
pub(super) fn demo_mode(&self) -> bool {
|
||||
self.prefs.trading.account_mode == AccountMode::Demo
|
||||
}
|
||||
|
||||
/// The portfolio the panes show: the demo account, or the live (or sample) brokerage.
|
||||
pub(super) fn shown_portfolio(&self) -> Option<&Portfolio> {
|
||||
if self.demo_mode() { Some(&self.demo.pf) } else { self.broker.portfolio.as_deref() }
|
||||
}
|
||||
|
||||
/// Keep quotes coming for demo positions and open orders, and expire Day orders.
|
||||
pub(super) fn demo_periodic(&mut self) {
|
||||
let had_error = self.demo.save_error.is_some();
|
||||
self.demo.retry_save();
|
||||
if had_error && self.demo.save_error.is_none() {
|
||||
self.trade_toast("Demo account saved", true);
|
||||
}
|
||||
let now = chrono::Utc::now();
|
||||
let expired = self.demo.book.expire(now);
|
||||
if !expired.is_empty() {
|
||||
self.demo.changed();
|
||||
self.demo_events(expired);
|
||||
}
|
||||
let watched = self.demo.book.watched();
|
||||
let wanted = self.demo_mode() || self.demo.book.orders.iter().any(Order::is_open);
|
||||
if !wanted || watched.is_empty() || self.demo.last_quotes.is_some_and(|t| t.elapsed() < QUOTES_EVERY) {
|
||||
return;
|
||||
}
|
||||
let keys: Vec<String> = watched.iter().filter_map(|s| market_key(s)).collect();
|
||||
if !keys.is_empty() {
|
||||
self.worker.send(Request::Quotes(keys));
|
||||
}
|
||||
self.demo.last_quotes = Some(Instant::now());
|
||||
}
|
||||
|
||||
/// New quotes arrived: mark demo positions and fill resting demo orders they cross.
|
||||
pub(super) fn demo_on_quotes(&mut self, keys: &[String]) {
|
||||
let now = chrono::Utc::now();
|
||||
// Expire overdue Day orders before any quote can fill them.
|
||||
let mut events = self.demo.book.expire(now);
|
||||
let mut touched = !events.is_empty();
|
||||
for sym in self.demo.book.watched() {
|
||||
let Some(k) = market_key(&sym).filter(|k| keys.contains(k)) else { continue };
|
||||
let Some(q) = self.quotes.get(&k).map(|q| q.last).filter(|x| x.is_finite() && *x > 0.0) else { continue };
|
||||
events.extend(self.demo.book.on_price(&sym, q, now));
|
||||
touched = true;
|
||||
}
|
||||
if touched {
|
||||
self.demo.changed();
|
||||
}
|
||||
self.demo_events(events);
|
||||
}
|
||||
|
||||
fn demo_events(&mut self, events: Vec<Event>) {
|
||||
for e in events {
|
||||
let ok = matches!(e, Event::Filled(_));
|
||||
self.trade_toast(format!("Demo: {}{}", e.message(), self.unsaved_note()), ok);
|
||||
}
|
||||
}
|
||||
|
||||
/// ` (not saved yet: …)` while the demo book cannot be written.
|
||||
fn unsaved_note(&self) -> String {
|
||||
self.demo.save_error.as_ref().map_or(String::new(), |e| format!(" — not saved yet ({e}); retrying"))
|
||||
}
|
||||
|
||||
/// Place a demo order now (one click: no preview, no confirmation).
|
||||
pub(super) fn demo_submit(&mut self, req: &OrderRequest, quote: Option<f64>) -> Result<Order, String> {
|
||||
let result = self.demo.book.submit(req, quote, chrono::Utc::now());
|
||||
match &result {
|
||||
Ok(o) => {
|
||||
self.demo.changed();
|
||||
let msg = match o.status.as_str() {
|
||||
"EXECUTED" => tr_broker::demo::fill_message(o),
|
||||
"CANCELED" => format!("{} cancelled (not fillable now)", tr_broker::demo::order_line(o)),
|
||||
_ => format!("{} working", tr_broker::demo::order_line(o)),
|
||||
};
|
||||
let saved = self.demo.save_error.is_none();
|
||||
self.trade_toast(format!("Demo: {msg}{}", self.unsaved_note()), saved && o.status != "CANCELED");
|
||||
}
|
||||
Err(e) => self.trade_toast(format!("Demo order not placed: {e}"), false),
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(super) fn demo_cancel(&mut self, id: &str) {
|
||||
match self.demo.book.cancel(id, chrono::Utc::now()) {
|
||||
Ok(o) => {
|
||||
self.demo.changed();
|
||||
let note = self.unsaved_note();
|
||||
self.trade_toast(format!("Demo: cancelled {}{note}", tr_broker::demo::order_line(&o)), note.is_empty());
|
||||
}
|
||||
Err(e) => self.trade_toast(format!("Could not cancel: {e}"), false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sell a whole demo position at market.
|
||||
pub(super) fn demo_close(&mut self, symbol: &str) {
|
||||
let Some(req) = self.demo.book.close_request(symbol) else {
|
||||
return self.trade_toast(format!("No {symbol} shares to close (check open sell orders)"), false);
|
||||
};
|
||||
let quote = market_key(symbol).and_then(|k| self.quotes.get(&k)).map(|q| q.last);
|
||||
let _ = self.demo_submit(&req, quote);
|
||||
}
|
||||
|
||||
pub(super) fn reset_demo(&mut self, cash: f64) {
|
||||
self.demo.book.reset(cash, chrono::Utc::now());
|
||||
self.demo.changed();
|
||||
self.demo.confirm_reset = false;
|
||||
if let Some(t) = &mut self.trading.ticket {
|
||||
t.error = None;
|
||||
t.done = None;
|
||||
}
|
||||
self.trade_toast(
|
||||
format!("Demo account reset to {}", super::portfolio_view::money(self.demo.book.cash, "USD")),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/// Switch accounts. Going live asks first (see `live_dialog`); going back to the demo
|
||||
/// account is immediate. The choice is saved.
|
||||
pub(super) fn set_account_mode(&mut self, mode: AccountMode, confirmed: bool) {
|
||||
if mode == self.prefs.trading.account_mode {
|
||||
return;
|
||||
}
|
||||
if mode == AccountMode::Live && !confirmed {
|
||||
self.trading.live_prompt = true;
|
||||
return;
|
||||
}
|
||||
self.prefs.trading.account_mode = mode;
|
||||
self.trading.live_prompt = false;
|
||||
if let Err(e) = self.prefs.save(&super::prefs_path()) {
|
||||
self.status = format!("Could not save preferences: {e}");
|
||||
}
|
||||
// The ticket's account belongs to the other mode now.
|
||||
if let Some(t) = &mut self.trading.ticket {
|
||||
t.account = None;
|
||||
t.preview = None;
|
||||
t.confirming = false;
|
||||
t.review = false;
|
||||
t.error = None;
|
||||
t.done = None;
|
||||
}
|
||||
match mode {
|
||||
AccountMode::Demo => {
|
||||
self.trade_toast("Switched to the DEMO account: orders are simulated with virtual cash", true);
|
||||
if let Some(t) = &mut self.trading.ticket {
|
||||
t.account = Some(tr_broker::demo::ACCOUNT_ID.into());
|
||||
}
|
||||
}
|
||||
AccountMode::Live => {
|
||||
self.trade_toast("Switched to LIVE accounts: orders go to your real brokerage", true);
|
||||
if self.broker.portfolio.as_ref().is_none_or(|p| p.demo) && !self.broker.loading {
|
||||
self.refresh_portfolio();
|
||||
}
|
||||
self.ensure_capabilities(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1426
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,810 @@
|
||||
//! Hyperliquid's windows: the ticket body for a Hyperliquid account, the confirmations,
|
||||
//! the enable dialog and Settings › Trading › Hyperliquid. Every one shows the amber
|
||||
//! TESTNET badge.
|
||||
|
||||
use egui::{Color32, RichText, Ui};
|
||||
use tr_broker::trading::Side;
|
||||
use tr_hl::actions::Tif;
|
||||
use tr_hl::caps::{MAX_LEVERAGE, MAX_MARKET_SLIPPAGE, MAX_ORDER_NOTIONAL_USD};
|
||||
use tr_hl::rounding::to_wire;
|
||||
use tr_hl::trader::{EXPIRES_AFTER_MS, OrderKind};
|
||||
|
||||
use super::TerminalApp;
|
||||
use super::hl::{HlConfirm, HlKind, intent, is_hl_account, market_edited, resolve_coin, settle_size};
|
||||
use super::portfolio_view::{money, price, qty};
|
||||
use super::settings_view::SettingsCmd;
|
||||
use super::trading::TicketAct;
|
||||
use crate::prefs::AccountMode;
|
||||
use crate::theme::{self, amber, dp, mono, muted, text, ui_font};
|
||||
use crate::widgets;
|
||||
|
||||
/// The amber TESTNET pill.
|
||||
pub fn testnet_badge(ui: &mut Ui) -> egui::Response {
|
||||
ui.add(
|
||||
egui::Button::new(RichText::new("TESTNET").font(mono(11.0)).strong().color(Color32::BLACK))
|
||||
.fill(amber())
|
||||
.corner_radius(9.0)
|
||||
.sense(egui::Sense::hover()),
|
||||
)
|
||||
.on_hover_text("Hyperliquid testnet: test funds only. This build cannot reach mainnet.")
|
||||
}
|
||||
|
||||
pub(super) fn masked(addr: &str) -> String {
|
||||
tr_hl::key::parse_address(addr).map(|a| tr_hl::mask_address(&a)).unwrap_or_else(|_| addr.to_string())
|
||||
}
|
||||
|
||||
const HIP3_RISK: &str = "A leveraged perpetual future with funding, priced by the deployer's oracle. It can diverge from the \
|
||||
listed stock (off-hours, opens, earnings, corporate actions), and the deployer can halt the market and settle it at mark.";
|
||||
|
||||
impl TerminalApp {
|
||||
/// The ticket's body when a Hyperliquid account is selected.
|
||||
pub(super) fn hl_ticket_ui(&mut self, ui: &mut Ui) -> Option<TicketAct> {
|
||||
let accounts = self.trade_accounts_pub();
|
||||
let blocker = self.hl_blocker();
|
||||
let reg = self.hl.registry.clone();
|
||||
let sending = self.hl.sending.is_some();
|
||||
let coin = self
|
||||
.trading
|
||||
.ticket
|
||||
.as_ref()
|
||||
.and_then(|t| reg.as_ref().map(|r| resolve_coin(&t.symbol, r)))
|
||||
.unwrap_or_else(|| Err("Loading Hyperliquid testnet markets…".into()));
|
||||
let ctx = coin.as_ref().map(|c| self.hl.ctx(c)).unwrap_or_default();
|
||||
let asset = coin.as_ref().ok().and_then(|c| reg.as_ref()?.get(c).cloned());
|
||||
let t = self.trading.ticket.as_mut()?;
|
||||
let mut act = None;
|
||||
let mut review = false;
|
||||
let mut leverage = None;
|
||||
let mut settings = false;
|
||||
ui.spacing_mut().item_spacing.y = dp(6.0);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(i) = widgets::segmented(ui, "ticket-mode", &["DEMO", "LIVE"], Some(1), 22.0)
|
||||
&& i == 0
|
||||
&& !sending
|
||||
{
|
||||
act = Some(TicketAct::Mode(AccountMode::Demo));
|
||||
}
|
||||
testnet_badge(ui);
|
||||
ui.label(RichText::new("Hyperliquid · test funds only").font(ui_font(12.0)).color(amber()));
|
||||
});
|
||||
let current = t
|
||||
.account
|
||||
.as_ref()
|
||||
.and_then(|id| accounts.iter().find(|a| &a.id == id))
|
||||
.map_or("Choose an account…".to_string(), |a| format!("{} {}", a.label(), a.masked_number()));
|
||||
ui.add_enabled_ui(!sending, |ui| {
|
||||
egui::ComboBox::from_id_salt("hl-ticket-account")
|
||||
.width(ui.available_width())
|
||||
.selected_text(current)
|
||||
.show_ui(ui, |ui| {
|
||||
for a in &accounts {
|
||||
let label = if is_hl_account(&a.id) {
|
||||
format!("{} {} · TESTNET", a.label(), a.masked_number())
|
||||
} else {
|
||||
format!("{} · {} {}", a.label(), a.institution, a.masked_number())
|
||||
};
|
||||
ui.selectable_value(&mut t.account, Some(a.id.clone()), label);
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some(b) = &blocker {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(RichText::new(b).font(ui_font(11.5)).color(amber()));
|
||||
if ui.small_button("Settings…").clicked() {
|
||||
settings = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
let w = (ui.available_width() - dp(6.0)) / 2.0;
|
||||
for (side, color) in [(Side::Buy, theme::up()), (Side::Sell, theme::down())] {
|
||||
let b = if t.side == side {
|
||||
egui::Button::new(RichText::new(side.label()).strong().color(Color32::WHITE)).fill(color)
|
||||
} else {
|
||||
egui::Button::new(RichText::new(side.label()).color(color))
|
||||
};
|
||||
if ui.add_enabled(!sending, b.min_size(egui::vec2(w, dp(26.0)))).clicked() {
|
||||
t.side = side;
|
||||
}
|
||||
}
|
||||
});
|
||||
let label = |ui: &mut Ui, s: &str| {
|
||||
ui.add_sized([dp(64.0), dp(20.0)], egui::Label::new(RichText::new(s).font(ui_font(12.0)).color(muted())));
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
label(ui, "Market");
|
||||
let r = ui.add_enabled(
|
||||
!sending,
|
||||
egui::TextEdit::singleline(&mut t.symbol)
|
||||
.hint_text("BTC, xyz:AAPL")
|
||||
.font(mono(13.0))
|
||||
.desired_width(dp(96.0)),
|
||||
);
|
||||
if r.changed() {
|
||||
t.error = None;
|
||||
market_edited(t);
|
||||
}
|
||||
match ctx.mid {
|
||||
Some(m) => {
|
||||
ui.label(RichText::new(price(m)).font(mono(12.5)).color(text()))
|
||||
.on_hover_text("Hyperliquid testnet mid (not the Terminal server's mainnet quote)");
|
||||
}
|
||||
None if coin.is_ok() => {
|
||||
ui.label(RichText::new("testnet mid…").font(ui_font(11.5)).color(muted()));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
});
|
||||
match (&coin, &asset) {
|
||||
(Ok(c), Some(a)) => {
|
||||
let kind = if a.is_hip3() { "perpetual, not a share" } else { "perpetual" };
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(dp(70.0));
|
||||
ui.label(
|
||||
RichText::new(format!("{} ({kind})", a.label())).font(mono(11.5)).strong().color(theme::cyan()),
|
||||
)
|
||||
.on_hover_text(format!("{c}: asset {} on Hyperliquid testnet", a.id));
|
||||
});
|
||||
}
|
||||
(Err(e), _) if !t.symbol.trim().is_empty() => {
|
||||
ui.label(RichText::new(e).font(ui_font(11.5)).color(amber()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut unsized_note = None;
|
||||
ui.horizontal(|ui| {
|
||||
label(ui, if t.dollars { "Amount" } else { "Size" });
|
||||
let r = ui.add_enabled(
|
||||
!sending,
|
||||
egui::TextEdit::singleline(&mut t.qty)
|
||||
.hint_text(if t.dollars { "$" } else { "units" })
|
||||
.font(mono(13.0))
|
||||
.desired_width(dp(84.0)),
|
||||
);
|
||||
if r.changed() {
|
||||
t.hl.from_dollars = None;
|
||||
}
|
||||
if t.focus_qty > 0 {
|
||||
t.focus_qty -= 1;
|
||||
if !r.has_focus() {
|
||||
r.request_focus();
|
||||
}
|
||||
}
|
||||
// `BUY xyz:AAPL $50` gets its size from the testnet mid. It is never 50 contracts.
|
||||
unsized_note = reg.as_ref().and_then(|r| settle_size(t, r, ctx.mid, asset.as_ref()));
|
||||
if let (false, Some(m), Ok(q)) = (t.dollars, ctx.mid, t.qty.trim().parse::<f64>()) {
|
||||
let from = t.hl.from_dollars.map_or(String::new(), |d| format!(" (from ${d}, at the testnet mid)"));
|
||||
ui.label(RichText::new(format!("≈ {}{from}", money(q * m, "USD"))).font(mono(11.5)).color(muted()));
|
||||
}
|
||||
});
|
||||
if let Some(n) = unsized_note {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.add_space(dp(70.0));
|
||||
ui.label(RichText::new(n).font(ui_font(11.5)).color(amber()));
|
||||
});
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
label(ui, "Type");
|
||||
let labels: Vec<&str> = HlKind::ALL.iter().map(|k| k.label()).collect();
|
||||
let sel = HlKind::ALL.iter().position(|k| *k == t.hl.kind);
|
||||
if let Some(i) = widgets::segmented(ui, "hl-ticket-type", &labels, sel, 20.0)
|
||||
&& !sending
|
||||
{
|
||||
t.hl.kind = HlKind::ALL[i];
|
||||
if t.hl.kind == HlKind::Limit
|
||||
&& t.limit.trim().is_empty()
|
||||
&& let Some(m) = ctx.mid
|
||||
{
|
||||
t.limit = to_wire(tr_hl::rounding::round_price(
|
||||
m,
|
||||
asset.as_ref().map_or(0, |a| a.sz_decimals),
|
||||
tr_hl::rounding::Round::Nearest,
|
||||
))
|
||||
.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
});
|
||||
match t.hl.kind {
|
||||
HlKind::Limit => {
|
||||
ui.horizontal(|ui| {
|
||||
label(ui, "Limit");
|
||||
ui.add_enabled(
|
||||
!sending,
|
||||
egui::TextEdit::singleline(&mut t.limit)
|
||||
.hint_text("price")
|
||||
.font(mono(13.0))
|
||||
.desired_width(dp(84.0)),
|
||||
);
|
||||
if let Some(m) = ctx.mid
|
||||
&& ui.small_button("Mid").on_hover_text("Use the testnet mid").clicked()
|
||||
{
|
||||
t.limit = format!("{m}");
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
label(ui, "Time");
|
||||
let tifs = [Tif::Gtc, Tif::Ioc, Tif::Alo];
|
||||
let labels: Vec<&str> = tifs.iter().map(|x| x.label()).collect();
|
||||
if let Some(i) =
|
||||
widgets::segmented(ui, "hl-ticket-tif", &labels, tifs.iter().position(|x| *x == t.hl.tif), 20.0)
|
||||
&& !sending
|
||||
{
|
||||
t.hl.tif = tifs[i];
|
||||
}
|
||||
});
|
||||
}
|
||||
HlKind::StopLoss | HlKind::TakeProfit => {
|
||||
ui.horizontal(|ui| {
|
||||
label(ui, "Trigger");
|
||||
ui.add_enabled(
|
||||
!sending,
|
||||
egui::TextEdit::singleline(&mut t.hl.trigger)
|
||||
.hint_text("price")
|
||||
.font(mono(13.0))
|
||||
.desired_width(dp(84.0)),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new("market order when it trades, reduce-only").font(ui_font(11.0)).color(muted()),
|
||||
);
|
||||
});
|
||||
}
|
||||
HlKind::Market => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(dp(70.0));
|
||||
ui.label(
|
||||
RichText::new(format!("IOC limit {:.0}% through the testnet mid", MAX_MARKET_SLIPPAGE * 100.0))
|
||||
.font(ui_font(11.0))
|
||||
.color(muted()),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(dp(70.0));
|
||||
let forced = matches!(t.hl.kind, HlKind::StopLoss | HlKind::TakeProfit);
|
||||
let mut ro = t.hl.reduce_only || forced;
|
||||
if ui.add_enabled(!forced && !sending, egui::Checkbox::new(&mut ro, "Reduce only")).changed() {
|
||||
t.hl.reduce_only = ro;
|
||||
}
|
||||
});
|
||||
if let (Ok(c), Some(a)) = (&coin, &asset) {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
label(ui, "Leverage");
|
||||
match ctx.leverage {
|
||||
Some(l) => {
|
||||
let over = l.value > MAX_LEVERAGE;
|
||||
ui.label(
|
||||
RichText::new(format!("{}× {}", l.value, if l.cross { "cross" } else { "isolated" }))
|
||||
.font(mono(12.0))
|
||||
.color(if over { amber() } else { text() }),
|
||||
);
|
||||
if over {
|
||||
ui.label(
|
||||
RichText::new(format!("over this build's {MAX_LEVERAGE}× limit"))
|
||||
.font(ui_font(11.0))
|
||||
.color(amber()),
|
||||
);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
ui.label(RichText::new("reading…").font(ui_font(11.5)).color(muted()));
|
||||
}
|
||||
}
|
||||
let target = MAX_LEVERAGE.min(a.max_leverage);
|
||||
if ctx.leverage.is_none_or(|l| l.value != target)
|
||||
&& blocker.is_none()
|
||||
&& ui
|
||||
.add_enabled(!sending, egui::Button::new(format!("Set {target}×")))
|
||||
.on_hover_text("Asks for confirmation, then signs updateLeverage")
|
||||
.clicked()
|
||||
{
|
||||
leverage = Some((c.clone(), target));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
// Value it with the market `intent` resolved now, not the one read before an edit.
|
||||
let hl = &self.hl;
|
||||
let planned = reg.as_ref().map(|r| {
|
||||
intent(t, r).and_then(|i| {
|
||||
tr_hl::trader::plan_order(r, &i, &hl.ctx(&i.coin), &tr_hl::actions::cloid(1)).map_err(|e| e.to_string())
|
||||
})
|
||||
});
|
||||
let row = |ui: &mut Ui, k: &str, v: String, strong: bool| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(
|
||||
[dp(110.0), dp(16.0)],
|
||||
egui::Label::new(RichText::new(k).font(ui_font(11.5)).color(muted())),
|
||||
);
|
||||
let rt = RichText::new(v).font(mono(if strong { 13.0 } else { 12.0 })).color(text());
|
||||
ui.label(if strong { rt.strong() } else { rt });
|
||||
});
|
||||
};
|
||||
if let Some(Ok(p)) = &planned {
|
||||
row(ui, "Est. value", money(p.notional(), "USD"), true);
|
||||
row(
|
||||
ui,
|
||||
"Sends",
|
||||
format!("{} @ {}", to_wire(p.size()).unwrap_or_default(), to_wire(p.price()).unwrap_or_default()),
|
||||
false,
|
||||
);
|
||||
}
|
||||
row(
|
||||
ui,
|
||||
"Limits",
|
||||
format!(
|
||||
"${MAX_ORDER_NOTIONAL_USD:.0}/order · {MAX_LEVERAGE}× · {:.0}% slippage",
|
||||
MAX_MARKET_SLIPPAGE * 100.0
|
||||
),
|
||||
false,
|
||||
);
|
||||
if asset.as_ref().is_some_and(|a| a.is_hip3()) {
|
||||
let coin = asset.as_ref().map(|a| a.coin.clone()).unwrap_or_default();
|
||||
let under = coin.split(':').nth(1).unwrap_or_default().to_string();
|
||||
ui.label(
|
||||
RichText::new(format!("⚠ Perpetual future on Hyperliquid (xyz). Not a share of {under}."))
|
||||
.font(ui_font(11.5))
|
||||
.strong()
|
||||
.color(amber()),
|
||||
);
|
||||
}
|
||||
if let Some(e) = &t.error {
|
||||
ui.label(RichText::new(e).font(ui_font(12.0)).color(theme::down()));
|
||||
} else if let Some(Err(e)) = &planned
|
||||
&& !t.qty.trim().is_empty()
|
||||
{
|
||||
ui.label(RichText::new(e).font(ui_font(11.5)).color(amber()));
|
||||
}
|
||||
if let Some(d) = &t.done {
|
||||
ui.label(RichText::new(d).font(ui_font(12.0)).color(theme::up()));
|
||||
}
|
||||
let can = blocker.is_none() && !sending && matches!(&planned, Some(Ok(_)));
|
||||
let fill = if t.side == Side::Buy { theme::up() } else { theme::down() };
|
||||
ui.horizontal(|ui| {
|
||||
let label = format!("Review {}…", t.side.label());
|
||||
let b = if can {
|
||||
egui::Button::new(RichText::new(&label).strong().color(Color32::WHITE)).fill(fill)
|
||||
} else {
|
||||
egui::Button::new(RichText::new(&label).strong())
|
||||
};
|
||||
// Review stays clickable with a refusal, so the cap's reason shows in the ticket.
|
||||
let clickable = blocker.is_none() && !sending;
|
||||
if ui
|
||||
.add_enabled(clickable, b.min_size(egui::vec2(ui.available_width() - dp(24.0), dp(30.0))))
|
||||
.on_hover_text("Rounds the order, checks the limits, then asks you to confirm")
|
||||
.clicked()
|
||||
{
|
||||
review = true;
|
||||
}
|
||||
if sending {
|
||||
ui.add(egui::Spinner::new().size(14.0));
|
||||
}
|
||||
});
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"Signed on this device by the agent key and sent to Hyperliquid testnet; valid {} s.",
|
||||
EXPIRES_AFTER_MS / 1000
|
||||
))
|
||||
.font(ui_font(11.0))
|
||||
.color(muted()),
|
||||
);
|
||||
if review {
|
||||
self.hl_review();
|
||||
}
|
||||
if let Some((c, l)) = leverage {
|
||||
self.hl_request_leverage(&c, l);
|
||||
}
|
||||
if settings {
|
||||
self.settings_tab = super::settings_view::SettingsTab::Trading;
|
||||
self.open_panel(super::PaneKind::Settings);
|
||||
}
|
||||
act
|
||||
}
|
||||
|
||||
/// The one confirmation for every Hyperliquid action.
|
||||
pub(super) fn hl_confirm_dialog(&mut self, ctx: &egui::Context) {
|
||||
let Some(c) = self.hl.confirm.clone() else { return };
|
||||
// Editing the ticket after Review invalidates the confirmation.
|
||||
if let HlConfirm::Order { ticket, plan, .. } = &c {
|
||||
let same = self
|
||||
.trading
|
||||
.ticket
|
||||
.as_ref()
|
||||
.filter(|t| t.id == *ticket)
|
||||
.zip(self.hl.registry.as_ref())
|
||||
.is_some_and(|(t, r)| intent(t, r).is_ok_and(|i| &i == plan.intent()));
|
||||
if !same {
|
||||
self.hl.confirm = None;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let master = self.hl_master().map(|m| masked(&m)).unwrap_or_default();
|
||||
let agent = self
|
||||
.prefs
|
||||
.trading
|
||||
.hl
|
||||
.agent
|
||||
.as_ref()
|
||||
.map(|a| tr_hl::trader::agent_base_name(&a.name).to_string())
|
||||
.unwrap_or_default();
|
||||
let mut choice = None;
|
||||
let modal = egui::Modal::new(egui::Id::new("hl-confirm")).show(ctx, |ui| {
|
||||
ui.set_width(dp(430.0));
|
||||
let grid_row = |ui: &mut Ui, k: &str, v: RichText| {
|
||||
ui.label(RichText::new(k).font(ui_font(12.0)).color(muted()));
|
||||
ui.label(v);
|
||||
ui.end_row();
|
||||
};
|
||||
let plain = |s: String| RichText::new(s).font(ui_font(12.5)).color(text());
|
||||
let (title, go, go_color) = match &c {
|
||||
HlConfirm::Order { plan, modify, .. } => {
|
||||
let side = if plan.intent().is_buy { "buy" } else { "sell" };
|
||||
let t =
|
||||
if modify.is_some() { format!("Confirm modify ({side})") } else { format!("Confirm {side}") };
|
||||
let color = if plan.intent().is_buy { theme::up() } else { theme::down() };
|
||||
(
|
||||
t,
|
||||
format!(
|
||||
"{} {} — Sign & send",
|
||||
if plan.intent().is_buy { "Buy" } else { "Sell" },
|
||||
plan.asset().coin
|
||||
),
|
||||
color,
|
||||
)
|
||||
}
|
||||
HlConfirm::Cancel { .. } => {
|
||||
("Cancel order?".to_string(), "Cancel order — Sign & send".to_string(), theme::down())
|
||||
}
|
||||
HlConfirm::Kill { .. } => {
|
||||
("Kill switch".to_string(), "Cancel all and turn orders off".to_string(), theme::down())
|
||||
}
|
||||
HlConfirm::Leverage { coin, leverage, .. } => {
|
||||
(format!("Set {coin} leverage?"), format!("Set {leverage}× — Sign & send"), theme::accent())
|
||||
}
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
testnet_badge(ui);
|
||||
ui.label(RichText::new(title).font(ui_font(16.0)).strong());
|
||||
});
|
||||
ui.add_space(dp(4.0));
|
||||
egui::Grid::new("hl-confirm-grid").num_columns(2).spacing([dp(14.0), dp(5.0)]).show(ui, |ui| {
|
||||
grid_row(
|
||||
ui,
|
||||
"Network",
|
||||
RichText::new("Hyperliquid TESTNET (test funds)").font(ui_font(12.5)).strong().color(amber()),
|
||||
);
|
||||
grid_row(ui, "Account", plain(format!("{master} · agent {agent}")));
|
||||
match &c {
|
||||
HlConfirm::Order { plan, modify, .. } => {
|
||||
let a = plan.asset();
|
||||
let kind = if a.is_hip3() { "perpetual, not a share" } else { "perpetual" };
|
||||
grid_row(
|
||||
ui,
|
||||
"Market",
|
||||
RichText::new(format!("{} · {} ({kind})", a.coin, a.label()))
|
||||
.font(mono(12.5))
|
||||
.color(amber()),
|
||||
);
|
||||
if let Some(oid) = modify {
|
||||
grid_row(ui, "Replaces", plain(format!("order #{oid}")));
|
||||
}
|
||||
let side = if plan.intent().is_buy { "BUY" } else { "SELL" };
|
||||
let color = if plan.intent().is_buy { theme::up() } else { theme::down() };
|
||||
grid_row(
|
||||
ui,
|
||||
"Order",
|
||||
RichText::new(format!("{side} {} {}", qty(plan.size()), a.coin))
|
||||
.font(mono(13.0))
|
||||
.strong()
|
||||
.color(color),
|
||||
);
|
||||
grid_row(ui, "Type", plain(plan.kind_label()));
|
||||
let px = match plan.intent().kind {
|
||||
OrderKind::Market => format!(
|
||||
"IOC limit {} (at most {:.0}% from the mid)",
|
||||
price(plan.price()),
|
||||
MAX_MARKET_SLIPPAGE * 100.0
|
||||
),
|
||||
OrderKind::Trigger { .. } => format!(
|
||||
"trigger {} · fills down to {}",
|
||||
plan.trigger().map_or("—".into(), price),
|
||||
price(plan.price())
|
||||
),
|
||||
OrderKind::Limit { .. } => price(plan.price()),
|
||||
};
|
||||
grid_row(ui, "Price", RichText::new(px).font(mono(12.5)).color(text()));
|
||||
grid_row(
|
||||
ui,
|
||||
"Value",
|
||||
RichText::new(money(plan.notional(), "USD")).font(mono(14.0)).strong().color(text()),
|
||||
);
|
||||
let lev = self.hl.ctx(&a.coin).leverage.map_or("unknown".into(), |l| {
|
||||
format!("{}× {}", l.value, if l.cross { "cross" } else { "isolated" })
|
||||
});
|
||||
grid_row(ui, "Leverage", plain(lev));
|
||||
grid_row(
|
||||
ui,
|
||||
"Reduce only",
|
||||
plain(if plan.intent().reduce_only { "Yes".into() } else { "No".into() }),
|
||||
);
|
||||
grid_row(ui, "Fees", plain("Hyperliquid's maker/taker fee · no builder fee".into()));
|
||||
grid_row(ui, "Expires", plain(format!("{} s after you confirm", EXPIRES_AFTER_MS / 1000)));
|
||||
grid_row(ui, "Client id", RichText::new(plan.cloid()).font(mono(11.0)).color(muted()));
|
||||
}
|
||||
HlConfirm::Cancel { line, oid, .. } => {
|
||||
grid_row(ui, "Order", RichText::new(line).font(mono(12.5)).color(text()));
|
||||
grid_row(ui, "Order id", plain(format!("#{oid}")));
|
||||
}
|
||||
HlConfirm::Kill { orders } => {
|
||||
grid_row(
|
||||
ui,
|
||||
"Cancels",
|
||||
plain(format!("every open order ({} seen; read again before cancelling)", orders.len())),
|
||||
);
|
||||
grid_row(ui, "Then", plain("Hyperliquid orders turn off until you enable them again".into()));
|
||||
}
|
||||
HlConfirm::Leverage { coin, leverage, cross, current } => {
|
||||
grid_row(ui, "Market", RichText::new(coin).font(mono(12.5)).color(amber()));
|
||||
let now = current.map_or("unknown".into(), |l| {
|
||||
format!("{}× {}", l.value, if l.cross { "cross" } else { "isolated" })
|
||||
});
|
||||
grid_row(ui, "Now", plain(now));
|
||||
grid_row(
|
||||
ui,
|
||||
"New",
|
||||
plain(format!("{leverage}× {}", if *cross { "cross" } else { "isolated" })),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(dp(6.0));
|
||||
if let HlConfirm::Order { plan, .. } = &c {
|
||||
if plan.asset().is_hip3() {
|
||||
ui.label(RichText::new(HIP3_RISK).font(ui_font(11.5)).color(amber()));
|
||||
}
|
||||
ui.label(
|
||||
RichText::new("Orders on Hyperliquid are on-chain and can't be undone once they fill.")
|
||||
.font(ui_font(11.5))
|
||||
.color(muted()),
|
||||
);
|
||||
}
|
||||
if let HlConfirm::Kill { orders } = &c
|
||||
&& orders.is_empty()
|
||||
{
|
||||
ui.label(RichText::new("No open orders seen right now.").font(ui_font(11.5)).color(muted()));
|
||||
}
|
||||
ui.add_space(dp(6.0));
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Cancel").clicked() {
|
||||
choice = Some(false);
|
||||
}
|
||||
let b = egui::Button::new(RichText::new(go).strong().color(Color32::WHITE)).fill(go_color);
|
||||
if ui.add(b).clicked() {
|
||||
choice = Some(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
if choice.is_none() && modal.should_close() {
|
||||
choice = Some(false);
|
||||
}
|
||||
match choice {
|
||||
Some(true) => self.hl_confirmed(),
|
||||
Some(false) => self.hl.confirm = None,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn hl_enable_dialog(&mut self, ctx: &egui::Context) {
|
||||
let mut choice = None;
|
||||
let modal = egui::Modal::new(egui::Id::new("hl-enable")).show(ctx, |ui| {
|
||||
ui.set_width(dp(450.0));
|
||||
ui.horizontal(|ui| {
|
||||
testnet_badge(ui);
|
||||
ui.label(RichText::new("Enable Hyperliquid orders?").font(ui_font(16.0)).strong());
|
||||
});
|
||||
ui.add_space(dp(4.0));
|
||||
for line in [
|
||||
"This build trades on Hyperliquid testnet only, with test funds. It cannot reach mainnet.",
|
||||
"Orders are on-chain transactions: once they fill they can't be undone.",
|
||||
"Perps are leveraged and can be liquidated. HIP-3 stock markets (xyz) are perpetual futures with funding, not shares.",
|
||||
"Hyperliquid's Terms of Use restrict some regions, including Ontario, Canada and the United States. You are responsible for whether you may use it.",
|
||||
"Fixed limits: $100 per order, 3× leverage, 1% market slippage. Every order, cancel and leverage change asks for confirmation.",
|
||||
] {
|
||||
ui.label(RichText::new(format!("• {line}")).font(ui_font(12.0)).color(text()));
|
||||
}
|
||||
ui.add_space(dp(6.0));
|
||||
ui.checkbox(&mut self.hl.ack, "I am not a Restricted Person under Hyperliquid's Terms, and I understand the above");
|
||||
ui.add_space(dp(6.0));
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Keep Off").clicked() {
|
||||
choice = Some(false);
|
||||
}
|
||||
let on = self.hl.ack;
|
||||
let b = if on {
|
||||
egui::Button::new(RichText::new("Enable Hyperliquid orders").strong().color(Color32::BLACK)).fill(amber())
|
||||
} else {
|
||||
egui::Button::new(RichText::new("Enable Hyperliquid orders").strong())
|
||||
};
|
||||
if ui.add_enabled(on, b).clicked() {
|
||||
choice = Some(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
if choice.is_none() && modal.should_close() {
|
||||
choice = Some(false);
|
||||
}
|
||||
if let Some(on) = choice {
|
||||
self.hl.enable_prompt = false;
|
||||
self.hl.ack = false;
|
||||
if on {
|
||||
self.prefs.trading.hl.enabled = true;
|
||||
if let Err(e) = self.prefs.save(&super::prefs_path()) {
|
||||
self.status = format!("Could not save preferences: {e}");
|
||||
}
|
||||
self.hl.message = Some(("Hyperliquid testnet orders enabled.".into(), false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings › Trading › Hyperliquid.
|
||||
pub(super) fn hl_settings(&mut self, ui: &mut Ui, cmds: &mut Vec<SettingsCmd>) {
|
||||
let note = |ui: &mut Ui, s: &str| {
|
||||
ui.label(RichText::new(s).font(ui_font(11.5)).color(muted()));
|
||||
};
|
||||
ui.add_space(dp(10.0));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("Hyperliquid").font(ui_font(13.5)).color(text()));
|
||||
testnet_badge(ui);
|
||||
});
|
||||
ui.separator();
|
||||
let h = self.prefs.trading.hl.clone();
|
||||
let on = h.enabled && self.hl_signing_blocker().is_none();
|
||||
ui.label(
|
||||
RichText::new(match (h.master.is_some(), h.agent.is_some(), on) {
|
||||
(false, _, _) => {
|
||||
"No wallet yet. Create one to trade perps on the core dex and HIP-3 xyz (BTC, xyz:AAPL), testnet only."
|
||||
.to_string()
|
||||
}
|
||||
(true, false, _) if h.wallet.is_some() => {
|
||||
"Wallet ready. Approve a trading key to trade; until then PORT, POS and ORD are read-only.".into()
|
||||
}
|
||||
(true, false, _) => {
|
||||
"Watching an address (read-only): positions and orders show in PORT, POS and ORD.".into()
|
||||
}
|
||||
(true, true, true) => {
|
||||
"Orders are ON (testnet): confirmed orders are signed here and sent to Hyperliquid testnet.".into()
|
||||
}
|
||||
(true, true, false) => "Orders are OFF.".into(),
|
||||
})
|
||||
.font(ui_font(12.5))
|
||||
.color(if on { amber() } else { theme::accent() }),
|
||||
);
|
||||
|
||||
self.wallet_section(ui, &h);
|
||||
if let Some(e) = &self.hl.store_error {
|
||||
ui.label(
|
||||
RichText::new(format!("Trading is off on this device: {e}")).font(ui_font(12.0)).color(theme::down()),
|
||||
);
|
||||
}
|
||||
|
||||
// Orders on or off.
|
||||
ui.add_space(dp(6.0));
|
||||
ui.horizontal(|ui| {
|
||||
if h.enabled {
|
||||
if ui.button("Turn Hyperliquid Orders Off").clicked() {
|
||||
self.prefs.trading.hl.enabled = false;
|
||||
self.hl.confirm = None;
|
||||
cmds.push(SettingsCmd::SavePrefs);
|
||||
}
|
||||
} else if ui
|
||||
.add_enabled(h.agent.is_some(), egui::Button::new(RichText::new("Enable Hyperliquid Orders…").strong()))
|
||||
.on_disabled_hover_text("Approve a trading key first")
|
||||
.clicked()
|
||||
{
|
||||
self.hl.enable_prompt = true;
|
||||
self.hl.ack = false;
|
||||
}
|
||||
if h.master.is_some()
|
||||
&& ui.button("KILL").on_hover_text("Cancel every open Hyperliquid order and turn orders off").clicked()
|
||||
{
|
||||
self.hl_kill();
|
||||
}
|
||||
});
|
||||
note(
|
||||
ui,
|
||||
&format!(
|
||||
"Fixed in this build: ${MAX_ORDER_NOTIONAL_USD:.0} per order, {MAX_LEVERAGE}× leverage, {:.0}% market slippage, no builder fee. KILL cancels every open order and turns orders off.",
|
||||
MAX_MARKET_SLIPPAGE * 100.0
|
||||
),
|
||||
);
|
||||
let mut dms = self.prefs.trading.hl.dead_mans_switch;
|
||||
if ui
|
||||
.checkbox(&mut dms, "Dead man's switch: cancel all open orders if Terminal stops for a minute")
|
||||
.on_hover_text(
|
||||
"Keeps a scheduleCancel about 60 s ahead while Terminal runs. It also cancels orders placed elsewhere.",
|
||||
)
|
||||
.changed()
|
||||
{
|
||||
self.prefs.trading.hl.dead_mans_switch = dms;
|
||||
cmds.push(SettingsCmd::SavePrefs);
|
||||
}
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
let mut keep = self.prefs.trading.hl.remember_on_browser;
|
||||
if ui
|
||||
.checkbox(
|
||||
&mut keep,
|
||||
"Remember the agent key on this browser (encrypted with a key the page can't read)",
|
||||
)
|
||||
.on_hover_text(
|
||||
"Off: the key lasts for this tab only, and each visit needs a new approval, which is one paid Turnkey signature.",
|
||||
)
|
||||
.changed()
|
||||
{
|
||||
self.prefs.trading.hl.remember_on_browser = keep;
|
||||
cmds.push(SettingsCmd::SavePrefs);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if keep {
|
||||
self.hl_web_remember_key();
|
||||
} else {
|
||||
self.hl_web_forget_key();
|
||||
}
|
||||
}
|
||||
if !self.prefs.trading.hl.remember_on_browser {
|
||||
ui.label(
|
||||
RichText::new("Each visit without it approves a new trading key: one Turnkey signature each time.")
|
||||
.font(ui_font(11.5))
|
||||
.color(muted()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Roles.
|
||||
if h.master.is_some() {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if ui
|
||||
.add_enabled(!self.hl.roles_busy, egui::Button::new("Check on Hyperliquid"))
|
||||
.on_hover_text("userRole and extraAgents")
|
||||
.clicked()
|
||||
{
|
||||
self.hl_check_roles();
|
||||
}
|
||||
match &self.hl.roles {
|
||||
Some(Ok(r)) => {
|
||||
let agents: Vec<String> =
|
||||
r.agents.iter().map(|a| format!("{} ({})", a.name, masked(&a.address))).collect();
|
||||
note(
|
||||
ui,
|
||||
&format!(
|
||||
"account role: {}{} · approved agents: {}",
|
||||
r.master,
|
||||
r.agent.as_ref().map_or(String::new(), |a| format!(" · agent role: {a}")),
|
||||
if agents.is_empty() { "none".into() } else { agents.join(", ") }
|
||||
),
|
||||
);
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
ui.label(RichText::new(e).font(ui_font(11.5)).color(theme::down()));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(b) = self.hl.busy {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::Spinner::new().size(12.0));
|
||||
note(ui, b);
|
||||
});
|
||||
}
|
||||
if let Some((m, err)) = &self.hl.message {
|
||||
ui.label(RichText::new(m).font(ui_font(12.0)).color(if *err { theme::down() } else { theme::up() }));
|
||||
}
|
||||
}
|
||||
|
||||
/// Accounts the ticket can choose from (as `trade_accounts`, for the HL body).
|
||||
fn trade_accounts_pub(&self) -> Vec<tr_broker::Account> {
|
||||
self.broker.portfolio.as_ref().map(|p| p.accounts.clone()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Hyperliquid in the browser: the built-in wallet's sign-in popup, and the agent key, which
|
||||
//! lives in memory for the session or, if the user opts in, in IndexedDB encrypted with a
|
||||
//! non-extractable WebCrypto AES-GCM key (the page can use that key but never read it).
|
||||
//!
|
||||
//! Sign-in opens this site's `/wallet/` page in a popup with the session key's public half
|
||||
//! and a `state`. The page answers with `postMessage`; only messages from this page's own
|
||||
//! origin, carrying the same `state`, are taken (see `super::wallet`). Browser wallets
|
||||
//! (EIP-1193) are not used.
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use tr_hl::{AgentKey, SigningKey};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use super::TerminalApp;
|
||||
use super::hl::store_account;
|
||||
|
||||
#[wasm_bindgen(inline_js = r#"
|
||||
const DB = 'terminal-hl', STORE = 'keys';
|
||||
function open() {
|
||||
return new Promise((res, rej) => {
|
||||
const r = indexedDB.open(DB, 1);
|
||||
r.onupgradeneeded = () => r.result.createObjectStore(STORE);
|
||||
r.onsuccess = () => res(r.result);
|
||||
r.onerror = () => rej(r.error);
|
||||
});
|
||||
}
|
||||
function tx(db, mode, f) {
|
||||
return new Promise((res, rej) => {
|
||||
const t = db.transaction(STORE, mode);
|
||||
const req = f(t.objectStore(STORE));
|
||||
t.oncomplete = () => res(req ? req.result : undefined);
|
||||
t.onerror = () => rej(t.error);
|
||||
});
|
||||
}
|
||||
// A non-extractable AES-GCM key, kept as a CryptoKey in IndexedDB: usable, never readable.
|
||||
async function wrapping(db) {
|
||||
let k = await tx(db, 'readonly', s => s.get('wrap'));
|
||||
if (!k) {
|
||||
k = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
||||
await tx(db, 'readwrite', s => s.put(k, 'wrap'));
|
||||
}
|
||||
return k;
|
||||
}
|
||||
export async function hl_key_save(account, hex) {
|
||||
const db = await open();
|
||||
const k = await wrapping(db);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, k, new TextEncoder().encode(hex)));
|
||||
await tx(db, 'readwrite', s => s.put({ iv, ct }, 'agent:' + account));
|
||||
}
|
||||
export async function hl_key_load(account) {
|
||||
const db = await open();
|
||||
const rec = await tx(db, 'readonly', s => s.get('agent:' + account));
|
||||
const k = await tx(db, 'readonly', s => s.get('wrap'));
|
||||
if (!rec || !k) return null;
|
||||
return new TextDecoder().decode(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: rec.iv }, k, rec.ct));
|
||||
}
|
||||
export async function hl_key_delete(account) {
|
||||
const db = await open();
|
||||
await tx(db, 'readwrite', s => s.delete('agent:' + account));
|
||||
}
|
||||
// The sign-in page's answer, from this origin only. Kept until Rust takes it.
|
||||
let walletInbox = null;
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.origin !== window.location.origin) return;
|
||||
const d = e.data;
|
||||
if (!d || d.type !== 'terminal-wallet' || typeof d.state !== 'string') return;
|
||||
walletInbox = JSON.stringify({ state: d.state, session: typeof d.session === 'string' ? d.session : null,
|
||||
error: typeof d.error === 'string' ? d.error : null });
|
||||
});
|
||||
}
|
||||
export function wallet_popup(url) {
|
||||
const w = window.open(url, 'terminal-wallet', 'popup,width=480,height=760');
|
||||
return !!w;
|
||||
}
|
||||
export function wallet_take() { const v = walletInbox; walletInbox = null; return v; }
|
||||
export function page_origin() { return window.location.origin; }
|
||||
"#)]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(catch)]
|
||||
async fn hl_key_save(account: &str, hex: &str) -> Result<JsValue, JsValue>;
|
||||
#[wasm_bindgen(catch)]
|
||||
async fn hl_key_load(account: &str) -> Result<JsValue, JsValue>;
|
||||
#[wasm_bindgen(catch)]
|
||||
async fn hl_key_delete(account: &str) -> Result<JsValue, JsValue>;
|
||||
#[wasm_bindgen(js_name = wallet_popup)]
|
||||
fn js_wallet_popup(url: &str) -> bool;
|
||||
fn wallet_take() -> Option<String>;
|
||||
fn page_origin() -> String;
|
||||
}
|
||||
|
||||
/// Open the sign-in page in a popup; `false` if the browser blocked it.
|
||||
pub(super) fn wallet_popup(url: &str) -> bool {
|
||||
js_wallet_popup(url)
|
||||
}
|
||||
|
||||
/// This page's origin (`https://terminal.rasmic.xyz`).
|
||||
pub(super) fn origin() -> String {
|
||||
page_origin()
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct PageAnswer {
|
||||
state: String,
|
||||
session: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// What the page's promises came back with, for the next frame.
|
||||
enum Event {
|
||||
Restored { account: String, hex: Option<zeroize::Zeroizing<String>> },
|
||||
Note(String, bool),
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static EVENTS: RefCell<Vec<Event>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
fn post(ctx: &egui::Context, e: Event) {
|
||||
EVENTS.with(|v| v.borrow_mut().push(e));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
fn js_err(e: JsValue) -> String {
|
||||
e.as_string()
|
||||
.or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string()))
|
||||
.unwrap_or_else(|| format!("{e:?}"))
|
||||
}
|
||||
|
||||
impl TerminalApp {
|
||||
/// Handle what the page's promises returned.
|
||||
pub(super) fn hl_web_poll(&mut self) {
|
||||
if let Some(a) = wallet_take().and_then(|t| serde_json::from_str::<PageAnswer>(&t).ok()) {
|
||||
self.wallet_page_answered(&a.state, a.session, a.error);
|
||||
}
|
||||
let events = EVENTS.with(|v| std::mem::take(&mut *v.borrow_mut()));
|
||||
for e in events {
|
||||
match e {
|
||||
Event::Restored { account, hex } => {
|
||||
let Some(hex) = hex else { continue };
|
||||
let expected = self.prefs.trading.hl.agent.as_ref().map(|a| a.address.clone());
|
||||
if let Ok(k) = AgentKey::from_hex(&hex)
|
||||
&& Some(tr_hl::key::address_hex(&k.address())) == expected
|
||||
&& self.hl_master().is_some_and(|m| store_account(&m) == account)
|
||||
{
|
||||
let _ = self.hl.store.save(&account, &hex);
|
||||
self.hl.key = Some(std::sync::Arc::new(k));
|
||||
}
|
||||
}
|
||||
Event::Note(m, err) => {
|
||||
self.hl.busy = None;
|
||||
self.hl.message = Some((m, err));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// After loading from memory finds nothing: try the encrypted copy, if the user opted in.
|
||||
pub(super) fn hl_web_restore_key(&mut self) {
|
||||
if self.hl.key.is_some() || !self.prefs.trading.hl.remember_on_browser || self.prefs.trading.hl.agent.is_none()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(account) = self.hl_master().map(|m| store_account(&m)) else { return };
|
||||
let Some(ctx) = self.hl.ctx.clone() else { return };
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let hex = match hl_key_load(&account).await {
|
||||
Ok(v) => v.as_string().map(zeroize::Zeroizing::new),
|
||||
Err(e) => {
|
||||
tracing::warn!("hyperliquid: reading the remembered key: {}", js_err(e));
|
||||
None
|
||||
}
|
||||
};
|
||||
post(&ctx, Event::Restored { account, hex });
|
||||
});
|
||||
}
|
||||
|
||||
/// Keep an encrypted copy in this browser (opted in).
|
||||
pub(super) fn hl_web_remember_key(&mut self) {
|
||||
let (Some(key), Some(master)) = (self.hl.key.clone(), self.hl_master()) else { return };
|
||||
if !self.prefs.trading.hl.remember_on_browser {
|
||||
return;
|
||||
}
|
||||
let account = store_account(&master);
|
||||
let Some(ctx) = self.hl.ctx.clone() else { return };
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let hex = key.to_hex();
|
||||
if let Err(e) = hl_key_save(&account, &hex).await {
|
||||
post(&ctx, Event::Note(format!("Couldn't remember the key on this browser: {}", js_err(e)), true));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Delete the encrypted copy.
|
||||
pub(super) fn hl_web_forget_key(&mut self) {
|
||||
let Some(master) = self.hl_master() else { return };
|
||||
let account = store_account(&master);
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Err(e) = hl_key_delete(&account).await {
|
||||
tracing::warn!("hyperliquid: deleting the remembered key: {}", js_err(e));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
//! Native keyboard-shortcut table (click a binding to record a new one) and the Help pane.
|
||||
|
||||
use egui::{Align2, Color32, Id, Rect, RichText, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use tr_core::indicators::registry::{self, Category};
|
||||
|
||||
use super::TerminalApp;
|
||||
use crate::command::IndicatorSpec;
|
||||
use crate::prefs::{Action, Keymap};
|
||||
use crate::theme::{self, amber, dp, mono, muted, text, ui_font};
|
||||
|
||||
@@ -35,13 +37,15 @@ fn para(ui: &mut Ui, s: &str) {
|
||||
ui.label(RichText::new(s).font(ui_font(12.5)).color(muted()));
|
||||
}
|
||||
|
||||
fn chip(ui: &mut Ui, code: &str) -> egui::Response {
|
||||
ui.label(RichText::new(format!(" {code} ")).font(mono(12.0)).color(amber()).background_color(theme::control()))
|
||||
}
|
||||
|
||||
fn chips(ui: &mut Ui, codes: &[&str]) {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
for c in codes {
|
||||
ui.label(
|
||||
RichText::new(format!(" {c} ")).font(mono(12.0)).color(amber()).background_color(theme::control()),
|
||||
);
|
||||
chip(ui, c);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -238,6 +242,10 @@ impl TerminalApp {
|
||||
(&["BTCUSD CRNCY", "EURUSD CRNCY"], "Crypto (live) and FX"),
|
||||
(&["US10Y GOVT"], "US Treasury yields"),
|
||||
(&["CL1 CMDTY", "GC1 CMDTY"], "Futures and commodities"),
|
||||
(
|
||||
&["xyz:AAPL", "AAPL PERP", "PERP"],
|
||||
"Hyperliquid HIP-3 perps (needs the Terminal server); PERP or ⇧P switches",
|
||||
),
|
||||
(&["SECF gold"], "Search securities"),
|
||||
],
|
||||
);
|
||||
@@ -256,6 +264,7 @@ impl TerminalApp {
|
||||
(&["POL", "POL PELOSI", "TRACK PELOSI"], "Politicians, profiles and alerts"),
|
||||
(&["WL", "WL ADD SOLUSD CRNCY"], "Watchlist"),
|
||||
(&["PREFS"], "Settings: theme, fonts, density, hotkeys, layouts"),
|
||||
(&["UPDATE"], "Check for updates"),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -297,7 +306,7 @@ impl TerminalApp {
|
||||
(&["1", "5", "1H", "D", "1W"], "Interval"),
|
||||
(&["1D", "5D", "YTD", "1Y", "ALL"], "Quick range"),
|
||||
(&["CANDLE", "BAR", "LINE", "AREA", "LOG"], "Chart type and log scale"),
|
||||
(&["SMA 50", "EMA 20", "BB 20 2", "RSI", "MACD", "VWAP"], "Toggle indicators (CLEAR removes all)"),
|
||||
(&["SMA 50", "RSI", "IND"], "Indicators (see below)"),
|
||||
(&["CMP MSFT"], "Compare, rebased to the main symbol"),
|
||||
(&["TL", "HL", "VL", "FIB", "MAGNET"], "Drawing tools and snapping"),
|
||||
],
|
||||
@@ -307,6 +316,33 @@ impl TerminalApp {
|
||||
"Wheel zooms, drag pans, drag an axis to stretch, double-click resets. Right-click for the chart menu.",
|
||||
);
|
||||
|
||||
section(ui, &format!("Indicators ({})", registry::all().len()));
|
||||
command_table(
|
||||
ui,
|
||||
"help-ind",
|
||||
&[
|
||||
(&["RSI", "SMA 200", "BB 20 2.5"], "Toggle an indicator; numbers set its parameters"),
|
||||
(&["IND", "IND VOLUME"], "Indicator picker: search by name, code or category"),
|
||||
(&["AAPL ADX"], "Load a security and add the indicator"),
|
||||
(&["CLEAR"], "Remove all indicators"),
|
||||
],
|
||||
);
|
||||
para(
|
||||
ui,
|
||||
"Missing parameters take their defaults and out-of-range ones are clamped. Moving averages can be \
|
||||
added several times with different lengths. Hover a code for its name and defaults.",
|
||||
);
|
||||
for cat in Category::ALL {
|
||||
ui.add_space(dp(4.0));
|
||||
ui.label(RichText::new(cat.label()).font(ui_font(12.5)).color(text()));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
for d in registry::all().iter().filter(|d| d.category == cat) {
|
||||
chip(ui, d.code).on_hover_text(format!("{} · {}", d.name, IndicatorSpec::of(d).label()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
section(ui, "Workspace");
|
||||
command_table(
|
||||
ui,
|
||||
|
||||
@@ -6,6 +6,7 @@ use tr_core::options::Greeks;
|
||||
use tr_data::OptionContract;
|
||||
|
||||
use super::OptionsPane;
|
||||
use crate::anim;
|
||||
use crate::congress_view::Action;
|
||||
use crate::theme::{self, amber, dp, mono, muted, text, ui_font};
|
||||
use crate::widgets;
|
||||
@@ -245,16 +246,24 @@ pub fn options_chain(ui: &mut Ui, o: &mut OptionsPane) -> (Option<Action>, bool)
|
||||
|
||||
let spot = ch.spot;
|
||||
let itm_wash = theme::amber().gamma_multiply(0.07);
|
||||
// Rows cascade in when the chain comes into view or a chain for another expiration
|
||||
// arrives (not when one is requested: the old rows stay until then).
|
||||
let cascade = anim::appear(ui.ctx(), ui.id().with(("omon-rows", &o.symbol, ch.expiration)), 0.9) * 0.9;
|
||||
egui::ScrollArea::vertical().id_salt(("omon", &o.symbol)).auto_shrink([false, false]).show_rows(
|
||||
ui,
|
||||
row_h,
|
||||
rows.len(),
|
||||
|ui, range| {
|
||||
let mut spot_line = None;
|
||||
let first = range.start;
|
||||
for ri in range {
|
||||
let row = &rows[ri];
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, row_h), Sense::click());
|
||||
let p = ui.painter_at(r);
|
||||
let delay = ((ri - first) as f32 * 0.03).min(0.45);
|
||||
let e = anim::ease_out(anim::span(cascade, delay, delay + 0.35));
|
||||
let r = r.translate(vec2(0.0, 6.0 * (1.0 - e)));
|
||||
let mut p = ui.painter_at(r);
|
||||
p.multiply_opacity(e);
|
||||
let call_side = Rect::from_min_max(pos2(strike_x - side_w, r.top()), pos2(strike_x, r.bottom()));
|
||||
let put_side = Rect::from_min_max(
|
||||
pos2(strike_x + strike_w, r.top()),
|
||||
|
||||
+197
-28
@@ -2,10 +2,12 @@
|
||||
//! 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::command::{self, Function};
|
||||
use crate::anim;
|
||||
use crate::command::{self, Function, IndicatorSpec};
|
||||
use crate::layout::Layout;
|
||||
use crate::palette::{Item, Kind, Target};
|
||||
use crate::prefs::Action;
|
||||
@@ -35,6 +37,7 @@ const COMMANDS: &[(&str, &str, &str, Option<Action>)] = &[
|
||||
("WL", "", "watchlist monitor", Some(Action::Watchlist)),
|
||||
("N", "", "news headlines", None),
|
||||
("OB", "", "order book depth dom", None),
|
||||
("PERP", "", "hip-3 perpetual hyperliquid xyz perps stock perp", Some(Action::TogglePerp)),
|
||||
("WEI", "", "world equity indices markets", None),
|
||||
("GC", "", "yield curve treasury rates", None),
|
||||
("SECF", "Security search", "search find", None),
|
||||
@@ -54,19 +57,14 @@ 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)),
|
||||
("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),
|
||||
("IND", "", "indicators studies technical analysis picker", None),
|
||||
("CLEAR", "", "indicators remove", None),
|
||||
("CMP", "", "compare comparison remove", None),
|
||||
("TL", "", "trendline draw", Some(Action::DrawTrendline)),
|
||||
@@ -145,6 +143,35 @@ 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));
|
||||
}
|
||||
@@ -174,7 +201,36 @@ 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 {
|
||||
use tr_core::perps;
|
||||
if perps::is_perp(key) {
|
||||
// A perp's row says it is one, and finds it by the underlying's ticker and name too.
|
||||
let link = perps::link_for(key);
|
||||
let badge = perps::badge(key);
|
||||
let sub = match link {
|
||||
Some(l) => format!("{} · {badge}", l.name),
|
||||
None => badge.clone(),
|
||||
};
|
||||
let under = link.and_then(|l| tr_core::Security::parse(l.underlying)).map(|s| s.ticker).unwrap_or_default();
|
||||
let words = format!("{under} {} perp perpetual hyperliquid hip-3 {}", link.map_or("", |l| l.name), badge);
|
||||
return Item::new(Kind::Ticker, key, sub, Target::Run(key.into())).keywords(words);
|
||||
}
|
||||
let name = tr_core::Security::parse(key).and_then(|s| s.name()).unwrap_or("");
|
||||
Item::new(
|
||||
Kind::Ticker,
|
||||
@@ -194,6 +250,7 @@ fn kind_color(k: Kind) -> Color32 {
|
||||
Kind::Theme => theme::series(4),
|
||||
Kind::Settings => theme::muted(),
|
||||
Kind::Action => theme::up(),
|
||||
Kind::Indicator => theme::series(3),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,16 +285,67 @@ 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()));
|
||||
}
|
||||
for key in tr_core::perps::search(q, self.perp_candidates(), 8) {
|
||||
v.push(ticker_item(key));
|
||||
}
|
||||
// Whatever the command line would do with the query, offered as-is.
|
||||
match command::parse(q) {
|
||||
Some(command::Command::Symbol(key)) => v.push(ticker_item(&key)),
|
||||
// An indicator with custom parameters (`SMA 200`, `BB 20 3`).
|
||||
Some(command::Command::Function(Function::Indicator(spec)))
|
||||
if !offered(&v) && spec.def().is_some_and(|d| IndicatorSpec::of(d) != spec) =>
|
||||
{
|
||||
let sub = format!("{} · toggle on the chart", spec.name());
|
||||
v.push(Item::new(Kind::Indicator, spec.label(), sub, Target::Run(q.into())));
|
||||
}
|
||||
Some(command::Command::Symbol(key)) => {
|
||||
v.push(ticker_item(&key));
|
||||
let sec = tr_data::security(&key);
|
||||
if matches!(sec.class, tr_core::AssetClass::Equity | tr_core::AssetClass::Etf) {
|
||||
let t = &sec.ticker;
|
||||
let mode = if self.demo_mode() { "demo" } else { "LIVE" };
|
||||
v.push(
|
||||
Item::new(
|
||||
Kind::Command,
|
||||
format!("Buy {t}"),
|
||||
format!("Order ticket · {mode}"),
|
||||
Target::Run(format!("BUY {key}")),
|
||||
)
|
||||
.keywords("trade order"),
|
||||
);
|
||||
v.push(
|
||||
Item::new(
|
||||
Kind::Command,
|
||||
format!("Sell {t}"),
|
||||
format!("Order ticket · {mode}"),
|
||||
Target::Run(format!("SELL {key}")),
|
||||
)
|
||||
.keywords("trade order"),
|
||||
);
|
||||
let held = self.shown_portfolio().is_some_and(|pf| {
|
||||
pf.positions.iter().any(|p| p.units > 0.0 && p.symbol.eq_ignore_ascii_case(t))
|
||||
});
|
||||
if held {
|
||||
v.push(
|
||||
Item::new(
|
||||
Kind::Command,
|
||||
format!("Sell all {t}"),
|
||||
format!("Close the whole position · {mode}"),
|
||||
Target::Run(format!("SELL ALL {key}")),
|
||||
)
|
||||
.keywords("close exit position"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Kept as typed: arguments such as paths and titles are case-sensitive.
|
||||
Some(_) if !v.iter().any(|it| matches!(&it.target, Target::Run(l) if l.eq_ignore_ascii_case(q))) => {
|
||||
Some(_) if !offered(&v) => {
|
||||
v.push(Item::new(Kind::Command, q, command::hint(q), Target::Run(q.into())));
|
||||
}
|
||||
Some(_) => {}
|
||||
@@ -286,16 +394,20 @@ 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));
|
||||
let top = screen.top() + (screen.height() * 0.14).clamp(dp(16.0), dp(160.0)) + dp(10.0) * (1.0 - shown);
|
||||
let left = screen.center().x - w / 2.0;
|
||||
let pad = vec2(dp(10.0), dp(6.0));
|
||||
let field_h = dp(46.0);
|
||||
@@ -308,7 +420,11 @@ impl TerminalApp {
|
||||
.font(ui_font(16.0))
|
||||
.frame(egui::Frame::NONE)
|
||||
.text_color(text())
|
||||
.hint_text("Search commands, tickers, panels, layouts, themes, settings…")
|
||||
.hint_text(if scoped {
|
||||
"Search indicators by name, code or category…"
|
||||
} else {
|
||||
"Search commands, tickers, panels, layouts, themes, settings…"
|
||||
})
|
||||
.desired_width(field.width() - dp(36.0));
|
||||
let field_rect = Rect::from_min_max(pos2(field.left() + dp(28.0), field.top()), field.max);
|
||||
let resp = ui
|
||||
@@ -327,11 +443,7 @@ impl TerminalApp {
|
||||
|
||||
let items = self.palette_items();
|
||||
let results = self.palette.results(&items);
|
||||
let recents = if self.palette.query.trim().is_empty() {
|
||||
crate::palette::live_recents(&items, &self.palette.recents).len()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let recents = self.palette.recent_count(&items);
|
||||
if up {
|
||||
self.palette.step(-1, results.len());
|
||||
}
|
||||
@@ -356,8 +468,9 @@ impl TerminalApp {
|
||||
}
|
||||
// Dim everything beneath, then the card, on a layer above the panes and
|
||||
// below this one.
|
||||
let under =
|
||||
let mut under =
|
||||
ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Middle, Id::new("command-palette-dim")));
|
||||
under.multiply_opacity(shown);
|
||||
let dark = theme::palette().dark;
|
||||
under.rect_filled(screen, 0.0, Color32::from_black_alpha(if dark { 120 } else { 70 }));
|
||||
widgets::card_on(&under, card, 1.0);
|
||||
@@ -376,6 +489,7 @@ 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(
|
||||
@@ -390,7 +504,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, keyed) {
|
||||
if self.palette_row(ui, it, (i == self.palette.selected).then_some(&glider), keyed) {
|
||||
picked = Some(i);
|
||||
}
|
||||
}
|
||||
@@ -405,7 +519,11 @@ impl TerminalApp {
|
||||
p.text(
|
||||
pos2(foot.left() + dp(4.0), foot.center().y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
"↑↓ select ↵ open esc close",
|
||||
if scoped {
|
||||
"↑↓ select ↵ add/remove esc close"
|
||||
} else {
|
||||
"↑↓ select ↵ open esc close"
|
||||
},
|
||||
mono(11.0),
|
||||
muted(),
|
||||
);
|
||||
@@ -421,8 +539,12 @@ impl TerminalApp {
|
||||
.inner;
|
||||
|
||||
if let Some(item) = picked.and_then(|i| results.get(i)).cloned() {
|
||||
self.palette.close();
|
||||
self.palette.record(&item);
|
||||
// The indicator picker stays open to toggle several, and leaves recents alone
|
||||
// so rows do not reorder under the selection.
|
||||
if !scoped {
|
||||
self.palette.close();
|
||||
self.palette.record(&item);
|
||||
}
|
||||
self.run_palette_target(item.target);
|
||||
} else if picked.is_some() && !self.palette.query.trim().is_empty() {
|
||||
// Nothing matched: behave like the command line.
|
||||
@@ -437,14 +559,17 @@ impl TerminalApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// One result row: kind badge, title, subtitle and shortcut caps. `selected` carries
|
||||
/// the selection highlight when this row has it. Returns whether it was clicked.
|
||||
fn palette_row(&self, ui: &mut Ui, it: &Item, selected: Option<&widgets::Glider>, keyed: bool) -> bool {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), dp(ROW_H)), Sense::click());
|
||||
if selected && keyed {
|
||||
resp.scroll_to_me(None);
|
||||
if let Some(glider) = selected {
|
||||
if keyed {
|
||||
resp.scroll_to_me(None);
|
||||
}
|
||||
glider.on(ui, r);
|
||||
}
|
||||
widgets::hover_wash(ui, r, &resp, selected);
|
||||
widgets::hover_wash(ui, r, &resp, false);
|
||||
let p = ui.painter();
|
||||
let cy = r.center().y;
|
||||
|
||||
@@ -515,6 +640,7 @@ 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())));
|
||||
@@ -544,4 +670,47 @@ mod tests {
|
||||
let b = best2("yield curve");
|
||||
assert!(b.contains(&"GC".to_string()), "{b:?}");
|
||||
}
|
||||
|
||||
/// A perp's row is labelled as one and found by its underlying, after the listed stock.
|
||||
#[test]
|
||||
fn perp_rows_say_what_they_are() {
|
||||
let perp = ticker_item("xyz:AAPL");
|
||||
assert_eq!((perp.title.as_str(), perp.subtitle.as_str()), ("xyz:AAPL", "Apple Inc perp · PERP · HL xyz"));
|
||||
assert_eq!(perp.target, Target::Run("xyz:AAPL".into()));
|
||||
assert_eq!(command::parse("xyz:AAPL"), Some(command::Command::Symbol("xyz:AAPL".into())));
|
||||
assert_eq!(ticker_item("xyz:SKHX").subtitle, "PERP · HL xyz");
|
||||
let items = vec![ticker_item("AAPL US Equity"), perp];
|
||||
let titles = |q: &str| rank(&items, &[], q).into_iter().map(|i| i.title).collect::<Vec<_>>();
|
||||
assert_eq!(titles("aapl"), ["AAPL US Equity", "xyz:AAPL"], "the listed stock first");
|
||||
assert_eq!(titles("apple perp"), ["xyz:AAPL"]);
|
||||
assert_eq!(titles("hyperliquid"), ["xyz:AAPL"]);
|
||||
let perp_cmd = static_items().into_iter().find(|i| i.title == "PERP").expect("PERP is offered");
|
||||
assert_eq!(perp_cmd.hotkey, Some(Action::TogglePerp));
|
||||
}
|
||||
|
||||
#[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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
-19
@@ -1,6 +1,6 @@
|
||||
//! Pane content for every panel kind.
|
||||
|
||||
use std::time::Instant;
|
||||
use web_time::Instant;
|
||||
|
||||
use egui::{Align2, Color32, Rect, RichText, Sense, Stroke, Ui, UiBuilder, pos2, vec2};
|
||||
use tr_data::OrderBook;
|
||||
@@ -18,8 +18,14 @@ impl TerminalApp {
|
||||
let mut actions = Vec::new();
|
||||
let id = self.panes[i].id;
|
||||
let dragging = self.drag.is_some_and(|d| d.active);
|
||||
let switch_to = self.panes[i].kind.symbol().and_then(|s| self.switch_target(s));
|
||||
let perps: Vec<String> = match self.panes[i].kind {
|
||||
PaneKind::Search { .. } => self.perp_candidates().into_iter().map(String::from).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
if let PaneKind::Chart(c) = &mut self.panes[i].kind {
|
||||
c.hover_enabled = !dragging;
|
||||
c.switch_to = switch_to;
|
||||
let clicked = c.show(ui, rect, focused, &self.worker);
|
||||
actions.append(&mut c.actions);
|
||||
return (actions, clicked);
|
||||
@@ -55,7 +61,8 @@ impl TerminalApp {
|
||||
PaneKind::Curve => actions.extend(markets_view::curve(ui, self.curves.as_deref())),
|
||||
PaneKind::Search { query } => {
|
||||
let hits = if self.net_hits.0 == *query { self.net_hits.1.as_slice() } else { &[] };
|
||||
let (a, changed) = markets_view::search(ui, query, hits);
|
||||
let perps: Vec<&str> = perps.iter().map(String::as_str).collect();
|
||||
let (a, changed) = markets_view::search(ui, query, hits, &perps);
|
||||
actions.extend(a);
|
||||
if changed && query.trim().len() >= 2 {
|
||||
self.search_pending = Some((query.clone(), Instant::now()));
|
||||
@@ -76,12 +83,14 @@ impl TerminalApp {
|
||||
}
|
||||
}
|
||||
PaneKind::OrderBook(b) => {
|
||||
if let Some(a) = order_book(ui, b) {
|
||||
if let Some(a) = order_book(ui, b, switch_to) {
|
||||
actions.push(a);
|
||||
}
|
||||
}
|
||||
PaneKind::Portfolio(pp) => {
|
||||
for a in super::portfolio_view::portfolio(ui, pp, &self.broker, &self.quotes) {
|
||||
let demo =
|
||||
(self.prefs.trading.account_mode == crate::prefs::AccountMode::Demo).then_some(&*self.demo.pf);
|
||||
for a in super::portfolio_view::portfolio(ui, pp, &self.broker, demo, &self.quotes, &self.hl.mids) {
|
||||
use super::portfolio_view::PortAction as P;
|
||||
match a {
|
||||
P::Core(a) => actions.push(a),
|
||||
@@ -100,6 +109,12 @@ impl TerminalApp {
|
||||
account,
|
||||
),
|
||||
P::CancelOrder { account, id } => self.request_cancel(&account, &id),
|
||||
P::ClosePosition { account, key } => self.close_position(&account, &key),
|
||||
P::Kill => self.hl_kill(),
|
||||
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}");
|
||||
@@ -142,14 +157,14 @@ impl TerminalApp {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, row_h), Sense::click());
|
||||
widgets::hover_wash(ui, r, &resp, false);
|
||||
let p = ui.painter();
|
||||
p.text(pos2(col(r, 0), r.center().y), Align2::LEFT_CENTER, &sec.ticker, mono(12.5), amber());
|
||||
p.text(
|
||||
pos2(col(r, 0) + 74.0, r.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
sec.class.yellow_key().label(),
|
||||
ui_font(11.0),
|
||||
muted(),
|
||||
);
|
||||
let tick = p.layout_no_wrap(sec.ticker.clone(), mono(12.5), amber());
|
||||
let label_x = col(r, 0) + tick.size().x.max(66.0) + 8.0;
|
||||
p.galley(pos2(col(r, 0), r.center().y - tick.size().y / 2.0), tick, amber());
|
||||
let (label, lc) = match sec.class {
|
||||
tr_core::AssetClass::Perp => (tr_core::perps::badge(s), theme::perp()),
|
||||
c => (c.yellow_key().label().to_string(), muted()),
|
||||
};
|
||||
p.text(pos2(label_x, r.center().y), Align2::LEFT_CENTER, label, ui_font(11.0), lc);
|
||||
match self.quotes.get(s) {
|
||||
Some(q) => {
|
||||
let c = if q.change() >= 0.0 { theme::up() } else { theme::down() };
|
||||
@@ -180,11 +195,17 @@ impl TerminalApp {
|
||||
p.text(pos2(col(r, 4), r.center().y), Align2::RIGHT_CENTER, "…", mono(12.5), muted());
|
||||
}
|
||||
}
|
||||
if resp.clicked() {
|
||||
out.push(Action::OpenChart(s.clone()));
|
||||
let tradable = matches!(sec.class, tr_core::AssetClass::Equity | tr_core::AssetClass::Etf);
|
||||
let pills = if tradable { widgets::buy_sell_pills().to_vec() } else { Vec::new() };
|
||||
let tips = [format!("Buy {} (⇧B)", sec.ticker), format!("Sell {} (⇧S)", sec.ticker)];
|
||||
let tips: Vec<&str> = tips.iter().map(String::as_str).collect();
|
||||
match widgets::row_pills(ui, r, ("wl-pills", s), &pills, &tips) {
|
||||
Some(i) => out.push(Action::Command(format!("{} {s}", if i == 0 { "BUY" } else { "SELL" }))),
|
||||
None if resp.clicked() => out.push(Action::OpenChart(s.clone())),
|
||||
None => {}
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if matches!(sec.class, tr_core::AssetClass::Equity | tr_core::AssetClass::Etf) {
|
||||
if tradable {
|
||||
for (label, side) in [("Buy", "BUY"), ("Sell", "SELL")] {
|
||||
if ui.button(format!("{label} {}…", sec.ticker)).clicked() {
|
||||
out.push(Action::Command(format!("{side} {s}")));
|
||||
@@ -315,17 +336,30 @@ fn fmt_size(q: f64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Price ladder: asks above, bids below, depth bars by cumulative size.
|
||||
fn order_book(ui: &mut Ui, b: &mut super::BookPane) -> Option<Action> {
|
||||
/// Price ladder: asks above, bids below, depth bars by cumulative size. `perp` is the live
|
||||
/// HIP-3 perp linked to the pane's security, whose book it can show instead of a simulated one.
|
||||
fn order_book(ui: &mut Ui, b: &mut super::BookPane, perp: Option<&'static str>) -> Option<Action> {
|
||||
let mut action = None;
|
||||
let key = tr_data::security(&b.symbol);
|
||||
let book: Option<OrderBook> = b.book.as_ref().map(|x| x.grouped(b.group));
|
||||
let mut show_perp = false;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let r = ui.label(RichText::new(&key.ticker).font(mono(13.0)).color(amber())).interact(Sense::click());
|
||||
if r.clicked() {
|
||||
action = Some(Action::OpenChart(b.symbol.clone()));
|
||||
}
|
||||
r.on_hover_text("Open chart");
|
||||
if key.class == tr_core::AssetClass::Perp {
|
||||
let tip = match tr_core::perps::link_for(&b.symbol) {
|
||||
Some(l) => format!(
|
||||
"The perp's own book on Hyperliquid. It references {}; it is not that market's book.",
|
||||
l.reference
|
||||
),
|
||||
None => "The perp's own book on Hyperliquid.".into(),
|
||||
};
|
||||
ui.label(RichText::new(tr_core::perps::badge(&b.symbol)).font(ui_font(11.5)).color(theme::perp()))
|
||||
.on_hover_text(tip);
|
||||
}
|
||||
if let Some(bk) = &book {
|
||||
ui.label(RichText::new(&bk.source).font(ui_font(11.5)).color(muted()));
|
||||
if bk.simulated {
|
||||
@@ -336,6 +370,26 @@ fn order_book(ui: &mut Ui, b: &mut super::BookPane) -> Option<Action> {
|
||||
}
|
||||
}
|
||||
});
|
||||
// A listed instrument with no public book, but a live perp on the same underlying.
|
||||
if let Some(perp) = perp.filter(|p| tr_core::perps::is_perp(p) && book.as_ref().is_none_or(|bk| bk.simulated)) {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let label = format!("Show the {perp} perp's book");
|
||||
let tip = format!(
|
||||
"Load the book of {perp} ({}) here. It is the perp's order book on Hyperliquid, not {}'s.",
|
||||
tr_core::perps::badge(perp),
|
||||
key.key()
|
||||
);
|
||||
show_perp = ui.small_button(RichText::new(label).color(theme::perp())).on_hover_text(tip).clicked();
|
||||
});
|
||||
}
|
||||
if show_perp && let Some(perp) = perp {
|
||||
b.symbol = perp.to_string();
|
||||
b.book = None;
|
||||
b.error = None;
|
||||
b.fetched = None;
|
||||
b.trades.clear();
|
||||
return action;
|
||||
}
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let depths = [10usize, 20, 50];
|
||||
let dl = ["10", "20", "50"];
|
||||
@@ -363,7 +417,20 @@ fn order_book(ui: &mut Ui, b: &mut super::BookPane) -> Option<Action> {
|
||||
ui.add(egui::Spinner::new());
|
||||
return action;
|
||||
};
|
||||
let rect = ui.available_rect_before_wrap();
|
||||
let full = ui.available_rect_before_wrap();
|
||||
// The book's trades go beside the ladder in a wide pane, below it in a tall one.
|
||||
let has_tape = !b.trades.is_empty();
|
||||
let (rect, tape) = if has_tape && full.width() >= dp(560.0) {
|
||||
let split = full.left() + (full.width() * 0.6).floor();
|
||||
let tape = Rect::from_min_max(pos2(split + dp(10.0), full.top()), full.max);
|
||||
(Rect::from_min_max(full.min, pos2(split, full.bottom())), Some(tape))
|
||||
} else if has_tape && full.height() >= dp(280.0) {
|
||||
let split = full.bottom() - (full.height() * 0.36).floor();
|
||||
let tape = Rect::from_min_max(pos2(full.left(), split + dp(4.0)), full.max);
|
||||
(Rect::from_min_max(full.min, pos2(full.right(), split)), Some(tape))
|
||||
} else {
|
||||
(full, None)
|
||||
};
|
||||
let d = key.decimals(book.mid().unwrap_or(1.0));
|
||||
let row = dp(18.0);
|
||||
let rows_each = (((rect.height() - row * 2.0) / row / 2.0).floor() as usize).max(1);
|
||||
@@ -428,11 +495,49 @@ fn order_book(ui: &mut Ui, b: &mut super::BookPane) -> Option<Action> {
|
||||
for (k, level) in bids.iter().enumerate() {
|
||||
draw((y + k as f32 * row) as f64, *level, true);
|
||||
}
|
||||
ui.allocate_rect(rect, Sense::hover());
|
||||
if let Some(tape) = tape {
|
||||
trades_tape(&ui.painter_at(tape), tape, &key, &b.symbol, &b.trades, d);
|
||||
}
|
||||
ui.allocate_rect(full, Sense::hover());
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||
action
|
||||
}
|
||||
|
||||
/// Recent trades next to the book, newest first: time, price coloured by the aggressor's side,
|
||||
/// size. A perp's tape is titled with its venue.
|
||||
fn trades_tape(
|
||||
p: &egui::Painter,
|
||||
rect: Rect,
|
||||
key: &tr_core::Security,
|
||||
symbol: &str,
|
||||
trades: &[tr_data::TradePrint],
|
||||
d: usize,
|
||||
) {
|
||||
let row = dp(18.0);
|
||||
let x_price = rect.left() + (rect.width() * 0.62).clamp(dp(110.0), (rect.width() - dp(60.0)).max(dp(110.0)));
|
||||
let x_size = rect.right() - 6.0;
|
||||
let head = Rect::from_min_size(rect.min, vec2(rect.width(), row));
|
||||
p.rect_filled(head, 3.0, theme::header());
|
||||
let title = match key.class {
|
||||
tr_core::AssetClass::Perp => format!("Trades · {} · {}", key.ticker, tr_core::perps::badge(symbol)),
|
||||
_ => format!("Trades · {}", key.ticker),
|
||||
};
|
||||
let tc = if key.class == tr_core::AssetClass::Perp { theme::perp() } else { muted() };
|
||||
p.text(pos2(rect.left() + 6.0, head.center().y), Align2::LEFT_CENTER, title, ui_font(11.0), tc);
|
||||
let mut y = head.bottom();
|
||||
for t in trades.iter().rev().take(((rect.bottom() - y) / row).floor().max(0.0) as usize) {
|
||||
let c = if t.buy { theme::up() } else { theme::down() };
|
||||
let when = chrono::DateTime::from_timestamp_millis(t.ts_ms)
|
||||
.map(|x| x.with_timezone(&chrono::Local).format("%H:%M:%S").to_string())
|
||||
.unwrap_or_default();
|
||||
let cy = y + row / 2.0;
|
||||
p.text(pos2(rect.left() + 6.0, cy), Align2::LEFT_CENTER, when, mono(11.5), muted());
|
||||
p.text(pos2(x_price, cy), Align2::RIGHT_CENTER, format!("{:.*}", d, t.px), mono(12.0), c);
|
||||
p.text(pos2(x_size, cy), Align2::RIGHT_CENTER, fmt_size(t.sz), mono(12.0), text());
|
||||
y += row;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+144
-20
@@ -31,11 +31,20 @@ pub enum PortAction {
|
||||
account: Option<String>,
|
||||
units: Option<f64>,
|
||||
},
|
||||
/// Ask to cancel an open order (confirmed in a dialog).
|
||||
/// Ask to cancel an open order (confirmed in a dialog for live accounts).
|
||||
CancelOrder {
|
||||
account: String,
|
||||
id: String,
|
||||
},
|
||||
/// Open Settings › Trading.
|
||||
TradingSettings,
|
||||
/// Sell a whole position (at once in the demo account; through the ticket live).
|
||||
ClosePosition {
|
||||
account: String,
|
||||
key: String,
|
||||
},
|
||||
/// The Hyperliquid kill switch (`KILL`).
|
||||
Kill,
|
||||
}
|
||||
|
||||
// ---- formatting ---------------------------------------------------------------------------
|
||||
@@ -279,41 +288,50 @@ 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>,
|
||||
testnet: &HashMap<String, f64>,
|
||||
) -> Vec<PortAction> {
|
||||
let mut out = Vec::new();
|
||||
toolbar(ui, pane, b, &mut out);
|
||||
toolbar(ui, pane, b, demo, &mut out);
|
||||
ui.add_space(dp(4.0));
|
||||
match (b.mode(), b.portfolio.as_deref()) {
|
||||
(BrokerMode::Setup, _) => setup_screen(ui, b, &mut out),
|
||||
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) {
|
||||
// Without SnapTrade the panes still show a Hyperliquid account.
|
||||
(BrokerMode::Setup, pf) if pf.is_none_or(|p| p.accounts.is_empty()) => setup_screen(ui, b, &mut out),
|
||||
(_, None) if b.loading => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::Spinner::new().size(14.0));
|
||||
ui.label(RichText::new("Loading brokerage accounts…").color(muted()));
|
||||
});
|
||||
}
|
||||
(_, None) => match &b.error {
|
||||
(_, None) => match error {
|
||||
Some(e) => error_card(ui, e, &mut out),
|
||||
None => note(ui, "Waiting for the first sync…"),
|
||||
},
|
||||
(_, Some(pf)) if pf.accounts.is_empty() => {
|
||||
if let Some(e) = &b.error {
|
||||
if let Some(e) = error {
|
||||
error_card(ui, e, &mut out);
|
||||
}
|
||||
empty_state(ui, &mut out);
|
||||
}
|
||||
(_, Some(pf)) => {
|
||||
if let Some(e) = &b.error {
|
||||
if let Some(e) = error {
|
||||
ui.label(RichText::new(format!("Last refresh failed: {e}")).font(ui_font(11.5)).color(theme::down()));
|
||||
}
|
||||
if pane.account.as_ref().is_some_and(|a| pf.account(a).is_none()) {
|
||||
pane.account = None;
|
||||
}
|
||||
let price_of = |p: &Position| b.live_price(quotes, p);
|
||||
let price_of = |p: &Position| super::broker::live_price(quotes, testnet, p);
|
||||
match pane.view {
|
||||
PortView::Summary => {
|
||||
egui::ScrollArea::vertical()
|
||||
@@ -342,13 +360,32 @@ pub fn portfolio(
|
||||
out
|
||||
}
|
||||
|
||||
fn toolbar(ui: &mut Ui, pane: &mut PortfolioPane, b: &BrokerState, out: &mut Vec<PortAction>) {
|
||||
fn toolbar(
|
||||
ui: &mut Ui,
|
||||
pane: &mut PortfolioPane,
|
||||
b: &BrokerState,
|
||||
demo: Option<&Portfolio>,
|
||||
out: &mut Vec<PortAction>,
|
||||
) {
|
||||
ui.horizontal(|ui| {
|
||||
let labels: Vec<&str> = PortView::ALL.iter().map(|v| v.label()).collect();
|
||||
let sel = PortView::ALL.iter().position(|v| *v == pane.view);
|
||||
if let Some(i) = widgets::segmented(ui, "port-view", &labels, sel, 22.0) {
|
||||
pane.view = PortView::ALL[i];
|
||||
}
|
||||
if demo.is_some() {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::icon_button(ui, widgets::Icon::Gear, "Trading settings: demo account, live accounts", false)
|
||||
.clicked()
|
||||
{
|
||||
out.push(PortAction::TradingSettings);
|
||||
}
|
||||
ui.label(RichText::new("Paper trading · virtual cash").font(ui_font(11.0)).color(muted()));
|
||||
super::trading::mode_badge(ui, false)
|
||||
.on_hover_text("The demo account is selected. Switch to live accounts from the toolbar badge.");
|
||||
});
|
||||
return;
|
||||
}
|
||||
if let Some(pf) = b.portfolio.as_deref()
|
||||
&& pf.accounts.len() > 1
|
||||
{
|
||||
@@ -421,7 +458,7 @@ fn setup_screen(ui: &mut Ui, b: &BrokerState, out: &mut Vec<PortAction>) {
|
||||
ui.add_space(dp(4.0));
|
||||
note(
|
||||
ui,
|
||||
"terminal-rs reads accounts, balances, positions, orders and activity through SnapTrade. Access is read-only \
|
||||
"Terminal reads accounts, balances, positions, orders and activity through SnapTrade. Access is read-only \
|
||||
unless you turn on trading in Settings › Trading.",
|
||||
);
|
||||
ui.add_space(dp(8.0));
|
||||
@@ -696,12 +733,61 @@ fn position_row(
|
||||
(None, _) => resp.on_hover_text("Not available as a terminal market key"),
|
||||
};
|
||||
if let Some(k) = &key {
|
||||
if resp.clicked() {
|
||||
out.push(PortAction::Core(Action::OpenChart(k.clone())));
|
||||
// Hyperliquid perps trade on their own account; a short closes too (reduce-only).
|
||||
let perp = super::hl::is_hl_account(&p.account_id) && p.market.is_some();
|
||||
let tradable = perp
|
||||
|| (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 || (perp && p.units != 0.0) {
|
||||
pills.push(("Close", theme::control(), text()));
|
||||
}
|
||||
}
|
||||
let tips = if perp {
|
||||
[
|
||||
"Buy (Hyperliquid testnet)",
|
||||
"Sell (Hyperliquid testnet)",
|
||||
"Close: a reduce-only market order for all of it",
|
||||
]
|
||||
} else {
|
||||
["Buy more (⇧B)", "Sell (⇧S)", "Close: sell the whole position"]
|
||||
};
|
||||
let pill = widgets::row_pills(ui, r, ("pos-pills", &p.account_id, &p.symbol), &pills, &tips);
|
||||
match pill {
|
||||
Some(0) => out.push(PortAction::Trade {
|
||||
key: k.clone(),
|
||||
buy: true,
|
||||
account: Some(p.account_id.clone()),
|
||||
units: None,
|
||||
}),
|
||||
Some(1) => out.push(PortAction::Trade {
|
||||
key: k.clone(),
|
||||
buy: false,
|
||||
account: Some(p.account_id.clone()),
|
||||
units: None,
|
||||
}),
|
||||
Some(_) => out.push(PortAction::ClosePosition { account: p.account_id.clone(), key: k.clone() }),
|
||||
None if resp.clicked() => out.push(PortAction::Core(Action::OpenChart(k.clone()))),
|
||||
None => {}
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if p.kind != tr_broker::InstrumentKind::Option {
|
||||
if perp {
|
||||
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();
|
||||
} else if p.kind != tr_broker::InstrumentKind::Option {
|
||||
trade_items(ui, k, Some(&p.account_id), Some(p.units), out);
|
||||
if tradable && p.units > 0.0 {
|
||||
if ui.button(format!("Close Position ({} {})…", qty(p.units), p.short_label())).clicked() {
|
||||
out.push(PortAction::ClosePosition { account: p.account_id.clone(), key: k.clone() });
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
}
|
||||
}
|
||||
security_menu(ui, k, &p.symbol, out)
|
||||
});
|
||||
@@ -879,7 +965,30 @@ fn orders(ui: &mut Ui, pane: &mut PortfolioPane, pf: &Portfolio, out: &mut Vec<P
|
||||
if let Some(i) = widgets::segmented(ui, "port-orders", &["Open", "All"], sel, 20.0) {
|
||||
pane.open_only = i == 0;
|
||||
}
|
||||
note(ui, format!("Last {} days · right-click an open order to cancel it", tr_broker::snaptrade::ORDER_DAYS));
|
||||
if pf.accounts.iter().any(|a| super::hl::is_hl_account(&a.id)) {
|
||||
let b = egui::Button::new(RichText::new("KILL").font(mono(11.5)).strong().color(Color32::WHITE))
|
||||
.fill(theme::down());
|
||||
if ui
|
||||
.add(b)
|
||||
.on_hover_text(
|
||||
"Kill switch: cancel every open Hyperliquid testnet order and turn Hyperliquid orders off",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
out.push(PortAction::Kill);
|
||||
}
|
||||
}
|
||||
if pf.demo {
|
||||
note(ui, "Demo orders · hover an open order to cancel it");
|
||||
} else {
|
||||
note(
|
||||
ui,
|
||||
format!(
|
||||
"Last {} days · hover or right-click an open order to cancel it",
|
||||
tr_broker::snaptrade::ORDER_DAYS
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
let sel = selected(pane);
|
||||
let list: Vec<_> = pf
|
||||
@@ -952,12 +1061,16 @@ fn orders(ui: &mut Ui, pane: &mut PortfolioPane, pf: &Portfolio, out: &mut Vec<P
|
||||
);
|
||||
}
|
||||
let key = o.market_key();
|
||||
if let Some(k) = &key
|
||||
let cancellable = o.is_open() && !o.id.is_empty();
|
||||
let pills: &[widgets::Pill<'_>] =
|
||||
if cancellable { &[("Cancel", theme::down(), Color32::WHITE)] } else { &[] };
|
||||
if widgets::row_pills(ui, r, ("ord-pill", &o.account_id, &o.id), pills, &["Cancel this order"]).is_some() {
|
||||
out.push(PortAction::CancelOrder { account: o.account_id.clone(), id: o.id.clone() });
|
||||
} else if let Some(k) = &key
|
||||
&& resp.clicked()
|
||||
{
|
||||
out.push(PortAction::Core(Action::OpenChart(k.clone())));
|
||||
}
|
||||
let cancellable = o.is_open() && !o.id.is_empty();
|
||||
if cancellable || key.is_some() {
|
||||
resp.context_menu(|ui| {
|
||||
if cancellable {
|
||||
@@ -1095,20 +1208,31 @@ mod tests {
|
||||
#[test]
|
||||
fn renders_every_view_without_panicking() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut b = BrokerState::load(true);
|
||||
// A store that holds nothing, so the test never reads (or moves) the user's Keychain item.
|
||||
let none = std::env::temp_dir().join(format!("tr-portfolio-test-{}/snaptrade.json", std::process::id()));
|
||||
let mut b = BrokerState::with_store(tr_broker::SecretStore::file(none), true);
|
||||
b.creds = tr_broker::Credentials::default();
|
||||
b.portfolio = Some(Box::new(tr_broker::sample::portfolio()));
|
||||
let quotes = HashMap::new();
|
||||
for view in PortView::ALL {
|
||||
let mut pane = PortfolioPane::new(view);
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| {
|
||||
let _ = portfolio(ui, &mut pane, &b, "es);
|
||||
let _ = portfolio(ui, &mut pane, &b, None, "es, &HashMap::new());
|
||||
});
|
||||
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, "es);
|
||||
let _ = portfolio(ui, &mut pane, &b, None, "es, &HashMap::new());
|
||||
});
|
||||
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, &HashMap::new());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
@@ -1116,7 +1240,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, "es);
|
||||
let _ = portfolio(ui, &mut pane, &b, None, "es, &HashMap::new());
|
||||
});
|
||||
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 _ = std::fs::remove_file(f);
|
||||
let _ = crate::storage::remove(&f);
|
||||
}
|
||||
self.themes[i].source = Some(path);
|
||||
}
|
||||
@@ -519,7 +519,7 @@ impl TerminalApp {
|
||||
expand(given).with_extension(op)
|
||||
};
|
||||
self.status = match themes::ThemeStore::export(&self.themes[idx], &path) {
|
||||
Ok(()) => format!("Exported {name:?} to {}", path.display()),
|
||||
Ok(to) => format!("Exported {name:?} to {to}"),
|
||||
Err(e) => format!("Export failed: {e:#}"),
|
||||
};
|
||||
}
|
||||
@@ -551,7 +551,7 @@ impl TerminalApp {
|
||||
}
|
||||
"import" => {
|
||||
let path = expand(self.theme_io.trim());
|
||||
match themes::load_file(&path) {
|
||||
match themes::import_file(&path) {
|
||||
Ok(mut t) => {
|
||||
t.name = themes::unique_name(&self.themes, &t.name);
|
||||
t.source = None;
|
||||
@@ -566,9 +566,7 @@ impl TerminalApp {
|
||||
}
|
||||
}
|
||||
"folder" => {
|
||||
let _ = std::fs::create_dir_all(&self.theme_store.dir);
|
||||
let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
|
||||
if let Err(e) = std::process::Command::new(opener).arg(&self.theme_store.dir).spawn() {
|
||||
if let Err(e) = crate::storage::reveal(&self.theme_store.dir) {
|
||||
self.status = format!("Could not open {}: {e}", self.theme_store.dir.display());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
//! configured dark/light pair), cycle, the THEME command, copy-on-write edits of built-ins,
|
||||
//! and debounced saving of edited custom themes.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::TerminalApp;
|
||||
use crate::prefs::{Density, UiFont};
|
||||
use crate::themes::{self, SYSTEM, Theme};
|
||||
use web_time::Instant;
|
||||
|
||||
impl TerminalApp {
|
||||
/// Index of the theme currently in effect.
|
||||
|
||||
+1076
-368
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,464 @@
|
||||
//! The built-in wallet (Turnkey), testnet only: sign-in through the browser, the short Turnkey
|
||||
//! session, approving and revoking the trading (agent) key with the wallet, Deposit and backup.
|
||||
//!
|
||||
//! The wallet's key never leaves Turnkey. Turnkey signs only `approveAgent` (see
|
||||
//! `crate::wallet_worker`); orders are signed by the agent key on this device, as before.
|
||||
//! See `docs/architecture/trading-hyperliquid.md` §4.5.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tr_hl::model::SpotUsdc;
|
||||
use tr_turnkey::session::random_state;
|
||||
use tr_turnkey::{Session, SessionKey};
|
||||
use web_time::Instant;
|
||||
|
||||
use super::TerminalApp;
|
||||
use crate::prefs::HlWallet;
|
||||
use crate::wallet_worker::{SignedIn, WalletRequest, WalletResponse, session_account};
|
||||
use crate::worker::Request;
|
||||
|
||||
/// A session with less than this left isn't used to sign: the user signs in again first.
|
||||
const SESSION_MARGIN_SECS: u64 = 60;
|
||||
/// How often the Deposit window re-reads spot USDC while it is open.
|
||||
const BALANCES_EVERY: Duration = Duration::from_secs(15);
|
||||
|
||||
/// What to do once a sign-in finishes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum After {
|
||||
Approve,
|
||||
Revoke,
|
||||
}
|
||||
|
||||
/// A sign-in waiting for the browser.
|
||||
pub struct SignIn {
|
||||
id: u64,
|
||||
state: String,
|
||||
/// The device key's public half (the page asks Turnkey for a session bound to it).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
public_key: String,
|
||||
/// The page to open (again), once known.
|
||||
pub url: Option<String>,
|
||||
pub started: Instant,
|
||||
then: Option<After>,
|
||||
/// The wallet the pending action is for. A sign-in to any other wallet is refused.
|
||||
expected: Option<String>,
|
||||
/// Web: the key waits here for the page's message. Desktop: the worker holds it.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
key: Option<SessionKey>,
|
||||
}
|
||||
|
||||
impl SignIn {
|
||||
fn new(id: u64, state: String, then: Option<After>, expected: Option<String>) -> Self {
|
||||
Self {
|
||||
id,
|
||||
state,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
public_key: String::new(),
|
||||
url: None,
|
||||
started: Instant::now(),
|
||||
then,
|
||||
expected,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
key: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Past the sign-in's deadline ([`tr_turnkey::SIGN_IN_TIMEOUT`]).
|
||||
pub fn expired(&self) -> bool {
|
||||
self.started.elapsed() > tr_turnkey::SIGN_IN_TIMEOUT
|
||||
}
|
||||
|
||||
/// Refuse a wallet other than the one the pending action is for.
|
||||
fn check_account(&self, address: &str) -> Result<(), String> {
|
||||
match &self.expected {
|
||||
Some(want) if !want.eq_ignore_ascii_case(address) => Err(format!(
|
||||
"You signed in to wallet {}, but this was for wallet {}. Nothing changed; sign in with that wallet's email or passkey.",
|
||||
super::hl_view::masked(address),
|
||||
super::hl_view::masked(want)
|
||||
)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct WalletState {
|
||||
/// The live Turnkey session (at most 15 minutes), if any.
|
||||
pub session: Option<Arc<Session>>,
|
||||
/// The secret store item already read, so it isn't read again every frame.
|
||||
session_tried: Option<String>,
|
||||
pub signing_in: Option<SignIn>,
|
||||
next_id: u64,
|
||||
pub deposit: bool,
|
||||
pub backup_prompt: bool,
|
||||
pub backup_ack: bool,
|
||||
/// Spot USDC for the Deposit window: `(address, result)`.
|
||||
pub spot: Option<(String, Result<SpotUsdc, String>)>,
|
||||
spot_at: Option<Instant>,
|
||||
/// A revoke is with the wallet: the agent is forgotten only once it really happens.
|
||||
pub revoking: bool,
|
||||
}
|
||||
|
||||
impl WalletState {
|
||||
/// A new id for a sign-in, never one used before in this run.
|
||||
fn next_request_id(&mut self) -> u64 {
|
||||
self.next_id += 1;
|
||||
self.next_id
|
||||
}
|
||||
|
||||
/// Forget the wallet's state but keep counting request ids, so a late answer to an old
|
||||
/// sign-in can never match a new one.
|
||||
fn reset(&mut self) {
|
||||
*self = Self { next_id: self.next_id, ..Default::default() };
|
||||
}
|
||||
|
||||
/// Minutes left on the session, if it can still sign.
|
||||
pub fn session_minutes(&self) -> Option<u64> {
|
||||
let s = self.session.as_ref()?;
|
||||
let left = s.remaining(now_secs());
|
||||
(left > SESSION_MARGIN_SECS).then(|| left.div_ceil(60))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn now_secs() -> u64 {
|
||||
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The sign-in page in the browser build: this site's own `/wallet/`.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn web_page() -> String {
|
||||
format!("{}/wallet", super::hl_web::origin())
|
||||
}
|
||||
|
||||
impl TerminalApp {
|
||||
fn wallet_send(&self, r: WalletRequest) {
|
||||
self.worker.send(Request::Wallet(r));
|
||||
}
|
||||
|
||||
/// The signed-in wallet, if any.
|
||||
pub(super) fn wallet(&self) -> Option<&HlWallet> {
|
||||
self.prefs.trading.hl.wallet.as_ref()
|
||||
}
|
||||
|
||||
/// Open the sign-in page. `then` runs once the session is back.
|
||||
pub(super) fn wallet_sign_in(&mut self, then: Option<After>) {
|
||||
let id = self.hl.wallet.next_request_id();
|
||||
let key = SessionKey::generate();
|
||||
let state = random_state();
|
||||
let public_key = key.public_hex();
|
||||
// An approve or revoke is for the wallet shown now, and only that one.
|
||||
let expected = then.and(self.wallet().map(|w| w.address.clone()));
|
||||
#[allow(unused_mut)]
|
||||
let mut sign_in = SignIn::new(id, state.clone(), then, expected);
|
||||
self.hl.message = None;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
sign_in.public_key = public_key;
|
||||
self.hl.wallet.signing_in = Some(sign_in);
|
||||
self.wallet_send(WalletRequest::SignIn { id, store: self.hl.store.clone(), key, state });
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let url = tr_turnkey::web_sign_in_url(&web_page(), &public_key, &state);
|
||||
let opened = super::hl_web::wallet_popup(&url);
|
||||
sign_in.url = Some(url);
|
||||
sign_in.key = Some(key);
|
||||
self.hl.wallet.signing_in = Some(sign_in);
|
||||
if !opened {
|
||||
self.hl.message = Some((
|
||||
"The browser blocked the sign-in window: allow pop-ups, then press Open Again.".into(),
|
||||
true,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop waiting for the browser.
|
||||
pub(super) fn wallet_cancel_sign_in(&mut self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
crate::wallet_worker::cancel_sign_in();
|
||||
self.hl.wallet.signing_in = None;
|
||||
}
|
||||
|
||||
/// Open the sign-in page again (the browser tab was closed).
|
||||
pub(super) fn wallet_reopen(&mut self) {
|
||||
let Some(url) = self.hl.wallet.signing_in.as_ref().and_then(|s| s.url.clone()) else { return };
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Err(e) = super::broker::open_external(&url) {
|
||||
self.hl.message = Some((format!("Couldn't open the browser: {e}"), true));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if !super::hl_web::wallet_popup(&url) {
|
||||
self.hl.message = Some(("The browser blocked the sign-in window: allow pop-ups.".into(), true));
|
||||
}
|
||||
}
|
||||
|
||||
/// Web: the page's message for the sign-in with `state`.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn wallet_page_answered(&mut self, state: &str, session: Option<String>, error: Option<String>) {
|
||||
let Some(sign_in) = self.hl.wallet.signing_in.as_mut() else { return };
|
||||
if !tr_turnkey::session::same_secret(&sign_in.state, state) {
|
||||
return;
|
||||
}
|
||||
match (session, sign_in.key.take()) {
|
||||
(Some(token), Some(key)) => {
|
||||
let id = sign_in.id;
|
||||
self.wallet_send(WalletRequest::Finish { id, store: self.hl.store.clone(), key, token });
|
||||
}
|
||||
(_, _) => {
|
||||
self.hl.wallet.signing_in = None;
|
||||
self.hl.message =
|
||||
Some((format!("Not signed in: {}", error.unwrap_or_else(|| "no answer".into())), true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Approve a trading key with the wallet (signing in first if there's no session).
|
||||
pub(super) fn wallet_approve(&mut self) {
|
||||
let Some(w) = self.wallet().cloned() else { return };
|
||||
let Some(session) = self.hl.wallet.session.clone().filter(|_| self.hl.wallet.session_minutes().is_some())
|
||||
else {
|
||||
return self.wallet_sign_in(Some(After::Approve));
|
||||
};
|
||||
self.hl.busy = Some("Approving the trading key: the wallet signs approveAgent on Turnkey…");
|
||||
self.hl.message = None;
|
||||
self.hl.pending_approval = Some(w.address.clone());
|
||||
self.wallet_send(WalletRequest::Approve {
|
||||
store: self.hl.store.clone(),
|
||||
session,
|
||||
master: w.address,
|
||||
device: super::hl::device(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Replace the agent on Hyperliquid with one nobody holds (the wallet signs), and delete its
|
||||
/// key here.
|
||||
pub(super) fn wallet_revoke(&mut self) {
|
||||
let Some(w) = self.wallet().cloned() else { return };
|
||||
let Some(session) = self.hl.wallet.session.clone().filter(|_| self.hl.wallet.session_minutes().is_some())
|
||||
else {
|
||||
return self.wallet_sign_in(Some(After::Revoke));
|
||||
};
|
||||
let base = self
|
||||
.prefs
|
||||
.trading
|
||||
.hl
|
||||
.agent
|
||||
.as_ref()
|
||||
.map(|a| tr_hl::trader::agent_base_name(&a.name).to_string())
|
||||
.unwrap_or_else(|| format!("terminal-{}", super::hl::device()));
|
||||
// Orders stop now; the agent itself is forgotten when the revoke happens.
|
||||
self.prefs.trading.hl.enabled = false;
|
||||
self.save_hl_prefs();
|
||||
self.hl.wallet.revoking = true;
|
||||
self.hl.busy = Some("Revoking: the wallet signs the replacement on Turnkey…");
|
||||
self.wallet_send(WalletRequest::Revoke { store: self.hl.store.clone(), session, master: w.address, base });
|
||||
}
|
||||
|
||||
/// Forget the Turnkey session on this device (trading keeps working with the agent key).
|
||||
pub(super) fn wallet_end_session(&mut self) {
|
||||
let Some(w) = self.wallet() else { return };
|
||||
let account = session_account(&w.address);
|
||||
self.hl.wallet.session = None;
|
||||
self.wallet_send(WalletRequest::EndSession { store: self.hl.store.clone(), account });
|
||||
}
|
||||
|
||||
/// Back up the wallet: the sign-in page's export mode, where Turnkey's own iframe shows
|
||||
/// the recovery phrase. Terminal never sees it.
|
||||
pub(super) fn wallet_backup(&mut self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let url = tr_turnkey::export_url(&tr_turnkey::sign_in_page());
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let url = tr_turnkey::export_url(&web_page());
|
||||
if let Err(e) = super::broker::open_external(&url) {
|
||||
self.hl.message = Some((format!("Couldn't open the browser: {e}"), true));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn wallet_open_deposit(&mut self) {
|
||||
self.hl.wallet.deposit = true;
|
||||
self.hl.wallet.spot_at = None;
|
||||
}
|
||||
|
||||
/// Read the session kept in the store (once), drop an expired one, refresh Deposit.
|
||||
pub(super) fn wallet_periodic(&mut self) {
|
||||
let Some(w) = self.wallet().cloned() else { return };
|
||||
let account = session_account(&w.address);
|
||||
if self.hl.wallet.session.is_none() && self.hl.wallet.session_tried.as_deref() != Some(&account) {
|
||||
self.hl.wallet.session_tried = Some(account.clone());
|
||||
self.wallet_send(WalletRequest::LoadSession { store: self.hl.store.clone(), account: account.clone() });
|
||||
}
|
||||
if self.hl.wallet.session.as_ref().is_some_and(|s| s.expired(now_secs())) {
|
||||
self.hl.wallet.session = None;
|
||||
self.wallet_send(WalletRequest::EndSession { store: self.hl.store.clone(), account });
|
||||
}
|
||||
if self.hl.wallet.deposit && self.hl.wallet.spot_at.is_none_or(|t| t.elapsed() > BALANCES_EVERY) {
|
||||
self.hl.wallet.spot_at = Some(Instant::now());
|
||||
self.wallet_send(WalletRequest::Balances { user: w.address });
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn on_wallet(&mut self, r: WalletResponse) {
|
||||
match r {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
WalletResponse::Listening { id, port } => {
|
||||
let Some(s) = self.hl.wallet.signing_in.as_mut().filter(|s| s.id == id) else { return };
|
||||
let url = tr_turnkey::desktop_sign_in_url(&tr_turnkey::sign_in_page(), &s.public_key, &s.state, port);
|
||||
s.url = Some(url);
|
||||
self.wallet_reopen();
|
||||
}
|
||||
WalletResponse::SignedIn { id, result } => {
|
||||
let Some(s) = self.hl.wallet.signing_in.take() else { return };
|
||||
if s.id != id {
|
||||
self.hl.wallet.signing_in = Some(s);
|
||||
return;
|
||||
}
|
||||
match result {
|
||||
Ok(signed) => self.wallet_signed_in(*signed, &s),
|
||||
Err(e) => self.hl.message = Some((tr_hl::mask_addresses(&e), true)),
|
||||
}
|
||||
}
|
||||
WalletResponse::SessionLoaded { account, session } => {
|
||||
if self.wallet().is_some_and(|w| session_account(&w.address) == account)
|
||||
&& self.hl.wallet.session.is_none()
|
||||
{
|
||||
self.hl.wallet.session = session;
|
||||
}
|
||||
}
|
||||
WalletResponse::ApproveNotSigned(e) => {
|
||||
self.hl.busy = None;
|
||||
self.hl.pending_approval = None;
|
||||
let e = tr_hl::mask_addresses(&e);
|
||||
self.hl.message = Some((format!("Nothing was approved: {e}"), true));
|
||||
}
|
||||
WalletResponse::RevokeNotSigned(e) => {
|
||||
self.hl.wallet.revoking = false;
|
||||
self.hl.busy = None;
|
||||
let e = tr_hl::mask_addresses(&e);
|
||||
self.hl.message = Some((format!("Nothing was revoked; the trading key still works: {e}"), true));
|
||||
}
|
||||
WalletResponse::Balances { user, result } => {
|
||||
if self.wallet().is_some_and(|w| w.address == user) {
|
||||
self.hl.wallet.spot = Some((user, result));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wallet_signed_in(&mut self, s: SignedIn, sign_in: &SignIn) {
|
||||
let SignedIn { session, address, user, saved } = s;
|
||||
if let Err(e) = sign_in.check_account(&address) {
|
||||
// Not the wallet the action was for: drop that session and change nothing.
|
||||
let account = session_account(&address);
|
||||
self.wallet_send(WalletRequest::EndSession { store: self.hl.store.clone(), account });
|
||||
self.hl.message = Some((e, true));
|
||||
return;
|
||||
}
|
||||
let then = sign_in.then;
|
||||
let organization_id = session.organization_id().to_string();
|
||||
if self.hl_master().as_deref() != Some(&address) {
|
||||
// Another account: its agent (if any) is not this one's.
|
||||
self.prefs.trading.hl.agent = None;
|
||||
self.prefs.trading.hl.enabled = false;
|
||||
self.hl.key = None;
|
||||
self.hl.reset_key_lookup();
|
||||
}
|
||||
self.prefs.trading.hl.master = Some(address.clone());
|
||||
self.prefs.trading.hl.wallet = Some(HlWallet { address: address.clone(), organization_id, user: user.clone() });
|
||||
self.save_hl_prefs();
|
||||
self.hl.wallet.session_tried = Some(session_account(&address));
|
||||
self.hl.wallet.session = Some(Arc::new(session));
|
||||
let masked = super::hl_view::masked(&address);
|
||||
let mut note = format!("Signed in: wallet {masked} (Turnkey user {user}).");
|
||||
if let Err(e) = saved {
|
||||
note.push_str(&format!(" The session couldn't be kept in {} ({e}).", self.hl.store.describe()));
|
||||
}
|
||||
self.hl.message = Some((note, false));
|
||||
self.hl_start();
|
||||
match then {
|
||||
Some(After::Approve) => self.wallet_approve(),
|
||||
Some(After::Revoke) => self.wallet_revoke(),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop showing the wallet here and forget its session (its agent key stays unless revoked).
|
||||
pub(super) fn wallet_remove(&mut self) {
|
||||
self.wallet_cancel_sign_in();
|
||||
self.wallet_end_session();
|
||||
self.prefs.trading.hl.wallet = None;
|
||||
self.hl.wallet.reset();
|
||||
}
|
||||
|
||||
/// Give up on a sign-in the browser never finished (the desktop's listener stops at the
|
||||
/// same deadline; the browser build has no listener to time out).
|
||||
pub(super) fn wallet_sign_in_deadline(&mut self) {
|
||||
if self.hl.wallet.signing_in.as_ref().is_some_and(SignIn::expired) {
|
||||
self.wallet_cancel_sign_in();
|
||||
let mins = tr_turnkey::SIGN_IN_TIMEOUT.as_secs() / 60;
|
||||
self.hl.message =
|
||||
Some((format!("The sign-in wasn't finished within {mins} minutes. Start it again."), true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Greptile #38: removing the wallet reset the request counter, so a cancelled sign-in's
|
||||
/// late answer carried the same id as the next one and ended it.
|
||||
#[test]
|
||||
fn request_ids_stay_unique_when_the_wallet_is_removed() {
|
||||
let mut w = WalletState::default();
|
||||
let first = w.next_request_id();
|
||||
w.reset();
|
||||
assert_ne!(w.next_request_id(), first);
|
||||
}
|
||||
|
||||
/// Greptile #38: a revoke (or approval) that needed a sign-in must not act on whichever
|
||||
/// wallet the user happened to sign in to.
|
||||
#[test]
|
||||
fn a_sign_in_for_an_action_accepts_only_that_wallet() {
|
||||
let a = "0x00000000000000000000000000000000000000aa";
|
||||
let b = "0x00000000000000000000000000000000000000bb";
|
||||
let pending = SignIn::new(1, "s".into(), Some(After::Revoke), Some(a.into()));
|
||||
assert!(pending.check_account(a).is_ok());
|
||||
assert!(pending.check_account(&a.to_ascii_uppercase().replace("0X", "0x")).is_ok());
|
||||
assert!(pending.check_account(b).unwrap_err().contains("0x0000…00aa"));
|
||||
let plain = SignIn::new(2, "s".into(), None, None);
|
||||
assert!(plain.check_account(b).is_ok(), "a plain sign-in takes any wallet");
|
||||
}
|
||||
|
||||
/// Greptile #38: the browser build's sign-in had no deadline at all.
|
||||
#[test]
|
||||
fn a_sign_in_expires_after_ten_minutes() {
|
||||
let mut s = SignIn::new(1, "s".into(), None, None);
|
||||
assert!(!s.expired());
|
||||
s.started = Instant::now() - (tr_turnkey::SIGN_IN_TIMEOUT + Duration::from_secs(1));
|
||||
assert!(s.expired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_close_to_expiry_does_not_sign() {
|
||||
let key = SessionKey::generate();
|
||||
let claims = |exp| tr_turnkey::session::Claims {
|
||||
organization_id: "org".into(),
|
||||
user_id: "u".into(),
|
||||
public_key: key.public_hex(),
|
||||
exp,
|
||||
session_type: String::new(),
|
||||
};
|
||||
let mut w = WalletState::default();
|
||||
assert_eq!(w.session_minutes(), None);
|
||||
let token = tr_turnkey::session::unsigned_token(&claims(now_secs() + 600));
|
||||
w.session =
|
||||
Some(Arc::new(Session::accept(SessionKey::from_hex(&key.to_hex()).unwrap(), &token, now_secs()).unwrap()));
|
||||
assert_eq!(w.session_minutes(), Some(10));
|
||||
let token = tr_turnkey::session::unsigned_token(&claims(now_secs() + 30));
|
||||
w.session =
|
||||
Some(Arc::new(Session::accept(SessionKey::from_hex(&key.to_hex()).unwrap(), &token, now_secs()).unwrap()));
|
||||
assert_eq!(w.session_minutes(), None, "under a minute left: sign in again first");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
//! The built-in wallet's views: its part of Settings › Trading › Hyperliquid, the Deposit
|
||||
//! window (address, QR code, testnet balances) and the warning before a backup. Each shows
|
||||
//! the amber TESTNET badge.
|
||||
|
||||
use egui::{Color32, RichText, Ui};
|
||||
use tr_hl::nonce::now_ms;
|
||||
|
||||
use super::TerminalApp;
|
||||
use super::hl_view::{masked, testnet_badge};
|
||||
use super::portfolio_view::money;
|
||||
use crate::prefs::HlPrefs;
|
||||
use crate::theme::{self, amber, dp, mono, muted, text, ui_font};
|
||||
|
||||
/// A QR code of `data`, `side` points square, dark modules on white with a 4-module margin.
|
||||
pub fn qr_code(ui: &mut Ui, data: &str, side: f32) -> egui::Response {
|
||||
let (rect, resp) = ui.allocate_exact_size(egui::vec2(side, side), egui::Sense::hover());
|
||||
let Ok(code) = qrcode::QrCode::new(data.as_bytes()) else { return resp };
|
||||
let n = code.width();
|
||||
let colors = code.to_colors();
|
||||
let cell = side / (n + 8) as f32;
|
||||
let p = ui.painter_at(rect);
|
||||
p.rect_filled(rect, 4.0, Color32::WHITE);
|
||||
for (i, c) in colors.iter().enumerate() {
|
||||
if *c == qrcode::Color::Dark {
|
||||
let (x, y) = ((i % n) as f32 + 4.0, (i / n) as f32 + 4.0);
|
||||
let min = rect.min + egui::vec2(x * cell, y * cell);
|
||||
p.rect_filled(egui::Rect::from_min_size(min, egui::vec2(cell, cell)), 0.0, Color32::BLACK);
|
||||
}
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
impl TerminalApp {
|
||||
/// Settings › Trading › Hyperliquid: the wallet (or a watched address) and the trading key.
|
||||
pub(super) fn wallet_section(&mut self, ui: &mut Ui, h: &HlPrefs) {
|
||||
let note = |ui: &mut Ui, s: &str| {
|
||||
ui.label(RichText::new(s).font(ui_font(11.5)).color(muted()));
|
||||
};
|
||||
let busy = self.hl.busy.is_some();
|
||||
let signing_in = self.hl.wallet.signing_in.is_some();
|
||||
|
||||
match (&h.wallet, &h.master) {
|
||||
(Some(w), _) => {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(RichText::new(format!("Wallet {}", masked(&w.address))).font(mono(12.5)).color(text()));
|
||||
self.stream_dot(ui);
|
||||
if !w.user.is_empty() {
|
||||
note(ui, &format!("· Turnkey · {}", w.user));
|
||||
}
|
||||
});
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if ui
|
||||
.button("Deposit…")
|
||||
.on_hover_text("The wallet's address, a QR code and its testnet balances")
|
||||
.clicked()
|
||||
{
|
||||
self.wallet_open_deposit();
|
||||
}
|
||||
if ui
|
||||
.button("Back Up Wallet…")
|
||||
.on_hover_text("Show the recovery phrase in Turnkey's own window")
|
||||
.clicked()
|
||||
{
|
||||
self.hl.wallet.backup_prompt = true;
|
||||
self.hl.wallet.backup_ack = false;
|
||||
}
|
||||
if ui
|
||||
.add_enabled(!busy, egui::Button::new("Remove").small())
|
||||
.on_hover_text("Stop showing this wallet here (the trading key stays unless revoked)")
|
||||
.clicked()
|
||||
{
|
||||
self.hl_disconnect();
|
||||
}
|
||||
});
|
||||
ui.horizontal_wrapped(|ui| match self.hl.wallet.session_minutes() {
|
||||
Some(m) => {
|
||||
note(ui, &format!("Signed in to Turnkey for {m} more min."));
|
||||
if ui.add(egui::Button::new("End Session").small()).clicked() {
|
||||
self.wallet_end_session();
|
||||
}
|
||||
}
|
||||
None => {
|
||||
note(ui, "Not signed in to Turnkey. You only need to be to approve or revoke a trading key.")
|
||||
}
|
||||
});
|
||||
}
|
||||
(None, Some(m)) => {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(
|
||||
RichText::new(format!("Watching {} (read-only)", masked(m))).font(mono(12.5)).color(text()),
|
||||
);
|
||||
self.stream_dot(ui);
|
||||
if ui
|
||||
.add_enabled(!busy, egui::Button::new("Remove").small())
|
||||
.on_hover_text("Stop showing this account")
|
||||
.clicked()
|
||||
{
|
||||
self.hl_disconnect();
|
||||
}
|
||||
});
|
||||
self.sign_in_button(ui, signing_in, "Create Wallet / Sign In…");
|
||||
}
|
||||
(None, None) => {
|
||||
self.sign_in_button(ui, signing_in, "Create Wallet / Sign In…");
|
||||
note(
|
||||
ui,
|
||||
"Opens Terminal's sign-in page in your browser. Sign in with an email code or a passkey; a new account gets a wallet held by Turnkey. Terminal never sees the wallet's key, and you never paste one.",
|
||||
);
|
||||
egui::CollapsingHeader::new(RichText::new("Watch an address (read-only)").font(ui_font(12.0)))
|
||||
.id_salt("hl-watch")
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.hl.connect_form)
|
||||
.hint_text("0x… (testnet address)")
|
||||
.font(mono(12.0))
|
||||
.desired_width(dp(330.0)),
|
||||
);
|
||||
if ui.button("Show Account").on_hover_text("Read-only: no key involved").clicked() {
|
||||
let a = self.hl.connect_form.clone();
|
||||
self.hl_connect(&a);
|
||||
}
|
||||
});
|
||||
note(ui, "Fills PORT, POS, ORD and ACTV from Hyperliquid testnet. Nothing is signed.");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(s) = &self.hl.wallet.signing_in {
|
||||
let waited = s.started.elapsed().as_secs() / 60;
|
||||
let reopen = s.url.is_some();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.add(egui::Spinner::new().size(12.0));
|
||||
note(
|
||||
ui,
|
||||
&format!("Finish signing in in your browser (terminal.rasmic.xyz/wallet). {waited} min so far, 10 at most."),
|
||||
);
|
||||
if reopen && ui.add(egui::Button::new("Open Again").small()).clicked() {
|
||||
self.wallet_reopen();
|
||||
}
|
||||
if ui.add(egui::Button::new("Cancel").small()).clicked() {
|
||||
self.wallet_cancel_sign_in();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The trading (agent) key.
|
||||
ui.add_space(dp(6.0));
|
||||
match &h.agent {
|
||||
Some(a) => {
|
||||
let days = (a.valid_until.saturating_sub(now_ms())) / (24 * 3600 * 1000);
|
||||
let expiry = chrono::DateTime::from_timestamp_millis(a.valid_until as i64)
|
||||
.map(|t| t.with_timezone(&chrono::Local).format("%d %b %Y").to_string())
|
||||
.unwrap_or_default();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"Trading key {} · {}",
|
||||
tr_hl::trader::agent_base_name(&a.name),
|
||||
masked(&a.address)
|
||||
))
|
||||
.font(mono(12.0))
|
||||
.color(text()),
|
||||
);
|
||||
note(ui, &format!("· expires {expiry} · key in {}", self.hl.store.describe()));
|
||||
});
|
||||
if days < 7 {
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"The trading key expires in {days} days: revoke it and approve a new one."
|
||||
))
|
||||
.font(ui_font(12.0))
|
||||
.color(amber()),
|
||||
);
|
||||
}
|
||||
if self.hl.key.is_none() && !self.hl.key_loading {
|
||||
ui.label(
|
||||
RichText::new("This device has no key for this trading key.")
|
||||
.font(ui_font(12.0))
|
||||
.color(amber()),
|
||||
);
|
||||
}
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if h.wallet.is_some()
|
||||
&& ui
|
||||
.add_enabled(!busy && !signing_in, egui::Button::new("Revoke on Hyperliquid"))
|
||||
.on_hover_text(
|
||||
"The wallet replaces this key on Hyperliquid with one nobody holds: one Turnkey signature, after a sign-in if needed",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.wallet_revoke();
|
||||
}
|
||||
if ui
|
||||
.add_enabled(!busy, egui::Button::new("Delete Key Here"))
|
||||
.on_hover_text("Terminal can't sign with it any more; it stays approved on Hyperliquid until it expires or is revoked")
|
||||
.clicked()
|
||||
{
|
||||
self.hl_delete_agent_key();
|
||||
}
|
||||
});
|
||||
}
|
||||
None if h.wallet.is_some() => {
|
||||
let go = ui
|
||||
.add_enabled(
|
||||
!busy && !signing_in && self.hl.store_error.is_none(),
|
||||
egui::Button::new(RichText::new("Approve Trading Key").strong()),
|
||||
)
|
||||
.on_hover_text("Signs in to Turnkey first if there's no session")
|
||||
.clicked();
|
||||
if go {
|
||||
self.wallet_approve();
|
||||
}
|
||||
note(
|
||||
ui,
|
||||
"Terminal makes a trading key on this device, and your wallet signs approveAgent for it once on Turnkey (one signature). It is named terminal-<device> and valid 180 days. It can trade but not withdraw. The wallet itself never signs orders.",
|
||||
);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn sign_in_button(&mut self, ui: &mut Ui, signing_in: bool, label: &str) {
|
||||
let b = egui::Button::new(RichText::new(label).strong().color(Color32::BLACK)).fill(amber());
|
||||
if ui.add_enabled(!signing_in && self.hl.busy.is_none(), b).clicked() {
|
||||
self.wallet_sign_in(None);
|
||||
}
|
||||
}
|
||||
|
||||
fn stream_dot(&self, ui: &mut Ui) {
|
||||
let (dot, status) = if self.hl.connected {
|
||||
(theme::up(), self.hl.stream_status.clone())
|
||||
} else if self.hl.stream_status.is_empty() {
|
||||
(amber(), "Connecting…".to_string())
|
||||
} else {
|
||||
(amber(), self.hl.stream_status.clone())
|
||||
};
|
||||
let (r, _) = ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover());
|
||||
ui.painter().circle_filled(r.center(), 4.0, dot);
|
||||
ui.label(RichText::new(status).font(ui_font(11.5)).color(muted()));
|
||||
if let Some(u) = &self.hl.user {
|
||||
ui.label(
|
||||
RichText::new(format!("· value {}", money(u.account_value(), "USD")))
|
||||
.font(ui_font(11.5))
|
||||
.color(muted()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Deposit window and the backup warning, when open.
|
||||
pub(super) fn wallet_windows(&mut self, ctx: &egui::Context) {
|
||||
if self.hl.wallet.deposit {
|
||||
self.deposit_window(ctx);
|
||||
}
|
||||
if self.hl.wallet.backup_prompt {
|
||||
self.backup_dialog(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn deposit_window(&mut self, ctx: &egui::Context) {
|
||||
let Some(w) = self.wallet().cloned() else {
|
||||
self.hl.wallet.deposit = false;
|
||||
return;
|
||||
};
|
||||
let mut close = false;
|
||||
let modal = egui::Modal::new(egui::Id::new("wallet-deposit")).show(ctx, |ui| {
|
||||
ui.set_width(dp(440.0));
|
||||
ui.horizontal(|ui| {
|
||||
testnet_badge(ui);
|
||||
ui.label(RichText::new("Deposit").font(ui_font(16.0)).strong());
|
||||
});
|
||||
ui.add_space(dp(4.0));
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"Hyperliquid testnet only. Send test USDC on Hyperliquid testnet to this address. Never send real funds: this build can't reach mainnet.",
|
||||
)
|
||||
.font(ui_font(12.0))
|
||||
.color(amber()),
|
||||
);
|
||||
ui.add_space(dp(8.0));
|
||||
ui.vertical_centered(|ui| {
|
||||
qr_code(ui, &w.address, dp(196.0)).on_hover_text("Your wallet's address");
|
||||
});
|
||||
ui.add_space(dp(8.0));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(&w.address).font(mono(12.0)).color(text()));
|
||||
if ui.button("Copy").clicked() {
|
||||
ui.ctx().copy_text(w.address.clone());
|
||||
self.trade_toast("Wallet address copied", true);
|
||||
}
|
||||
});
|
||||
ui.add_space(dp(6.0));
|
||||
let user = self.hl.user.clone();
|
||||
egui::Grid::new("wallet-balances").num_columns(2).spacing([dp(24.0), dp(4.0)]).show(ui, |ui| {
|
||||
let row = |ui: &mut Ui, k: &str, v: String| {
|
||||
ui.label(RichText::new(k).font(ui_font(12.0)).color(muted()));
|
||||
ui.label(RichText::new(v).font(mono(12.5)).color(text()));
|
||||
ui.end_row();
|
||||
};
|
||||
let perp = |f: fn(&tr_hl::stream::UserState) -> f64| {
|
||||
user.as_deref().map(|u| money(f(u), "USD")).unwrap_or_else(|| "loading…".into())
|
||||
};
|
||||
row(ui, "Perp account value", perp(|u| u.account_value()));
|
||||
row(ui, "Withdrawable", perp(|u| u.withdrawable()));
|
||||
let spot = match &self.hl.wallet.spot {
|
||||
Some((_, Ok(s))) if s.hold > 0.0 => format!("{} ({} on hold)", money(s.total, "USD"), money(s.hold, "USD")),
|
||||
Some((_, Ok(s))) => money(s.total, "USD"),
|
||||
Some((_, Err(e))) => format!("unavailable ({})", tr_hl::mask_addresses(e)),
|
||||
None => "loading…".into(),
|
||||
};
|
||||
row(ui, "Spot USDC", spot);
|
||||
});
|
||||
ui.add_space(dp(6.0));
|
||||
ui.label(
|
||||
RichText::new("Test USDC comes from Hyperliquid's testnet faucet or a transfer from another testnet account.")
|
||||
.font(ui_font(11.5))
|
||||
.color(muted()),
|
||||
);
|
||||
ui.add_space(dp(6.0));
|
||||
if ui.button("Close").clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
if close || modal.should_close() {
|
||||
self.hl.wallet.deposit = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn backup_dialog(&mut self, ctx: &egui::Context) {
|
||||
let mut choice = None;
|
||||
let modal = egui::Modal::new(egui::Id::new("wallet-backup")).show(ctx, |ui| {
|
||||
ui.set_width(dp(450.0));
|
||||
ui.horizontal(|ui| {
|
||||
testnet_badge(ui);
|
||||
ui.label(RichText::new("Back up your wallet?").font(ui_font(16.0)).strong());
|
||||
});
|
||||
ui.add_space(dp(4.0));
|
||||
for line in [
|
||||
"Your recovery phrase controls the wallet. Anyone who has it can take everything in it.",
|
||||
"You'll sign in again on terminal.rasmic.xyz, and Turnkey shows the phrase in its own secure frame there. Terminal and Goshen never see it and can't recover it for you.",
|
||||
"Write it down offline. Never type it into a website, and never share it, not even with support.",
|
||||
] {
|
||||
ui.label(RichText::new(format!("• {line}")).font(ui_font(12.0)).color(text()));
|
||||
}
|
||||
ui.add_space(dp(6.0));
|
||||
ui.checkbox(&mut self.hl.wallet.backup_ack, "I'm somewhere private, and I understand the above");
|
||||
ui.add_space(dp(6.0));
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Cancel").clicked() {
|
||||
choice = Some(false);
|
||||
}
|
||||
let on = self.hl.wallet.backup_ack;
|
||||
let b = if on {
|
||||
egui::Button::new(RichText::new("Open the Backup Page").strong().color(Color32::BLACK)).fill(amber())
|
||||
} else {
|
||||
egui::Button::new(RichText::new("Open the Backup Page").strong())
|
||||
};
|
||||
if ui.add_enabled(on, b).clicked() {
|
||||
choice = Some(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
if choice.is_none() && modal.should_close() {
|
||||
choice = Some(false);
|
||||
}
|
||||
if let Some(go) = choice {
|
||||
self.hl.wallet.backup_prompt = false;
|
||||
self.hl.wallet.backup_ack = false;
|
||||
if go {
|
||||
self.wallet_backup();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+229
-8
@@ -37,6 +37,109 @@ pub const PANELS: [(&str, &str); 14] = [
|
||||
("Help", "HELP"),
|
||||
];
|
||||
|
||||
/// Length of a dock transition (panes added, closed, moved, or a layout switch).
|
||||
const DOCK_SECS: f32 = 0.42;
|
||||
|
||||
/// Eases the dock between arrangements: when panes are added, closed or moved, groups
|
||||
/// glide from where they were to their new rectangles and new groups rise and fade in
|
||||
/// (staggered, for a layout switch). Window and divider resizes follow immediately.
|
||||
#[derive(Default)]
|
||||
pub(super) struct DockMotion {
|
||||
/// The arrangement last frame, to notice structural changes.
|
||||
shape: Vec<ShapePart>,
|
||||
/// Rectangles shown last frame, by group key.
|
||||
shown: HashMap<PaneId, Rect>,
|
||||
/// Where each surviving group started the current transition.
|
||||
from: HashMap<PaneId, Rect>,
|
||||
/// Groups new in the current transition, by key, with their stagger rank.
|
||||
new: HashMap<PaneId, usize>,
|
||||
start: Option<f64>,
|
||||
}
|
||||
|
||||
/// The dock's arrangement without its proportions: split directions, child order and the
|
||||
/// panes in each group. A layout switch or a swap changes it; dragging a divider does not.
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum ShapePart {
|
||||
Split(Dir),
|
||||
End,
|
||||
Group(Vec<PaneId>),
|
||||
}
|
||||
|
||||
fn shape_of(node: &Node, out: &mut Vec<ShapePart>) {
|
||||
match node {
|
||||
Node::Tabs { panes, .. } => {
|
||||
let mut p = panes.clone();
|
||||
p.sort_unstable();
|
||||
out.push(ShapePart::Group(p));
|
||||
}
|
||||
Node::Split { dir, children, .. } => {
|
||||
out.push(ShapePart::Split(*dir));
|
||||
children.iter().for_each(|c| shape_of(c, out));
|
||||
out.push(ShapePart::End);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DockMotion {
|
||||
/// A group keeps its key while it keeps its oldest pane.
|
||||
fn key(g: &crate::dock::Group) -> PaneId {
|
||||
g.panes.iter().copied().min().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Move `groups` (laid out from `dock`) to where they are shown this frame; returns
|
||||
/// how far each has appeared (1 = fully).
|
||||
fn apply(&mut self, ctx: &egui::Context, dock: &Node, groups: &mut [crate::dock::Group]) -> Vec<f32> {
|
||||
let mut shape = Vec::new();
|
||||
shape_of(dock, &mut shape);
|
||||
let now = ctx.input(|i| i.time);
|
||||
if shape != self.shape {
|
||||
// The first arrangement shows as is.
|
||||
if !self.shape.is_empty() {
|
||||
self.from.clear();
|
||||
self.new.clear();
|
||||
for g in groups.iter() {
|
||||
let k = Self::key(g);
|
||||
match self.shown.get(&k) {
|
||||
Some(r) => {
|
||||
self.from.insert(k, *r);
|
||||
}
|
||||
None => {
|
||||
let rank = self.new.len();
|
||||
self.new.insert(k, rank);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.start = Some(now);
|
||||
}
|
||||
self.shape = shape;
|
||||
}
|
||||
let t = self.start.map_or(1.0, |s| anim::since(ctx, now - s, DOCK_SECS));
|
||||
if t >= 1.0 {
|
||||
self.start = None;
|
||||
}
|
||||
self.shown.clear();
|
||||
groups
|
||||
.iter_mut()
|
||||
.map(|g| {
|
||||
let k = Self::key(g);
|
||||
let mut appear = 1.0;
|
||||
if t < 1.0 {
|
||||
if let Some(from) = self.from.get(&k) {
|
||||
let e = anim::ease_out(anim::span(t, 0.0, 0.8));
|
||||
g.rect = Rect::from_min_max(from.min.lerp(g.rect.min, e), from.max.lerp(g.rect.max, e));
|
||||
} else if let Some(&rank) = self.new.get(&k) {
|
||||
let delay = 0.1 + (rank as f32 * 0.08).min(0.3);
|
||||
appear = anim::ease_out(anim::span(t, delay, delay + 0.6));
|
||||
g.rect = g.rect.translate(vec2(0.0, 14.0 * (1.0 - appear)));
|
||||
}
|
||||
}
|
||||
self.shown.insert(k, g.rect);
|
||||
appear
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Saved-layout operations from the sidebar, settings and command line.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum LayoutOp {
|
||||
@@ -216,6 +319,54 @@ impl TerminalApp {
|
||||
})
|
||||
}
|
||||
|
||||
/// HIP-3 perps search can offer: those the server lists as live, or the linked ones until it
|
||||
/// has answered.
|
||||
pub(super) fn perp_candidates(&self) -> Vec<&str> {
|
||||
match &self.live_perps {
|
||||
Some(v) => v.iter().map(String::as_str).collect(),
|
||||
None => tr_core::perps::LINKS.iter().map(|l| l.coin).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the Terminal server lists the HIP-3 perp `coin` as live.
|
||||
pub(super) fn perp_is_live(&self, coin: &str) -> bool {
|
||||
self.live_perps.as_ref().is_some_and(|v| v.iter().any(|x| x == coin))
|
||||
}
|
||||
|
||||
/// Where a pane on `key` can switch to: a perp's linked instrument, or an instrument's live
|
||||
/// perp. Links to perps show only once the server has listed them live.
|
||||
pub(super) fn switch_target(&self, key: &str) -> Option<&'static str> {
|
||||
tr_core::perps::switch_target(key, |c| self.perp_is_live(c))
|
||||
}
|
||||
|
||||
/// `PERP`: from a security to its HIP-3 perp, or from a perp back to its security.
|
||||
pub(super) fn open_perp(&mut self, key: Option<String>) {
|
||||
use tr_core::perps;
|
||||
let Some(key) = key.map(|k| tr_data::security(&k).key()) else {
|
||||
self.status = "PERP needs a security, for example AAPL PERP".into();
|
||||
return;
|
||||
};
|
||||
if perps::is_perp(&key) {
|
||||
match perps::link_for(&key) {
|
||||
Some(l) => self.open_security(l.underlying),
|
||||
None => self.status = format!("{key} is a HIP-3 perp; no listed instrument is linked to it"),
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(l) = perps::perp_for(&key) else {
|
||||
self.status = format!("No HIP-3 perp is linked to {key}");
|
||||
return;
|
||||
};
|
||||
// Until the server has answered, try anyway: the pane says why if it can't load.
|
||||
if self.live_perps.is_some() && !self.perp_is_live(l.coin) {
|
||||
self.status = format!("{} isn't live on the Terminal server right now", l.coin);
|
||||
return;
|
||||
}
|
||||
self.open_security(l.coin);
|
||||
self.status =
|
||||
format!("{} ({}) references {}. It is a perp, not {key}.", l.coin, perps::badge(l.coin), l.reference);
|
||||
}
|
||||
|
||||
/// The focused security if it has listed options (US equity, ETF, index), else AAPL.
|
||||
pub(super) fn optionable_symbol(&self) -> String {
|
||||
use tr_core::AssetClass as C;
|
||||
@@ -526,6 +677,19 @@ impl TerminalApp {
|
||||
id
|
||||
}
|
||||
|
||||
/// The existing chart [`Self::chart_target`] would pick, without revealing or creating one.
|
||||
pub(super) fn peek_chart(&self) -> Option<&crate::chart_view::ChartState> {
|
||||
let chart = |id: PaneId| {
|
||||
self.idx(id).and_then(|i| match &self.panes[i].kind {
|
||||
PaneKind::Chart(c) => Some(&**c),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
chart(self.focus)
|
||||
.or_else(|| self.dock.visible().into_iter().find_map(chart))
|
||||
.or_else(|| self.panes.iter().find_map(|p| chart(p.id)))
|
||||
}
|
||||
|
||||
pub(super) fn with_chart(&mut self, f: impl FnOnce(&mut crate::chart_view::ChartState, &Worker)) {
|
||||
let id = self.chart_target();
|
||||
self.focus = id;
|
||||
@@ -838,8 +1002,8 @@ impl TerminalApp {
|
||||
let dir = if downloads.is_dir() { downloads } else { self.store.dir.join("exports") };
|
||||
dir.join(format!("{}.json", crate::layouts::Store::file_stem(&name)))
|
||||
});
|
||||
self.store.export(&name, &path)?;
|
||||
Ok(format!("Exported {name:?} to {}", path.display()))
|
||||
let to = self.store.export(&name, &path)?;
|
||||
Ok(format!("Exported {name:?} to {to}"))
|
||||
}
|
||||
LayoutOp::Import(path) => {
|
||||
let name = self.store.import(&path)?;
|
||||
@@ -848,9 +1012,7 @@ impl TerminalApp {
|
||||
Ok(format!("Imported {} as {name:?}", path.display()))
|
||||
}
|
||||
LayoutOp::OpenFolder => {
|
||||
std::fs::create_dir_all(&self.store.dir)?;
|
||||
let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
|
||||
std::process::Command::new(opener).arg(&self.store.dir).spawn()?;
|
||||
crate::storage::reveal(&self.store.dir)?;
|
||||
Ok(format!("Opened {}", self.store.dir.display()))
|
||||
}
|
||||
})();
|
||||
@@ -868,9 +1030,13 @@ impl TerminalApp {
|
||||
if self.dock.is_empty() {
|
||||
self.empty_state(ui, area, &mut cmds);
|
||||
}
|
||||
let (groups, dividers) = self.dock.layout(area, gap);
|
||||
for g in &groups {
|
||||
let (mut groups, dividers) = self.dock.layout(area, gap);
|
||||
let appear = self.dock_motion.apply(ui.ctx(), &self.dock, &mut groups);
|
||||
for (g, a) in groups.iter().zip(appear) {
|
||||
let opacity = ui.opacity();
|
||||
ui.multiply_opacity(a);
|
||||
self.render_group(ui, g, &mut actions, &mut cmds);
|
||||
ui.set_opacity(opacity);
|
||||
}
|
||||
for d in ÷rs {
|
||||
let hit = d.rect.expand(2.0);
|
||||
@@ -1130,7 +1296,12 @@ impl TerminalApp {
|
||||
// Content.
|
||||
let content = Rect::from_min_max(pos2(inner.left(), strip.bottom()), inner.max);
|
||||
let Some(i) = self.idx(active) else { return };
|
||||
// Switching tabs fades the new content in.
|
||||
let fade = anim::ease_out(anim::progress(ui.ctx(), Id::new(("tab-fade", DockMotion::key(g))), active, 0.22));
|
||||
let opacity = ui.opacity();
|
||||
ui.multiply_opacity(fade);
|
||||
let (mut acts, clicked) = self.render_content(ui, i, content, focused);
|
||||
ui.set_opacity(opacity);
|
||||
// Clicks in a linked list panel drive its link group.
|
||||
if let Some(l) = self.panes[i].link
|
||||
&& !self.panes[i].kind.follows_symbol()
|
||||
@@ -1322,7 +1493,7 @@ impl TerminalApp {
|
||||
let Some(geom) = self.panes[i].window else { continue };
|
||||
let title = self.panes[i].display_title();
|
||||
let builder = ViewportBuilder::default()
|
||||
.with_title(format!("{title} — terminal-rs"))
|
||||
.with_title(format!("{title} — Terminal"))
|
||||
.with_inner_size([geom.w, geom.h])
|
||||
.with_position([geom.x, geom.y])
|
||||
.with_min_inner_size([320.0, 220.0]);
|
||||
@@ -1471,4 +1642,54 @@ mod tests {
|
||||
m.insert_at(&t.path, t.zone, 1);
|
||||
assert!(matches!(&m, Node::Split { dir: Dir::Column, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_motion_glides_splits_and_follows_resizes() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut motion = DockMotion::default();
|
||||
let mut step = |n: &Node, t: f64, width: f32| {
|
||||
let area = Rect::from_min_size(pos2(0.0, 0.0), vec2(width, 600.0));
|
||||
let mut gs = n.layout(area, 6.0).0;
|
||||
let target: Vec<Rect> = gs.iter().map(|g| g.rect).collect();
|
||||
let mut appear = Vec::new();
|
||||
let input = egui::RawInput { time: Some(t), ..Default::default() };
|
||||
ctx.run_ui(input, |ui| appear = motion.apply(ui.ctx(), n, &mut gs)).textures_delta.clear();
|
||||
(gs.iter().map(|g| g.rect).collect::<Vec<_>>(), target, appear)
|
||||
};
|
||||
// The first arrangement and plain resizes show as laid out.
|
||||
let one = Node::preset(Layout::One, &[1]);
|
||||
let (shown, target, appear) = step(&one, 0.0, 1000.0);
|
||||
assert_eq!((shown, appear), (target, vec![1.0]));
|
||||
let (shown, target, _) = step(&one, 0.1, 900.0);
|
||||
assert_eq!(shown, target);
|
||||
|
||||
// Splitting: the old pane starts where it was and glides; the new one rises in.
|
||||
let mut two = one.clone();
|
||||
two.insert(1, Zone::Right, 2);
|
||||
let (shown, target, appear) = step(&two, 1.0, 900.0);
|
||||
assert_eq!(shown[0].width(), 900.0, "starts at its old size");
|
||||
assert_eq!(appear[1], 0.0);
|
||||
assert!(shown[1].top() > target[1].top());
|
||||
let (shown, target, appear) = step(&two, 1.0 + DOCK_SECS as f64 * 0.5, 900.0);
|
||||
assert!(shown[0].width() < 900.0 && shown[0].width() > target[0].width());
|
||||
assert!(appear[1] > 0.0 && appear[1] < 1.0);
|
||||
let (shown, target, appear) = step(&two, 2.0, 900.0);
|
||||
assert_eq!((shown, appear), (target, vec![1.0, 1.0]));
|
||||
|
||||
// Switching between layouts of the same groups glides, and so does a swap.
|
||||
let rows = Node::preset(Layout::TwoRows, &[1, 2]);
|
||||
let (shown, target, _) = step(&rows, 3.0, 900.0);
|
||||
assert_ne!(shown, target, "starts from the columns");
|
||||
let (shown, target, _) = step(&rows, 4.0, 900.0);
|
||||
assert_eq!(shown, target);
|
||||
let swapped = Node::split(Dir::Column, vec![Node::leaf(2), Node::leaf(1)]);
|
||||
let (shown, target, _) = step(&swapped, 5.0, 900.0);
|
||||
assert_ne!(shown, target, "starts from the old order");
|
||||
step(&swapped, 6.0, 900.0);
|
||||
|
||||
// Dragging a divider follows immediately.
|
||||
let dragged = Node::split_with(Dir::Column, vec![Node::leaf(2), Node::leaf(1)], vec![0.3, 0.7]);
|
||||
let (shown, target, _) = step(&dragged, 6.1, 900.0);
|
||||
assert_eq!(shown, target);
|
||||
}
|
||||
}
|
||||
+446
-95
@@ -8,11 +8,12 @@ use egui::{
|
||||
vec2,
|
||||
};
|
||||
use tr_chart::drawings::{self, Anchor, Drawing, Shape, Tool};
|
||||
use tr_chart::gpu::{ChartCallback, Frame, LineKind, LineRef, Style};
|
||||
use tr_chart::gpu::{ChartCallback, Frame, LineKind, LineRef, Reveal, Style};
|
||||
use tr_chart::scale;
|
||||
use tr_chart::scene::{self, ChartType, Overlay, PricePane, SeriesGpu};
|
||||
use tr_chart::viewport::{PriceScale, Viewport};
|
||||
use tr_congress::{Trade, TxType};
|
||||
use tr_core::indicators::registry::{self, Category};
|
||||
use tr_core::{AssetClass, Bar, Security, Timeframe};
|
||||
|
||||
use crate::command::{IndicatorSpec, Range};
|
||||
@@ -29,6 +30,36 @@ const RANGE_H: f32 = 18.0;
|
||||
/// Height of the interval / chart-type segmented controls.
|
||||
pub const CONTROL_H: f32 = 20.0;
|
||||
const DEFAULT_VISIBLE: usize = 160;
|
||||
/// Oscillator panes shown under the price pane. GPU frame ids are `id * 8 + 1 + k`, so a
|
||||
/// chart has room for at most seven.
|
||||
const MAX_SUB_PANES: usize = 6;
|
||||
|
||||
/// Length of the load-in sweep across the visible bars.
|
||||
const REVEAL_SECS: f32 = 0.7;
|
||||
/// Length of an eased jump (quick ranges, reset, keyboard zoom and pan).
|
||||
const GLIDE_SECS: f32 = 0.32;
|
||||
/// Time constant of the autoscale easing.
|
||||
const SCALE_TAU: f64 = 0.07;
|
||||
|
||||
/// A programmatic viewport change in progress; `start` is set on the first frame shown.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Glide {
|
||||
from: Viewport,
|
||||
to: Viewport,
|
||||
start: Option<f64>,
|
||||
}
|
||||
|
||||
impl Glide {
|
||||
/// Pans linearly and zooms geometrically, so a zoom feels even all the way.
|
||||
fn at(&self, e: f64) -> Viewport {
|
||||
let (a, b) = (self.from.bar_px.ln(), self.to.bar_px.ln());
|
||||
Viewport {
|
||||
right: self.from.right + (self.to.right - self.from.right) * e,
|
||||
bar_px: (a + (b - a) * e).exp(),
|
||||
..self.to
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DrawState {
|
||||
@@ -55,10 +86,19 @@ 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,
|
||||
error: Option<String>,
|
||||
/// Why the bars shown, from the disk cache, couldn't be refreshed.
|
||||
stale: Option<String>,
|
||||
built: Vec<BuiltIndicator>,
|
||||
compare_line: Option<LineRef>,
|
||||
compare_scale: f64,
|
||||
@@ -68,8 +108,8 @@ pub struct ChartState {
|
||||
hover_marker: Option<usize>,
|
||||
/// Resolved security for `symbol`.
|
||||
sec: Security,
|
||||
last_live: Option<std::time::Instant>,
|
||||
last_indicator_req: Option<std::time::Instant>,
|
||||
last_live: Option<web_time::Instant>,
|
||||
last_indicator_req: Option<web_time::Instant>,
|
||||
/// App-level actions requested from this chart (context menu), drained by the app.
|
||||
pub actions: Vec<Action>,
|
||||
/// False while a pane is being dragged over the workspace: no crosshair or hover cards.
|
||||
@@ -77,6 +117,9 @@ pub struct ChartState {
|
||||
/// The workspace drew this chart's controls in the pane's tab strip, so skip the
|
||||
/// header row and give the plot the space.
|
||||
pub controls_in_strip: bool,
|
||||
/// What `PERP` would switch this chart to (its live HIP-3 perp, or a perp's listed
|
||||
/// instrument), set by the app each frame.
|
||||
pub switch_to: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl ChartState {
|
||||
@@ -94,7 +137,7 @@ impl ChartState {
|
||||
magnet: false,
|
||||
show_volume: true,
|
||||
show_congress: true,
|
||||
indicators: vec![IndicatorSpec::Sma(20), IndicatorSpec::Sma(50)],
|
||||
indicators: crate::command::default_indicators(),
|
||||
drawings: Vec::new(),
|
||||
compare: None,
|
||||
bars: Arc::new(Vec::new()),
|
||||
@@ -102,10 +145,15 @@ 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,
|
||||
error: None,
|
||||
stale: None,
|
||||
built: Vec::new(),
|
||||
compare_line: None,
|
||||
compare_scale: 1.0,
|
||||
@@ -119,6 +167,7 @@ impl ChartState {
|
||||
actions: Vec::new(),
|
||||
hover_enabled: true,
|
||||
controls_in_strip: false,
|
||||
switch_to: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,8 +182,10 @@ impl ChartState {
|
||||
self.paging = false;
|
||||
self.exhausted = false;
|
||||
self.error = None;
|
||||
self.stale = None;
|
||||
self.viewport = None;
|
||||
self.manual_scale = None;
|
||||
self.glide = None;
|
||||
self.compare_line = None;
|
||||
self.markers.clear();
|
||||
// Never show the previous symbol's bars under the new title.
|
||||
@@ -180,7 +231,23 @@ impl ChartState {
|
||||
self.tf = r.timeframe();
|
||||
self.load(w);
|
||||
} else {
|
||||
self.apply_range();
|
||||
self.glide(Self::apply_range);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply `change` to the view (on top of any glide's destination) and ease to it.
|
||||
fn glide(&mut self, change: impl FnOnce(&mut Self)) {
|
||||
let shown = self.viewport;
|
||||
if let Some(g) = self.glide.take() {
|
||||
self.viewport = Some(g.to);
|
||||
}
|
||||
change(self);
|
||||
if let (Some(from), Some(to)) = (shown, self.viewport)
|
||||
&& from != to
|
||||
&& !crate::anim::reduced()
|
||||
{
|
||||
self.viewport = Some(from);
|
||||
self.glide = Some(Glide { from, to, start: None });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +275,13 @@ impl ChartState {
|
||||
self.request_indicators(w);
|
||||
}
|
||||
|
||||
/// Remove every instance of the indicator `code` (all moving averages of one kind).
|
||||
fn remove_indicator(&mut self, code: &str, w: &Worker) {
|
||||
self.indicators.retain(|s| s.code != code);
|
||||
self.built.retain(|b| b.spec.code != code);
|
||||
self.request_indicators(w);
|
||||
}
|
||||
|
||||
pub fn clear_indicators(&mut self) {
|
||||
self.indicators.clear();
|
||||
self.built.clear();
|
||||
@@ -245,25 +319,54 @@ impl ChartState {
|
||||
}
|
||||
|
||||
pub fn reset_view(&mut self) {
|
||||
if let Some(v) = self.viewport.as_mut() {
|
||||
*v = Viewport::fit_last(self.bars.len(), DEFAULT_VISIBLE, v.width_px);
|
||||
}
|
||||
self.manual_scale = None;
|
||||
self.glide(|s| {
|
||||
if let Some(v) = s.viewport.as_mut() {
|
||||
*v = Viewport::fit_last(s.bars.len(), DEFAULT_VISIBLE, v.width_px);
|
||||
}
|
||||
s.manual_scale = None;
|
||||
});
|
||||
}
|
||||
|
||||
pub fn pan_bars(&mut self, n: f64) {
|
||||
if let Some(v) = self.viewport.as_mut() {
|
||||
v.pan_bars(n);
|
||||
v.clamp(self.bars.len());
|
||||
}
|
||||
self.glide(|s| {
|
||||
if let Some(v) = s.viewport.as_mut() {
|
||||
v.pan_bars(n);
|
||||
v.clamp(s.bars.len());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn zoom(&mut self, factor: f64) {
|
||||
if let Some(v) = self.viewport.as_mut() {
|
||||
let anchor = v.width_px * 0.85;
|
||||
v.zoom(factor, anchor);
|
||||
v.clamp(self.bars.len());
|
||||
}
|
||||
self.glide(|s| {
|
||||
if let Some(v) = s.viewport.as_mut() {
|
||||
let anchor = v.width_px * 0.85;
|
||||
v.zoom(factor, anchor);
|
||||
v.clamp(s.bars.len());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Ease the shown autoscale range toward `target`, so the axis glides as bars scroll
|
||||
/// in rather than jumping. Snaps on new data and when switching log scale.
|
||||
fn ease_scale(&mut self, ctx: &egui::Context, target: PriceScale) -> PriceScale {
|
||||
let (a, b) = target.bounds_t();
|
||||
let (lo, hi) = match self.shown_scale {
|
||||
Some((lo, hi, log)) if log == target.log && !crate::anim::reduced() => {
|
||||
let dt = ctx.input(|i| i.stable_dt).clamp(0.0, 0.1) as f64;
|
||||
let k = 1.0 - (-dt / SCALE_TAU).exp();
|
||||
let (lo, hi) = (lo + (a - lo) * k, hi + (b - hi) * k);
|
||||
if (lo - a).abs() + (hi - b).abs() < (b - a).abs() * 1e-4 {
|
||||
(a, b)
|
||||
} else {
|
||||
ctx.request_repaint();
|
||||
(lo, hi)
|
||||
}
|
||||
}
|
||||
_ => (a, b),
|
||||
};
|
||||
self.shown_scale = Some((lo, hi, target.log));
|
||||
let inv = |x: f64| if target.log { x.exp() } else { x };
|
||||
PriceScale { lo: inv(lo), hi: inv(hi), ..target }
|
||||
}
|
||||
|
||||
fn request_indicators(&self, w: &Worker) {
|
||||
@@ -302,6 +405,7 @@ impl ChartState {
|
||||
prepended: usize,
|
||||
from_cache: bool,
|
||||
exhausted: bool,
|
||||
full: bool,
|
||||
live: bool,
|
||||
w: &Worker,
|
||||
) {
|
||||
@@ -309,16 +413,27 @@ impl ChartState {
|
||||
return;
|
||||
}
|
||||
if live {
|
||||
self.last_live = Some(std::time::Instant::now());
|
||||
self.last_live = Some(web_time::Instant::now());
|
||||
}
|
||||
let old_len = self.bars.len();
|
||||
let new_len = bars.len();
|
||||
if old_len == 0 && new_len > 0 {
|
||||
self.reveal_key = crate::anim::fresh_key();
|
||||
self.shown_scale = None;
|
||||
}
|
||||
// Keep a glide in progress on the same bars as the data shifts under it.
|
||||
if let Some(g) = self.glide.as_mut() {
|
||||
let shift = if prepended > 0 { prepended as f64 } else { new_len as f64 - old_len as f64 };
|
||||
g.from.right += shift;
|
||||
g.to.right += shift;
|
||||
}
|
||||
self.bars = bars;
|
||||
self.gpu = Some(gpu);
|
||||
self.paging = false;
|
||||
self.exhausted = exhausted || self.exhausted && prepended == 0;
|
||||
self.exhausted = end_of_history(self.exhausted, exhausted, prepended, full);
|
||||
if !from_cache {
|
||||
self.loading = false;
|
||||
self.stale = None;
|
||||
}
|
||||
self.error = None;
|
||||
match self.viewport.as_mut() {
|
||||
@@ -334,24 +449,44 @@ impl ChartState {
|
||||
}
|
||||
self.apply_range();
|
||||
// Live ticks only move the last bar: refresh indicators at most every 1.5 s and
|
||||
// leave the compare series alone.
|
||||
// leave the compare series alone. (A live feed filling a gap arrives as a refresh.)
|
||||
let due = self.last_indicator_req.is_none_or(|t| t.elapsed().as_millis() >= 1500);
|
||||
if !live || due {
|
||||
self.request_indicators(w);
|
||||
self.last_indicator_req = Some(std::time::Instant::now());
|
||||
self.last_indicator_req = Some(web_time::Instant::now());
|
||||
}
|
||||
if !live {
|
||||
self.request_compare(w);
|
||||
}
|
||||
}
|
||||
|
||||
/// The range row's status: errors, loading, or how many bars are shown and from where.
|
||||
fn status_text(&self) -> String {
|
||||
match (&self.error, self.loading, self.paging) {
|
||||
(Some(e), _, _) => format!("ERR {e}"),
|
||||
(_, true, _) => "loading…".into(),
|
||||
(_, _, true) => "loading history…".into(),
|
||||
_ => format!(
|
||||
"{} {}bars{}{}{}",
|
||||
self.bars.len(),
|
||||
if self.stale.is_some() { "cached " } else { "" },
|
||||
self.stale.as_deref().map(|e| format!(" · {e}")).unwrap_or_default(),
|
||||
if self.log { " LOG" } else { "" },
|
||||
if self.magnet { " MAGNET" } else { "" }
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_history_error(&mut self, generation: u64, error: String) {
|
||||
if generation == self.generation {
|
||||
let paging = std::mem::take(&mut self.paging);
|
||||
self.loading = false;
|
||||
self.paging = false;
|
||||
self.exhausted = true;
|
||||
if self.bars.is_empty() {
|
||||
self.error = Some(error);
|
||||
} else if !paging {
|
||||
// The refresh after painting from the disk cache failed.
|
||||
self.stale = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,6 +505,9 @@ impl ChartState {
|
||||
|
||||
pub fn on_markers(&mut self, symbol: &str, trades: Vec<Trade>) {
|
||||
if symbol == self.symbol {
|
||||
if self.markers.is_empty() && !trades.is_empty() {
|
||||
self.markers_key = crate::anim::fresh_key();
|
||||
}
|
||||
self.markers = trades;
|
||||
}
|
||||
}
|
||||
@@ -453,6 +591,9 @@ impl ChartState {
|
||||
);
|
||||
hui.spacing_mut().item_spacing.x = 6.0;
|
||||
hui.label(RichText::new(&self.sec.ticker).font(mono(12.0)).color(amber()));
|
||||
if self.sec.class == AssetClass::Perp {
|
||||
hui.label(RichText::new(tr_core::perps::badge(&self.symbol)).font(ui_font(11.0)).color(theme::perp()));
|
||||
}
|
||||
if hui.available_width() > Self::controls_width(&hui) {
|
||||
clicked |= self.controls(&mut hui, w);
|
||||
} else {
|
||||
@@ -505,24 +646,18 @@ impl ChartState {
|
||||
}
|
||||
rx = lr.right();
|
||||
}
|
||||
let status = match (&self.error, self.loading, self.paging) {
|
||||
(Some(e), _, _) => format!("ERR {e}"),
|
||||
(_, true, _) => "loading…".into(),
|
||||
(_, _, true) => "loading history…".into(),
|
||||
_ => format!(
|
||||
"{} bars{}{}",
|
||||
self.bars.len(),
|
||||
if self.log { " LOG" } else { "" },
|
||||
if self.magnet { " MAGNET" } else { "" }
|
||||
),
|
||||
};
|
||||
painter.text(
|
||||
let status_color = if self.error.is_some() || self.stale.is_some() { theme::down() } else { muted() };
|
||||
let status = painter.text(
|
||||
pos2(range_row.right() - 6.0, range_row.center().y),
|
||||
Align2::RIGHT_CENTER,
|
||||
status,
|
||||
self.status_text(),
|
||||
mono(10.5),
|
||||
muted(),
|
||||
status_color,
|
||||
);
|
||||
if let Some(target) = self.switch_to {
|
||||
let room = status.left() - 12.0 - (rx + 10.0);
|
||||
clicked |= self.switch_chip(ui, &painter, pos2(rx + 10.0, range_row.center().y), room, target);
|
||||
}
|
||||
|
||||
let body = Rect::from_min_max(pos2(rect.left(), header.bottom()), pos2(rect.right(), range_row.top()));
|
||||
let plot_all = Rect::from_min_max(body.min, pos2(body.right() - AXIS_W, body.bottom() - TIME_H));
|
||||
@@ -532,18 +667,36 @@ impl ChartState {
|
||||
|
||||
let Some(gpu) = self.gpu.clone() else {
|
||||
let msg = self.error.clone().unwrap_or_else(|| format!("Loading {} {}…", self.symbol, self.tf));
|
||||
if self.sec.class == AssetClass::Perp {
|
||||
let badge = tr_core::perps::badge(&self.symbol);
|
||||
let below = plot_all.center() + vec2(0.0, 20.0);
|
||||
painter.text(below, Align2::CENTER_CENTER, badge, ui_font(11.5), theme::perp());
|
||||
}
|
||||
painter.text(plot_all.center(), Align2::CENTER_CENTER, msg, mono(13.0), amber_dim());
|
||||
return clicked;
|
||||
};
|
||||
|
||||
// Split plot height between the price pane and oscillator sub-panes.
|
||||
let pane_specs: Vec<usize> = (0..self.built.len()).filter(|&i| self.built[i].spec.is_pane()).collect();
|
||||
let n_sub = pane_specs.len().min(3);
|
||||
let sub_h = if n_sub > 0 { (plot_all.height() * 0.2).clamp(50.0, 160.0) } else { 0.0 };
|
||||
let n_sub = pane_specs.len().min(MAX_SUB_PANES);
|
||||
// Sub-panes share at most 60% of the plot so prices stay readable.
|
||||
let sub_h = if n_sub > 0 {
|
||||
(plot_all.height() * 0.2).clamp(50.0, 160.0).min(plot_all.height() * 0.6 / n_sub as f32)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let price_rect =
|
||||
Rect::from_min_max(plot_all.min, pos2(plot_all.right(), plot_all.bottom() - sub_h * n_sub as f32));
|
||||
|
||||
let len = self.bars.len();
|
||||
if let Some(g) = self.glide.as_mut() {
|
||||
let now = ui.input(|i| i.time);
|
||||
let t = crate::anim::since(ui.ctx(), now - *g.start.get_or_insert(now), GLIDE_SECS);
|
||||
self.viewport = Some(g.at(crate::anim::ease_out(t) as f64));
|
||||
if t >= 1.0 {
|
||||
self.glide = None;
|
||||
}
|
||||
}
|
||||
let v =
|
||||
self.viewport.get_or_insert_with(|| Viewport::fit_last(len, DEFAULT_VISIBLE, price_rect.width() as f64));
|
||||
v.set_width(price_rect.width() as f64);
|
||||
@@ -566,6 +719,9 @@ impl ChartState {
|
||||
|
||||
if plot_resp.hovered() || axis_resp.hovered() || time_resp.hovered() {
|
||||
let (scroll, zoom_delta) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta()));
|
||||
if scroll != egui::Vec2::ZERO || zoom_delta != 1.0 {
|
||||
self.glide = None;
|
||||
}
|
||||
let v = self.viewport.as_mut().expect("viewport");
|
||||
if let Some(p) = ui.input(|i| i.pointer.hover_pos()) {
|
||||
let anchor = (p.x - price_rect.left()).clamp(0.0, price_rect.width()) as f64;
|
||||
@@ -580,6 +736,9 @@ impl ChartState {
|
||||
v.pan_px(scroll.x as f64);
|
||||
}
|
||||
}
|
||||
if (self.draw.tool.is_none() && plot_resp.dragged()) || time_resp.dragged() {
|
||||
self.glide = None;
|
||||
}
|
||||
if self.draw.tool.is_none() && plot_resp.dragged() {
|
||||
let d = plot_resp.drag_delta();
|
||||
self.viewport.as_mut().expect("viewport").pan_px(d.x as f64);
|
||||
@@ -633,14 +792,18 @@ impl ChartState {
|
||||
scale,
|
||||
});
|
||||
}
|
||||
let reveal_t =
|
||||
crate::anim::progress(ui.ctx(), egui::Id::new(("chart-reveal", self.id)), self.reveal_key, REVEAL_SECS);
|
||||
let reveal = Reveal { opacity: ui.opacity(), ..Reveal::sweep(reveal_t, v.left(), v.visible_bars()) };
|
||||
let style = Style {
|
||||
up: theme::rgba(theme::candle_up()),
|
||||
down: theme::rgba(theme::candle_down()),
|
||||
color: theme::rgba(amber()),
|
||||
pixels_per_point: ui.ctx().pixels_per_point(),
|
||||
reveal,
|
||||
..Style::default()
|
||||
};
|
||||
let out = scene::price_pane(&PricePane {
|
||||
let mut pane = PricePane {
|
||||
series: &gpu,
|
||||
chart_type: self.chart_type,
|
||||
show_volume: self.show_volume,
|
||||
@@ -653,7 +816,16 @@ impl ChartState {
|
||||
s
|
||||
}),
|
||||
style,
|
||||
};
|
||||
pane.manual_scale = Some(match pane.manual_scale {
|
||||
Some(s) => {
|
||||
let (a, b) = s.bounds_t();
|
||||
self.shown_scale = Some((a, b, s.log));
|
||||
s
|
||||
}
|
||||
None => self.ease_scale(ui.ctx(), scene::autoscale(&pane)),
|
||||
});
|
||||
let out = scene::price_pane(&pane);
|
||||
let mut scale = out.scale;
|
||||
scale.height_px = price_rect.height() as f64;
|
||||
if axis_resp.dragged() {
|
||||
@@ -686,20 +858,17 @@ impl ChartState {
|
||||
let b = &self.built[bi];
|
||||
let top = price_rect.bottom() + sub_h * k as f32;
|
||||
let r = Rect::from_min_max(pos2(plot_all.left(), top), pos2(plot_all.right(), top + sub_h));
|
||||
let fixed = matches!(b.spec, IndicatorSpec::Rsi(_)).then_some((0.0, 100.0));
|
||||
let def = b.spec.def();
|
||||
let ovs: Vec<Overlay> = b.overlays.iter().map(|o| o.1.clone()).collect();
|
||||
let mut s = style;
|
||||
s.thickness_px = 1.3;
|
||||
let so = scene::sub_pane(&ovs, &v, r.height() as f64, fixed, s);
|
||||
let so = scene::sub_pane(&ovs, &v, r.height() as f64, def.and_then(|d| d.range), s);
|
||||
let p = ui.painter_at(r);
|
||||
p.hline(r.x_range(), r.top(), Stroke::new(1.0, theme::border()));
|
||||
if matches!(b.spec, IndicatorSpec::Rsi(_)) {
|
||||
for lvl in [30.0, 70.0] {
|
||||
p.hline(
|
||||
r.x_range(),
|
||||
r.top() + so.scale.y_of(lvl) as f32,
|
||||
Stroke::new(1.0, Color32::from_rgb(60, 45, 80)),
|
||||
);
|
||||
for &lvl in def.map_or(&[][..], |d| d.levels) {
|
||||
let y = r.top() + so.scale.y_of(lvl) as f32;
|
||||
if r.y_range().contains(y) {
|
||||
p.hline(r.x_range(), y, Stroke::new(1.0, muted().gamma_multiply(0.35)));
|
||||
}
|
||||
}
|
||||
let frame = Frame { pane: self.id * 8 + 1 + k as u64, frame_no, cmds: so.cmds };
|
||||
@@ -736,7 +905,7 @@ impl ChartState {
|
||||
axis_painter.text(
|
||||
pos2(plot_all.right() + 6.0, y),
|
||||
Align2::LEFT_CENTER,
|
||||
format!("{t:.1}"),
|
||||
scale::format_value(t),
|
||||
mono(10.5),
|
||||
muted(),
|
||||
);
|
||||
@@ -759,8 +928,11 @@ impl ChartState {
|
||||
}
|
||||
}
|
||||
|
||||
// Last price line + axis tag.
|
||||
if let Some(last) = self.bars.last() {
|
||||
// Last price line + axis tag, fading in as the sweep reaches the last bar.
|
||||
let tag_fade = crate::anim::span(reveal_t, 0.8, 1.0);
|
||||
if let Some(last) = self.bars.last()
|
||||
&& tag_fade > 0.0
|
||||
{
|
||||
let y = price_rect.top() + scale.y_of(last.close) as f32;
|
||||
if price_rect.y_range().contains(y) {
|
||||
let col = if last.is_up() { theme::up() } else { theme::down() };
|
||||
@@ -769,18 +941,18 @@ impl ChartState {
|
||||
price_painter.hline(
|
||||
xx..=(xx + 3.0).min(price_rect.right()),
|
||||
y,
|
||||
Stroke::new(1.0, col.gamma_multiply(0.6)),
|
||||
Stroke::new(1.0, col.gamma_multiply(0.6 * tag_fade)),
|
||||
);
|
||||
xx += 6.0;
|
||||
}
|
||||
let tag = Rect::from_min_size(pos2(plot_all.right() + 1.0, y - 8.0), vec2(AXIS_W - 2.0, 16.0));
|
||||
axis_painter.rect_filled(tag, 0.0, col);
|
||||
axis_painter.rect_filled(tag, 0.0, col.gamma_multiply(tag_fade));
|
||||
axis_painter.text(
|
||||
tag.left_center() + vec2(5.0, 0.0),
|
||||
Align2::LEFT_CENTER,
|
||||
scale::format_price(last.close, self.sec.decimals(last.close)),
|
||||
mono(11.0),
|
||||
Color32::BLACK,
|
||||
Color32::BLACK.gamma_multiply(tag_fade),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -791,7 +963,7 @@ impl ChartState {
|
||||
// ---- congress markers ----
|
||||
self.hover_marker = None;
|
||||
if self.show_congress {
|
||||
self.paint_markers(ui, price_rect, &v, &scale, hover);
|
||||
self.paint_markers(ui, price_rect, &v, &scale, hover, &reveal);
|
||||
}
|
||||
|
||||
// ---- crosshair + legend ----
|
||||
@@ -826,11 +998,39 @@ impl ChartState {
|
||||
}
|
||||
}
|
||||
self.paint_legend(ui, price_rect, hovered_bar.unwrap_or(len.saturating_sub(1)), &sub_rects);
|
||||
if matches!(self.sec.class, tr_core::AssetClass::Equity | tr_core::AssetClass::Etf) {
|
||||
clicked |= self.trade_buttons(ui, plot_all);
|
||||
}
|
||||
|
||||
let _ = focused; // the surrounding card shows focus
|
||||
clicked
|
||||
}
|
||||
|
||||
/// Buy / Sell buttons in the plot's top-right corner: they open the order ticket.
|
||||
fn trade_buttons(&mut self, ui: &mut Ui, plot: Rect) -> bool {
|
||||
let h = 20.0;
|
||||
let mut x = plot.right() - 8.0;
|
||||
let mut clicked = false;
|
||||
for (label, cmd, color, tip) in
|
||||
[("SELL", "SELL", theme::down(), "Sell (⇧S)"), ("BUY", "BUY", theme::up(), "Buy (⇧B)")]
|
||||
{
|
||||
let g = ui.painter().layout_no_wrap(label.into(), mono(11.0), Color32::WHITE);
|
||||
let w = g.size().x + 18.0;
|
||||
let r = Rect::from_min_size(pos2(x - w, plot.top() + 6.0), vec2(w, h));
|
||||
x -= w + 6.0;
|
||||
let resp = ui.interact(r, ui.id().with((self.id, "trade", label)), Sense::click()).on_hover_text(tip);
|
||||
let fill = if resp.hovered() { color } else { color.gamma_multiply(0.8) };
|
||||
let p = ui.painter();
|
||||
p.rect_filled(r, h / 2.0, fill);
|
||||
p.galley(r.center() - g.size() / 2.0, g, Color32::WHITE);
|
||||
if resp.clicked() {
|
||||
self.actions.push(Action::Command(format!("{cmd} {}", self.symbol)));
|
||||
clicked = true;
|
||||
}
|
||||
}
|
||||
clicked
|
||||
}
|
||||
|
||||
fn context_menu(&mut self, ui: &mut Ui, w: &Worker) {
|
||||
ui.set_min_width(190.0);
|
||||
if ui.button("Reset View").clicked() {
|
||||
@@ -846,21 +1046,40 @@ impl ChartState {
|
||||
}
|
||||
});
|
||||
ui.menu_button("Indicators", |ui| {
|
||||
for spec in [
|
||||
IndicatorSpec::Sma(20),
|
||||
IndicatorSpec::Sma(50),
|
||||
IndicatorSpec::Ema(20),
|
||||
IndicatorSpec::Vwap,
|
||||
IndicatorSpec::Bollinger(20, 2.0),
|
||||
IndicatorSpec::Rsi(14),
|
||||
IndicatorSpec::Macd(12, 26, 9),
|
||||
] {
|
||||
let on = self.indicators.contains(&spec);
|
||||
if ui.checkbox(&mut on.clone(), spec.label()).clicked() {
|
||||
self.toggle_indicator(spec, w);
|
||||
ui.set_min_width(220.0);
|
||||
if ui.button("Search Indicators…").clicked() {
|
||||
self.actions.push(Action::Command("IND".into()));
|
||||
ui.close();
|
||||
}
|
||||
if !self.indicators.is_empty() {
|
||||
ui.separator();
|
||||
ui.label(RichText::new("On this chart").small().color(muted()));
|
||||
for spec in self.indicators.clone() {
|
||||
if ui.checkbox(&mut true, spec.label()).on_hover_text(spec.name()).clicked() {
|
||||
self.toggle_indicator(spec, w);
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
for cat in Category::ALL {
|
||||
ui.menu_button(cat.label(), |ui| {
|
||||
ui.set_min_width(260.0);
|
||||
egui::ScrollArea::vertical().max_height(440.0).show(ui, |ui| {
|
||||
for def in registry::all().iter().filter(|d| d.category == cat) {
|
||||
let on = self.indicators.iter().any(|s| s.code == def.code);
|
||||
let hover = IndicatorSpec::of(def).label();
|
||||
if ui.checkbox(&mut on.clone(), def.name).on_hover_text(hover).clicked() {
|
||||
if on {
|
||||
self.remove_indicator(def.code, w);
|
||||
} else {
|
||||
self.toggle_indicator(IndicatorSpec::of(def), w);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Remove All").clicked() {
|
||||
self.clear_indicators();
|
||||
ui.close();
|
||||
@@ -898,6 +1117,36 @@ impl ChartState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The range row's link between a security and its HIP-3 perp, drawn when it fits in
|
||||
/// `room`. Clicking it (or ⇧P) loads the other one here.
|
||||
fn switch_chip(&mut self, ui: &Ui, painter: &egui::Painter, left: Pos2, room: f32, target: &str) -> bool {
|
||||
let to_perp = tr_core::perps::is_perp(target);
|
||||
let label = if to_perp { format!("⇄ {target} PERP") } else { format!("⇄ {target}") };
|
||||
let g = painter.layout_no_wrap(label, mono(11.0), theme::perp());
|
||||
if g.size().x + 10.0 > room {
|
||||
return false;
|
||||
}
|
||||
let r = Rect::from_min_size(pos2(left.x, left.y - RANGE_H / 2.0 + 2.0), vec2(g.size().x + 10.0, RANGE_H - 4.0));
|
||||
let resp = ui.interact(r, ui.id().with((self.id, "perp-switch")), Sense::click());
|
||||
let fill = if resp.hovered() { theme::control_hover() } else { theme::control() };
|
||||
painter.rect_filled(r, 3.0, fill);
|
||||
painter.galley(pos2(r.left() + 5.0, r.center().y - g.size().y / 2.0), g, theme::perp());
|
||||
let tip = match tr_core::perps::link_for(if to_perp { target } else { &self.symbol }) {
|
||||
Some(l) if to_perp => format!(
|
||||
"Open {target}, the HIP-3 perp on Hyperliquid's {} dex (⇧P). It references {}. A perp's price \
|
||||
can differ from the listed instrument's, and it trades when that market is closed.",
|
||||
tr_core::perps::venue(target).unwrap_or("xyz"),
|
||||
l.reference
|
||||
),
|
||||
_ => format!("Open {target}, the listed instrument this perp references (⇧P)"),
|
||||
};
|
||||
let resp = resp.on_hover_text(tip);
|
||||
if resp.clicked() {
|
||||
self.actions.push(Action::OpenChart(target.to_string()));
|
||||
}
|
||||
resp.clicked()
|
||||
}
|
||||
|
||||
fn paint_legend(&self, ui: &Ui, price_rect: Rect, i: usize, sub_rects: &[(Rect, usize, PriceScale)]) {
|
||||
let Some(b) = self.bars.get(i) else { return };
|
||||
let p = ui.painter_at(price_rect);
|
||||
@@ -913,6 +1162,9 @@ impl ChartState {
|
||||
*x += g.size().x + 8.0;
|
||||
};
|
||||
put(format!("{} · {}", self.sec.ticker, self.tf), amber(), &mut x);
|
||||
if self.sec.class == AssetClass::Perp {
|
||||
put(tr_core::perps::badge(&self.symbol), theme::perp(), &mut x);
|
||||
}
|
||||
for (k, val) in [("O", b.open), ("H", b.high), ("L", b.low), ("C", b.close)] {
|
||||
put(format!("{k}{val:.dec$}"), col, &mut x);
|
||||
}
|
||||
@@ -923,20 +1175,12 @@ impl ChartState {
|
||||
let mut ly = y + 16.0;
|
||||
let value_at = |l: &LineRef| l.pyramid.data.get(i).map(|p| p[1] as f64).filter(|v| v.is_finite());
|
||||
for bi in self.built.iter().filter(|b| !b.spec.is_pane()) {
|
||||
let vals: Vec<String> =
|
||||
bi.overlays.iter().filter_map(|(_, o)| value_at(&o.line).map(|v| format!("{v:.2}"))).collect();
|
||||
let c = bi
|
||||
let vals: Vec<String> = bi
|
||||
.overlays
|
||||
.get(bi.overlays.len() / 2)
|
||||
.map(|o| {
|
||||
Color32::from_rgba_unmultiplied(
|
||||
(o.1.color[0] * 255.0) as u8,
|
||||
(o.1.color[1] * 255.0) as u8,
|
||||
(o.1.color[2] * 255.0) as u8,
|
||||
255,
|
||||
)
|
||||
})
|
||||
.unwrap_or(text());
|
||||
.iter()
|
||||
.filter_map(|(_, o)| value_at(&o.line).map(|v| format!("{v:.prec$}", prec = dec.min(4))))
|
||||
.collect();
|
||||
let c = bi.overlays.get(bi.overlays.len() / 2).map_or(text(), |o| opaque(&o.1));
|
||||
p.text(
|
||||
pos2(price_rect.left() + 6.0, ly),
|
||||
Align2::LEFT_TOP,
|
||||
@@ -946,6 +1190,13 @@ impl ChartState {
|
||||
);
|
||||
ly += 14.0;
|
||||
}
|
||||
let hidden = self.built.iter().filter(|b| b.spec.is_pane()).count().saturating_sub(sub_rects.len());
|
||||
if hidden > 0 {
|
||||
let note =
|
||||
format!("+{hidden} more oscillator{} (max {MAX_SUB_PANES} panes)", if hidden == 1 { "" } else { "s" });
|
||||
p.text(pos2(price_rect.left() + 6.0, ly), Align2::LEFT_TOP, note, mono(11.0), muted());
|
||||
ly += 14.0;
|
||||
}
|
||||
if let (Some(sym), Some(line)) = (&self.compare, &self.compare_line) {
|
||||
// Raw price plus performance relative to the left edge of the view.
|
||||
let raw = value_at(line);
|
||||
@@ -973,15 +1224,17 @@ impl ChartState {
|
||||
}
|
||||
for (r, bi, _) in sub_rects {
|
||||
let b = &self.built[*bi];
|
||||
let vals: Vec<String> =
|
||||
b.overlays.iter().filter_map(|(n, o)| value_at(&o.line).map(|v| format!("{n} {v:.2}"))).collect();
|
||||
ui.painter_at(*r).text(
|
||||
r.left_top() + vec2(90.0, 3.0),
|
||||
Align2::LEFT_TOP,
|
||||
vals.join(" "),
|
||||
mono(10.5),
|
||||
text(),
|
||||
);
|
||||
let painter = ui.painter_at(*r);
|
||||
// Values start after the pane's label (drawn with the axes).
|
||||
let mut x = r.left() + 18.0 + painter.layout_no_wrap(b.spec.label(), mono(10.5), muted()).size().x;
|
||||
for (name, o) in &b.overlays {
|
||||
let Some(v) = value_at(&o.line) else { continue };
|
||||
let color = if o.kind == LineKind::Hist { text() } else { opaque(o) };
|
||||
let g = painter.layout_no_wrap(format!("{name} {}", scale::format_value(v)), mono(10.5), color);
|
||||
let w = g.size().x;
|
||||
painter.galley(pos2(x, r.top() + 3.0), g, color);
|
||||
x += w + 10.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1099,16 +1352,27 @@ impl ChartState {
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_markers(&mut self, ui: &Ui, rect: Rect, v: &Viewport, s: &PriceScale, hover: Option<Pos2>) {
|
||||
/// Congress trade markers. They pop in once the reveal sweep has passed their bar, and
|
||||
/// left to right when they arrive after it.
|
||||
fn paint_markers(
|
||||
&mut self,
|
||||
ui: &Ui,
|
||||
rect: Rect,
|
||||
v: &Viewport,
|
||||
s: &PriceScale,
|
||||
hover: Option<Pos2>,
|
||||
reveal: &Reveal,
|
||||
) {
|
||||
if self.markers.is_empty() || self.bars.is_empty() {
|
||||
return;
|
||||
}
|
||||
let arrive = crate::anim::progress(ui.ctx(), egui::Id::new(("chart-markers", self.id)), self.markers_key, 0.6);
|
||||
let p = ui.painter_at(rect);
|
||||
let len = self.bars.len();
|
||||
let (lo, hi) = v.visible_range(len);
|
||||
// Stack multiple trades on the same bar.
|
||||
let mut stack: std::collections::HashMap<(usize, bool), u32> = std::collections::HashMap::new();
|
||||
let r = (v.bar_px as f32 * 0.45).clamp(4.0, 7.0);
|
||||
let full_r = (v.bar_px as f32 * 0.45).clamp(4.0, 7.0);
|
||||
let mut best: Option<(f32, usize, Pos2)> = None;
|
||||
for (mi, t) in self.markers.iter().enumerate() {
|
||||
let Some(ts) = t.tx_date.and_hms_opt(12, 0, 0).map(|d| d.and_utc().timestamp()) else { continue };
|
||||
@@ -1121,7 +1385,15 @@ impl ChartState {
|
||||
let sell = t.tx_type.is_sell();
|
||||
let n = stack.entry((i, sell)).or_insert(0);
|
||||
let x = rect.left() + v.x_center(i) as f32;
|
||||
let off = 6.0 + r + *n as f32 * (2.0 * r + 1.0);
|
||||
let off = 6.0 + full_r + *n as f32 * (2.0 * full_r + 1.0);
|
||||
let swept = ((reveal.front - (i as f64 + 0.5)) / reveal.ramp as f64).clamp(0.0, 1.0) as f32;
|
||||
let lead = 0.5 * (x - rect.left()) / rect.width().max(1.0);
|
||||
let pop = crate::anim::ease_out_back(swept.min(crate::anim::span(arrive, lead, lead + 0.5)));
|
||||
if pop <= 0.01 {
|
||||
*n += 1;
|
||||
continue;
|
||||
}
|
||||
let r = full_r * pop;
|
||||
let y = if sell {
|
||||
rect.top() + s.y_of(bar.high) as f32 - off
|
||||
} else {
|
||||
@@ -1131,13 +1403,14 @@ impl ChartState {
|
||||
let c = Pos2::new(x, y);
|
||||
let col = theme::party_color(t.party);
|
||||
p.circle_filled(c, r, col);
|
||||
if r >= 5.0 {
|
||||
if full_r >= 5.0 {
|
||||
let letter = match t.tx_type {
|
||||
TxType::Purchase => "B",
|
||||
TxType::Sale | TxType::SalePartial => "S",
|
||||
_ => "E",
|
||||
};
|
||||
p.text(c, Align2::CENTER_CENTER, letter, mono(r * 1.6), Color32::WHITE);
|
||||
let fade = crate::anim::span(pop, 0.5, 1.0);
|
||||
p.text(c, Align2::CENTER_CENTER, letter, mono(full_r * 1.6), Color32::WHITE.gamma_multiply(fade));
|
||||
} else {
|
||||
p.circle_stroke(c, r, Stroke::new(1.0, Color32::WHITE));
|
||||
}
|
||||
@@ -1156,6 +1429,11 @@ impl ChartState {
|
||||
}
|
||||
}
|
||||
|
||||
fn opaque(o: &Overlay) -> Color32 {
|
||||
let [r, g, b, _] = o.color.map(|c| (c.clamp(0.0, 1.0) * 255.0) as u8);
|
||||
Color32::from_rgb(r, g, b)
|
||||
}
|
||||
|
||||
fn dashed_h(p: &egui::Painter, x: egui::Rangef, y: f32, s: Stroke) {
|
||||
let mut a = x.min;
|
||||
while a < x.max {
|
||||
@@ -1221,3 +1499,76 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the chart has reached the start of its history after a response. A full load decides
|
||||
/// it, since it may come from another source than the bars before it. A page decides it too. Live
|
||||
/// updates and refreshes keep what the chart knew.
|
||||
fn end_of_history(was: bool, exhausted: bool, prepended: usize, full: bool) -> bool {
|
||||
if full { exhausted } else { exhausted || was && prepended == 0 }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Bars from the disk cache whose refresh failed say so, so a perp whose server is down
|
||||
/// never passes for live. A failed page of older bars leaves the shown ones as they are.
|
||||
#[test]
|
||||
fn cached_bars_that_could_not_refresh_say_why() {
|
||||
let down = "the Terminal server is not reachable";
|
||||
let mut c = ChartState::new(1, "xyz:AAPL", Timeframe::M15);
|
||||
c.on_history_error(c.generation, down.into());
|
||||
assert_eq!(c.status_text(), format!("ERR {down}"), "with nothing to show, the error is the message");
|
||||
c.error = None;
|
||||
c.bars = Arc::new((0..3).map(|i| Bar::new(i * 900, 1.0, 1.0, 1.0, 1.0, 1.0)).collect());
|
||||
c.on_history_error(c.generation, down.into());
|
||||
assert_eq!(c.status_text(), format!("3 cached bars · {down}"));
|
||||
c.on_history_error(c.generation + 1, "another load".into());
|
||||
assert_eq!(c.status_text(), format!("3 cached bars · {down}"), "other generations are ignored");
|
||||
let mut p = ChartState::new(2, "xyz:AAPL", Timeframe::M15);
|
||||
p.bars = c.bars.clone();
|
||||
p.paging = true;
|
||||
p.on_history_error(p.generation, down.into());
|
||||
assert_eq!(p.status_text(), "3 bars", "a failed page doesn't make the shown bars stale");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_load_from_a_new_source_can_page_again() {
|
||||
assert!(!end_of_history(true, false, 0, true), "a reload from a source with more history");
|
||||
assert!(end_of_history(false, true, 0, true));
|
||||
assert!(end_of_history(true, false, 0, false), "a live update keeps the end");
|
||||
assert!(!end_of_history(true, false, 5, false), "a page that added bars");
|
||||
assert!(end_of_history(false, true, 0, false), "an empty page");
|
||||
}
|
||||
|
||||
#[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)));
|
||||
}
|
||||
}
|
||||
+167
-66
@@ -1,49 +1,18 @@
|
||||
//! Bloomberg-style command line: `<SECURITY> [<FUNCTION> args] <GO>`.
|
||||
//!
|
||||
//! Examples: `AAPL`, `AAPL US EQUITY <GO>`, `5`, `1W`, `RSI`, `SMA 50`, `CMP MSFT`,
|
||||
//! `AAPL CONG`, `CONG`, `POL PELOSI`, `TRACK PELOSI`, `LAYOUT 4`, `FIB`, `HELP`.
|
||||
//! Examples: `AAPL`, `AAPL US EQUITY <GO>`, `5`, `1W`, `RSI`, `SMA 50`, `ICHIMOKU`, `IND MOMENTUM`,
|
||||
//! `CMP MSFT`, `AAPL CONG`, `CONG`, `POL PELOSI`, `TRACK PELOSI`, `LAYOUT 4`, `FIB`, `HELP`.
|
||||
//! Hyperliquid HIP-3 perps go by their venue key (`xyz:AAPL`), and `AAPL PERP` opens the perp
|
||||
//! linked to a security.
|
||||
|
||||
use tr_chart::drawings::Tool;
|
||||
use tr_chart::scene::ChartType;
|
||||
pub use tr_core::indicators::registry::Spec as IndicatorSpec;
|
||||
use tr_core::{Security, Timeframe, YellowKey};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum IndicatorSpec {
|
||||
Sma(usize),
|
||||
Ema(usize),
|
||||
Vwap,
|
||||
Bollinger(usize, f64),
|
||||
Rsi(usize),
|
||||
Macd(usize, usize, usize),
|
||||
}
|
||||
|
||||
impl IndicatorSpec {
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
IndicatorSpec::Sma(n) => format!("SMA {n}"),
|
||||
IndicatorSpec::Ema(n) => format!("EMA {n}"),
|
||||
IndicatorSpec::Vwap => "VWAP".into(),
|
||||
IndicatorSpec::Bollinger(n, k) => format!("BB {n} {k}"),
|
||||
IndicatorSpec::Rsi(n) => format!("RSI {n}"),
|
||||
IndicatorSpec::Macd(a, b, c) => format!("MACD {a} {b} {c}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Oscillators get their own sub-pane.
|
||||
pub fn is_pane(&self) -> bool {
|
||||
matches!(self, IndicatorSpec::Rsi(_) | IndicatorSpec::Macd(..))
|
||||
}
|
||||
|
||||
/// Same indicator family (used to toggle and replace).
|
||||
pub fn same_kind(&self, other: &IndicatorSpec) -> bool {
|
||||
std::mem::discriminant(self) == std::mem::discriminant(other)
|
||||
&& match (self, other) {
|
||||
(IndicatorSpec::Sma(a), IndicatorSpec::Sma(b)) | (IndicatorSpec::Ema(a), IndicatorSpec::Ema(b)) => {
|
||||
a == b
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
/// Indicators on a new chart: the 20 and 50 bar simple moving averages.
|
||||
pub fn default_indicators() -> Vec<IndicatorSpec> {
|
||||
[20.0, 50.0].iter().filter_map(|n| IndicatorSpec::new("SMA", &[*n])).collect()
|
||||
}
|
||||
|
||||
/// Views of the brokerage portfolio pane (`PORT`, `ACCT`, `POS`, `ORD`, `ACTV`).
|
||||
@@ -97,6 +66,8 @@ pub struct TradeArgs {
|
||||
pub notional: Option<f64>,
|
||||
pub limit: Option<f64>,
|
||||
pub stop: Option<f64>,
|
||||
/// `SELL ALL TSLA`: the whole position.
|
||||
pub all: bool,
|
||||
}
|
||||
|
||||
impl TradeArgs {
|
||||
@@ -136,6 +107,18 @@ impl TradeArgs {
|
||||
if t.units.is_some() && t.notional.is_some() {
|
||||
return None;
|
||||
}
|
||||
// `ALL` means the whole position when another word names the security (`ALL` alone
|
||||
// is Allstate's ticker).
|
||||
if words.len() > 1
|
||||
&& let Some(i) = words.iter().position(|w| w.eq_ignore_ascii_case("ALL"))
|
||||
{
|
||||
if t.buy == Some(true) || t.units.is_some() || t.notional.is_some() {
|
||||
return None;
|
||||
}
|
||||
words.remove(i);
|
||||
t.all = true;
|
||||
t.buy = Some(false);
|
||||
}
|
||||
if !words.is_empty() {
|
||||
let (sec, used) = Security::resolve_tokens(&words)?;
|
||||
if used != words.len() {
|
||||
@@ -229,7 +212,10 @@ impl Range {
|
||||
pub enum Function {
|
||||
Timeframe(Timeframe),
|
||||
ChartType(ChartType),
|
||||
/// Add or remove an indicator (any code from the catalog: `RSI`, `BB 20 2.5`, `ICHIMOKU`).
|
||||
Indicator(IndicatorSpec),
|
||||
/// Open the indicator picker, pre-filled with a search (`IND`, `IND VOLUME`).
|
||||
IndicatorPicker(String),
|
||||
ClearIndicators,
|
||||
Compare(Option<String>),
|
||||
LogScale,
|
||||
@@ -271,6 +257,8 @@ pub enum Function {
|
||||
/// `SIDEBAR LEFT|RIGHT|TOGGLE|SHOW|HIDE|SWAP`.
|
||||
Sidebar(String),
|
||||
Settings,
|
||||
/// Check for a newer release of the app.
|
||||
Update,
|
||||
/// `THEME LIGHT|DARK|SYSTEM|COMPACT|REGULAR|COMFORTABLE`.
|
||||
Theme(String),
|
||||
/// Rename the focused tab (empty resets).
|
||||
@@ -287,6 +275,13 @@ 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),
|
||||
/// `KILL`: cancel every open Hyperliquid order and turn Hyperliquid orders off (confirmed).
|
||||
Kill,
|
||||
/// `AAPL PERP`: open the HIP-3 perp linked to a security. Alone, or on a perp, it switches
|
||||
/// the focused pane between the two.
|
||||
Perp,
|
||||
}
|
||||
|
||||
fn num<T: std::str::FromStr>(t: Option<&&str>) -> Option<T> {
|
||||
@@ -302,19 +297,13 @@ fn parse_function(head: &str, rest: &[&str]) -> Option<Function> {
|
||||
"BAR" | "BARS" | "OHLC" => Function::ChartType(ChartType::Bars),
|
||||
"LINE" => Function::ChartType(ChartType::Line),
|
||||
"AREA" | "MOUNTAIN" => Function::ChartType(ChartType::Area),
|
||||
"SMA" => Function::Indicator(IndicatorSpec::Sma(num(rest.first()).unwrap_or(20))),
|
||||
"EMA" => Function::Indicator(IndicatorSpec::Ema(num(rest.first()).unwrap_or(20))),
|
||||
"VWAP" => Function::Indicator(IndicatorSpec::Vwap),
|
||||
"BB" | "BOLL" | "BOLLINGER" => Function::Indicator(IndicatorSpec::Bollinger(
|
||||
num(rest.first()).unwrap_or(20),
|
||||
num(rest.get(1)).unwrap_or(2.0),
|
||||
)),
|
||||
"RSI" => Function::Indicator(IndicatorSpec::Rsi(num(rest.first()).unwrap_or(14))),
|
||||
"MACD" => Function::Indicator(IndicatorSpec::Macd(
|
||||
num(rest.first()).unwrap_or(12),
|
||||
num(rest.get(1)).unwrap_or(26),
|
||||
num(rest.get(2)).unwrap_or(9),
|
||||
)),
|
||||
// `IND RSI 21` adds directly; anything else searches the picker.
|
||||
"IND" | "INDICATOR" | "INDICATORS" | "STUDY" | "STUDIES" => {
|
||||
match rest.split_first().and_then(|(code, args)| IndicatorSpec::parse(code, args)) {
|
||||
Some(spec) => Function::Indicator(spec),
|
||||
None => Function::IndicatorPicker(joined()),
|
||||
}
|
||||
}
|
||||
"CLEAR" | "CLR" => match words().first().map(String::as_str) {
|
||||
Some("DRAW") | Some("DRAWINGS") => Function::ClearDrawings,
|
||||
_ => Function::ClearIndicators,
|
||||
@@ -357,6 +346,7 @@ fn parse_function(head: &str, rest: &[&str]) -> Option<Function> {
|
||||
"OMON" | "OMST" | "OPT" | "OPTIONS" | "CHAIN" => Function::Options,
|
||||
"SIDEBAR" | "SB" => Function::Sidebar(words().join(" ")),
|
||||
"PREFS" | "SETTINGS" | "PDF" | "CONFIG" => Function::Settings,
|
||||
"UPDATE" | "UPDATES" => Function::Update,
|
||||
"THEME" => Function::Theme(words().join(" ")),
|
||||
"TITLE" | "RENAME" => Function::Title(joined()),
|
||||
"CLOSE" => Function::ClosePane,
|
||||
@@ -375,14 +365,24 @@ 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)?),
|
||||
"KILL" if rest.is_empty() => Function::Kill,
|
||||
"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`).
|
||||
"PERP" if rest.is_empty() => Function::Perp,
|
||||
// Timeframes win where spellings overlap (`1D`, `1M`); otherwise try a range (`5D`, `YTD`),
|
||||
// then the indicator catalog (`RSI`, `BB 20 2.5`).
|
||||
_ => {
|
||||
return head
|
||||
.parse::<Timeframe>()
|
||||
.ok()
|
||||
.map(Function::Timeframe)
|
||||
.or_else(|| Range::parse(&up).map(Function::Range));
|
||||
.or_else(|| Range::parse(&up).map(Function::Range))
|
||||
.or_else(|| IndicatorSpec::parse(head, rest).map(Function::Indicator));
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -403,6 +403,21 @@ pub fn parse(input: &str) -> Option<Command> {
|
||||
let (sec, used) = Security::resolve_tokens(&tokens)?;
|
||||
match tokens[used..].split_first() {
|
||||
None => Some(Command::Symbol(sec.key())),
|
||||
// `TSLA SELL ALL`: the security is already named, so `ALL` is the whole position.
|
||||
Some((&f, args))
|
||||
if ["SELL", "TRADE"].iter().any(|h| f.eq_ignore_ascii_case(h))
|
||||
&& args.iter().any(|a| a.eq_ignore_ascii_case("ALL")) =>
|
||||
{
|
||||
let rest: Vec<&str> = args.iter().copied().filter(|a| !a.eq_ignore_ascii_case("ALL")).collect();
|
||||
match parse_function(f, &rest)? {
|
||||
Function::Trade(mut t) if t.symbol.is_none() && t.units.is_none() && t.notional.is_none() => {
|
||||
t.all = true;
|
||||
t.buy = Some(false);
|
||||
Some(Command::SymbolFunction(sec.key(), Function::Trade(t)))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
Some((&f, args)) => parse_function(f, args).map(|func| Command::SymbolFunction(sec.key(), func)),
|
||||
}
|
||||
}
|
||||
@@ -412,9 +427,20 @@ pub fn hint(input: &str) -> String {
|
||||
match parse(input) {
|
||||
None if input.trim().is_empty() => "Type a ticker and press <GO> (Enter). HELP for functions.".into(),
|
||||
None => "Unrecognised command".into(),
|
||||
Some(Command::Symbol(s)) => match Security::parse(&s).and_then(|x| x.name()) {
|
||||
Some(name) => format!("Load {s} — {name}"),
|
||||
None => format!("Load {s}"),
|
||||
Some(Command::Symbol(s)) => {
|
||||
let badge = tr_core::perps::is_perp(&s).then(|| tr_core::perps::badge(&s));
|
||||
match (Security::parse(&s).and_then(|x| x.name()), badge) {
|
||||
(Some(name), Some(b)) => format!("Load {s} — {name} · {b}"),
|
||||
(None, Some(b)) => format!("Load {s} · {b}"),
|
||||
(Some(name), None) => format!("Load {s} — {name}"),
|
||||
(None, None) => format!("Load {s}"),
|
||||
}
|
||||
}
|
||||
Some(Command::SymbolFunction(s, Function::Perp)) => match tr_core::perps::counterpart(&s) {
|
||||
Some(other) if tr_core::perps::is_perp(&s) => format!("{s}: switch to {other}"),
|
||||
Some(perp) => format!("{s}: open its HIP-3 perp {perp} ({})", tr_core::perps::badge(perp)),
|
||||
None if tr_core::perps::is_perp(&s) => format!("{s}: no listed instrument is linked to it"),
|
||||
None => format!("{s}: no HIP-3 perp is linked to it"),
|
||||
},
|
||||
Some(Command::SymbolFunction(s, f)) => format!("{s}: {}", describe(&f)),
|
||||
Some(Command::Function(f)) => describe(&f),
|
||||
@@ -438,7 +464,9 @@ fn describe(f: &Function) -> String {
|
||||
}
|
||||
),
|
||||
Function::ChartType(c) => format!("Chart type {}", c.label()),
|
||||
Function::Indicator(i) => format!("Toggle {}", i.label()),
|
||||
Function::Indicator(i) => format!("Toggle {} — {}", i.label(), i.name()),
|
||||
Function::IndicatorPicker(q) if q.is_empty() => "Browse indicators".into(),
|
||||
Function::IndicatorPicker(q) => format!("Search indicators for \"{q}\""),
|
||||
Function::ClearIndicators => "Remove all indicators".into(),
|
||||
Function::Compare(Some(s)) => format!("Compare with {s}"),
|
||||
Function::Compare(None) => "Remove comparison".into(),
|
||||
@@ -477,6 +505,7 @@ fn describe(f: &Function) -> String {
|
||||
Function::Sidebar(s) if s.is_empty() => "Toggle the sidebar".into(),
|
||||
Function::Sidebar(s) => format!("Sidebar {}", s.to_lowercase()),
|
||||
Function::Settings => "Settings: theme, fonts, density, hotkeys, panels, layouts".into(),
|
||||
Function::Update => "Check for updates".into(),
|
||||
Function::Theme(t) => format!("Theme {t}"),
|
||||
Function::Title(t) => format!("Rename tab to {t:?}"),
|
||||
Function::ClosePane => "Close focused pane".into(),
|
||||
@@ -518,8 +547,17 @@ fn describe(f: &Function) -> String {
|
||||
if let Some(p) = t.limit {
|
||||
s += &format!(" limit {p}");
|
||||
}
|
||||
s + " (preview and confirm before anything is sent)"
|
||||
if t.all {
|
||||
s += " (whole position)";
|
||||
}
|
||||
s
|
||||
}
|
||||
Function::AccountMode(false) => "Switch to the DEMO account (paper trading)".into(),
|
||||
Function::AccountMode(true) => "Switch to LIVE brokerage accounts (asks first)".into(),
|
||||
Function::Kill => {
|
||||
"Kill switch: cancel every open Hyperliquid testnet order, then turn Hyperliquid orders off".into()
|
||||
}
|
||||
Function::Perp => "Switch between the security and its HIP-3 perp (PERP · HL xyz)".into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -560,10 +598,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn functions() {
|
||||
assert_eq!(parse("rsi"), Some(Command::Function(Function::Indicator(IndicatorSpec::Rsi(14)))));
|
||||
assert_eq!(parse("SMA 50"), Some(Command::Function(Function::Indicator(IndicatorSpec::Sma(50)))));
|
||||
assert_eq!(parse("bb 20 2.5"), Some(Command::Function(Function::Indicator(IndicatorSpec::Bollinger(20, 2.5)))));
|
||||
assert_eq!(parse("MACD"), Some(Command::Function(Function::Indicator(IndicatorSpec::Macd(12, 26, 9)))));
|
||||
let ind = |code: &str, params: &[f64]| {
|
||||
Some(Command::Function(Function::Indicator(IndicatorSpec::new(code, params).unwrap())))
|
||||
};
|
||||
assert_eq!(parse("rsi"), ind("RSI", &[14.0]));
|
||||
assert_eq!(parse("SMA 50"), ind("SMA", &[50.0]));
|
||||
assert_eq!(parse("bb 20 2.5"), ind("BB", &[20.0, 2.5]));
|
||||
assert_eq!(parse("MACD"), ind("MACD", &[12.0, 26.0, 9.0]));
|
||||
assert_eq!(parse("ichimoku"), ind("ICHIMOKU", &[]));
|
||||
assert_eq!(parse("IND STOCH 5 3 3"), ind("STOCH", &[5.0, 3.0, 3.0]));
|
||||
assert_eq!(parse("IND"), Some(Command::Function(Function::IndicatorPicker(String::new()))));
|
||||
assert_eq!(parse("study volume"), Some(Command::Function(Function::IndicatorPicker("volume".into()))));
|
||||
let Some(Command::SymbolFunction(_, Function::Indicator(adx))) = parse("AAPL ADX") else { panic!("AAPL ADX") };
|
||||
assert_eq!(adx.label(), "ADX 14 14");
|
||||
assert_eq!(parse("CMP msft"), Some(Command::Function(Function::Compare(Some("MSFT".into())))));
|
||||
assert_eq!(parse("CMP"), Some(Command::Function(Function::Compare(None))));
|
||||
assert_eq!(parse("FIB"), Some(Command::Function(Function::Draw(Tool::Fib))));
|
||||
@@ -593,6 +640,7 @@ mod tests {
|
||||
Some(Command::SymbolFunction("BTCUSD Curncy".into(), Function::OrderBook(Some(50))))
|
||||
);
|
||||
assert_eq!(parse("PREFS"), Some(Command::Function(Function::Settings)));
|
||||
assert_eq!(parse("update"), Some(Command::Function(Function::Update)));
|
||||
assert_eq!(parse("AAPL OMON"), Some(Command::SymbolFunction("AAPL US Equity".into(), Function::Options)));
|
||||
assert_eq!(parse("SPX INDEX OMON"), Some(Command::SymbolFunction("SPX Index".into(), Function::Options)));
|
||||
assert_eq!(parse("OMON"), Some(Command::Function(Function::Options)));
|
||||
@@ -661,6 +709,19 @@ 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))));
|
||||
assert_eq!(parse("KILL"), Some(Command::Function(Function::Kill)));
|
||||
assert_eq!(parse("kill"), Some(Command::Function(Function::Kill)));
|
||||
let perp = t("BUY xyz:AAPL 0.5 @226.4").unwrap();
|
||||
assert_eq!((perp.symbol.as_deref(), perp.units, perp.limit), (Some("xyz:AAPL"), Some(0.5), Some(226.4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -680,6 +741,30 @@ mod tests {
|
||||
assert_eq!(parse("AAPL WHATEVER"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hip3_perps() {
|
||||
let sym = |s: &str| Some(Command::Symbol(s.into()));
|
||||
assert_eq!(parse("xyz:aapl"), sym("xyz:AAPL"));
|
||||
assert_eq!(parse("XYZ:AAPL <GO>"), sym("xyz:AAPL"));
|
||||
assert_eq!(parse("aapl"), sym("AAPL US Equity"), "a ticker is still the listed stock");
|
||||
assert_eq!(
|
||||
parse("xyz:AAPL 5"),
|
||||
Some(Command::SymbolFunction("xyz:AAPL".into(), Function::Timeframe(Timeframe::M5)))
|
||||
);
|
||||
assert_eq!(parse("xyz:GOLD OB"), Some(Command::SymbolFunction("xyz:GOLD".into(), Function::OrderBook(None))));
|
||||
// `PERP` after a security asks for its perp; alone it switches the focused pane.
|
||||
let perp = |s: &str| Some(Command::SymbolFunction(s.into(), Function::Perp));
|
||||
assert_eq!(parse("AAPL PERP"), perp("AAPL US Equity"));
|
||||
assert_eq!(parse("aapl <equity> perp"), perp("AAPL US Equity"));
|
||||
assert_eq!(parse("EURUSD CRNCY PERP"), perp("EURUSD Curncy"));
|
||||
assert_eq!(parse("PERP"), Some(Command::Function(Function::Perp)));
|
||||
assert_eq!(hint("xyz:aapl"), "Load xyz:AAPL — Apple Inc perp · PERP · HL xyz");
|
||||
assert_eq!(hint("xyz:SKHX"), "Load xyz:SKHX · PERP · HL xyz");
|
||||
assert_eq!(hint("AAPL PERP"), "AAPL US Equity: open its HIP-3 perp xyz:AAPL (PERP · HL xyz)");
|
||||
assert_eq!(hint("xyz:AAPL PERP"), "xyz:AAPL: switch to AAPL US Equity");
|
||||
assert_eq!(hint("NDX INDEX PERP"), "NDX Index: no HIP-3 perp is linked to it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hints() {
|
||||
assert_eq!(hint("5"), "Interval 5 minutes");
|
||||
@@ -690,8 +775,24 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn indicator_identity() {
|
||||
assert!(IndicatorSpec::Rsi(14).same_kind(&IndicatorSpec::Rsi(21)));
|
||||
assert!(!IndicatorSpec::Sma(20).same_kind(&IndicatorSpec::Sma(50)));
|
||||
assert!(IndicatorSpec::Macd(12, 26, 9).is_pane());
|
||||
let spec = |code: &str, params: &[f64]| IndicatorSpec::new(code, params).unwrap();
|
||||
assert!(spec("RSI", &[14.0]).same_kind(&spec("RSI", &[21.0])));
|
||||
assert!(!spec("SMA", &[20.0]).same_kind(&spec("SMA", &[50.0])));
|
||||
assert!(spec("MACD", &[]).is_pane());
|
||||
}
|
||||
|
||||
/// Every indicator code and alias reaches the indicator: no function, timeframe or range
|
||||
/// spelling shadows it.
|
||||
#[test]
|
||||
fn every_indicator_code_parses_to_its_indicator() {
|
||||
for def in tr_core::indicators::registry::all() {
|
||||
for code in std::iter::once(&def.code).chain(def.aliases) {
|
||||
match parse(code) {
|
||||
Some(Command::Function(Function::Indicator(s))) => assert_eq!(s.code, def.code, "{code}"),
|
||||
other => panic!("{code} parses as {other:?}"),
|
||||
}
|
||||
assert_eq!(parse(&format!("IND {code}")), parse(code), "IND {code}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,10 @@ use tr_congress::{Chamber, Party, Trade, TxType};
|
||||
use crate::theme::{self, amber, mono, muted, text, ui_font};
|
||||
use crate::worker::Profile;
|
||||
|
||||
/// Shown instead of Congress data in the browser build, which can't fetch disclosures yet.
|
||||
pub const WEB_NOTE: &str =
|
||||
"Congress data needs the desktop app for now: browsers can't reach Senate eFD or the House Clerk.";
|
||||
|
||||
/// What a click inside a panel asks the app to do.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Action {
|
||||
@@ -192,7 +196,12 @@ pub fn feed(ui: &mut Ui, trades: &[Trade], filter: &mut FeedFilter, status: &str
|
||||
header(ui, "FILED TRADED LAG POLITICIAN PTY C TYPE TICKER AMOUNT OWNER ASSET");
|
||||
let rows: Vec<&Trade> = trades.iter().filter(|t| filter.matches(t)).collect();
|
||||
if rows.is_empty() {
|
||||
plain(ui, "No disclosures yet. Ingest runs in the background (REFRESH to force).", muted());
|
||||
let empty = if cfg!(target_arch = "wasm32") {
|
||||
WEB_NOTE
|
||||
} else {
|
||||
"No disclosures yet. Ingest runs in the background (REFRESH to force)."
|
||||
};
|
||||
plain(ui, empty, muted());
|
||||
}
|
||||
let row_h = 16.0;
|
||||
ScrollArea::both().id_salt("feed").auto_shrink([false, false]).show_rows(ui, row_h, rows.len(), |ui, range| {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+18
-29
@@ -1,6 +1,6 @@
|
||||
//! Named, saved workspace layouts: the dock tree, each pane's kind and settings, link
|
||||
//! groups and popped-out windows. Stored one JSON file per layout in
|
||||
//! `<data dir>/layouts/` (`~/Library/Application Support/terminal-rs/layouts` on macOS),
|
||||
//! `<data dir>/layouts/` (`~/Library/Application Support/terminal/layouts` on macOS),
|
||||
//! so they can be shared, hand-edited, imported and exported.
|
||||
|
||||
use std::collections::HashSet;
|
||||
@@ -16,6 +16,7 @@ use crate::command::IndicatorSpec;
|
||||
use crate::congress_view::FeedFilter;
|
||||
use crate::dock::{Node, PaneId};
|
||||
use crate::layout::Layout;
|
||||
use crate::storage;
|
||||
|
||||
pub const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
@@ -267,7 +268,7 @@ pub fn chart_settings(symbol: &str, d: &crate::prefs::PanelDefaults) -> PaneSett
|
||||
tf: d.timeframe,
|
||||
chart_type: d.chart_type,
|
||||
log: false,
|
||||
indicators: vec![IndicatorSpec::Sma(20), IndicatorSpec::Sma(50)],
|
||||
indicators: crate::command::default_indicators(),
|
||||
drawings: Vec::new(),
|
||||
compare: None,
|
||||
show_volume: d.show_volume,
|
||||
@@ -276,16 +277,6 @@ pub fn chart_settings(symbol: &str, d: &crate::prefs::PanelDefaults) -> PaneSett
|
||||
}
|
||||
}
|
||||
|
||||
/// Write via a temp file and rename, so a crash never leaves a half-written file.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, bytes)?;
|
||||
std::fs::rename(tmp, path)
|
||||
}
|
||||
|
||||
/// The layouts folder and its files.
|
||||
pub struct Store {
|
||||
pub dir: PathBuf,
|
||||
@@ -314,19 +305,17 @@ impl Store {
|
||||
/// Saved layout names, sorted case-insensitively. Names come from the documents, so a
|
||||
/// file copied in under any file name keeps its display name.
|
||||
pub fn list(&self) -> Vec<String> {
|
||||
let mut names: Vec<String> = std::fs::read_dir(&self.dir)
|
||||
let mut names: Vec<String> = storage::list(&self.dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
|
||||
.filter_map(|e| {
|
||||
let bytes = std::fs::read(e.path()).ok()?;
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "json"))
|
||||
.filter_map(|p| {
|
||||
let bytes = storage::read(&p).ok()?;
|
||||
let doc: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
|
||||
Some(
|
||||
doc["name"]
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| e.path().file_stem().unwrap_or_default().to_string_lossy().into_owned()),
|
||||
.unwrap_or_else(|| p.file_stem().unwrap_or_default().to_string_lossy().into_owned()),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -343,12 +332,12 @@ impl Store {
|
||||
/// that name (e.g. dropped into the folder by hand).
|
||||
fn find_file(&self, name: &str) -> Option<PathBuf> {
|
||||
let p = self.path(name);
|
||||
if p.exists() {
|
||||
if storage::exists(&p) {
|
||||
return Some(p);
|
||||
}
|
||||
std::fs::read_dir(&self.dir).ok()?.flatten().map(|e| e.path()).find(|p| {
|
||||
storage::list(&self.dir).into_iter().find(|p| {
|
||||
p.extension().is_some_and(|x| x == "json")
|
||||
&& std::fs::read(p)
|
||||
&& storage::read(p)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
|
||||
.is_some_and(|v| v["name"].as_str().is_some_and(|n| n.eq_ignore_ascii_case(name)))
|
||||
@@ -357,7 +346,7 @@ impl Store {
|
||||
|
||||
pub fn load(&self, name: &str) -> Result<LayoutDoc> {
|
||||
let path = self.find_file(name).with_context(|| format!("no saved layout named {name:?}"))?;
|
||||
let mut doc = LayoutDoc::from_json(&std::fs::read(&path)?).with_context(|| format!("{}", path.display()))?;
|
||||
let mut doc = LayoutDoc::from_json(&storage::read(&path)?).with_context(|| format!("{}", path.display()))?;
|
||||
if doc.name.trim().is_empty() {
|
||||
doc.name = name.to_string();
|
||||
}
|
||||
@@ -366,13 +355,13 @@ impl Store {
|
||||
|
||||
pub fn save(&self, doc: &LayoutDoc) -> Result<PathBuf> {
|
||||
let path = self.path(&doc.name);
|
||||
write_atomic(&path, &doc.to_json()).with_context(|| format!("writing {}", path.display()))?;
|
||||
storage::write_atomic(&path, &doc.to_json()).with_context(|| format!("writing {}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn delete(&self, name: &str) -> Result<()> {
|
||||
let path = self.find_file(name).with_context(|| format!("no saved layout named {name:?}"))?;
|
||||
std::fs::remove_file(path)?;
|
||||
storage::remove(&path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -411,16 +400,16 @@ impl Store {
|
||||
(2..).map(|i| format!("{base} {i}")).find(|n| !self.exists(n)).expect("unbounded")
|
||||
}
|
||||
|
||||
/// Write a layout to an arbitrary path.
|
||||
pub fn export(&self, name: &str, to: &Path) -> Result<()> {
|
||||
/// Write a layout to an arbitrary path (a download in the browser). Returns where it went.
|
||||
pub fn export(&self, name: &str, to: &Path) -> Result<String> {
|
||||
let doc = self.load(name)?;
|
||||
write_atomic(to, &doc.to_json()).with_context(|| format!("writing {}", to.display()))
|
||||
storage::export(to, &doc.to_json()).with_context(|| format!("writing {}", to.display()))
|
||||
}
|
||||
|
||||
/// Copy a layout file into the store (renaming on conflict). Returns its name.
|
||||
pub fn import(&self, from: &Path) -> Result<String> {
|
||||
let mut doc =
|
||||
LayoutDoc::from_json(&std::fs::read(from).with_context(|| format!("reading {}", from.display()))?)?;
|
||||
LayoutDoc::from_json(&storage::import(from).with_context(|| format!("reading {}", from.display()))?)?;
|
||||
if doc.name.trim().is_empty() {
|
||||
doc.name = from.file_stem().unwrap_or_default().to_string_lossy().into_owned();
|
||||
}
|
||||
|
||||
+54
-18
@@ -1,4 +1,4 @@
|
||||
//! terminal-rs: a keyboard-driven, Bloomberg-style market terminal with GPU charts and
|
||||
//! Terminal: a keyboard-driven, Bloomberg-style market terminal with GPU charts and
|
||||
//! congressional (STOCK Act) trade tracking.
|
||||
|
||||
mod anim;
|
||||
@@ -7,18 +7,65 @@ mod chart_view;
|
||||
mod command;
|
||||
mod congress_view;
|
||||
mod dock;
|
||||
mod hl_worker;
|
||||
mod layout;
|
||||
mod layouts;
|
||||
mod markets_view;
|
||||
mod palette;
|
||||
mod prefs;
|
||||
mod storage;
|
||||
mod theme;
|
||||
mod themes;
|
||||
mod wallet_worker;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
mod widgets;
|
||||
mod worker;
|
||||
|
||||
use std::time::Instant;
|
||||
use app::TerminalApp;
|
||||
use web_time::Instant;
|
||||
|
||||
/// Build the terminal once eframe has a GPU: register the chart renderer, start the worker
|
||||
/// and restore the saved workspace. Shared by the desktop and browser entry points.
|
||||
fn create_app(
|
||||
cc: &eframe::CreationContext<'_>,
|
||||
provider: tr_data::Provider,
|
||||
msaa: u32,
|
||||
t0: Instant,
|
||||
) -> Result<TerminalApp, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let rs = cc.wgpu_render_state.as_ref().ok_or("Terminal requires the wgpu renderer")?;
|
||||
// Charts read their series from storage buffers in the vertex shader. WebGL2 has none,
|
||||
// so there chart panes keep their axes and labels but draw no series.
|
||||
let charts = rs.adapter.get_downlevel_capabilities().flags.contains(eframe::wgpu::DownlevelFlags::VERTEX_STORAGE);
|
||||
if charts {
|
||||
rs.renderer.write().callback_resources.insert(tr_chart::gpu::ChartRenderer::new(
|
||||
&rs.device,
|
||||
rs.target_format,
|
||||
msaa,
|
||||
));
|
||||
}
|
||||
let name = provider.name().to_string();
|
||||
let worker = worker::Worker::spawn(cc.egui_ctx.clone(), provider)?;
|
||||
let info = rs.adapter.get_info();
|
||||
tracing::info!("window + GPU ready in {:?} (adapter: {}, backend: {:?})", t0.elapsed(), info.name, info.backend);
|
||||
let mut app = TerminalApp::new(&cc.egui_ctx, worker, name);
|
||||
if !charts {
|
||||
tracing::warn!("this GPU backend has no vertex storage buffers; charts are off");
|
||||
app.set_status(if cfg!(target_arch = "wasm32") {
|
||||
"Charts need WebGPU. This browser fell back to WebGL2, so they are off.".into()
|
||||
} else {
|
||||
"This GPU backend has no vertex storage buffers, so charts are off.".into()
|
||||
});
|
||||
}
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
web::start();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() -> eframe::Result {
|
||||
let t0 = Instant::now();
|
||||
tracing_subscriber::fmt()
|
||||
@@ -35,8 +82,9 @@ fn main() -> eframe::Result {
|
||||
.filter(|n| matches!(n, 1 | 2 | 4 | 8))
|
||||
.unwrap_or(if cfg!(target_os = "macos") { 4 } else { 1 });
|
||||
let mut viewport = egui::ViewportBuilder::default()
|
||||
.with_title("terminal-rs")
|
||||
.with_app_id("terminal-rs")
|
||||
.with_title("Terminal")
|
||||
.with_app_id("terminal")
|
||||
.with_icon(eframe::icon_data::from_png_bytes(include_bytes!("../assets/icon.png")).expect("app icon"))
|
||||
.with_inner_size([1600.0, 960.0])
|
||||
.with_min_inner_size([900.0, 560.0]);
|
||||
if cfg!(target_os = "macos") {
|
||||
@@ -55,20 +103,8 @@ fn main() -> eframe::Result {
|
||||
..Default::default()
|
||||
};
|
||||
eframe::run_native(
|
||||
"terminal-rs",
|
||||
"terminal",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let rs = cc.wgpu_render_state.as_ref().ok_or("terminal-rs requires the wgpu renderer")?;
|
||||
rs.renderer.write().callback_resources.insert(tr_chart::gpu::ChartRenderer::new(
|
||||
&rs.device,
|
||||
rs.target_format,
|
||||
u32::from(msaa),
|
||||
));
|
||||
let provider = tr_data::from_env();
|
||||
let name = provider.name().to_string();
|
||||
let worker = worker::Worker::spawn(cc.egui_ctx.clone(), provider)?;
|
||||
tracing::info!("window + GPU ready in {:?} (adapter: {})", t0.elapsed(), rs.adapter.get_info().name);
|
||||
Ok(Box::new(app::TerminalApp::new(&cc.egui_ctx, worker, name)))
|
||||
}),
|
||||
Box::new(move |cc| Ok(Box::new(create_app(cc, tr_data::from_env(), u32::from(msaa), t0)?))),
|
||||
)
|
||||
}
|
||||
+29
-6
@@ -261,12 +261,22 @@ pub fn curve(ui: &mut Ui, curves: Option<&YieldCurves>) -> Vec<Action> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Merge catalog matches with network hits, catalog first, without duplicates.
|
||||
pub fn merged_hits(query: &str, network: &[SearchHit]) -> Vec<SearchHit> {
|
||||
/// A search hit for a HIP-3 perp, labelled with its venue.
|
||||
pub fn perp_hit(key: &str) -> SearchHit {
|
||||
let name = tr_core::perps::link_for(key).map_or_else(
|
||||
|| format!("Hyperliquid {} perp", tr_core::perps::venue(key).unwrap_or("HIP-3")),
|
||||
|l| l.name.to_string(),
|
||||
);
|
||||
SearchHit { key: key.to_string(), name, class: AssetClass::Perp, exchange: Some(tr_core::perps::badge(key)) }
|
||||
}
|
||||
|
||||
/// Merge catalog matches, then HIP-3 perps among `perps`, then network hits, without duplicates.
|
||||
pub fn merged_hits(query: &str, network: &[SearchHit], perps: &[&str]) -> Vec<SearchHit> {
|
||||
let mut out: Vec<SearchHit> = catalog::search(query, 30)
|
||||
.into_iter()
|
||||
.map(|l| SearchHit { key: l.key(), name: l.name.to_string(), class: l.class, exchange: None })
|
||||
.collect();
|
||||
out.extend(tr_core::perps::search(query, perps.iter().copied(), 10).into_iter().map(perp_hit));
|
||||
for h in network {
|
||||
if !out.iter().any(|o| o.key == h.key) {
|
||||
out.push(h.clone());
|
||||
@@ -284,11 +294,12 @@ pub fn class_color(c: AssetClass) -> Color32 {
|
||||
AssetClass::Crypto => Color32::from_rgb(255, 150, 60),
|
||||
AssetClass::Govt => Color32::from_rgb(190, 160, 255),
|
||||
AssetClass::Cmdty => Color32::from_rgb(230, 200, 90),
|
||||
AssetClass::Perp => theme::perp(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Security search panel (`SECF <query>`).
|
||||
pub fn search(ui: &mut Ui, query: &mut String, network: &[SearchHit]) -> (Vec<Action>, bool) {
|
||||
/// Security search panel (`SECF <query>`). `perps` are the HIP-3 perps it can offer.
|
||||
pub fn search(ui: &mut Ui, query: &mut String, network: &[SearchHit], perps: &[&str]) -> (Vec<Action>, bool) {
|
||||
let mut out = Vec::new();
|
||||
let mut changed = false;
|
||||
ui.horizontal(|ui| {
|
||||
@@ -306,7 +317,7 @@ pub fn search(ui: &mut Ui, query: &mut String, network: &[SearchHit]) -> (Vec<Ac
|
||||
.color(amber())
|
||||
.background_color(theme::header()),
|
||||
);
|
||||
let hits = merged_hits(query, network);
|
||||
let hits = merged_hits(query, network, perps);
|
||||
if hits.is_empty() && !query.trim().is_empty() {
|
||||
ui.label(RichText::new("No matches.").font(mono(12.0)).color(muted()));
|
||||
}
|
||||
@@ -374,8 +385,20 @@ mod tests {
|
||||
exchange: None,
|
||||
},
|
||||
];
|
||||
let m = merged_hits("bitcoin", &net);
|
||||
let m = merged_hits("bitcoin", &net, &[]);
|
||||
assert_eq!(m.iter().filter(|h| h.key == "BTCUSD Curncy").count(), 1);
|
||||
assert!(m.iter().any(|h| h.key == "BITO US Equity"));
|
||||
}
|
||||
|
||||
/// The listed instrument comes first; its perp follows, labelled as one.
|
||||
#[test]
|
||||
fn search_offers_perps_after_listed_instruments() {
|
||||
let m = merged_hits("aapl", &[], &["xyz:AAPL", "xyz:GOLD"]);
|
||||
let keys: Vec<&str> = m.iter().map(|h| h.key.as_str()).collect();
|
||||
assert_eq!(keys, ["AAPL US Equity", "xyz:AAPL"]);
|
||||
assert_eq!((m[1].class, m[1].name.as_str()), (AssetClass::Perp, "Apple Inc perp"));
|
||||
assert_eq!(m[1].exchange.as_deref(), Some("PERP · HL xyz"));
|
||||
assert_eq!(perp_hit("xyz:SKHX").name, "Hyperliquid xyz perp");
|
||||
assert!(merged_hits("aapl", &[], &[]).iter().all(|h| h.class != AssetClass::Perp));
|
||||
}
|
||||
}
|
||||
+52
-3
@@ -25,6 +25,7 @@ pub enum Kind {
|
||||
Theme,
|
||||
Settings,
|
||||
Action,
|
||||
Indicator,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
@@ -37,6 +38,7 @@ impl Kind {
|
||||
Kind::Theme => "THEME",
|
||||
Kind::Settings => "SETTINGS",
|
||||
Kind::Action => "ACTION",
|
||||
Kind::Indicator => "STUDY",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,6 +173,8 @@ 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>,
|
||||
@@ -179,7 +183,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 = std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
|
||||
let recents = crate::storage::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
|
||||
Self { recents, path: Some(path.to_path_buf()), ..Default::default() }
|
||||
}
|
||||
|
||||
@@ -188,6 +192,18 @@ 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) {
|
||||
@@ -214,7 +230,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::layouts::write_atomic(path, &json)
|
||||
&& let Err(e) = crate::storage::write_atomic(path, &json)
|
||||
{
|
||||
tracing::warn!("saving palette recents: {e}");
|
||||
}
|
||||
@@ -222,7 +238,22 @@ impl Palette {
|
||||
|
||||
/// Ranked results for the current query over `items`.
|
||||
pub fn results(&self, items: &[Item]) -> Vec<Item> {
|
||||
rank(items, &self.recents, &self.query)
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,6 +395,24 @@ 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();
|
||||
|
||||
+109
-6
@@ -194,16 +194,81 @@ impl Default for PanelDefaults {
|
||||
}
|
||||
}
|
||||
|
||||
/// Order entry. Disabled by default; enabling it in Settings › Trading takes an explicit
|
||||
/// acknowledgement that orders are real.
|
||||
/// Which account the terminal trades and shows: the local paper-trading demo account, or
|
||||
/// the real brokerage accounts connected through SnapTrade.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum AccountMode {
|
||||
#[default]
|
||||
Demo,
|
||||
Live,
|
||||
}
|
||||
|
||||
/// Order entry. The demo account is selected by default and trades without any setup;
|
||||
/// switching to live accounts is an explicit choice that is remembered. Sending live orders
|
||||
/// additionally needs `enabled`, which takes an explicit acknowledgement that orders are real.
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct TradingPrefs {
|
||||
pub account_mode: AccountMode,
|
||||
/// Live orders may be sent (Settings › Trading).
|
||||
pub enabled: bool,
|
||||
/// Refuse orders whose estimated value exceeds this (`None` = no limit).
|
||||
pub max_order_value: Option<f64>,
|
||||
/// Account the ticket preselects.
|
||||
pub default_account: Option<String>,
|
||||
/// Hyperliquid (testnet only in this build), with its own opt-in.
|
||||
pub hl: HlPrefs,
|
||||
}
|
||||
|
||||
/// Hyperliquid order entry. Separate from SnapTrade's `enabled`: turning one on never turns on
|
||||
/// the other. Holds only public data: the agent's private key lives in the OS secret store.
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct HlPrefs {
|
||||
/// Orders may be signed and sent (after the acknowledgement dialog). Off by default, and
|
||||
/// turned off again by the kill switch.
|
||||
pub enabled: bool,
|
||||
/// Always testnet: any stored value (`"mainnet"` included) reads as testnet.
|
||||
pub network: tr_hl::Network,
|
||||
/// The account (master address) shown in the portfolio panes, read-only until an agent
|
||||
/// is approved.
|
||||
pub master: Option<String>,
|
||||
/// The approved agent's public details (never its key).
|
||||
pub agent: Option<HlAgent>,
|
||||
/// The built-in wallet, once signed in (its address is `master`). Public details only:
|
||||
/// Turnkey holds the wallet's key, and the session lives in the OS secret store.
|
||||
pub wallet: Option<HlWallet>,
|
||||
/// Keep a `scheduleCancel` about a minute ahead while the app is open, so open orders are
|
||||
/// cancelled if Terminal disappears. Opt-in: it cancels orders placed elsewhere too.
|
||||
pub dead_mans_switch: bool,
|
||||
/// Web only: keep the agent key in this browser (encrypted with a key the page can't
|
||||
/// export) instead of only for the session.
|
||||
pub remember_on_browser: bool,
|
||||
}
|
||||
|
||||
/// The user's built-in wallet on Turnkey, as Terminal remembers it.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct HlWallet {
|
||||
/// Lower-case `0x…`: the Hyperliquid account.
|
||||
pub address: String,
|
||||
/// The user's Turnkey sub-organization.
|
||||
pub organization_id: String,
|
||||
/// Turnkey's name for the user (`whoami`), shown so a wrong account is noticed.
|
||||
#[serde(default)]
|
||||
pub user: String,
|
||||
}
|
||||
|
||||
/// An approved API wallet, as Terminal remembers it.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct HlAgent {
|
||||
/// Lower-case `0x…`.
|
||||
pub address: String,
|
||||
/// `terminal-<device> valid_until <ms>`.
|
||||
pub name: String,
|
||||
/// Expiry, Unix milliseconds.
|
||||
pub valid_until: u64,
|
||||
#[serde(default)]
|
||||
pub network: tr_hl::Network,
|
||||
}
|
||||
|
||||
/// Every rebindable action.
|
||||
@@ -219,6 +284,9 @@ pub enum Action {
|
||||
Portfolio,
|
||||
Positions,
|
||||
Orders,
|
||||
Buy,
|
||||
Sell,
|
||||
TogglePerp,
|
||||
NextPane,
|
||||
PrevPane,
|
||||
ToggleSidebar,
|
||||
@@ -262,7 +330,7 @@ pub enum Action {
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub const ALL: [Action; 50] = [
|
||||
pub const ALL: [Action; 53] = [
|
||||
Action::Help,
|
||||
Action::Congress,
|
||||
Action::Politicians,
|
||||
@@ -273,6 +341,9 @@ impl Action {
|
||||
Action::Portfolio,
|
||||
Action::Positions,
|
||||
Action::Orders,
|
||||
Action::Buy,
|
||||
Action::Sell,
|
||||
Action::TogglePerp,
|
||||
Action::NextPane,
|
||||
Action::PrevPane,
|
||||
Action::ToggleSidebar,
|
||||
@@ -327,6 +398,9 @@ 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::TogglePerp => "Switch between a security and its HIP-3 perp".into(),
|
||||
Action::NextPane => "Next pane".into(),
|
||||
Action::PrevPane => "Previous pane".into(),
|
||||
Action::ToggleSidebar => "Toggle sidebar".into(),
|
||||
@@ -388,11 +462,13 @@ impl Action {
|
||||
| A::ResetView
|
||||
| A::LogScale
|
||||
| A::Magnet => "Chart",
|
||||
A::Buy | A::Sell => "Trading",
|
||||
_ => "Panels and data",
|
||||
}
|
||||
}
|
||||
|
||||
pub const CATEGORIES: [&'static str; 5] = ["New panels", "Panels and data", "Workspace", "Layouts", "Chart"];
|
||||
pub const CATEGORIES: [&'static str; 6] =
|
||||
["New panels", "Panels and data", "Trading", "Workspace", "Layouts", "Chart"];
|
||||
|
||||
/// The panel command word for `New…` actions.
|
||||
pub fn panel_spec(self) -> Option<&'static str> {
|
||||
@@ -562,6 +638,10 @@ 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::TogglePerp, "Shift+P");
|
||||
bind(Action::NextPane, "Tab");
|
||||
bind(Action::PrevPane, "Shift+Tab");
|
||||
bind(Action::ToggleSidebar, if mac { "Ctrl+Cmd+S" } else { "Ctrl+Shift+S" });
|
||||
@@ -661,7 +741,8 @@ impl Default for Prefs {
|
||||
|
||||
impl Prefs {
|
||||
pub fn load(path: &std::path::Path) -> Prefs {
|
||||
let mut p: Prefs = std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
|
||||
let mut p: Prefs =
|
||||
crate::storage::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
|
||||
p.migrate();
|
||||
p.hotkeys = p.hotkeys.with_defaults();
|
||||
p.appearance.font_scale = p.appearance.font_scale.clamp(0.7, 1.6);
|
||||
@@ -733,7 +814,7 @@ impl Prefs {
|
||||
}
|
||||
|
||||
pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> {
|
||||
crate::layouts::write_atomic(path, &serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?)
|
||||
crate::storage::write_atomic(path, &serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -817,6 +898,28 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyperliquid_prefs_never_select_mainnet() {
|
||||
// A hand-edited or future file asking for mainnet still loads as testnet, and the rest
|
||||
// of the preferences survive.
|
||||
let json = r#"{"appearance":{"theme":"Nord"},"trading":{"enabled":true,"hl":{"enabled":true,"network":"mainnet",
|
||||
"master":"0xabc","agent":{"address":"0xdef","name":"terminal-mac valid_until 1","valid_until":1,"network":"Mainnet"}}}}"#;
|
||||
let p: Prefs = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(p.trading.hl.network, tr_hl::Network::Testnet);
|
||||
assert_eq!(p.trading.hl.agent.as_ref().unwrap().network, tr_hl::Network::Testnet);
|
||||
assert!(p.trading.hl.enabled && p.trading.enabled);
|
||||
assert_eq!(p.appearance.theme, "Nord", "the rest of the file still loads");
|
||||
for v in ["\"MAINNET\"", "\"a\"", "7", "null", "[]"] {
|
||||
let p: Prefs = serde_json::from_str(&format!(r#"{{"trading":{{"hl":{{"network":{v}}}}}}}"#)).unwrap();
|
||||
assert_eq!(p.trading.hl.network, tr_hl::Network::Testnet, "{v}");
|
||||
}
|
||||
// Off by default, opt-in switches off, nothing secret in the file.
|
||||
let d = Prefs::default().trading.hl;
|
||||
assert!(!d.enabled && !d.dead_mans_switch && !d.remember_on_browser && d.agent.is_none());
|
||||
let saved = serde_json::to_string(&p).unwrap();
|
||||
assert!(saved.contains(r#""network":"Testnet""#) && !saved.contains("ainnet"), "{saved}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_prefs_migrate_layout_bindings_to_alt() {
|
||||
// A v1 file: layouts on ⌘1…, one customised, no panel actions yet.
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
//! Where preferences, settings, layouts, themes, palette history and the demo account are
|
||||
//! kept: files under the data directory on the desktop, `localStorage` entries keyed by path
|
||||
//! in the browser. Every saved document is UTF-8 text (JSON or TOML).
|
||||
//!
|
||||
//! [`export`], [`import`] and [`reveal`] are for files the user names: on the desktop they are
|
||||
//! ordinary files and folders; in the browser an export is a download, and there is no import
|
||||
//! or folder yet.
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use imp::*;
|
||||
|
||||
/// Saved documents keyed by path, as the browser keeps them.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
trait KeyStore {
|
||||
fn keys(&self) -> Vec<String>;
|
||||
fn get(&self, key: &str) -> Option<String>;
|
||||
/// False when the store refuses the write (full or blocked).
|
||||
fn set(&mut self, key: &str, value: &str) -> bool;
|
||||
fn remove(&mut self, key: &str);
|
||||
}
|
||||
|
||||
/// Copies every key under `old/` to the same path under `new/`, once. Returns whether to use
|
||||
/// `new`. Keys already under `new/` are never overwritten, and the old keys stay as they are. A
|
||||
/// marker key records that the copy finished, so a file deleted afterwards doesn't come back on
|
||||
/// the next load. If the store refuses a write, the copies made so far are removed and this
|
||||
/// returns false, so the page keeps its complete old set and the next load tries again.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn copy_keys(store: &mut impl KeyStore, old: &str, new: &str) -> bool {
|
||||
let marker = marker(new);
|
||||
if store.get(&marker).is_some() {
|
||||
return true;
|
||||
}
|
||||
let keys: Vec<(String, String)> = store
|
||||
.keys()
|
||||
.into_iter()
|
||||
.filter_map(|k| Some((format!("{new}/{}", k.strip_prefix(old)?.strip_prefix('/')?), k)))
|
||||
.collect();
|
||||
if keys.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let mut copied = Vec::new();
|
||||
let mut ok = true;
|
||||
for (to, from) in keys {
|
||||
if store.get(&to).is_some() {
|
||||
continue;
|
||||
}
|
||||
let Some(value) = store.get(&from) else { continue };
|
||||
if !store.set(&to, &value) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
copied.push(to);
|
||||
}
|
||||
if ok && store.set(&marker, old) {
|
||||
tracing::info!("copied {} saved files from {old}/ to {new}/", copied.len());
|
||||
return true;
|
||||
}
|
||||
for k in &copied {
|
||||
store.remove(k);
|
||||
}
|
||||
tracing::warn!("browser storage refused copying saved files from {old}/ to {new}/; using {old}/ for now");
|
||||
false
|
||||
}
|
||||
|
||||
/// The key [`copy_keys`] sets once everything under `new/` is in place.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn marker(new: &str) -> String {
|
||||
format!("{new}/.migrated")
|
||||
}
|
||||
|
||||
/// Where a page that kept the old prefix (because [`copy_keys`] failed) should read and write
|
||||
/// `key`. Once another tab has finished the copy, keys under `old` map to `new`, so this page's
|
||||
/// saves land where later loads look instead of under keys they no longer read.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn redirect(store: &impl KeyStore, old: &str, new: &str, key: String) -> String {
|
||||
match key.strip_prefix(old) {
|
||||
Some(rest) if (rest.is_empty() || rest.starts_with('/')) && store.get(&marker(new)).is_some() => {
|
||||
format!("{new}{rest}")
|
||||
}
|
||||
_ => key,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod imp {
|
||||
use super::*;
|
||||
|
||||
/// Moves the data folder from `old` (its name before the app was renamed) to `new` the first
|
||||
/// time, and leaves a link at the old path so builds that still use the old name find the
|
||||
/// same files. Returns the folder to use: `new`, or `old` if it couldn't be moved. Never
|
||||
/// replaces an existing `new`.
|
||||
pub fn migrate(old: &Path, new: &Path) -> PathBuf {
|
||||
if new.exists() || !old.is_dir() {
|
||||
return new.to_path_buf();
|
||||
}
|
||||
if let Err(e) = std::fs::rename(old, new) {
|
||||
// Another copy of the app may have moved it first.
|
||||
if new.is_dir() {
|
||||
return new.to_path_buf();
|
||||
}
|
||||
tracing::warn!("could not move {} to {} ({e}); using it where it is", old.display(), new.display());
|
||||
return old.to_path_buf();
|
||||
}
|
||||
tracing::info!("moved {} to {}", old.display(), new.display());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// Name the sibling rather than repeating `new`, which may be relative (an unset HOME
|
||||
// or a relative XDG_DATA_HOME) and would then resolve from the link's folder.
|
||||
let target = match new.file_name() {
|
||||
Some(name) if old.parent() == new.parent() => PathBuf::from(name),
|
||||
_ => std::path::absolute(new).unwrap_or_else(|_| new.to_path_buf()),
|
||||
};
|
||||
if let Err(e) = std::os::unix::fs::symlink(&target, old) {
|
||||
tracing::warn!("linking {} to {}: {e}", old.display(), target.display());
|
||||
}
|
||||
}
|
||||
new.to_path_buf()
|
||||
}
|
||||
|
||||
pub fn read(path: &Path) -> io::Result<Vec<u8>> {
|
||||
std::fs::read(path)
|
||||
}
|
||||
|
||||
/// Write via a temp file and rename, so a crash never leaves a half-written file.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, bytes)?;
|
||||
std::fs::rename(tmp, path)
|
||||
}
|
||||
|
||||
/// Entries directly inside `dir` (none if it doesn't exist).
|
||||
pub fn list(dir: &Path) -> Vec<PathBuf> {
|
||||
std::fs::read_dir(dir).into_iter().flatten().flatten().map(|e| e.path()).collect()
|
||||
}
|
||||
|
||||
pub fn exists(path: &Path) -> bool {
|
||||
path.exists()
|
||||
}
|
||||
|
||||
pub fn remove(path: &Path) -> io::Result<()> {
|
||||
std::fs::remove_file(path)
|
||||
}
|
||||
|
||||
pub fn rename(from: &Path, to: &Path) -> io::Result<()> {
|
||||
std::fs::rename(from, to)
|
||||
}
|
||||
|
||||
/// Write a file the user exports. Returns where it went, for the status line.
|
||||
pub fn export(path: &Path, bytes: &[u8]) -> io::Result<String> {
|
||||
write_atomic(path, bytes)?;
|
||||
Ok(path.display().to_string())
|
||||
}
|
||||
|
||||
/// Read a file the user imports.
|
||||
pub fn import(path: &Path) -> io::Result<Vec<u8>> {
|
||||
std::fs::read(path)
|
||||
}
|
||||
|
||||
/// Open a folder in the system file manager, creating it first.
|
||||
pub fn reveal(dir: &Path) -> io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
|
||||
std::process::Command::new(opener).arg(dir).spawn().map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod imp {
|
||||
use super::*;
|
||||
|
||||
fn store() -> io::Result<web_sys::Storage> {
|
||||
web_sys::window()
|
||||
.and_then(|w| w.local_storage().ok().flatten())
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::Unsupported, "browser storage is unavailable"))
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The old and new prefixes while this page keeps the old one (see [`migrate`]).
|
||||
static FALLBACK: std::cell::RefCell<Option<(String, String)>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn key(path: &Path) -> String {
|
||||
let key = path.to_string_lossy().into_owned();
|
||||
let Some((old, new)) = FALLBACK.with_borrow(Clone::clone) else { return key };
|
||||
match store() {
|
||||
Ok(s) => redirect(&s, &old, &new, key),
|
||||
Err(_) => key,
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyStore for web_sys::Storage {
|
||||
fn keys(&self) -> Vec<String> {
|
||||
(0..self.length().unwrap_or(0)).filter_map(|i| self.key(i).ok().flatten()).collect()
|
||||
}
|
||||
fn get(&self, key: &str) -> Option<String> {
|
||||
self.get_item(key).ok().flatten()
|
||||
}
|
||||
fn set(&mut self, key: &str, value: &str) -> bool {
|
||||
self.set_item(key, value).is_ok()
|
||||
}
|
||||
fn remove(&mut self, key: &str) {
|
||||
let _ = self.remove_item(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies what the page saved under `old/` (its prefix before the app was renamed) to
|
||||
/// `new/`, once (see [`copy_keys`]). Returns the prefix to use. If the copy fails, the page
|
||||
/// keeps `old`, and its keys follow [`redirect`] from then on, in case another tab finishes
|
||||
/// the copy while this one is open.
|
||||
pub fn migrate(old: &Path, new: &Path) -> PathBuf {
|
||||
let Ok(mut s) = store() else { return new.to_path_buf() };
|
||||
let (old, new) = (key(old), key(new));
|
||||
if copy_keys(&mut s, &old, &new) {
|
||||
return PathBuf::from(new);
|
||||
}
|
||||
FALLBACK.with_borrow_mut(|f| *f = Some((old.clone(), new)));
|
||||
PathBuf::from(old)
|
||||
}
|
||||
|
||||
pub fn read(path: &Path) -> io::Result<Vec<u8>> {
|
||||
match store()?.get_item(&key(path)) {
|
||||
Ok(Some(text)) => Ok(text.into_bytes()),
|
||||
Ok(None) => Err(io::ErrorKind::NotFound.into()),
|
||||
Err(_) => Err(io::Error::other("browser storage refused the read")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A single `setItem`, which is already all-or-nothing.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
let text = std::str::from_utf8(bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
store()?.set_item(&key(path), text).map_err(|_| io::Error::other("browser storage is full or blocked"))
|
||||
}
|
||||
|
||||
/// Keys directly under `dir/`.
|
||||
pub fn list(dir: &Path) -> Vec<PathBuf> {
|
||||
let Ok(s) = store() else { return Vec::new() };
|
||||
let prefix = format!("{}/", key(dir).trim_end_matches('/'));
|
||||
(0..s.length().unwrap_or(0))
|
||||
.filter_map(|i| s.key(i).ok().flatten())
|
||||
.filter(|k| k.strip_prefix(&prefix).is_some_and(|rest| !rest.is_empty() && !rest.contains('/')))
|
||||
.map(PathBuf::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn exists(path: &Path) -> bool {
|
||||
store().ok().and_then(|s| s.get_item(&key(path)).ok().flatten()).is_some()
|
||||
}
|
||||
|
||||
pub fn remove(path: &Path) -> io::Result<()> {
|
||||
if !exists(path) {
|
||||
return Err(io::ErrorKind::NotFound.into());
|
||||
}
|
||||
store()?.remove_item(&key(path)).map_err(|_| io::Error::other("browser storage refused the delete"))
|
||||
}
|
||||
|
||||
pub fn rename(from: &Path, to: &Path) -> io::Result<()> {
|
||||
write_atomic(to, &read(from)?)?;
|
||||
remove(from)
|
||||
}
|
||||
|
||||
/// Download the exported file under its file name. Returns where it went, for the status line.
|
||||
pub fn export(path: &Path, bytes: &[u8]) -> io::Result<String> {
|
||||
use wasm_bindgen::JsCast;
|
||||
let fail = |what: &str| io::Error::other(format!("the browser refused the download ({what})"));
|
||||
let name = path.file_name().map_or_else(|| "export".into(), |n| n.to_string_lossy().into_owned());
|
||||
let doc = web_sys::window().and_then(|w| w.document()).ok_or_else(|| fail("no document"))?;
|
||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence(&parts).map_err(|_| fail("blob"))?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(|_| fail("object URL"))?;
|
||||
let link: web_sys::HtmlAnchorElement =
|
||||
doc.create_element("a").map_err(|_| fail("link"))?.dyn_into().map_err(|_| fail("link"))?;
|
||||
link.set_href(&url);
|
||||
link.set_download(&name);
|
||||
link.click();
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
Ok(format!("your downloads as {name}"))
|
||||
}
|
||||
|
||||
// TODO(web): read imports through a file picker instead of a typed path.
|
||||
pub fn import(_path: &Path) -> io::Result<Vec<u8>> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "importing files needs the desktop app for now"))
|
||||
}
|
||||
|
||||
pub fn reveal(_dir: &Path) -> io::Result<()> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "the browser keeps these in its local storage, not a folder"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
let dir = std::env::temp_dir().join(format!("tr-storage-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let a = dir.join("sub/a.json");
|
||||
assert!(list(&dir.join("sub")).is_empty());
|
||||
assert_eq!(read(&a).unwrap_err().kind(), io::ErrorKind::NotFound);
|
||||
write_atomic(&a, b"{}").unwrap();
|
||||
assert!(exists(&a));
|
||||
assert_eq!(list(&dir.join("sub")), vec![a.clone()]);
|
||||
let b = dir.join("sub/b.json");
|
||||
rename(&a, &b).unwrap();
|
||||
assert!(!exists(&a));
|
||||
assert_eq!(read(&b).unwrap(), b"{}");
|
||||
remove(&b).unwrap();
|
||||
assert!(list(&dir.join("sub")).is_empty());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("tr-migrate-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_moves_the_old_folder_and_links_it() {
|
||||
let root = scratch("move");
|
||||
let (old, new) = (root.join("terminal-rs"), root.join("terminal"));
|
||||
write_atomic(&old.join("layouts/Desk.json"), b"{}").unwrap();
|
||||
assert_eq!(migrate(&old, &new), new);
|
||||
assert_eq!(read(&new.join("layouts/Desk.json")).unwrap(), b"{}");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
assert_eq!(std::fs::read_link(&old).unwrap(), Path::new("terminal"), "relative to the link's folder");
|
||||
assert_eq!(read(&old.join("layouts/Desk.json")).unwrap(), b"{}", "old builds still find the files");
|
||||
}
|
||||
// Later launches find the new folder and leave everything as it is.
|
||||
write_atomic(&new.join("settings.json"), b"[]").unwrap();
|
||||
assert_eq!(migrate(&old, &new), new);
|
||||
assert_eq!(read(&new.join("settings.json")).unwrap(), b"[]");
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_never_replaces_an_existing_folder() {
|
||||
let root = scratch("keep");
|
||||
let (old, new) = (root.join("terminal-rs"), root.join("terminal"));
|
||||
write_atomic(&old.join("settings.json"), b"old").unwrap();
|
||||
write_atomic(&new.join("settings.json"), b"new").unwrap();
|
||||
assert_eq!(migrate(&old, &new), new);
|
||||
assert_eq!(read(&new.join("settings.json")).unwrap(), b"new");
|
||||
assert_eq!(read(&old.join("settings.json")).unwrap(), b"old", "the old folder is left alone");
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_without_an_old_folder_creates_nothing() {
|
||||
let root = scratch("fresh");
|
||||
let (old, new) = (root.join("terminal-rs"), root.join("terminal"));
|
||||
assert_eq!(migrate(&old, &new), new);
|
||||
assert!(!old.exists() && !new.exists());
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn migrate_keeps_using_the_old_folder_when_it_cannot_move() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let root = scratch("stuck");
|
||||
let (old, new) = (root.join("terminal-rs"), root.join("terminal"));
|
||||
write_atomic(&old.join("settings.json"), b"{}").unwrap();
|
||||
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o555)).unwrap();
|
||||
if std::fs::create_dir(root.join("probe")).is_ok() {
|
||||
// Running as root, where a read-only folder doesn't stop the move.
|
||||
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
return;
|
||||
}
|
||||
let used = migrate(&old, &new);
|
||||
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert_eq!(used, old);
|
||||
assert_eq!(read(&old.join("settings.json")).unwrap(), b"{}");
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
/// `localStorage` stand-in: a map that refuses writes once it holds `room` keys.
|
||||
struct Keys {
|
||||
map: std::collections::BTreeMap<String, String>,
|
||||
room: usize,
|
||||
}
|
||||
|
||||
impl Keys {
|
||||
fn new(entries: &[(&str, &str)]) -> Self {
|
||||
Self { map: entries.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), room: usize::MAX }
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyStore for Keys {
|
||||
fn keys(&self) -> Vec<String> {
|
||||
self.map.keys().cloned().collect()
|
||||
}
|
||||
fn get(&self, key: &str) -> Option<String> {
|
||||
self.map.get(key).cloned()
|
||||
}
|
||||
fn set(&mut self, key: &str, value: &str) -> bool {
|
||||
if !self.map.contains_key(key) && self.map.len() >= self.room {
|
||||
return false;
|
||||
}
|
||||
self.map.insert(key.into(), value.into());
|
||||
true
|
||||
}
|
||||
fn remove(&mut self, key: &str) {
|
||||
self.map.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_keys_copies_once_without_overwriting() {
|
||||
let mut s = Keys::new(&[
|
||||
("terminal-rs/settings.json", "old settings"),
|
||||
("terminal-rs/layouts/Desk.json", "desk"),
|
||||
("terminal/settings.json", "newer settings"),
|
||||
("other-site-key", "untouched"),
|
||||
]);
|
||||
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
|
||||
assert_eq!(s.get("terminal/settings.json").as_deref(), Some("newer settings"), "newer data wins");
|
||||
assert_eq!(s.get("terminal/layouts/Desk.json").as_deref(), Some("desk"));
|
||||
assert_eq!(s.get("terminal-rs/layouts/Desk.json").as_deref(), Some("desk"), "the old keys stay");
|
||||
assert_eq!(s.get("other-site-key").as_deref(), Some("untouched"));
|
||||
// A layout deleted after the copy doesn't come back on the next load.
|
||||
s.remove("terminal/layouts/Desk.json");
|
||||
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
|
||||
assert_eq!(s.get("terminal/layouts/Desk.json"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_keys_undoes_a_partial_copy() {
|
||||
let mut s = Keys::new(&[("terminal-rs/a.json", "a"), ("terminal-rs/b.json", "b")]);
|
||||
s.room = 3;
|
||||
assert!(!copy_keys(&mut s, "terminal-rs", "terminal"), "storage is full: keep using the old keys");
|
||||
assert_eq!(s.keys(), ["terminal-rs/a.json", "terminal-rs/b.json"]);
|
||||
s.room = usize::MAX;
|
||||
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
|
||||
assert_eq!(s.get("terminal/b.json").as_deref(), Some("b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_left_on_the_old_prefix_follows_another_tabs_copy() {
|
||||
let mut s = Keys::new(&[("terminal-rs/settings.json", "a")]);
|
||||
s.room = 1;
|
||||
// Tab A can't copy, so it keeps the old prefix.
|
||||
assert!(!copy_keys(&mut s, "terminal-rs", "terminal"));
|
||||
let key = |s: &Keys, k: &str| redirect(s, "terminal-rs", "terminal", k.into());
|
||||
assert_eq!(key(&s, "terminal-rs/settings.json"), "terminal-rs/settings.json");
|
||||
// Space frees up and tab B finishes the copy; tab A's saves now go where loads read.
|
||||
s.room = usize::MAX;
|
||||
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
|
||||
assert_eq!(key(&s, "terminal-rs/settings.json"), "terminal/settings.json");
|
||||
assert_eq!(key(&s, "terminal-rs/layouts"), "terminal/layouts");
|
||||
assert_eq!(key(&s, "terminal-rs"), "terminal");
|
||||
assert_eq!(key(&s, "terminal-rsx/a.json"), "terminal-rsx/a.json", "only that prefix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_keys_with_nothing_saved_writes_nothing() {
|
||||
let mut s = Keys::new(&[]);
|
||||
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
|
||||
assert!(s.keys().is_empty());
|
||||
}
|
||||
}
|
||||
+46
-1
@@ -53,6 +53,9 @@ thread_local! {
|
||||
static DENSITY: Cell<f32> = const { Cell::new(1.0) };
|
||||
static UI_MONO: Cell<bool> = const { Cell::new(false) };
|
||||
static FONTS: Cell<Option<MonoFont>> = const { Cell::new(None) };
|
||||
/// Fonts fetched after start-up (the browser build's emoji fallbacks).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
static LATE_FONTS: std::cell::RefCell<Vec<(String, std::sync::Arc<FontData>)>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// Spacing multiplier from the density preference (compact < 1 < comfortable).
|
||||
@@ -138,6 +141,12 @@ pub fn accent_wash() -> Color32 {
|
||||
Color32::from_rgba_unmultiplied(a.r(), a.g(), a.b(), if palette().dark { 70 } else { 45 })
|
||||
}
|
||||
|
||||
/// HIP-3 perp badges and labels: a colour no listed instrument uses, so a perp never reads as
|
||||
/// the stock, future or FX rate it references.
|
||||
pub fn perp() -> Color32 {
|
||||
if palette().dark { Color32::from_rgb(196, 150, 255) } else { Color32::from_rgb(118, 64, 196) }
|
||||
}
|
||||
|
||||
pub fn dem() -> Color32 {
|
||||
if palette().dark { Color32::from_rgb(64, 156, 255) } else { Color32::from_rgb(0, 102, 214) }
|
||||
}
|
||||
@@ -176,13 +185,49 @@ pub fn ui_font(size: f32) -> FontId {
|
||||
pub const CARD_RADIUS: u8 = 10;
|
||||
pub const CONTROL_RADIUS: u8 = 6;
|
||||
|
||||
/// egui's bundled fonts. The browser build starts with only the two text fonts, which keeps
|
||||
/// the emoji fonts out of the download; they arrive later through [`add_late_font`].
|
||||
pub fn base_fonts() -> FontDefinitions {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let fonts = FontDefinitions::default();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let fonts = {
|
||||
use std::sync::Arc;
|
||||
let mut f = FontDefinitions::empty();
|
||||
f.font_data.insert("Hack".into(), Arc::new(FontData::from_static(epaint_default_fonts::HACK_REGULAR)));
|
||||
f.font_data.insert("Ubuntu-Light".into(), Arc::new(FontData::from_static(epaint_default_fonts::UBUNTU_LIGHT)));
|
||||
f.families.insert(FontFamily::Monospace, vec!["Hack".into(), "Ubuntu-Light".into()]);
|
||||
f.families.insert(FontFamily::Proportional, vec!["Ubuntu-Light".into()]);
|
||||
LATE_FONTS.with_borrow(|late| {
|
||||
for (name, data) in late {
|
||||
f.font_data.insert(name.clone(), data.clone());
|
||||
for family in [FontFamily::Proportional, FontFamily::Monospace] {
|
||||
f.families.entry(family).or_default().push(name.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
f
|
||||
};
|
||||
fonts
|
||||
}
|
||||
|
||||
/// Add a font fetched after start-up as the last fallback of every family.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn add_late_font(ctx: &egui::Context, name: &str, data: FontData) {
|
||||
LATE_FONTS.with_borrow_mut(|late| late.push((name.to_owned(), std::sync::Arc::new(data))));
|
||||
// Reinstall with the current monospace choice (or let the first configure pick it up).
|
||||
if let Some(mono) = FONTS.take() {
|
||||
install_fonts(ctx, mono);
|
||||
}
|
||||
}
|
||||
|
||||
/// Use the system UI fonts when present (read from the OS at runtime, not bundled).
|
||||
fn install_fonts(ctx: &egui::Context, mono: MonoFont) {
|
||||
if FONTS.with(Cell::get) == Some(mono) {
|
||||
return;
|
||||
}
|
||||
FONTS.with(|f| f.set(Some(mono)));
|
||||
let mut fonts = FontDefinitions::default();
|
||||
let mut fonts = base_fonts();
|
||||
let ui_paths: &[&str] = &[
|
||||
"/System/Library/Fonts/SFNS.ttf",
|
||||
"/System/Library/Fonts/SFNSText.ttf",
|
||||
|
||||
+20
-12
@@ -10,6 +10,7 @@ use egui::Color32;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
use crate::prefs::{Density, MonoFont, UiFont};
|
||||
use crate::storage;
|
||||
use crate::theme::Palette;
|
||||
|
||||
/// Selection that follows the OS appearance using the configured dark/light pair.
|
||||
@@ -561,11 +562,8 @@ impl ThemeStore {
|
||||
/// built-in or earlier name is listed with a suffix rather than shadowing it.
|
||||
pub fn load_all(&self) -> Vec<Theme> {
|
||||
let mut out = builtins();
|
||||
let mut files: Vec<PathBuf> = std::fs::read_dir(&self.dir)
|
||||
let mut files: Vec<PathBuf> = storage::list(&self.dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "toml" || x == "json"))
|
||||
.collect();
|
||||
files.sort();
|
||||
@@ -606,7 +604,7 @@ impl ThemeStore {
|
||||
.clone()
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
|
||||
.unwrap_or_else(|| self.path(&theme.name));
|
||||
crate::layouts::write_atomic(&path, theme.to_toml().as_bytes())
|
||||
storage::write_atomic(&path, theme.to_toml().as_bytes())
|
||||
.with_context(|| format!("writing {}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
@@ -617,23 +615,33 @@ impl ThemeStore {
|
||||
bail!("built-in themes cannot be deleted");
|
||||
}
|
||||
let path = theme.source.clone().unwrap_or_else(|| self.path(&theme.name));
|
||||
if !path.exists() {
|
||||
if !storage::exists(&path) {
|
||||
bail!("no file for theme {:?}", theme.name);
|
||||
}
|
||||
Ok(std::fs::remove_file(path)?)
|
||||
Ok(storage::remove(&path)?)
|
||||
}
|
||||
|
||||
/// Write a theme to `to` as JSON (`.json`) or TOML (anything else).
|
||||
pub fn export(theme: &Theme, to: &Path) -> Result<()> {
|
||||
/// Write a theme to `to` as JSON (`.json`) or TOML (anything else); a download in the
|
||||
/// browser. Returns where it went.
|
||||
pub fn export(theme: &Theme, to: &Path) -> Result<String> {
|
||||
let json = to.extension().is_some_and(|x| x.eq_ignore_ascii_case("json"));
|
||||
let text = if json { theme.to_json() } else { theme.to_toml() };
|
||||
crate::layouts::write_atomic(to, text.as_bytes()).with_context(|| format!("writing {}", to.display()))
|
||||
storage::export(to, text.as_bytes()).with_context(|| format!("writing {}", to.display()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a TOML or JSON theme file.
|
||||
/// Read a TOML or JSON theme file from the themes folder.
|
||||
pub fn load_file(path: &Path) -> Result<Theme> {
|
||||
let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
|
||||
parse_file(path, storage::read(path).with_context(|| format!("reading {}", path.display()))?)
|
||||
}
|
||||
|
||||
/// Read a theme file the user imports.
|
||||
pub fn import_file(path: &Path) -> Result<Theme> {
|
||||
parse_file(path, storage::import(path).with_context(|| format!("reading {}", path.display()))?)
|
||||
}
|
||||
|
||||
fn parse_file(path: &Path, bytes: Vec<u8>) -> Result<Theme> {
|
||||
let text = String::from_utf8(bytes).with_context(|| format!("{} is not UTF-8 text", path.display()))?;
|
||||
let json = path.extension().is_some_and(|x| x.eq_ignore_ascii_case("json"));
|
||||
let mut t = Theme::parse(&text, json)?;
|
||||
t.source = Some(path.to_path_buf());
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
//! The built-in wallet's background work: the desktop sign-in (a loopback listener the
|
||||
//! browser returns to), turning a Turnkey session into a signed-in wallet, the Turnkey
|
||||
//! session in the OS secret store, and `approveAgent` signed by the wallet.
|
||||
//!
|
||||
//! Turnkey signs only here, and only through `tr_hl::signing::sign_approve_agent_with`, which
|
||||
//! hands it the digest of a testnet approval and nothing else. Orders never come this way:
|
||||
//! they are signed by the agent key in `hl_worker`.
|
||||
|
||||
use std::sync::Arc;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use tr_broker::secrets::AgentKeyStore;
|
||||
use tr_data::rt;
|
||||
use tr_hl::model::SpotUsdc;
|
||||
use tr_hl::nonce::now_ms;
|
||||
use tr_hl::signing::sign_approve_agent_with;
|
||||
use tr_hl::{AgentKey, SigningKey, trader};
|
||||
use tr_turnkey::{Session, SessionKey};
|
||||
|
||||
use crate::hl_worker::{HlResponse, approve, revoke};
|
||||
use crate::prefs::HlAgent;
|
||||
|
||||
/// The sign-in that may still finish; starting another or cancelling bumps it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
static SIGN_IN: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Stop waiting for the browser (the listener closes within half a second).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn cancel_sign_in() {
|
||||
SIGN_IN.fetch_add(1 << 32, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub enum WalletRequest {
|
||||
/// Desktop: listen on 127.0.0.1, say which port, and wait for the browser's session.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
SignIn { id: u64, store: AgentKeyStore, key: SessionKey, state: String },
|
||||
/// Web: a session token for `key` from the sign-in page's message. Check it and read the
|
||||
/// wallet from Turnkey.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Finish { id: u64, store: AgentKeyStore, key: SessionKey, token: String },
|
||||
/// The session kept in the secret store, if it hasn't expired.
|
||||
LoadSession { store: AgentKeyStore, account: String },
|
||||
/// Forget the session on this device.
|
||||
EndSession { store: AgentKeyStore, account: String },
|
||||
/// Approve a fresh agent key, signed by the wallet.
|
||||
Approve { store: AgentKeyStore, session: Arc<Session>, master: String, device: String },
|
||||
/// Replace the agent on Hyperliquid (signed by the wallet) and delete its key here.
|
||||
Revoke { store: AgentKeyStore, session: Arc<Session>, master: String, base: String },
|
||||
/// Spot USDC for the Deposit window.
|
||||
Balances { user: String },
|
||||
}
|
||||
|
||||
/// A wallet the user just signed in to.
|
||||
pub struct SignedIn {
|
||||
pub session: Session,
|
||||
/// Lower-case `0x…`.
|
||||
pub address: String,
|
||||
pub user: String,
|
||||
/// `Err`: the session couldn't be kept in the secret store (it lasts until Terminal quits).
|
||||
pub saved: Result<(), String>,
|
||||
}
|
||||
|
||||
pub enum WalletResponse {
|
||||
/// The desktop listener is up: open the sign-in page with this port.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
Listening {
|
||||
id: u64,
|
||||
port: u16,
|
||||
},
|
||||
SignedIn {
|
||||
id: u64,
|
||||
result: Result<Box<SignedIn>, String>,
|
||||
},
|
||||
SessionLoaded {
|
||||
account: String,
|
||||
session: Option<Arc<Session>>,
|
||||
},
|
||||
Balances {
|
||||
user: String,
|
||||
result: Result<SpotUsdc, String>,
|
||||
},
|
||||
/// The wallet didn't sign the approval: nothing was sent, and the new key is dropped.
|
||||
ApproveNotSigned(String),
|
||||
/// The wallet didn't sign the revoke, so nothing changed: the agent key is still here.
|
||||
RevokeNotSigned(String),
|
||||
}
|
||||
|
||||
/// The secret store item for a wallet's Turnkey session.
|
||||
pub fn session_account(address: &str) -> String {
|
||||
format!("turnkey:testnet:{}", address.trim().to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Turnkey's API (or the local mock that `TR_TURNKEY_API_URL` names, loopback only).
|
||||
fn turnkey() -> Result<tr_turnkey::Client, String> {
|
||||
tr_turnkey::Client::new().map_err(|e| format!("{e:#}"))
|
||||
}
|
||||
|
||||
/// Run one request; answers go through `send`.
|
||||
pub async fn handle(req: WalletRequest, send: impl Fn(HlResponse) + Clone + 'static) {
|
||||
let wallet = |r: WalletResponse| HlResponse::Wallet(Box::new(r));
|
||||
match req {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
WalletRequest::SignIn { id, store, key, state } => {
|
||||
let this = SIGN_IN.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let cb = match tr_turnkey::callback::Callback::bind().await {
|
||||
Ok(cb) => cb,
|
||||
Err(e) => return send(wallet(WalletResponse::SignedIn { id, result: Err(format!("{e:#}")) })),
|
||||
};
|
||||
send(wallet(WalletResponse::Listening { id, port: cb.port() }));
|
||||
let cancelled = async {
|
||||
while SIGN_IN.load(Ordering::SeqCst) == this {
|
||||
rt::sleep(std::time::Duration::from_millis(400)).await;
|
||||
}
|
||||
};
|
||||
let answer = tokio::select! {
|
||||
a = cb.wait(&state, tr_turnkey::callback::SIGN_IN_TIMEOUT) => a.map_err(|e| format!("{e:#}")),
|
||||
_ = cancelled => Err("Sign-in cancelled.".into()),
|
||||
};
|
||||
let result = match answer {
|
||||
Ok(tr_turnkey::callback::Answer::Session(token)) => match turnkey() {
|
||||
Ok(tk) => finish(&tk, store, key, &token).await,
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
Ok(tr_turnkey::callback::Answer::Failed(e)) => Err(format!("The sign-in page said: {e}")),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
send(wallet(WalletResponse::SignedIn { id, result }));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
WalletRequest::Finish { id, store, key, token } => {
|
||||
let result = match turnkey() {
|
||||
Ok(tk) => finish(&tk, store, key, &token).await,
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
send(wallet(WalletResponse::SignedIn { id, result }));
|
||||
}
|
||||
WalletRequest::LoadSession { store, account } => {
|
||||
let acct = account.clone();
|
||||
let session = rt::blocking(move || {
|
||||
let text = store.load(&acct).ok().flatten()?;
|
||||
match Session::from_stored(&text, now_secs()) {
|
||||
Ok(Some(s)) => Some(Arc::new(s)),
|
||||
_ => {
|
||||
// Expired or unreadable: it can't sign anything, so don't keep it.
|
||||
let _ = store.delete(&acct);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.flatten();
|
||||
send(wallet(WalletResponse::SessionLoaded { account, session }));
|
||||
}
|
||||
WalletRequest::EndSession { store, account } => {
|
||||
let _ = rt::blocking(move || store.delete(&account)).await;
|
||||
}
|
||||
WalletRequest::Approve { store, session, master, device } => {
|
||||
let agent = AgentKey::generate();
|
||||
let action = trader::approve_agent(&agent.address(), &device, trader::DEFAULT_AGENT_DAYS, now_ms());
|
||||
let meta = HlAgent {
|
||||
address: action.agent_address.clone(),
|
||||
name: action.agent_name.clone(),
|
||||
valid_until: trader::agent_valid_until(&action.agent_name).unwrap_or(0),
|
||||
network: tr_hl::Network::Testnet,
|
||||
};
|
||||
let key = Arc::new(agent);
|
||||
let signed = match turnkey() {
|
||||
Ok(tk) => wallet_sign(&tk, &session, &master, &action).await,
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
let r = match signed {
|
||||
Ok(body) => match tr_hl::client::Client::new() {
|
||||
Ok(client) => approve(&client, store, master, key, body, meta).await,
|
||||
Err(e) => {
|
||||
HlResponse::Approved { master, agent: meta, key, result: Err(format!("{e:#}")), saved: Ok(()) }
|
||||
}
|
||||
},
|
||||
// The agent key is dropped here; nothing was sent.
|
||||
Err(e) => wallet(WalletResponse::ApproveNotSigned(e)),
|
||||
};
|
||||
send(r);
|
||||
}
|
||||
WalletRequest::Revoke { store, session, master, base } => {
|
||||
let action = trader::revoke_agent(&base, now_ms());
|
||||
let account = crate::app::hl_store_account(&master);
|
||||
let signed = match turnkey() {
|
||||
Ok(tk) => wallet_sign(&tk, &session, &master, &action).await,
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
let r = match (signed, tr_hl::client::Client::new()) {
|
||||
(Ok(body), Ok(client)) => revoke(&client, store, account, Some(body)).await,
|
||||
(Err(e), _) => wallet(WalletResponse::RevokeNotSigned(e)),
|
||||
(_, Err(e)) => wallet(WalletResponse::RevokeNotSigned(format!("{e:#}"))),
|
||||
};
|
||||
send(r);
|
||||
}
|
||||
WalletRequest::Balances { user } => {
|
||||
let result = match (tr_hl::client::Client::new(), tr_hl::key::parse_address(&user)) {
|
||||
(Ok(c), Ok(a)) => c.spot_usdc(&a).await.map_err(|e| format!("{e:#}")),
|
||||
(Err(e), _) | (_, Err(e)) => Err(format!("{e:#}")),
|
||||
};
|
||||
send(wallet(WalletResponse::Balances { user, result }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The wallet signs `action` (a testnet `approveAgent`) through Turnkey: the `/exchange` body.
|
||||
async fn wallet_sign(
|
||||
tk: &tr_turnkey::Client,
|
||||
session: &Session,
|
||||
master: &str,
|
||||
action: &tr_hl::actions::ApproveAgentAction,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
if session.expired(now_secs()) {
|
||||
return Err("the wallet session expired; sign in again".into());
|
||||
}
|
||||
let main = tr_hl::key::parse_address(master).map_err(|e| format!("{e:#}"))?;
|
||||
let sig = sign_approve_agent_with(action, &main, |d| async move { tk.sign_digest(session, &main, &d).await })
|
||||
.await
|
||||
.map_err(|e| tr_hl::mask_addresses(&format!("{e:#}")))?;
|
||||
Ok(trader::user_signed_body(action, &sig))
|
||||
}
|
||||
|
||||
/// Accept the session for `key`, read the wallet from Turnkey, and keep the session in the
|
||||
/// secret store for the rest of its 15 minutes.
|
||||
async fn finish(
|
||||
tk: &tr_turnkey::Client,
|
||||
store: AgentKeyStore,
|
||||
key: SessionKey,
|
||||
token: &str,
|
||||
) -> Result<Box<SignedIn>, String> {
|
||||
let session = Session::accept(key, token, now_secs()).map_err(|e| format!("{e:#}"))?;
|
||||
let me = tk.whoami(&session).await.map_err(|e| format!("Turnkey: {e:#}"))?;
|
||||
let account = tk.ethereum_account(&session).await.map_err(|e| format!("Turnkey: {e:#}"))?;
|
||||
let address = tr_hl::key::parse_address(&account.address)
|
||||
.map(|a| tr_hl::key::address_hex(&a))
|
||||
.map_err(|e| format!("Turnkey's wallet address: {e:#}"))?;
|
||||
let stored = session.to_stored();
|
||||
let item = session_account(&address);
|
||||
let saved = rt::blocking(move || store.save(&item, &stored).map_err(|e| format!("{e:#}")))
|
||||
.await
|
||||
.unwrap_or_else(|| Err("the key store task failed".into()));
|
||||
Ok(Box::new(SignedIn { session, address, user: me.username, saved }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tr_hl::signing::recover_approve_agent;
|
||||
use tr_turnkey::mock::{MockTurnkey, USERNAME};
|
||||
|
||||
use super::*;
|
||||
|
||||
async fn signed_in(mock: &MockTurnkey, store: &AgentKeyStore) -> Box<SignedIn> {
|
||||
let key = SessionKey::generate();
|
||||
let token = mock.mint_session(&key.public_hex(), 600);
|
||||
let tk = tr_turnkey::Client::with_base(&mock.base).unwrap();
|
||||
finish(&tk, store.clone(), key, &token).await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn signing_in_finds_the_wallet_and_keeps_the_session_in_the_store() {
|
||||
let mock = MockTurnkey::start(0, None).await.unwrap();
|
||||
let store = AgentKeyStore::memory();
|
||||
let s = signed_in(&mock, &store).await;
|
||||
assert_eq!(s.address, mock.address());
|
||||
assert_eq!(s.user, USERNAME);
|
||||
assert!(s.saved.is_ok());
|
||||
let kept = store.load(&session_account(&s.address)).unwrap().expect("the session is in the store");
|
||||
let back = Session::from_stored(&kept, now_secs()).unwrap().unwrap();
|
||||
assert_eq!(back.key.public_hex(), s.session.key.public_hex());
|
||||
// A token for another key is refused before Turnkey is asked anything.
|
||||
let before = mock.requests().len();
|
||||
let tk = tr_turnkey::Client::with_base(&mock.base).unwrap();
|
||||
let theirs = mock.mint_session(&SessionKey::generate().public_hex(), 600);
|
||||
assert!(finish(&tk, store, SessionKey::generate(), &theirs).await.is_err());
|
||||
assert_eq!(mock.requests().len(), before);
|
||||
}
|
||||
|
||||
/// The `/exchange` body the wallet's signature goes into is a testnet approval, and the
|
||||
/// signature recovers the wallet.
|
||||
#[tokio::test]
|
||||
async fn the_wallet_signs_a_testnet_approval_that_recovers_it() {
|
||||
let mock = MockTurnkey::start(0, None).await.unwrap();
|
||||
let store = AgentKeyStore::memory();
|
||||
let s = signed_in(&mock, &store).await;
|
||||
let tk = tr_turnkey::Client::with_base(&mock.base).unwrap();
|
||||
let agent = AgentKey::generate();
|
||||
let action = trader::approve_agent(&agent.address(), "ci", trader::DEFAULT_AGENT_DAYS, now_ms());
|
||||
let body = wallet_sign(&tk, &s.session, &s.address, &action).await.unwrap();
|
||||
assert_eq!(body["action"]["type"], "approveAgent");
|
||||
assert_eq!(body["action"]["hyperliquidChain"], "Testnet");
|
||||
assert_eq!(body["action"]["signatureChainId"], "0x66eee");
|
||||
let sig: tr_hl::signing::WireSignature = serde_json::from_value(body["signature"].clone()).unwrap();
|
||||
assert_eq!(tr_hl::key::address_hex(&recover_approve_agent(&action, &sig).unwrap()), s.address);
|
||||
// 180 days, the longest Hyperliquid allows, so approvals (paid signatures) are rare.
|
||||
let until = trader::agent_valid_until(&action.agent_name).unwrap();
|
||||
assert!(until >= now_ms() + 179 * 86_400_000, "{}", action.agent_name);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_expired_session_asks_turnkey_nothing() {
|
||||
let mock = MockTurnkey::start(0, None).await.unwrap();
|
||||
let key = SessionKey::generate();
|
||||
let token = mock.mint_session(&key.public_hex(), 2);
|
||||
let session = Session::accept(key, &token, now_secs()).unwrap();
|
||||
let tk = tr_turnkey::Client::with_base(&mock.base).unwrap();
|
||||
let action = trader::approve_agent(&AgentKey::generate().address(), "ci", 180, now_ms());
|
||||
let late =
|
||||
Session::accept(SessionKey::from_hex(&session.key.to_hex()).unwrap(), &session.token, now_secs()).unwrap();
|
||||
// Two seconds later it has expired.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(2_100)).await;
|
||||
let err = wallet_sign(&tk, &late, &mock.address(), &action).await.unwrap_err();
|
||||
assert!(err.contains("expired"), "{err}");
|
||||
assert!(mock.requests().is_empty());
|
||||
}
|
||||
|
||||
/// An approval the wallet didn't sign is reported as such, not as Hyperliquid's refusal.
|
||||
#[tokio::test]
|
||||
async fn an_approval_the_wallet_did_not_sign_says_so() {
|
||||
let mock = MockTurnkey::start(0, None).await.unwrap();
|
||||
let key = SessionKey::generate();
|
||||
let token = mock.mint_session(&key.public_hex(), 1);
|
||||
let session = Arc::new(Session::accept(key, &token, now_secs()).unwrap());
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
|
||||
let got = std::sync::Arc::new(std::sync::Mutex::new(None));
|
||||
let sink = got.clone();
|
||||
let req = WalletRequest::Approve {
|
||||
store: AgentKeyStore::memory(),
|
||||
session,
|
||||
master: mock.address(),
|
||||
device: "ci".into(),
|
||||
};
|
||||
handle(req, move |r| {
|
||||
*sink.lock().unwrap() = Some(match r {
|
||||
HlResponse::Wallet(w) => match *w {
|
||||
WalletResponse::ApproveNotSigned(e) => e,
|
||||
_ => "other".into(),
|
||||
},
|
||||
_ => "sent to Hyperliquid".into(),
|
||||
});
|
||||
})
|
||||
.await;
|
||||
assert!(got.lock().unwrap().as_deref().is_some_and(|e| e.contains("expired")), "{:?}", got.lock().unwrap());
|
||||
assert!(mock.requests().is_empty());
|
||||
}
|
||||
|
||||
/// A revoke the wallet didn't sign changes nothing: the agent key stays in the store and
|
||||
/// the app is told so (it keeps the agent until a revoke really happens).
|
||||
#[tokio::test]
|
||||
async fn a_revoke_the_wallet_did_not_sign_keeps_the_agent() {
|
||||
let mock = MockTurnkey::start(0, None).await.unwrap();
|
||||
let store = AgentKeyStore::memory();
|
||||
let master = mock.address();
|
||||
let agent_item = crate::app::hl_store_account(&master);
|
||||
store.save(&agent_item, &AgentKey::generate().to_hex()).unwrap();
|
||||
let key = SessionKey::generate();
|
||||
let token = mock.mint_session(&key.public_hex(), 1);
|
||||
let session = Arc::new(Session::accept(key, &token, now_secs()).unwrap());
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
|
||||
let got = std::sync::Arc::new(std::sync::Mutex::new(None));
|
||||
let sink = got.clone();
|
||||
let req = WalletRequest::Revoke { store: store.clone(), session, master, base: "terminal-ci".into() };
|
||||
handle(req, move |r| {
|
||||
*sink.lock().unwrap() = Some(match r {
|
||||
HlResponse::Wallet(w) if matches!(*w, WalletResponse::RevokeNotSigned(_)) => "not signed",
|
||||
HlResponse::Revoked(_) => "revoked",
|
||||
_ => "other",
|
||||
});
|
||||
})
|
||||
.await;
|
||||
assert_eq!(*got.lock().unwrap(), Some("not signed"));
|
||||
assert!(store.load(&agent_item).unwrap().is_some(), "the agent key is still here");
|
||||
assert!(mock.requests().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_expired_session_in_the_store_is_deleted_on_load() {
|
||||
let mock = MockTurnkey::start(0, None).await.unwrap();
|
||||
let store = AgentKeyStore::memory();
|
||||
let key = SessionKey::generate();
|
||||
let token = mock.mint_session(&key.public_hex(), 1);
|
||||
let session = Session::accept(key, &token, now_secs()).unwrap();
|
||||
let account = session_account(&mock.address());
|
||||
store.save(&account, &session.to_stored()).unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
|
||||
let got = std::sync::Arc::new(std::sync::Mutex::new(None));
|
||||
let sink = got.clone();
|
||||
handle(WalletRequest::LoadSession { store: store.clone(), account: account.clone() }, move |r| {
|
||||
if let HlResponse::Wallet(w) = r
|
||||
&& let WalletResponse::SessionLoaded { session, .. } = *w
|
||||
{
|
||||
*sink.lock().unwrap() = Some(session.is_some());
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert_eq!(*got.lock().unwrap(), Some(false));
|
||||
assert!(store.load(&account).unwrap().is_none(), "the expired session was deleted");
|
||||
}
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
//! 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`).
|
||||
//!
|
||||
//! Crypto markets come from the Terminal server that `TR_SERVER_URL` named when the page was built
|
||||
//! (scripts/build-web.sh). `?server=off` turns it off, and `?server=ws://localhost:8787/ws` uses a
|
||||
//! server on this machine instead (see `tr_data::web::server_url`).
|
||||
|
||||
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");
|
||||
let requested = web_sys::UrlSearchParams::new_with_str(&query).ok().and_then(|p| p.get("server"));
|
||||
let server = tr_data::web::server_url(option_env!("TR_SERVER_URL"), requested.as_deref());
|
||||
if let Some(url) = server.as_deref().filter(|_| !offline) {
|
||||
tracing::info!(
|
||||
"crypto markets come from the Terminal server at {url}, or Binance and Coinbase while it's down"
|
||||
);
|
||||
}
|
||||
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, server.as_deref()), 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,6 +265,35 @@ pub fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 {
|
||||
Color32::from_rgba_unmultiplied(l(a.r(), b.r()), l(a.g(), b.g()), l(a.b(), b.b()), l(a.a(), b.a()))
|
||||
}
|
||||
|
||||
/// A selection highlight that glides between list rows. Create it before the rows (it
|
||||
/// keeps a place under them) and call [`Glider::on`] with the selected row.
|
||||
pub struct Glider {
|
||||
id: egui::Id,
|
||||
slot: egui::layers::ShapeIdx,
|
||||
/// Top of the list; positions are kept relative to it so scrolling does not lag.
|
||||
origin: f32,
|
||||
}
|
||||
|
||||
impl Glider {
|
||||
pub fn new(ui: &Ui, id: egui::Id) -> Self {
|
||||
Self { id, slot: ui.painter().add(Shape::Noop), origin: ui.cursor().top() }
|
||||
}
|
||||
|
||||
pub fn on(&self, ui: &Ui, row: Rect) {
|
||||
let ctx = ui.ctx();
|
||||
// A list that was just opened shows its selection in place.
|
||||
let frame = ctx.cumulative_frame_nr();
|
||||
let seen = self.id.with("frame");
|
||||
if ctx.data(|d| d.get_temp::<u64>(seen)).is_none_or(|f| f + 1 < frame) {
|
||||
ctx.data_mut(|d| d.remove::<anim::Spring>(self.id));
|
||||
}
|
||||
ctx.data_mut(|d| d.insert_temp(seen, frame));
|
||||
let y = anim::spring_with(ctx, self.id, row.top() - self.origin, 0.22, 0.86);
|
||||
let r = row.translate(vec2(0.0, self.origin + y - row.top()));
|
||||
ui.painter().set(self.slot, RectShape::filled(r, theme::CONTROL_RADIUS, theme::accent_wash()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Animated hover wash for list rows.
|
||||
pub fn hover_wash(ui: &Ui, rect: Rect, resp: &Response, selected: bool) {
|
||||
let t = anim::spring_bool(ui.ctx(), resp.id.with("wash"), resp.hovered());
|
||||
@@ -275,6 +304,57 @@ pub fn hover_wash(ui: &Ui, rect: Rect, resp: &Response, selected: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A small action pill: label, fill and label colour.
|
||||
pub type Pill<'a> = (&'a str, Color32, Color32);
|
||||
|
||||
/// Buy / Sell / Close pills over the right end of a table row while the pointer is on it,
|
||||
/// on a backdrop so they never overlap the row's text. Returns the index clicked.
|
||||
pub fn row_pills(
|
||||
ui: &mut Ui,
|
||||
row: Rect,
|
||||
id: impl std::hash::Hash + std::fmt::Debug,
|
||||
pills: &[Pill<'_>],
|
||||
tips: &[&str],
|
||||
) -> Option<usize> {
|
||||
if !ui.rect_contains_pointer(row) || pills.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let h = (row.height() - 6.0).clamp(14.0, 18.0);
|
||||
let font = ui_font(10.5);
|
||||
let galleys: Vec<_> =
|
||||
pills.iter().map(|(l, _, c)| ui.painter().layout_no_wrap((*l).to_string(), font.clone(), *c)).collect();
|
||||
let total: f32 = galleys.iter().map(|g| g.size().x + 14.0).sum::<f32>() + 4.0 * (pills.len() - 1) as f32;
|
||||
let strip = Rect::from_min_max(
|
||||
pos2(row.right() - total - 12.0, row.top() + 1.0),
|
||||
pos2(row.right() - 1.0, row.bottom() - 1.0),
|
||||
);
|
||||
ui.painter().rect_filled(strip, theme::CONTROL_RADIUS, theme::card());
|
||||
let mut x = strip.left() + 6.0;
|
||||
let mut clicked = None;
|
||||
let base = ui.id().with(id);
|
||||
for (i, ((_, fill, _), g)) in pills.iter().zip(galleys).enumerate() {
|
||||
let w = g.size().x + 14.0;
|
||||
let r = Rect::from_min_size(pos2(x, row.center().y - h / 2.0), vec2(w, h));
|
||||
x += w + 4.0;
|
||||
let mut resp = ui.interact(r, base.with(i), Sense::click());
|
||||
let fill = if resp.hovered() { *fill } else { fill.gamma_multiply(0.82) };
|
||||
ui.painter().rect_filled(r, h / 2.0, fill);
|
||||
ui.painter().galley(r.center() - g.size() / 2.0, g, Color32::WHITE);
|
||||
if let Some(t) = tips.get(i) {
|
||||
resp = resp.on_hover_text(*t);
|
||||
}
|
||||
if resp.clicked() {
|
||||
clicked = Some(i);
|
||||
}
|
||||
}
|
||||
clicked
|
||||
}
|
||||
|
||||
/// The usual Buy / Sell pills.
|
||||
pub fn buy_sell_pills() -> [Pill<'static>; 2] {
|
||||
[("B", theme::up(), Color32::WHITE), ("S", theme::down(), Color32::WHITE)]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+548
-114
@@ -1,22 +1,30 @@
|
||||
//! Background worker. All network, SQLite, indicator and LOD-pyramid work runs on a
|
||||
//! tokio runtime; the UI thread only sends [`Request`]s and drains [`Response`]s, so it
|
||||
//! never blocks.
|
||||
//!
|
||||
//! In the browser (`wasm32`) the same requests run as tasks on the page's event loop
|
||||
//! ([`tr_data::rt`]). There is no bar cache or Congress ingest there, and CPU work such as
|
||||
//! indicators runs inline.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, mpsc};
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Datelike;
|
||||
use tokio::sync::Mutex;
|
||||
use tr_chart::gpu::{LineKind, LineRef};
|
||||
use tr_chart::scene::{Overlay, SeriesGpu, line_ref};
|
||||
use tr_chart::scene::{Overlay, SeriesGpu, line_ref, marker_ref};
|
||||
use tr_congress::db::{Alert, PoliticianSummary};
|
||||
use tr_congress::model::NewTradeAlert;
|
||||
use tr_congress::performance::{self, Performance, TradeReturn};
|
||||
use tr_congress::refresh::{self, HttpSource, NewTradeAlert, RefreshConfig};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use tr_congress::refresh::{self, HttpSource, RefreshConfig};
|
||||
use tr_congress::{Db, Politician, Trade};
|
||||
use tr_core::{Bar, Security, Series, Timeframe, indicators};
|
||||
use tr_data::{BarCache, Provider, Quote, SearchHit, YieldCurves};
|
||||
use tr_core::indicators::registry::{Input, Plot, Tone};
|
||||
use tr_core::{Bar, Security, Series, Timeframe};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use tr_data::BarCache;
|
||||
use tr_data::{Provider, Quote, SearchHit, YieldCurves, rt};
|
||||
|
||||
use crate::command::IndicatorSpec;
|
||||
use crate::theme;
|
||||
@@ -26,6 +34,12 @@ pub type PaneId = u64;
|
||||
/// Minutes between Congress disclosure checks (`TR_CONGRESS_REFRESH_MIN`; 0 disables).
|
||||
pub const CONGRESS_REFRESH_MIN: u64 = 30;
|
||||
|
||||
/// The least time between a chart's reloads after its provider ended the live feed.
|
||||
const RELOAD_AFTER_FEED_ENDS: Duration = Duration::from_secs(5);
|
||||
|
||||
/// How often a chart retries a HIP-3 perp's history while the Terminal server can't serve it.
|
||||
const PERP_RETRY: Duration = if cfg!(test) { Duration::from_millis(100) } else { Duration::from_secs(10) };
|
||||
|
||||
pub enum Request {
|
||||
/// Load the brokerage portfolio through SnapTrade (`None` = the simulated sample).
|
||||
Portfolio(Option<tr_broker::Credentials>),
|
||||
@@ -60,6 +74,13 @@ pub enum Request {
|
||||
pane: PaneId,
|
||||
symbol: String,
|
||||
depth: usize,
|
||||
/// Echoed in the response, so the pane knows which of its asks was answered.
|
||||
ask: u64,
|
||||
},
|
||||
/// The latest trades, for markets the Terminal server serves.
|
||||
Trades {
|
||||
pane: PaneId,
|
||||
symbol: String,
|
||||
},
|
||||
History {
|
||||
pane: PaneId,
|
||||
@@ -121,8 +142,14 @@ pub enum Request {
|
||||
Forget(PaneId),
|
||||
YieldCurves,
|
||||
Search(String),
|
||||
/// The HIP-3 perps the Terminal server lists as live.
|
||||
PerpMarkets,
|
||||
/// Order entry (see `app::trading`); only sent once trading is enabled.
|
||||
Trade(TradeRequest),
|
||||
/// Hyperliquid testnet: the user stream, reads, the agent key and signed actions.
|
||||
Hl(crate::hl_worker::HlRequest),
|
||||
/// The built-in wallet (Turnkey sign-in and approvals).
|
||||
Wallet(crate::wallet_worker::WalletRequest),
|
||||
}
|
||||
|
||||
/// A trading call through SnapTrade. Previews never place anything; placing takes a preview
|
||||
@@ -148,7 +175,8 @@ pub enum TradeRequest {
|
||||
creds: tr_broker::Credentials,
|
||||
dark: bool,
|
||||
},
|
||||
/// Which accounts can trade (brokerage support and connection access).
|
||||
/// Which brokerage connections can trade (brokerage support and connection access): one
|
||||
/// `/authorizations` request, however many accounts there are.
|
||||
Capabilities {
|
||||
creds: tr_broker::Credentials,
|
||||
},
|
||||
@@ -170,7 +198,17 @@ pub enum TradeResponse {
|
||||
result: Result<(), String>,
|
||||
},
|
||||
Portal(Result<String, String>),
|
||||
Capabilities(Result<std::collections::HashMap<String, tr_broker::trading::Capability>, String>),
|
||||
/// Capabilities by connection (SnapTrade authorization) id.
|
||||
Capabilities(Result<std::collections::HashMap<String, tr_broker::trading::Capability>, CapsError>),
|
||||
}
|
||||
|
||||
/// Why trading access could not be read. A rate limit is reported apart from real errors so
|
||||
/// the app can back off quietly instead of showing an error.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CapsError {
|
||||
pub message: String,
|
||||
pub throttled: bool,
|
||||
pub retry_after: Option<Duration>,
|
||||
}
|
||||
|
||||
pub struct BuiltIndicator {
|
||||
@@ -206,8 +244,14 @@ pub enum Response {
|
||||
OrderBook {
|
||||
pane: PaneId,
|
||||
symbol: String,
|
||||
ask: u64,
|
||||
book: Result<tr_data::OrderBook, String>,
|
||||
},
|
||||
Trades {
|
||||
pane: PaneId,
|
||||
symbol: String,
|
||||
trades: Result<Vec<tr_data::TradePrint>, String>,
|
||||
},
|
||||
History {
|
||||
pane: PaneId,
|
||||
generation: u64,
|
||||
@@ -219,6 +263,9 @@ pub enum Response {
|
||||
prepended: usize,
|
||||
from_cache: bool,
|
||||
exhausted: bool,
|
||||
/// A full load from the provider. Its `exhausted` replaces the chart's, which may have
|
||||
/// come from the source the chart showed before.
|
||||
full: bool,
|
||||
/// A live-feed update of the latest bar.
|
||||
live: bool,
|
||||
},
|
||||
@@ -243,7 +290,11 @@ pub enum Response {
|
||||
symbol: String,
|
||||
trades: Vec<Trade>,
|
||||
},
|
||||
Quotes(Vec<Quote>),
|
||||
/// Quotes for the keys `asked`, which may leave some out.
|
||||
Quotes {
|
||||
asked: Vec<String>,
|
||||
quotes: Vec<Quote>,
|
||||
},
|
||||
Latest(Vec<Trade>),
|
||||
TickerTrades {
|
||||
pane: PaneId,
|
||||
@@ -257,20 +308,27 @@ 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 {
|
||||
query: String,
|
||||
hits: Vec<SearchHit>,
|
||||
},
|
||||
/// Live HIP-3 perps (`xyz:AAPL`), or why the server couldn't say.
|
||||
PerpMarkets(Result<Vec<String>, String>),
|
||||
Trade(Box<TradeResponse>),
|
||||
Hl(Box<crate::hl_worker::HlResponse>),
|
||||
}
|
||||
|
||||
pub struct Worker {
|
||||
tx: tokio::sync::mpsc::UnboundedSender<Request>,
|
||||
pub rx: mpsc::Receiver<Response>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
_rt: tokio::runtime::Runtime,
|
||||
}
|
||||
|
||||
@@ -282,6 +340,10 @@ 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>,
|
||||
/// Counts each chart pane's reloads for a changed data source, up once as one starts and again
|
||||
/// when it's done (odd while it runs), so a page of older bars that spans one is dropped.
|
||||
sources: std::sync::Mutex<HashMap<PaneId, u64>>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cache: std::sync::Mutex<Option<BarCache>>,
|
||||
db: Mutex<Db>,
|
||||
out: mpsc::Sender<Response>,
|
||||
@@ -295,22 +357,35 @@ impl Shared {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where saved files live. In the browser this is the key prefix in `localStorage` (see
|
||||
/// [`crate::storage`]). The first call moves files saved under the app's old name, terminal-rs,
|
||||
/// to the new one ([`crate::storage::migrate`]).
|
||||
pub fn data_dir() -> PathBuf {
|
||||
if let Some(d) = std::env::var_os("TR_DATA_DIR") {
|
||||
return PathBuf::from(d);
|
||||
}
|
||||
let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
|
||||
if cfg!(target_os = "macos") {
|
||||
home.join("Library/Application Support/terminal-rs")
|
||||
} else {
|
||||
std::env::var_os("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| home.join(".local/share"))
|
||||
.join("terminal-rs")
|
||||
}
|
||||
static DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
DIR.get_or_init(|| {
|
||||
// Unit tests never read, write or move the real folder.
|
||||
if cfg!(test) {
|
||||
return std::env::temp_dir().join(format!("tr-test-data-{}", std::process::id()));
|
||||
}
|
||||
let parent = if cfg!(target_arch = "wasm32") {
|
||||
PathBuf::new()
|
||||
} else if let Some(d) = std::env::var_os("TR_DATA_DIR") {
|
||||
return PathBuf::from(d);
|
||||
} else {
|
||||
let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
|
||||
if cfg!(target_os = "macos") {
|
||||
home.join("Library/Application Support")
|
||||
} else {
|
||||
std::env::var_os("XDG_DATA_HOME").map(PathBuf::from).unwrap_or_else(|| home.join(".local/share"))
|
||||
}
|
||||
};
|
||||
crate::storage::migrate(&parent.join("terminal-rs"), &parent.join("terminal"))
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn spawn(ctx: egui::Context, provider: Provider) -> anyhow::Result<Self> {
|
||||
let dir = data_dir();
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
@@ -330,6 +405,7 @@ impl Worker {
|
||||
let shared = Arc::new(Shared {
|
||||
provider,
|
||||
series: Default::default(),
|
||||
sources: Default::default(),
|
||||
cache: std::sync::Mutex::new(cache),
|
||||
db: Mutex::new(db),
|
||||
out,
|
||||
@@ -365,11 +441,34 @@ 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(),
|
||||
sources: Default::default(),
|
||||
db: Mutex::new(Db::open_in_memory()?),
|
||||
out,
|
||||
ctx,
|
||||
});
|
||||
rt::spawn(async move {
|
||||
while let Some(req) = req_rx.recv().await {
|
||||
let s = shared.clone();
|
||||
rt::spawn(async move { handle(&s, req).await });
|
||||
}
|
||||
});
|
||||
Ok(Self { tx, rx })
|
||||
}
|
||||
|
||||
pub fn send(&self, req: Request) {
|
||||
let _ = self.tx.send(req);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
async fn congress_refresh(s: &Shared) {
|
||||
s.send(Response::Status("Refreshing congressional disclosures (Senate eFD + House Clerk)…".into()));
|
||||
let source = match HttpSource::new() {
|
||||
@@ -388,61 +487,80 @@ async fn congress_refresh(s: &Shared) {
|
||||
}
|
||||
}
|
||||
|
||||
fn session_key(tf: Timeframe) -> impl Fn(i64) -> i64 {
|
||||
move |ts| {
|
||||
let d = chrono::DateTime::from_timestamp(ts, 0).unwrap_or_default();
|
||||
match tf {
|
||||
t if t.is_intraday() => ts.div_euclid(86_400),
|
||||
Timeframe::D1 => (d.year() * 12 + d.month() as i32) as i64,
|
||||
_ => d.year() as i64,
|
||||
}
|
||||
// TODO(web proxy): Senate eFD and the House Clerk don't allow cross-origin requests.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
async fn congress_refresh(s: &Shared) {
|
||||
s.send(Response::Status(crate::congress_view::WEB_NOTE.into()));
|
||||
}
|
||||
|
||||
/// Bars saved by an earlier session, to paint before the network answers.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn cached_bars(s: &Shared, symbol: &str, tf: Timeframe, limit: usize) -> Option<Vec<Bar>> {
|
||||
s.cache.lock().ok().and_then(|c| c.as_ref().and_then(|c| c.load(symbol, tf, limit).ok()))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn cache_bars(s: &Shared, symbol: &str, tf: Timeframe, bars: &[Bar]) {
|
||||
if let Ok(mut c) = s.cache.lock()
|
||||
&& let Some(c) = c.as_mut()
|
||||
{
|
||||
let _ = c.store(symbol, tf, bars);
|
||||
}
|
||||
}
|
||||
|
||||
fn overlay(values: &[f64], color: egui::Color32, kind: LineKind) -> Overlay {
|
||||
Overlay { line: line_ref(values), color: theme::rgba(color), kind, scale: 1.0 }
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn cached_bars(_: &Shared, _: &str, _: Timeframe, _: usize) -> Option<Vec<Bar>> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn cache_bars(_: &Shared, _: &str, _: Timeframe, _: &[Bar]) {}
|
||||
|
||||
fn overlay(values: &[f64], color: egui::Color32, kind: LineKind) -> Overlay {
|
||||
let line = if kind == LineKind::Dots { marker_ref(values) } else { line_ref(values) };
|
||||
Overlay { line, color: theme::rgba(color), kind, scale: 1.0 }
|
||||
}
|
||||
|
||||
/// Compute every indicator on `bars`. The `i`-th indicator takes the `i`-th series colour;
|
||||
/// its signal lines, outer bands and up/down lines take theme roles.
|
||||
pub fn build_indicators(
|
||||
bars: &[Bar],
|
||||
tf: Timeframe,
|
||||
specs: &[IndicatorSpec],
|
||||
palette: &theme::Palette,
|
||||
) -> Vec<BuiltIndicator> {
|
||||
let closes: Vec<f64> = bars.iter().map(|b| b.close).collect();
|
||||
let input = Input::new(bars, tf);
|
||||
let colors = palette.series.len();
|
||||
specs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, spec)| {
|
||||
let c = palette.series[i % palette.series.len()];
|
||||
let overlays = match *spec {
|
||||
IndicatorSpec::Sma(n) => vec![(spec.label(), overlay(&indicators::sma(&closes, n), c, LineKind::Line))],
|
||||
IndicatorSpec::Ema(n) => vec![(spec.label(), overlay(&indicators::ema(&closes, n), c, LineKind::Line))],
|
||||
IndicatorSpec::Vwap => {
|
||||
vec![(spec.label(), overlay(&indicators::vwap_by(bars, session_key(tf)), c, LineKind::Line))]
|
||||
}
|
||||
IndicatorSpec::Bollinger(n, k) => {
|
||||
let b = indicators::bollinger(&closes, n, k);
|
||||
let dim = c.gamma_multiply(0.7);
|
||||
vec![
|
||||
("BB upper".into(), overlay(&b.upper, dim, LineKind::Line)),
|
||||
("BB basis".into(), overlay(&b.middle, c, LineKind::Line)),
|
||||
("BB lower".into(), overlay(&b.lower, dim, LineKind::Line)),
|
||||
]
|
||||
}
|
||||
IndicatorSpec::Rsi(n) => {
|
||||
vec![(spec.label(), overlay(&indicators::rsi(&closes, n), palette.series[1], LineKind::Line))]
|
||||
}
|
||||
IndicatorSpec::Macd(f, sl, sg) => {
|
||||
let m = indicators::macd(&closes, f, sl, sg);
|
||||
vec![
|
||||
("Hist".into(), overlay(&m.histogram, egui::Color32::WHITE, LineKind::Hist)),
|
||||
("MACD".into(), overlay(&m.macd, palette.cyan, LineKind::Line)),
|
||||
("Signal".into(), overlay(&m.signal, palette.amber, LineKind::Line)),
|
||||
]
|
||||
}
|
||||
};
|
||||
BuiltIndicator { spec: *spec, overlays }
|
||||
let main = palette.series[i % colors];
|
||||
let mut outputs = spec.compute(&input);
|
||||
// Columns first, so lines and markers draw over them.
|
||||
outputs.sort_by_key(|o| o.plot != Plot::Hist);
|
||||
let overlays = outputs
|
||||
.into_iter()
|
||||
.map(|o| {
|
||||
let color = match o.tone {
|
||||
Tone::Main => main,
|
||||
Tone::Signal if main == palette.amber => palette.cyan,
|
||||
Tone::Signal => palette.amber,
|
||||
Tone::Dim => main.gamma_multiply(0.6),
|
||||
Tone::Up => palette.up,
|
||||
Tone::Down => palette.down,
|
||||
Tone::Series(k) => palette.series[(i + 1 + k as usize) % colors],
|
||||
};
|
||||
let kind = match o.plot {
|
||||
Plot::Line => LineKind::Line,
|
||||
Plot::Hist => LineKind::Hist,
|
||||
Plot::Dots => LineKind::Dots,
|
||||
Plot::Steps => LineKind::Steps,
|
||||
};
|
||||
(o.name.to_string(), overlay(&o.values, color, kind))
|
||||
})
|
||||
.collect();
|
||||
BuiltIndicator { spec: spec.clone(), overlays }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -485,8 +603,7 @@ async fn handle(s: &Arc<Shared>, req: Request) {
|
||||
return; // a newer load for this pane is already running
|
||||
}
|
||||
// Paint from the disk cache immediately, then refresh from the network.
|
||||
let cached = s.cache.lock().ok().and_then(|c| c.as_ref().and_then(|c| c.load(&symbol, tf, limit).ok()));
|
||||
if let Some(bars) = cached.filter(|b| !b.is_empty()) {
|
||||
if let Some(bars) = cached_bars(s, &symbol, tf, limit).filter(|b| !b.is_empty()) {
|
||||
let bars = Arc::new(bars);
|
||||
let gpu = build_gpu(bars.clone()).await;
|
||||
s.send(Response::History {
|
||||
@@ -499,44 +616,31 @@ async fn handle(s: &Arc<Shared>, req: Request) {
|
||||
prepended: 0,
|
||||
from_cache: true,
|
||||
exhausted: false,
|
||||
full: false,
|
||||
live: false,
|
||||
});
|
||||
}
|
||||
match s.provider.history(&symbol, tf, None, limit).await {
|
||||
Ok(bars) if !bars.is_empty() => {
|
||||
if let Ok(mut c) = s.cache.lock()
|
||||
&& let Some(c) = c.as_mut()
|
||||
{
|
||||
let _ = c.store(&symbol, tf, &bars);
|
||||
}
|
||||
let exhausted = bars.len() < limit / 2;
|
||||
let bars = Arc::new(bars);
|
||||
if !store_series(s, pane, generation, bars.clone()) {
|
||||
return; // superseded while loading
|
||||
}
|
||||
let gpu = build_gpu(bars.clone()).await;
|
||||
s.send(Response::History {
|
||||
pane,
|
||||
generation,
|
||||
symbol: symbol.clone(),
|
||||
tf,
|
||||
bars,
|
||||
gpu,
|
||||
prepended: 0,
|
||||
from_cache: false,
|
||||
exhausted,
|
||||
live: false,
|
||||
});
|
||||
start_live(s.clone(), pane, generation, symbol, tf);
|
||||
}
|
||||
Ok(bars) if !bars.is_empty() => loaded(s, pane, generation, symbol, tf, limit, bars).await,
|
||||
Ok(_) => s.send(Response::HistoryError { pane, generation, error: format!("no data for {symbol}") }),
|
||||
Err(e) => s.send(Response::HistoryError { pane, generation, error: format!("{e:#}") }),
|
||||
Err(e) => {
|
||||
s.send(Response::HistoryError { pane, generation, error: format!("{e:#}") });
|
||||
if tr_data::is_perp(&symbol) {
|
||||
retry_perp(s.clone(), pane, generation, symbol, tf, limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Request::MoreHistory { pane, generation, symbol, tf, existing, limit } => {
|
||||
// Page from the worker's copy (it may hold newer live bars than the UI's).
|
||||
let base = current_series(s, pane, generation).filter(|b| !b.is_empty()).unwrap_or(existing);
|
||||
let Some(first) = base.first().copied() else { return };
|
||||
// A page that overlaps a reload for another source is dropped. The reload answers the
|
||||
// pane, and the chart pages again from the new source.
|
||||
let source = source_switches(s, pane);
|
||||
if source % 2 == 1 {
|
||||
return;
|
||||
}
|
||||
match s.provider.history(&symbol, tf, Some(first.ts), limit).await {
|
||||
Ok(older) => {
|
||||
let older: Vec<Bar> = older.into_iter().filter(|b| b.ts < first.ts).collect();
|
||||
@@ -545,6 +649,9 @@ async fn handle(s: &Arc<Shared>, req: Request) {
|
||||
let mut map = s.series.lock().expect("series lock");
|
||||
let Some((g, cur)) = map.get_mut(&pane).filter(|(g, _)| *g == generation) else { return };
|
||||
let _ = g;
|
||||
if source_switches(s, pane) != source {
|
||||
return;
|
||||
}
|
||||
let mut merged = older;
|
||||
merged.extend_from_slice(cur);
|
||||
*cur = Arc::new(merged);
|
||||
@@ -561,24 +668,26 @@ async fn handle(s: &Arc<Shared>, req: Request) {
|
||||
prepended,
|
||||
from_cache: false,
|
||||
exhausted: prepended == 0,
|
||||
full: false,
|
||||
live: false,
|
||||
});
|
||||
}
|
||||
Err(e) => s.send(Response::HistoryError { pane, generation, error: format!("{e:#}") }),
|
||||
Err(e) if source_switches(s, pane) == source => {
|
||||
s.send(Response::HistoryError { pane, generation, error: format!("{e:#}") })
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
Request::Indicators { pane, generation, bars, tf, specs, palette } => {
|
||||
let built = tokio::task::spawn_blocking(move || build_indicators(&bars, tf, &specs, &palette))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let built = rt::blocking(move || build_indicators(&bars, tf, &specs, &palette)).await.unwrap_or_default();
|
||||
s.send(Response::Indicators { pane, generation, built });
|
||||
}
|
||||
Request::Compare { pane, generation, symbol, tf, base } => {
|
||||
let limit = base.len().max(100);
|
||||
match s.provider.history(&symbol, tf, None, limit).await {
|
||||
Ok(other) => {
|
||||
let line = tokio::task::spawn_blocking(move || line_ref(&align_compare(&base, &other))).await;
|
||||
if let Ok(line) = line {
|
||||
let line = rt::blocking(move || line_ref(&align_compare(&base, &other))).await;
|
||||
if let Some(line) = line {
|
||||
s.send(Response::Compare { pane, generation, symbol, line });
|
||||
}
|
||||
}
|
||||
@@ -595,8 +704,11 @@ async fn handle(s: &Arc<Shared>, req: Request) {
|
||||
s.send(Response::Markers { pane, symbol, trades });
|
||||
}
|
||||
Request::Quotes(symbols) => match s.provider.quotes(&symbols).await {
|
||||
Ok(q) => s.send(Response::Quotes(q)),
|
||||
Err(e) => s.send(Response::Status(format!("quotes: {e:#}"))),
|
||||
Ok(quotes) => s.send(Response::Quotes { asked: symbols, quotes }),
|
||||
Err(e) => {
|
||||
s.send(Response::Status(format!("quotes: {e:#}")));
|
||||
s.send(Response::Quotes { asked: symbols, quotes: Vec::new() });
|
||||
}
|
||||
},
|
||||
Request::Latest { limit } => {
|
||||
let t = s.db.lock().await.latest_trades(limit).unwrap_or_default();
|
||||
@@ -642,6 +754,10 @@ async fn handle(s: &Arc<Shared>, req: Request) {
|
||||
Request::Forget(pane) => {
|
||||
s.series.lock().expect("series lock").remove(&pane);
|
||||
}
|
||||
Request::PerpMarkets => {
|
||||
let perps = s.provider.perp_markets().await.map_err(|e| format!("{e:#}"));
|
||||
s.send(Response::PerpMarkets(perps));
|
||||
}
|
||||
Request::YieldCurves => match s.provider.yield_curves().await {
|
||||
Ok(c) => s.send(Response::Curves(Box::new(c))),
|
||||
Err(e) => s.send(Response::Status(format!("yield curve: {e:#}"))),
|
||||
@@ -685,15 +801,27 @@ async fn handle(s: &Arc<Shared>, req: Request) {
|
||||
let items = s.provider.news(symbol.as_deref()).await.map_err(|e| format!("{e:#}"));
|
||||
s.send(Response::News { pane, symbol, items });
|
||||
}
|
||||
Request::OrderBook { pane, symbol, depth } => {
|
||||
Request::Trades { pane, symbol } => {
|
||||
let trades = s.provider.trades(&symbol, 40).await.map_err(|e| format!("{e:#}"));
|
||||
s.send(Response::Trades { pane, symbol, trades });
|
||||
}
|
||||
Request::OrderBook { pane, symbol, depth, ask } => {
|
||||
let book = s.provider.order_book(&symbol, depth).await.map_err(|e| format!("{e:#}"));
|
||||
s.send(Response::OrderBook { pane, symbol, book });
|
||||
s.send(Response::OrderBook { pane, symbol, ask, book });
|
||||
}
|
||||
Request::Search(query) => {
|
||||
let hits = s.provider.search(&query).await.unwrap_or_default();
|
||||
s.send(Response::SearchHits { query, hits });
|
||||
}
|
||||
Request::Trade(t) => s.send(Response::Trade(Box::new(trade(t).await))),
|
||||
Request::Hl(r) => {
|
||||
let out = s.clone();
|
||||
crate::hl_worker::handle(r, move |resp| out.send(Response::Hl(Box::new(resp)))).await
|
||||
}
|
||||
Request::Wallet(r) => {
|
||||
let out = s.clone();
|
||||
crate::wallet_worker::handle(r, move |resp| out.send(Response::Hl(Box::new(resp)))).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,8 +855,11 @@ async fn trade(t: TradeRequest) -> TradeResponse {
|
||||
Err(e) => Err(e),
|
||||
}),
|
||||
TradeRequest::Capabilities { creds } => TradeResponse::Capabilities(match client(creds) {
|
||||
Ok(c) => c.capabilities().await.map_err(err),
|
||||
Err(e) => Err(e),
|
||||
Ok(c) => c.connection_capabilities().await.map_err(|e| {
|
||||
let throttle = tr_broker::snaptrade::throttle(&e);
|
||||
CapsError { message: err(e), throttled: throttle.is_some(), retry_after: throttle.flatten() }
|
||||
}),
|
||||
Err(message) => Err(CapsError { message, throttled: false, retry_after: None }),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -759,24 +890,108 @@ fn store_series(s: &Shared, pane: PaneId, generation: u64, bars: Arc<Vec<Bar>>)
|
||||
}
|
||||
}
|
||||
|
||||
fn source_switches(s: &Shared, pane: PaneId) -> u64 {
|
||||
s.sources.lock().expect("sources lock").get(&pane).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn current_series(s: &Shared, pane: PaneId, generation: u64) -> Option<Arc<Vec<Bar>>> {
|
||||
s.series.lock().expect("series lock").get(&pane).filter(|(g, _)| *g == generation).map(|(_, b)| b.clone())
|
||||
}
|
||||
|
||||
/// Merge the provider's live feed (if any) into the pane's series, publishing at most
|
||||
/// 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) {
|
||||
/// What one live bar did to a series.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
struct Applied {
|
||||
changed: bool,
|
||||
/// It went in before the latest bar (a feed filling a gap after an outage), so every
|
||||
/// series aligned to the bars by index (compare lines) has to be rebuilt.
|
||||
inserted: bool,
|
||||
}
|
||||
|
||||
fn apply_live_bar(bars: &mut Vec<Bar>, bar: Bar) -> Applied {
|
||||
let (len, last) = (bars.len(), bars.last().map(|b| b.ts));
|
||||
let changed = Series::apply_live(bars, bar);
|
||||
Applied { changed, inserted: bars.len() > len && bars.last().map(|b| b.ts) == last }
|
||||
}
|
||||
|
||||
/// A full load's bars: cache them, show them and start the live feed, unless a newer load for
|
||||
/// the pane has taken over.
|
||||
async fn loaded(
|
||||
s: &Arc<Shared>,
|
||||
pane: PaneId,
|
||||
generation: u64,
|
||||
symbol: String,
|
||||
tf: Timeframe,
|
||||
limit: usize,
|
||||
bars: Vec<Bar>,
|
||||
) {
|
||||
cache_bars(s, &symbol, tf, &bars);
|
||||
let exhausted = bars.len() < limit / 2;
|
||||
let bars = Arc::new(bars);
|
||||
if !store_series(s, pane, generation, bars.clone()) {
|
||||
return; // superseded while loading
|
||||
}
|
||||
let gpu = build_gpu(bars.clone()).await;
|
||||
s.send(Response::History {
|
||||
pane,
|
||||
generation,
|
||||
symbol: symbol.clone(),
|
||||
tf,
|
||||
bars,
|
||||
gpu,
|
||||
prepended: 0,
|
||||
from_cache: false,
|
||||
exhausted,
|
||||
full: true,
|
||||
live: false,
|
||||
});
|
||||
start_live(s.clone(), pane, generation, symbol, tf, limit);
|
||||
}
|
||||
|
||||
/// A HIP-3 perp has no source but the Terminal server, so nothing else picks up its chart when
|
||||
/// the server is away. Ask again every [`PERP_RETRY`] while the pane still shows this load; the
|
||||
/// chart keeps showing the first failure (or its cached bars) until one works.
|
||||
fn retry_perp(s: Arc<Shared>, pane: PaneId, generation: u64, symbol: String, tf: Timeframe, limit: usize) {
|
||||
rt::spawn(async move {
|
||||
loop {
|
||||
rt::sleep(PERP_RETRY).await;
|
||||
if current_series(&s, pane, generation).is_none() {
|
||||
return;
|
||||
}
|
||||
match s.provider.history(&symbol, tf, None, limit).await {
|
||||
Ok(bars) if !bars.is_empty() => return loaded(&s, pane, generation, symbol, tf, limit, bars).await,
|
||||
Ok(_) => {
|
||||
return s.send(Response::HistoryError { pane, generation, error: format!("no data for {symbol}") });
|
||||
}
|
||||
Err(e) => tracing::debug!("{symbol} history, still failing: {e:#}"),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn start_live(s: Arc<Shared>, pane: PaneId, generation: u64, symbol: String, tf: Timeframe, limit: usize) {
|
||||
let Some(mut feed) = s.provider.live(&symbol, tf) else { return };
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(Duration::from_millis(250));
|
||||
rt::spawn(async move {
|
||||
let started = web_time::Instant::now();
|
||||
let mut tick = rt::interval(Duration::from_millis(250));
|
||||
let mut dirty = false;
|
||||
let mut inserted = false;
|
||||
let mut ended = false;
|
||||
loop {
|
||||
tokio::select! {
|
||||
bar = feed.recv() => {
|
||||
let Some(bar) = bar else { break };
|
||||
let Some(bar) = bar else {
|
||||
ended = true;
|
||||
break;
|
||||
};
|
||||
let mut map = s.series.lock().expect("series lock");
|
||||
match map.get_mut(&pane) {
|
||||
Some((g, bars)) if *g == generation => dirty |= Series::apply_live(Arc::make_mut(bars), bar),
|
||||
Some((g, bars)) if *g == generation => {
|
||||
let a = apply_live_bar(Arc::make_mut(bars), bar);
|
||||
dirty |= a.changed;
|
||||
inserted |= a.inserted;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
@@ -800,17 +1015,31 @@ fn start_live(s: Arc<Shared>, pane: PaneId, generation: u64, symbol: String, tf:
|
||||
prepended: 0,
|
||||
from_cache: false,
|
||||
exhausted: false,
|
||||
live: true,
|
||||
full: false,
|
||||
// A bar slotted into a gap shifts everything after it: send it as a
|
||||
// refresh so the chart rebuilds its compare line and indicators.
|
||||
live: !std::mem::take(&mut inserted),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::debug!("live feed for pane {pane} ({symbol}) stopped");
|
||||
// A provider ends a feed the pane still shows when the chart's source changes (the Terminal
|
||||
// server went away or came back): reload the chart from wherever its data comes from now.
|
||||
if ended {
|
||||
rt::sleep(RELOAD_AFTER_FEED_ENDS.saturating_sub(started.elapsed())).await;
|
||||
if current_series(&s, pane, generation).is_some() {
|
||||
let switch = || *s.sources.lock().expect("sources lock").entry(pane).or_default() += 1;
|
||||
switch();
|
||||
handle(&s, Request::History { pane, generation, symbol, tf, limit }).await;
|
||||
switch();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn build_gpu(bars: Arc<Vec<Bar>>) -> SeriesGpu {
|
||||
tokio::task::spawn_blocking(move || SeriesGpu::build(&bars)).await.expect("pyramid build task")
|
||||
rt::blocking(move || SeriesGpu::build(&bars)).await.expect("pyramid build task")
|
||||
}
|
||||
|
||||
fn resolve_politician(db: &Db, query: &str) -> Option<Politician> {
|
||||
@@ -881,6 +1110,20 @@ where
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bars_filling_a_gap_are_reported_as_inserted() {
|
||||
let b = |ts: i64| Bar::new(ts, 1.0, 1.0, 1.0, 1.0, 1.0);
|
||||
let mut bars = vec![b(0), b(60), b(240)];
|
||||
assert_eq!(apply_live_bar(&mut bars, b(300)), Applied { changed: true, inserted: false });
|
||||
assert_eq!(apply_live_bar(&mut bars, b(300)), Applied { changed: false, inserted: false });
|
||||
assert_eq!(apply_live_bar(&mut bars, b(120)), Applied { changed: true, inserted: true });
|
||||
assert_eq!(
|
||||
apply_live_bar(&mut bars, Bar::new(60, 1.0, 2.0, 1.0, 2.0, 1.0)),
|
||||
Applied { changed: true, inserted: false }
|
||||
);
|
||||
assert_eq!(bars.iter().map(|x| x.ts).collect::<Vec<_>>(), [0, 60, 120, 240, 300]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_alignment_carries_last_close() {
|
||||
let base: Vec<Bar> = (0..5).map(|i| Bar::new(i * 10, 100.0, 100.0, 100.0, 100.0 + i as f64, 1.0)).collect();
|
||||
@@ -893,15 +1136,22 @@ mod tests {
|
||||
#[test]
|
||||
fn indicators_build_expected_overlays() {
|
||||
let bars = tr_core::sample::generate("X", Timeframe::D1, 300, 1_790_000_000);
|
||||
let built = build_indicators(
|
||||
&bars,
|
||||
Timeframe::D1,
|
||||
&[IndicatorSpec::Bollinger(20, 2.0), IndicatorSpec::Macd(12, 26, 9), IndicatorSpec::Vwap],
|
||||
&crate::themes::terminal_light().palette(theme::DEFAULT_ACCENT),
|
||||
);
|
||||
let palette = crate::themes::terminal_light().palette(theme::DEFAULT_ACCENT);
|
||||
let specs: Vec<IndicatorSpec> =
|
||||
["BB", "MACD", "VWAP", "PSAR", "PIVOT"].iter().map(|c| IndicatorSpec::new(c, &[]).unwrap()).collect();
|
||||
let built = build_indicators(&bars, Timeframe::D1, &specs, &palette);
|
||||
assert_eq!(built[0].overlays.len(), 3);
|
||||
// The histogram draws first, under the MACD and signal lines.
|
||||
assert_eq!(built[1].overlays[0].1.kind, LineKind::Hist);
|
||||
assert_ne!(built[1].overlays[2].1.color, built[1].overlays[1].1.color, "signal stands out from MACD");
|
||||
assert_eq!(built[2].overlays.len(), 1);
|
||||
assert!(built[3].overlays.iter().all(|(_, o)| o.kind == LineKind::Dots));
|
||||
assert_eq!(built[3].overlays[0].1.color, theme::rgba(palette.up));
|
||||
// Every pair of bars holding a marker keeps one when zoomed out.
|
||||
let up = &built[3].overlays[0].1.line.pyramid;
|
||||
let pairs = up.data[..up.offsets[1]].chunks(2).filter(|c| c.iter().any(|p| p[1].is_finite())).count();
|
||||
assert_eq!(up.data[up.offsets[1]..up.offsets[2]].iter().filter(|p| p[1].is_finite()).count(), pairs);
|
||||
assert!(built[4].overlays.iter().all(|(_, o)| o.kind == LineKind::Steps));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -910,6 +1160,7 @@ mod tests {
|
||||
let s = Shared {
|
||||
provider: std::sync::Arc::new(tr_data::sample::SampleProvider::at(0, 10)),
|
||||
series: Default::default(),
|
||||
sources: Default::default(),
|
||||
cache: std::sync::Mutex::new(None),
|
||||
db: Mutex::new(Db::open_in_memory().unwrap()),
|
||||
out,
|
||||
@@ -924,6 +1175,189 @@ mod tests {
|
||||
assert!(current_series(&s, 1, 1).is_none());
|
||||
}
|
||||
|
||||
/// Bars whose close counts the loads. Reloads wait for `reload`, and older pages for `page`.
|
||||
/// The first chart's live feed ends at once, the way `RemoteMarkets` ends one when the chart's
|
||||
/// source changes.
|
||||
#[derive(Default)]
|
||||
struct SourceChanges {
|
||||
loads: std::sync::atomic::AtomicUsize,
|
||||
open: std::sync::Mutex<Vec<tokio::sync::mpsc::Sender<Bar>>>,
|
||||
reload: tokio::sync::Notify,
|
||||
page: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl tr_data::MarketData for SourceChanges {
|
||||
fn name(&self) -> &str {
|
||||
"test"
|
||||
}
|
||||
|
||||
async fn history(&self, _: &str, _: Timeframe, end: Option<i64>, _: usize) -> anyhow::Result<Vec<Bar>> {
|
||||
if end.is_some() {
|
||||
self.page.notified().await;
|
||||
return Ok(vec![Bar::new(-60, 1.0, 1.0, 1.0, 1.0, 1.0)]);
|
||||
}
|
||||
let n = self.loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
|
||||
if n > 1 {
|
||||
self.reload.notified().await;
|
||||
}
|
||||
Ok(vec![Bar::new(0, 1.0, 1.0, 1.0, n as f64, 1.0)])
|
||||
}
|
||||
|
||||
fn live(&self, _: &str, _: Timeframe) -> Option<tr_data::LiveFeed> {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(1);
|
||||
if self.loads.load(std::sync::atomic::Ordering::SeqCst) > 1 {
|
||||
self.open.lock().unwrap().push(tx);
|
||||
}
|
||||
Some(rx)
|
||||
}
|
||||
}
|
||||
|
||||
/// A server that's down for the first `down` history requests.
|
||||
struct ServerBack {
|
||||
down: usize,
|
||||
loads: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl tr_data::MarketData for ServerBack {
|
||||
fn name(&self) -> &str {
|
||||
"server"
|
||||
}
|
||||
|
||||
async fn history(&self, _: &str, _: Timeframe, _: Option<i64>, _: usize) -> anyhow::Result<Vec<Bar>> {
|
||||
let n = self.loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
|
||||
anyhow::ensure!(n > self.down, "the Terminal server is not reachable");
|
||||
Ok(vec![Bar::new(0, 1.0, 1.0, 1.0, 7.0, 1.0)])
|
||||
}
|
||||
}
|
||||
|
||||
/// A perp has no other source, so a chart whose first load failed keeps asking while it
|
||||
/// shows the perp, and loads once the server answers. It stops once the pane moves on.
|
||||
fn worker_on(provider: tr_data::Provider) -> (Arc<Shared>, mpsc::Receiver<Response>) {
|
||||
let (out, rx) = mpsc::channel();
|
||||
let s = Shared {
|
||||
provider,
|
||||
series: Default::default(),
|
||||
sources: Default::default(),
|
||||
cache: std::sync::Mutex::new(None),
|
||||
db: Mutex::new(Db::open_in_memory().unwrap()),
|
||||
out,
|
||||
ctx: egui::Context::default(),
|
||||
};
|
||||
(Arc::new(s), rx)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn perp_charts_retry_until_the_server_answers() {
|
||||
let provider = Arc::new(ServerBack { down: 2, loads: Default::default() });
|
||||
let (s, rx) = worker_on(provider.clone());
|
||||
let load = |symbol: &str| Request::History {
|
||||
pane: 1,
|
||||
generation: 1,
|
||||
symbol: symbol.into(),
|
||||
tf: Timeframe::D1,
|
||||
limit: 10,
|
||||
};
|
||||
handle(&s, load("xyz:AAPL")).await;
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut errors = 0;
|
||||
let bars = loop {
|
||||
assert!(t0.elapsed() < Duration::from_secs(10), "no retry after {errors} errors");
|
||||
match rx.try_recv() {
|
||||
Ok(Response::HistoryError { .. }) => errors += 1,
|
||||
Ok(Response::History { bars, full: true, .. }) => break bars,
|
||||
Ok(_) => {}
|
||||
Err(_) => tokio::time::sleep(Duration::from_millis(20)).await,
|
||||
}
|
||||
};
|
||||
assert_eq!((errors, bars.last().map(|b| b.close)), (1, Some(7.0)), "one error shown, then the bars");
|
||||
assert_eq!(provider.loads.load(std::sync::atomic::Ordering::SeqCst), 3);
|
||||
// A chart the user moved on from isn't retried.
|
||||
let provider = Arc::new(ServerBack { down: usize::MAX, loads: Default::default() });
|
||||
let (s, _rx) = worker_on(provider.clone());
|
||||
handle(&s, load("xyz:AAPL")).await;
|
||||
assert!(claim_series(&s, 1, 2), "the pane loads something else");
|
||||
tokio::time::sleep(PERP_RETRY * 3).await;
|
||||
assert_eq!(provider.loads.load(std::sync::atomic::Ordering::SeqCst), 1, "no retries for an old load");
|
||||
}
|
||||
|
||||
/// A worker on `SourceChanges` with pane 1 loading `BTCUSD Curncy`, and its responses.
|
||||
async fn switching_chart() -> (Arc<Shared>, mpsc::Receiver<Response>, Arc<SourceChanges>) {
|
||||
let (out, rx) = mpsc::channel();
|
||||
let provider = Arc::new(SourceChanges::default());
|
||||
let s = Arc::new(Shared {
|
||||
provider: provider.clone(),
|
||||
series: Default::default(),
|
||||
sources: Default::default(),
|
||||
cache: std::sync::Mutex::new(None),
|
||||
db: Mutex::new(Db::open_in_memory().unwrap()),
|
||||
out,
|
||||
ctx: egui::Context::default(),
|
||||
});
|
||||
let symbol = "BTCUSD Curncy".to_string();
|
||||
handle(&s, Request::History { pane: 1, generation: 1, symbol, tf: Timeframe::D1, limit: 10 }).await;
|
||||
(s, rx, provider)
|
||||
}
|
||||
|
||||
/// The last close of each chart update, until there are `n`.
|
||||
async fn closes(rx: &mpsc::Receiver<Response>, n: usize) -> Vec<f64> {
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut closes = Vec::new();
|
||||
while closes.len() < n {
|
||||
assert!(t0.elapsed() < Duration::from_secs(15), "no reload: {closes:?}");
|
||||
match rx.try_recv() {
|
||||
Ok(Response::History { generation: 1, bars, .. }) => closes.push(bars.last().unwrap().close),
|
||||
Ok(_) => {}
|
||||
Err(_) => tokio::time::sleep(Duration::from_millis(50)).await,
|
||||
}
|
||||
}
|
||||
closes
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn charts_reload_when_the_provider_ends_their_live_feed() {
|
||||
let t0 = std::time::Instant::now();
|
||||
let (_s, rx, provider) = switching_chart().await;
|
||||
provider.reload.notify_one();
|
||||
assert_eq!(closes(&rx, 2).await, [1.0, 2.0], "the reload replaced the chart");
|
||||
assert!(t0.elapsed() >= RELOAD_AFTER_FEED_ENDS, "not before the pause");
|
||||
assert_eq!(provider.loads.load(std::sync::atomic::Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pages_that_overlap_a_source_switch_are_dropped() {
|
||||
let (s, rx, provider) = switching_chart().await;
|
||||
let more = || Request::MoreHistory {
|
||||
pane: 1,
|
||||
generation: 1,
|
||||
symbol: "BTCUSD Curncy".into(),
|
||||
tf: Timeframe::D1,
|
||||
existing: Arc::new(Vec::new()),
|
||||
limit: 10,
|
||||
};
|
||||
// One page starts before the switch, and its bars arrive after it.
|
||||
let before = tokio::spawn({
|
||||
let (s, req) = (s.clone(), more());
|
||||
async move { handle(&s, req).await }
|
||||
});
|
||||
let t0 = std::time::Instant::now();
|
||||
while source_switches(&s, 1) != 1 {
|
||||
assert!(t0.elapsed() < Duration::from_secs(15), "no reload");
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
// Another starts while the chart reloads, and gives up at once.
|
||||
let during = tokio::time::timeout(Duration::from_secs(1), handle(&s, more())).await;
|
||||
assert!(during.is_ok(), "a page started during the reload went ahead");
|
||||
provider.reload.notify_one();
|
||||
assert_eq!(closes(&rx, 2).await, [1.0, 2.0]);
|
||||
provider.page.notify_one();
|
||||
before.await.unwrap();
|
||||
let ts: Vec<i64> = current_series(&s, 1, 1).unwrap().iter().map(|b| b.ts).collect();
|
||||
assert_eq!(ts, [0], "the old source's page joined the new source's bars");
|
||||
assert!(rx.try_iter().all(|r| !matches!(r, Response::History { prepended: 1.., .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_all_runs_everything() {
|
||||
let rt = tokio::runtime::Builder::new_current_thread().enable_time().build().unwrap();
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<title>Terminal</title>
|
||||
<link rel="icon" href="data:," />
|
||||
<script>
|
||||
// The WebGPU build when the browser has WebGPU, else the larger WebGL2 one (no charts).
|
||||
// Start downloading now; the module below cancels this if there is no WebGPU adapter.
|
||||
window.terminalLoad = { build: 'gpu' in navigator ? 'terminal' : 'terminal-webgl', abort: new AbortController() };
|
||||
// Each build's JS and wasm sit in a folder named after a hash of its files, so they can be
|
||||
// cached for good; scripts/build-web.sh fills in these paths.
|
||||
terminalLoad.paths = { terminal: './{terminal}', 'terminal-webgl': './{terminal-webgl}' };
|
||||
terminalLoad.wasm = fetch(`${terminalLoad.paths[terminalLoad.build]}_bg.wasm`, { signal: terminalLoad.abort.signal });
|
||||
document.head.append(Object.assign(document.createElement('link'), { rel: 'modulepreload', href: `${terminalLoad.paths[terminalLoad.build]}.js` }));
|
||||
</script>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #1e1e20;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
html,
|
||||
body {
|
||||
background: #f5f5f7;
|
||||
}
|
||||
}
|
||||
canvas {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
outline: none;
|
||||
}
|
||||
#status {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 0;
|
||||
font: 13px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
color: #8e8e93;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="terminal"></canvas>
|
||||
<p id="status">Loading Terminal…</p>
|
||||
<script type="module">
|
||||
const load = window.terminalLoad;
|
||||
if (load.build === 'terminal' && !(await navigator.gpu.requestAdapter().catch(() => null))) {
|
||||
load.wasm.catch(() => {}); // the abort below rejects it
|
||||
load.abort.abort();
|
||||
load.build = 'terminal-webgl';
|
||||
load.wasm = fetch(`${load.paths[load.build]}_bg.wasm`);
|
||||
}
|
||||
window.terminalBuild = load.build;
|
||||
try {
|
||||
const { default: init } = await import(`${load.paths[load.build]}.js`);
|
||||
await init({ module_or_path: load.wasm }); // runs the app, which removes #status after its first frame
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
document.getElementById('status').textContent = `Terminal couldn't start: ${e}`;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -16,10 +16,22 @@ futures = "0.3"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.23"
|
||||
zeroize = "1"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
security-framework = "3"
|
||||
|
||||
# Hyperliquid agent keys on Linux: the Secret Service over D-Bus (pure Rust, no libdbus).
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
keyring = { version = "3", default-features = false, features = ["async-secret-service", "tokio", "crypto-rust"] }
|
||||
|
||||
[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
|
||||
@@ -0,0 +1,589 @@
|
||||
//! 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,
|
||||
market: 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,
|
||||
market: 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),
|
||||
market: None,
|
||||
},
|
||||
);
|
||||
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,12 +1,13 @@
|
||||
//! Brokerage integration through [SnapTrade](https://snaptrade.com): a signed API client, a
|
||||
//! normalised portfolio model (accounts, balances, positions, orders, activity), secure
|
||||
//! credential storage and an offline simulated portfolio.
|
||||
//! credential storage, an offline simulated portfolio and a local paper-trading [`demo`] account.
|
||||
//!
|
||||
//! Order entry is in [`trading`]: every order is previewed with SnapTrade's order-impact check
|
||||
//! before it can be placed, and the terminal keeps trading off unless the user enables it.
|
||||
//! Besides orders and registering a user, the only write is removing a brokerage connection
|
||||
//! when the user explicitly asks to disconnect it.
|
||||
|
||||
pub mod demo;
|
||||
pub mod model;
|
||||
pub mod sample;
|
||||
pub mod secrets;
|
||||
|
||||
@@ -46,7 +46,7 @@ pub struct Connection {
|
||||
pub disabled: bool,
|
||||
pub created: Option<DateTime<Utc>>,
|
||||
pub updated: Option<DateTime<Utc>>,
|
||||
/// Whether the connection allows trading (terminal-rs only trades when the user enables it).
|
||||
/// Whether the connection allows trading (Terminal only trades when the user enables it).
|
||||
pub trading: bool,
|
||||
}
|
||||
|
||||
@@ -127,6 +127,10 @@ pub struct Position {
|
||||
pub cash_equivalent: bool,
|
||||
/// For options: the underlying ticker.
|
||||
pub underlying: Option<String>,
|
||||
/// The terminal market key the position prices and charts by, when the source names one
|
||||
/// exactly (`xyz:AAPL` for a Hyperliquid HIP-3 perp). It wins over the kind-based mapping.
|
||||
#[serde(default)]
|
||||
pub market: Option<String>,
|
||||
}
|
||||
|
||||
impl Position {
|
||||
@@ -153,9 +157,13 @@ impl Position {
|
||||
(cost.abs() > 1e-9).then(|| self.pnl_at(price).map(|p| p / cost.abs() * 100.0)).flatten()
|
||||
}
|
||||
|
||||
/// The terminal market key for charting, quotes and Congress lookups, when the
|
||||
/// instrument maps onto one (US-listed equities and ETFs, crypto, option underlyings).
|
||||
/// The terminal market key for charting, quotes and Congress lookups: [`Self::market`]
|
||||
/// when set, else the kind-based mapping (US-listed equities and ETFs, crypto, option
|
||||
/// underlyings).
|
||||
pub fn market_key(&self) -> Option<String> {
|
||||
if let Some(m) = &self.market {
|
||||
return Some(m.clone());
|
||||
}
|
||||
market_key(self.kind, self.underlying.as_deref().unwrap_or(&self.symbol), &self.currency)
|
||||
}
|
||||
|
||||
@@ -261,6 +269,9 @@ pub struct Order {
|
||||
pub placed: Option<DateTime<Utc>>,
|
||||
pub updated: Option<DateTime<Utc>>,
|
||||
pub underlying: Option<String>,
|
||||
/// As [`Position::market`].
|
||||
#[serde(default)]
|
||||
pub market: Option<String>,
|
||||
}
|
||||
|
||||
impl Order {
|
||||
@@ -285,6 +296,9 @@ impl Order {
|
||||
}
|
||||
|
||||
pub fn market_key(&self) -> Option<String> {
|
||||
if let Some(m) = &self.market {
|
||||
return Some(m.clone());
|
||||
}
|
||||
market_key(self.kind, self.underlying.as_deref().unwrap_or(&self.symbol), "USD")
|
||||
}
|
||||
|
||||
@@ -308,10 +322,16 @@ pub struct Activity {
|
||||
pub currency: String,
|
||||
pub trade_date: Option<DateTime<Utc>>,
|
||||
pub settlement_date: Option<DateTime<Utc>>,
|
||||
/// As [`Position::market`].
|
||||
#[serde(default)]
|
||||
pub market: Option<String>,
|
||||
}
|
||||
|
||||
impl Activity {
|
||||
pub fn market_key(&self) -> Option<String> {
|
||||
if let Some(m) = &self.market {
|
||||
return Some(m.clone());
|
||||
}
|
||||
market_key(InstrumentKind::Stock, self.symbol.as_deref()?, &self.currency)
|
||||
}
|
||||
}
|
||||
@@ -331,6 +351,9 @@ pub struct Portfolio {
|
||||
pub warnings: Vec<String>,
|
||||
/// Offline sample data, not a real brokerage.
|
||||
pub simulated: bool,
|
||||
/// The local paper-trading account ([`crate::demo`]), not a brokerage.
|
||||
#[serde(default)]
|
||||
pub demo: bool,
|
||||
}
|
||||
|
||||
/// Totals for a set of accounts, in the first account's currency.
|
||||
@@ -369,9 +392,16 @@ impl Portfolio {
|
||||
t.cash += a.cash.unwrap_or(0.0);
|
||||
t.buying_power += a.buying_power.unwrap_or(0.0);
|
||||
}
|
||||
// Futures (Hyperliquid perps) are margined: the account's cash backs them, so their value
|
||||
// is their profit or loss, never their notional.
|
||||
let mut futures_pnl = 0.0;
|
||||
for p in self.positions.iter().filter(|p| selected(&p.account_id) && !p.cash_equivalent) {
|
||||
let live = price_of(p);
|
||||
t.invested += p.market_value_at(live).unwrap_or(0.0);
|
||||
if p.kind == InstrumentKind::Future {
|
||||
futures_pnl += p.pnl_at(live).unwrap_or(0.0);
|
||||
} else {
|
||||
t.invested += p.market_value_at(live).unwrap_or(0.0);
|
||||
}
|
||||
if let (Some(cost), Some(pnl)) = (p.cost(), p.pnl_at(live)) {
|
||||
t.cost += cost;
|
||||
t.pnl += pnl;
|
||||
@@ -389,7 +419,7 @@ impl Portfolio {
|
||||
t.value = if reported.len() == n && n > 0 && price_of_is_trivial(&self.positions, &price_of) {
|
||||
reported.iter().sum()
|
||||
} else {
|
||||
t.cash + t.invested + cash_like
|
||||
t.cash + t.invested + futures_pnl + cash_like
|
||||
};
|
||||
t
|
||||
}
|
||||
@@ -535,6 +565,7 @@ pub fn parse_positions(account_id: &str, v: &Value) -> Vec<Position> {
|
||||
.unwrap_or_else(|| text(&inst["currency"])),
|
||||
cash_equivalent: p["cash_equivalent"].as_bool().unwrap_or(false),
|
||||
underlying,
|
||||
market: None,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -594,6 +625,7 @@ pub fn parse_orders(account_id: &str, v: &Value) -> Vec<Order> {
|
||||
placed: time(&o["time_placed"]),
|
||||
updated: time(&o["time_updated"]).or_else(|| time(&o["time_executed"])),
|
||||
underlying,
|
||||
market: None,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -620,6 +652,7 @@ pub fn parse_activities(account_id: &str, v: &Value) -> Vec<Activity> {
|
||||
currency: Some(text(&a["currency"]["code"])).filter(|c| !c.is_empty()).unwrap_or("USD".into()),
|
||||
trade_date: time(&a["trade_date"]),
|
||||
settlement_date: time(&a["settlement_date"]),
|
||||
market: None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -642,6 +675,7 @@ mod tests {
|
||||
currency: "USD".into(),
|
||||
cash_equivalent: false,
|
||||
underlying: None,
|
||||
market: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -683,6 +717,24 @@ mod tests {
|
||||
assert_eq!(opt.market_key().as_deref(), Some("AAPL US Equity"), "options chart their underlying");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exact_market_key_wins_over_the_kind() {
|
||||
// A Hyperliquid HIP-3 perp: a future whose key the source names exactly.
|
||||
let mut perp = pos(InstrumentKind::Future, "xyz:AAPL", -0.5, 320.0, 300.0);
|
||||
assert_eq!(perp.market_key(), None, "futures have no key of their own");
|
||||
perp.market = Some("xyz:AAPL".into());
|
||||
assert_eq!(perp.market_key().as_deref(), Some("xyz:AAPL"), "never AAPL US Equity");
|
||||
let mut btc = pos(InstrumentKind::Future, "BTC", 0.01, 76_000.0, 70_000.0);
|
||||
btc.market = Some("BTCUSD Curncy".into());
|
||||
assert_eq!(btc.market_key().as_deref(), Some("BTCUSD Curncy"));
|
||||
// SnapTrade rows have none and keep today's mapping.
|
||||
assert_eq!(pos(InstrumentKind::Stock, "AAPL", 1.0, 1.0, 1.0).market_key().as_deref(), Some("AAPL US Equity"));
|
||||
// Saved data from before the field existed still loads.
|
||||
let mut v = serde_json::to_value(pos(InstrumentKind::Stock, "AAPL", 1.0, 1.0, 1.0)).unwrap();
|
||||
v.as_object_mut().unwrap().remove("market");
|
||||
assert_eq!(serde_json::from_value::<Position>(v).unwrap().market, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totals_reprice_with_live_quotes() {
|
||||
let mut pf = Portfolio {
|
||||
@@ -721,6 +773,40 @@ mod tests {
|
||||
assert_eq!(pf.totals(Some("other"), |_| None), Totals::default());
|
||||
}
|
||||
|
||||
/// Greptile #37: a perp is margined, so repricing it moves equity by its P&L, never by its
|
||||
/// notional. 10 long at 190 marked 200 (P&L 100) in an account worth 1,000.
|
||||
#[test]
|
||||
fn futures_reprice_by_pnl_not_notional() {
|
||||
let account = |cash: f64| Account {
|
||||
id: "hl".into(),
|
||||
name: "Hyperliquid".into(),
|
||||
number: String::new(),
|
||||
institution: "Hyperliquid".into(),
|
||||
currency: "USD".into(),
|
||||
total: Some(1000.0),
|
||||
cash: Some(cash),
|
||||
buying_power: Some(200.0),
|
||||
kind: "Perps".into(),
|
||||
status: String::new(),
|
||||
is_paper: true,
|
||||
synced: None,
|
||||
connection_id: String::new(),
|
||||
};
|
||||
let mut long = pos(InstrumentKind::Future, "BTC", 10.0, 200.0, 190.0);
|
||||
long.account_id = "hl".into();
|
||||
// Cash is the collateral: equity less unrealised P&L.
|
||||
let pf = Portfolio { accounts: vec![account(900.0)], positions: vec![long.clone()], ..Default::default() };
|
||||
assert_eq!(pf.totals(None, |_| None).value, 1000.0, "reported equity");
|
||||
assert_eq!(pf.totals(None, |_| Some(200.0)).value, 1000.0, "the same price leaves equity alone");
|
||||
assert_eq!(pf.totals(None, |_| Some(210.0)).value, 1100.0, "+10 × 10");
|
||||
assert_eq!(pf.totals(None, |_| Some(200.0)).invested, 0.0, "perp notional is not invested cash");
|
||||
let mut short = pos(InstrumentKind::Future, "BTC", -10.0, 200.0, 210.0);
|
||||
short.account_id = "hl".into();
|
||||
let pf = Portfolio { accounts: vec![account(900.0)], positions: vec![short], ..Default::default() };
|
||||
assert_eq!(pf.totals(None, |_| Some(200.0)).value, 1000.0);
|
||||
assert_eq!(pf.totals(None, |_| Some(190.0)).value, 1100.0, "a short gains as the price falls");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occ_symbols() {
|
||||
let o = parse_occ("NVDA 261218C00250000").unwrap();
|
||||
|
||||
@@ -46,6 +46,7 @@ fn position(
|
||||
currency: "USD".into(),
|
||||
cash_equivalent: false,
|
||||
underlying: None,
|
||||
market: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +121,7 @@ pub fn portfolio() -> Portfolio {
|
||||
placed: Some(at(days, 14)),
|
||||
updated: Some(at(days, 15)),
|
||||
underlying: None,
|
||||
market: None,
|
||||
};
|
||||
let orders = vec![
|
||||
order("sim-brokerage", "sim-o-1", "NVDA", K::Stock, "BUY", "PENDING", "Limit", 10.0, Some(205.00), None, 0),
|
||||
@@ -162,6 +164,7 @@ pub fn portfolio() -> Portfolio {
|
||||
currency: "USD".into(),
|
||||
trade_date: Some(at(days, 14)),
|
||||
settlement_date: Some(at(days - 1, 0)),
|
||||
market: None,
|
||||
};
|
||||
let activities = vec![
|
||||
act(
|
||||
@@ -215,6 +218,7 @@ pub fn portfolio() -> Portfolio {
|
||||
fetched: Some(now),
|
||||
warnings: Vec::new(),
|
||||
simulated: true,
|
||||
demo: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+321
-20
@@ -2,9 +2,12 @@
|
||||
//!
|
||||
//! The partner keys (`clientId`, `consumerKey`) and the per-user `userId`/`userSecret` are
|
||||
//! stored together as one small JSON document: in the macOS Keychain (generic password,
|
||||
//! service `terminal-rs`, account `snaptrade`), or elsewhere in an owner-only (0600) file in
|
||||
//! service `terminal`, account `snaptrade`), or elsewhere in an owner-only (0600) file in
|
||||
//! the app's data directory. `SNAPTRADE_*` environment variables override stored values
|
||||
//! field by field. Nothing is ever written inside the repository.
|
||||
//!
|
||||
//! Before the app was renamed from terminal-rs, the Keychain service was `terminal-rs`; the
|
||||
//! first load moves credentials from there.
|
||||
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -12,7 +15,9 @@ use std::path::{Path, PathBuf};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const KEYCHAIN_SERVICE: &str = "terminal-rs";
|
||||
pub const KEYCHAIN_SERVICE: &str = "terminal";
|
||||
/// The service credentials were saved under before the rename.
|
||||
pub const LEGACY_KEYCHAIN_SERVICE: &str = "terminal-rs";
|
||||
pub const KEYCHAIN_ACCOUNT: &str = "snaptrade";
|
||||
|
||||
/// Environment variables that override stored credentials.
|
||||
@@ -102,8 +107,8 @@ pub fn mask(secret: &str) -> String {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Backend {
|
||||
/// macOS Keychain generic password.
|
||||
Keychain,
|
||||
/// macOS Keychain generic password under `service`, moved there from `legacy` on first load.
|
||||
Keychain { service: String, legacy: Option<String> },
|
||||
/// Owner-only JSON file.
|
||||
File(PathBuf),
|
||||
}
|
||||
@@ -119,10 +124,19 @@ impl SecretStore {
|
||||
pub fn platform(data_dir: &Path) -> Self {
|
||||
let file = Backend::File(data_dir.join("secrets").join("snaptrade.json"));
|
||||
let force_file = std::env::var("TR_SECRET_STORE").is_ok_and(|v| v.eq_ignore_ascii_case("file"));
|
||||
let backend = if cfg!(target_os = "macos") && !force_file { Backend::Keychain } else { file };
|
||||
let backend = if cfg!(target_os = "macos") && !force_file {
|
||||
Backend::Keychain { service: KEYCHAIN_SERVICE.into(), legacy: Some(LEGACY_KEYCHAIN_SERVICE.into()) }
|
||||
} else {
|
||||
file
|
||||
};
|
||||
Self { backend }
|
||||
}
|
||||
|
||||
/// The Keychain under `service`, taking over an item left under `legacy`.
|
||||
pub fn keychain(service: &str, legacy: Option<&str>) -> Self {
|
||||
Self { backend: Backend::Keychain { service: service.into(), legacy: legacy.map(Into::into) } }
|
||||
}
|
||||
|
||||
pub fn file(path: impl Into<PathBuf>) -> Self {
|
||||
Self { backend: Backend::File(path.into()) }
|
||||
}
|
||||
@@ -130,14 +144,14 @@ impl SecretStore {
|
||||
/// Where credentials live, for the settings screen.
|
||||
pub fn describe(&self) -> String {
|
||||
match &self.backend {
|
||||
Backend::Keychain => format!("macOS Keychain ({KEYCHAIN_SERVICE} / {KEYCHAIN_ACCOUNT})"),
|
||||
Backend::Keychain { service, .. } => format!("macOS Keychain ({service} / {KEYCHAIN_ACCOUNT})"),
|
||||
Backend::File(p) => format!("{} (owner-only)", p.display()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(&self) -> Result<Option<Credentials>> {
|
||||
let text = match &self.backend {
|
||||
Backend::Keychain => keychain::get()?,
|
||||
Backend::Keychain { service, legacy } => load_keychain(service, legacy.as_deref())?,
|
||||
Backend::File(p) => match std::fs::read_to_string(p) {
|
||||
Ok(t) => Some(t),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
|
||||
@@ -155,7 +169,7 @@ impl SecretStore {
|
||||
pub fn save(&self, creds: &Credentials) -> Result<()> {
|
||||
let json = serde_json::to_string(&creds.trimmed())?;
|
||||
match &self.backend {
|
||||
Backend::Keychain => keychain::set(&json),
|
||||
Backend::Keychain { service, .. } => keychain::set(service, &json),
|
||||
Backend::File(p) => write_private(p, &json),
|
||||
}
|
||||
}
|
||||
@@ -163,7 +177,11 @@ impl SecretStore {
|
||||
/// Remove stored credentials (environment variables are unaffected).
|
||||
pub fn clear(&self) -> Result<()> {
|
||||
match &self.backend {
|
||||
Backend::Keychain => keychain::delete(),
|
||||
Backend::Keychain { service, legacy } => {
|
||||
keychain::delete(service)?;
|
||||
// The old item too, or the credentials would come back from it on the next load.
|
||||
legacy.as_deref().map_or(Ok(()), keychain::delete)
|
||||
}
|
||||
Backend::File(p) => match std::fs::remove_file(p) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
|
||||
_ => Ok(()),
|
||||
@@ -172,6 +190,24 @@ impl SecretStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the Keychain item under `service`. If there is none, the item under `legacy` moves
|
||||
/// there: it is deleted only after the copy is written, so a failed move leaves it in place.
|
||||
fn load_keychain(service: &str, legacy: Option<&str>) -> Result<Option<String>> {
|
||||
if let Some(text) = keychain::get(service)? {
|
||||
return Ok(Some(text));
|
||||
}
|
||||
let Some(legacy) = legacy else { return Ok(None) };
|
||||
let Some(text) = keychain::get(legacy)? else { return Ok(None) };
|
||||
match keychain::set(service, &text) {
|
||||
Ok(()) => match keychain::delete(legacy) {
|
||||
Ok(()) => tracing::info!("moved SnapTrade credentials from Keychain service {legacy} to {service}"),
|
||||
Err(e) => tracing::warn!("removing SnapTrade credentials from Keychain service {legacy}: {e:#}"),
|
||||
},
|
||||
Err(e) => tracing::warn!("moving SnapTrade credentials to Keychain service {service}: {e:#}"),
|
||||
}
|
||||
Ok(Some(text))
|
||||
}
|
||||
|
||||
/// Write `text` to `path` readable only by the owner (0700 directory, 0600 file), replacing it
|
||||
/// atomically.
|
||||
fn write_private(path: &Path, text: &str) -> Result<()> {
|
||||
@@ -204,26 +240,38 @@ mod keychain {
|
||||
use anyhow::{Context, Result};
|
||||
use security_framework::passwords::{delete_generic_password, get_generic_password, set_generic_password};
|
||||
|
||||
use super::{KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE};
|
||||
use super::KEYCHAIN_ACCOUNT;
|
||||
|
||||
/// `errSecItemNotFound`.
|
||||
const NOT_FOUND: i32 = -25300;
|
||||
|
||||
pub fn get() -> Result<Option<String>> {
|
||||
match get_generic_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) {
|
||||
pub fn get(service: &str) -> Result<Option<String>> {
|
||||
get_item(service, KEYCHAIN_ACCOUNT)
|
||||
}
|
||||
|
||||
pub fn set(service: &str, value: &str) -> Result<()> {
|
||||
set_item(service, KEYCHAIN_ACCOUNT, value)
|
||||
}
|
||||
|
||||
pub fn delete(service: &str) -> Result<()> {
|
||||
delete_item(service, KEYCHAIN_ACCOUNT)
|
||||
}
|
||||
|
||||
pub fn get_item(service: &str, account: &str) -> Result<Option<String>> {
|
||||
match get_generic_password(service, account) {
|
||||
Ok(bytes) => Ok(Some(String::from_utf8(bytes).context("Keychain item is not UTF-8")?)),
|
||||
Err(e) if e.code() == NOT_FOUND => Ok(None),
|
||||
Err(e) => Err(anyhow::anyhow!("reading the Keychain: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set(value: &str) -> Result<()> {
|
||||
set_generic_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, value.as_bytes())
|
||||
pub fn set_item(service: &str, account: &str, value: &str) -> Result<()> {
|
||||
set_generic_password(service, account, value.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("writing the Keychain: {e}"))
|
||||
}
|
||||
|
||||
pub fn delete() -> Result<()> {
|
||||
match delete_generic_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) {
|
||||
pub fn delete_item(service: &str, account: &str) -> Result<()> {
|
||||
match delete_generic_password(service, account) {
|
||||
Err(e) if e.code() != NOT_FOUND => Err(anyhow::anyhow!("deleting from the Keychain: {e}")),
|
||||
_ => Ok(()),
|
||||
}
|
||||
@@ -234,15 +282,203 @@ mod keychain {
|
||||
mod keychain {
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
pub fn get() -> Result<Option<String>> {
|
||||
pub fn get(_: &str) -> Result<Option<String>> {
|
||||
bail!("the Keychain is only available on macOS")
|
||||
}
|
||||
pub fn set(_: &str) -> Result<()> {
|
||||
pub fn set(_: &str, _: &str) -> Result<()> {
|
||||
bail!("the Keychain is only available on macOS")
|
||||
}
|
||||
pub fn delete() -> Result<()> {
|
||||
pub fn delete(_: &str) -> Result<()> {
|
||||
bail!("the Keychain is only available on macOS")
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
pub fn get_item(_: &str, _: &str) -> Result<Option<String>> {
|
||||
bail!("the Keychain is only available on macOS")
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
pub fn set_item(_: &str, _: &str, _: &str) -> Result<()> {
|
||||
bail!("the Keychain is only available on macOS")
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
pub fn delete_item(_: &str, _: &str) -> Result<()> {
|
||||
bail!("the Keychain is only available on macOS")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Hyperliquid agent keys ----------------------------------------------------------------
|
||||
|
||||
/// The Keychain service (and Secret Service attribute) for Hyperliquid agent keys, apart
|
||||
/// from the SnapTrade item.
|
||||
pub const HL_KEYCHAIN_SERVICE: &str = "terminal.hyperliquid";
|
||||
|
||||
/// Where Hyperliquid agent (API wallet) keys live: the macOS Keychain, the Linux Secret
|
||||
/// Service, or memory (the browser's session-only keys). Never a file: without a system
|
||||
/// secret store the key has nowhere safe to go, so Hyperliquid trading stays off.
|
||||
///
|
||||
/// Items are named by account, `testnet:<master address>`. Values are the key's hex; this
|
||||
/// type hands them back in buffers that are wiped when dropped, and never logs them.
|
||||
#[derive(Clone)]
|
||||
pub struct AgentKeyStore {
|
||||
backend: KeyBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum KeyBackend {
|
||||
Keychain,
|
||||
SecretService,
|
||||
Memory(std::sync::Arc<std::sync::Mutex<std::collections::HashMap<String, zeroize::Zeroizing<String>>>>),
|
||||
Unavailable(String),
|
||||
}
|
||||
|
||||
impl fmt::Debug for AgentKeyStore {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.describe())
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentKeyStore {
|
||||
/// The system store for this platform, or why there is none.
|
||||
pub fn platform() -> Self {
|
||||
let backend = if cfg!(target_os = "macos") {
|
||||
KeyBackend::Keychain
|
||||
} else if cfg!(target_os = "linux") {
|
||||
KeyBackend::SecretService
|
||||
} else {
|
||||
KeyBackend::Unavailable(
|
||||
"Hyperliquid trading keys need the macOS Keychain or a Linux Secret Service, and this platform has neither in this build."
|
||||
.into(),
|
||||
)
|
||||
};
|
||||
Self { backend }
|
||||
}
|
||||
|
||||
/// Keys held only while the app runs (lost on quit or reload).
|
||||
pub fn memory() -> Self {
|
||||
Self { backend: KeyBackend::Memory(Default::default()) }
|
||||
}
|
||||
|
||||
pub fn unavailable(why: impl Into<String>) -> Self {
|
||||
Self { backend: KeyBackend::Unavailable(why.into()) }
|
||||
}
|
||||
|
||||
pub fn describe(&self) -> String {
|
||||
match &self.backend {
|
||||
KeyBackend::Keychain => format!("macOS Keychain ({HL_KEYCHAIN_SERVICE})"),
|
||||
KeyBackend::SecretService => format!("the Secret Service keyring ({HL_KEYCHAIN_SERVICE})"),
|
||||
KeyBackend::Memory(_) => "memory only (gone when Terminal closes)".into(),
|
||||
KeyBackend::Unavailable(_) => "no secret store".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_memory(&self) -> bool {
|
||||
matches!(self.backend, KeyBackend::Memory(_))
|
||||
}
|
||||
|
||||
/// Whether keys can be stored, and why not. On Linux this asks the Secret Service, so
|
||||
/// call it off the UI thread.
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
match &self.backend {
|
||||
KeyBackend::Unavailable(why) => Err(why.clone()),
|
||||
KeyBackend::SecretService => secret_service::probe(),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(&self, account: &str) -> Result<Option<zeroize::Zeroizing<String>>> {
|
||||
match &self.backend {
|
||||
KeyBackend::Keychain => Ok(keychain::get_item(HL_KEYCHAIN_SERVICE, account)?.map(zeroize::Zeroizing::new)),
|
||||
KeyBackend::SecretService => secret_service::get(account),
|
||||
KeyBackend::Memory(m) => Ok(m.lock().expect("key store").get(account).cloned()),
|
||||
KeyBackend::Unavailable(why) => anyhow::bail!("{why}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self, account: &str, secret: &str) -> Result<()> {
|
||||
match &self.backend {
|
||||
KeyBackend::Keychain => keychain::set_item(HL_KEYCHAIN_SERVICE, account, secret),
|
||||
KeyBackend::SecretService => secret_service::set(account, secret),
|
||||
KeyBackend::Memory(m) => {
|
||||
m.lock().expect("key store").insert(account.into(), zeroize::Zeroizing::new(secret.into()));
|
||||
Ok(())
|
||||
}
|
||||
KeyBackend::Unavailable(why) => anyhow::bail!("{why}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a key. Removing one that isn't there succeeds.
|
||||
pub fn delete(&self, account: &str) -> Result<()> {
|
||||
match &self.backend {
|
||||
KeyBackend::Keychain => keychain::delete_item(HL_KEYCHAIN_SERVICE, account),
|
||||
KeyBackend::SecretService => secret_service::delete(account),
|
||||
KeyBackend::Memory(m) => {
|
||||
m.lock().expect("key store").remove(account);
|
||||
Ok(())
|
||||
}
|
||||
KeyBackend::Unavailable(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Linux Secret Service (GNOME Keyring, KWallet) through `keyring`.
|
||||
#[cfg(target_os = "linux")]
|
||||
mod secret_service {
|
||||
use anyhow::Result;
|
||||
use keyring::{Entry, Error};
|
||||
|
||||
use super::HL_KEYCHAIN_SERVICE;
|
||||
|
||||
fn unavailable(e: Error) -> String {
|
||||
format!(
|
||||
"No Secret Service keyring is available ({e}). Hyperliquid trading keys are never written to a file, so trading stays off until a keyring (GNOME Keyring or KWallet) is running."
|
||||
)
|
||||
}
|
||||
|
||||
pub fn probe() -> Result<(), String> {
|
||||
let e = Entry::new(HL_KEYCHAIN_SERVICE, "probe").map_err(unavailable)?;
|
||||
match e.get_password() {
|
||||
Ok(_) | Err(Error::NoEntry) => Ok(()),
|
||||
Err(err) => Err(unavailable(err)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(account: &str) -> Result<Option<zeroize::Zeroizing<String>>> {
|
||||
match Entry::new(HL_KEYCHAIN_SERVICE, account)?.get_password() {
|
||||
Ok(s) => Ok(Some(zeroize::Zeroizing::new(s))),
|
||||
Err(Error::NoEntry) => Ok(None),
|
||||
Err(e) => Err(anyhow::anyhow!(unavailable(e))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set(account: &str, secret: &str) -> Result<()> {
|
||||
Entry::new(HL_KEYCHAIN_SERVICE, account)?.set_password(secret).map_err(|e| anyhow::anyhow!(unavailable(e)))
|
||||
}
|
||||
|
||||
pub fn delete(account: &str) -> Result<()> {
|
||||
match Entry::new(HL_KEYCHAIN_SERVICE, account)?.delete_credential() {
|
||||
Ok(()) | Err(Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(anyhow::anyhow!(unavailable(e))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
mod secret_service {
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
const WHY: &str = "the Secret Service is only used on Linux";
|
||||
|
||||
pub fn probe() -> Result<(), String> {
|
||||
Err(WHY.into())
|
||||
}
|
||||
pub fn get(_: &str) -> Result<Option<zeroize::Zeroizing<String>>> {
|
||||
bail!(WHY)
|
||||
}
|
||||
pub fn set(_: &str, _: &str) -> Result<()> {
|
||||
bail!(WHY)
|
||||
}
|
||||
pub fn delete(_: &str) -> Result<()> {
|
||||
bail!(WHY)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -253,7 +489,7 @@ mod tests {
|
||||
Credentials {
|
||||
client_id: "PARTNER-ID".into(),
|
||||
consumer_key: "consumer-key-0123456789".into(),
|
||||
user_id: "terminal-rs-1".into(),
|
||||
user_id: "terminal-1".into(),
|
||||
user_secret: "user-secret-abcdef".into(),
|
||||
}
|
||||
}
|
||||
@@ -313,4 +549,69 @@ mod tests {
|
||||
assert_eq!(store.load().unwrap(), None);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_keys_never_fall_back_to_a_file() {
|
||||
let none = AgentKeyStore::unavailable("no keyring here");
|
||||
assert_eq!(none.check(), Err("no keyring here".into()));
|
||||
assert!(none.save("testnet:0xabc", "secret").is_err(), "refused, not written anywhere");
|
||||
assert!(none.load("testnet:0xabc").is_err());
|
||||
assert!(none.delete("testnet:0xabc").is_ok());
|
||||
let platform = AgentKeyStore::platform();
|
||||
if cfg!(target_os = "macos") {
|
||||
assert!(platform.describe().contains("Keychain"));
|
||||
} else if cfg!(target_os = "linux") {
|
||||
assert!(platform.describe().contains("Secret Service"));
|
||||
} else {
|
||||
assert!(platform.check().is_err(), "other platforms have no store, so trading stays off");
|
||||
}
|
||||
let mem = AgentKeyStore::memory();
|
||||
mem.save("testnet:0xabc", "0x11").unwrap();
|
||||
assert_eq!(mem.load("testnet:0xabc").unwrap().as_deref().map(String::as_str), Some("0x11"));
|
||||
mem.delete("testnet:0xabc").unwrap();
|
||||
assert_eq!(mem.load("testnet:0xabc").unwrap(), None);
|
||||
assert!(!format!("{mem:?}").contains("0x11"));
|
||||
}
|
||||
|
||||
/// Writes a throwaway agent key to the login Keychain, so it runs only when asked:
|
||||
/// `cargo test -p tr-broker -- --ignored agent_key`.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
#[ignore = "writes to the login Keychain"]
|
||||
fn agent_key_round_trips_through_the_keychain() {
|
||||
let store = AgentKeyStore::platform();
|
||||
let account = format!("test:{}", std::process::id());
|
||||
store.save(&account, "0x00").unwrap();
|
||||
assert_eq!(store.load(&account).unwrap().as_deref().map(String::as_str), Some("0x00"));
|
||||
store.delete(&account).unwrap();
|
||||
assert_eq!(store.load(&account).unwrap(), None);
|
||||
store.delete(&account).unwrap();
|
||||
}
|
||||
|
||||
/// Writes throwaway items (dummy credentials) to the login Keychain, so it runs only when
|
||||
/// asked: `cargo test -p tr-broker -- --ignored keychain`.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
#[ignore = "writes to the login Keychain"]
|
||||
fn keychain_moves_credentials_from_the_old_service() {
|
||||
let pid = std::process::id();
|
||||
let (service, legacy) = (format!("terminal-test-{pid}"), format!("terminal-rs-test-{pid}"));
|
||||
let store = SecretStore::keychain(&service, Some(&legacy));
|
||||
let new_only = SecretStore::keychain(&service, None);
|
||||
let old_only = SecretStore::keychain(&legacy, None);
|
||||
old_only.save(&creds()).unwrap();
|
||||
|
||||
assert_eq!(store.load().unwrap(), Some(creds()), "found under the old service");
|
||||
assert_eq!(new_only.load().unwrap(), Some(creds()), "and moved to the new one");
|
||||
assert_eq!(old_only.load().unwrap(), None, "the old item is gone");
|
||||
|
||||
// A stale item under the old service never replaces what the new one holds.
|
||||
old_only.save(&Credentials { client_id: "STALE".into(), ..creds() }).unwrap();
|
||||
assert_eq!(store.load().unwrap(), Some(creds()));
|
||||
|
||||
// Forgetting credentials removes both, so the old item can't bring them back.
|
||||
store.clear().unwrap();
|
||||
assert_eq!(old_only.load().unwrap(), None);
|
||||
assert_eq!(store.load().unwrap(), None);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,8 @@
|
||||
//! This module holds the reads, user registration, the read-only Connection Portal link and
|
||||
//! deleting a connection on explicit request; order entry lives in [`crate::trading`].
|
||||
|
||||
use std::time::Duration;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use base64::Engine;
|
||||
@@ -32,6 +33,20 @@ pub const ORDER_DAYS: u32 = 30;
|
||||
pub const ACTIVITY_DAYS: i64 = 90;
|
||||
pub const ACTIVITY_LIMIT: u32 = 100;
|
||||
|
||||
/// At most this many SnapTrade requests are in flight at once, across every client in the
|
||||
/// process. SnapTrade rate-limits per key, and loading many accounts at once (four requests
|
||||
/// per account) is what trips it.
|
||||
pub const MAX_CONCURRENT: usize = 4;
|
||||
/// A throttled read is retried this many times.
|
||||
pub const MAX_RETRIES: u32 = 3;
|
||||
/// Longest `Retry-After` honoured inside one call; longer waits go back to the caller.
|
||||
pub const MAX_INLINE_WAIT: Duration = Duration::from_secs(20);
|
||||
|
||||
static IN_FLIGHT: LazyLock<tokio::sync::Semaphore> = LazyLock::new(|| tokio::sync::Semaphore::new(MAX_CONCURRENT));
|
||||
/// After a throttled answer nothing is sent until this instant, so one `Retry-After` pauses
|
||||
/// every request rather than each finding out on its own.
|
||||
static COOLDOWN: Mutex<Option<Instant>> = Mutex::new(None);
|
||||
|
||||
pub struct SnapTrade {
|
||||
http: reqwest::Client,
|
||||
base: Url,
|
||||
@@ -120,6 +135,49 @@ pub fn signature(consumer_key: &str, path: &str, query: &str, body: Option<&Valu
|
||||
pub struct ApiError {
|
||||
pub status: u16,
|
||||
pub message: String,
|
||||
/// Seconds to wait before trying again, from `Retry-After` or the throttle message.
|
||||
pub retry_after: Option<u64>,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
/// SnapTrade (or the broker behind it) refused the request for rate limiting.
|
||||
pub fn throttled(&self) -> bool {
|
||||
self.status == 429
|
||||
}
|
||||
}
|
||||
|
||||
/// The error is a SnapTrade rate limit, and how long to wait if SnapTrade said.
|
||||
pub fn throttle(e: &anyhow::Error) -> Option<Option<Duration>> {
|
||||
let api = e.downcast_ref::<ApiError>().filter(|a| a.throttled())?;
|
||||
Some(api.retry_after.map(Duration::from_secs))
|
||||
}
|
||||
|
||||
/// `Retry-After` in seconds (the HTTP-date form is not used by SnapTrade).
|
||||
fn retry_after_header(h: &reqwest::header::HeaderMap) -> Option<u64> {
|
||||
h.get(reqwest::header::RETRY_AFTER)?.to_str().ok()?.trim().parse::<u64>().ok()
|
||||
}
|
||||
|
||||
/// DRF's throttle text: `Request was throttled. Expected available in 37 seconds.`
|
||||
fn throttle_text(body: &str) -> Option<Option<u64>> {
|
||||
let l = body.to_ascii_lowercase();
|
||||
if !l.contains("throttled") && !l.contains("rate limit") && !l.contains("too many requests") {
|
||||
return None;
|
||||
}
|
||||
let secs = l.split("available in").nth(1).and_then(|rest| {
|
||||
rest.split(|c: char| !c.is_ascii_digit() && c != '.').find(|w| !w.is_empty())?.parse::<f64>().ok()
|
||||
});
|
||||
Some(secs.map(|s| s.ceil().max(1.0) as u64))
|
||||
}
|
||||
|
||||
/// A short, friendly line for a rate limit; never SnapTrade's raw text.
|
||||
pub fn throttle_message(retry_after: Option<u64>) -> String {
|
||||
match retry_after {
|
||||
Some(s) if s > 90 => {
|
||||
format!("SnapTrade is rate-limiting requests; trying again in about {} minutes", s.div_ceil(60))
|
||||
}
|
||||
Some(s) => format!("SnapTrade is rate-limiting requests; trying again in about {s} s"),
|
||||
None => "SnapTrade is rate-limiting requests; trying again shortly".into(),
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ApiError {
|
||||
@@ -130,6 +188,18 @@ impl std::fmt::Display for ApiError {
|
||||
|
||||
impl std::error::Error for ApiError {}
|
||||
|
||||
/// The request was not sent at all (for example its deadline passed while waiting).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NotSent(pub String);
|
||||
|
||||
impl std::fmt::Display for NotSent {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for NotSent {}
|
||||
|
||||
/// A readable message from a SnapTrade error body (`detail`, `message`, `code`), without
|
||||
/// echoing anything from the request.
|
||||
fn error_message(status: reqwest::StatusCode, body: &str) -> String {
|
||||
@@ -140,7 +210,6 @@ fn error_message(status: reqwest::StatusCode, body: &str) -> String {
|
||||
401 => " (check the client ID and consumer key)",
|
||||
403 => " (the key may not allow this, or the user secret is wrong)",
|
||||
404 => " (unknown user or account)",
|
||||
429 => " (rate limited; try again shortly)",
|
||||
_ => "",
|
||||
};
|
||||
match (detail.is_empty(), code.is_empty()) {
|
||||
@@ -150,6 +219,36 @@ fn error_message(status: reqwest::StatusCode, body: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn cooldown_left() -> Option<Duration> {
|
||||
let until = (*COOLDOWN.lock().unwrap_or_else(|p| p.into_inner()))?;
|
||||
until.checked_duration_since(Instant::now()).filter(|d| !d.is_zero())
|
||||
}
|
||||
|
||||
/// Sleep until the shared cooldown has passed, re-checking after each sleep because another
|
||||
/// throttled answer may have extended it meanwhile. Gives up (nothing sent) as soon as the
|
||||
/// time left is longer than [`MAX_INLINE_WAIT`], or would run past `deadline`.
|
||||
async fn wait_for_cooldown(deadline: Option<Instant>) -> Result<()> {
|
||||
loop {
|
||||
let Some(left) = cooldown_left() else { return Ok(()) };
|
||||
let too_late = deadline.is_some_and(|d| Instant::now() + left >= d);
|
||||
if left > MAX_INLINE_WAIT || too_late {
|
||||
let secs = left.as_secs().max(1);
|
||||
return Err(ApiError { status: 429, message: throttle_message(Some(secs)), retry_after: Some(secs) }.into());
|
||||
}
|
||||
tokio::time::sleep(left).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Pause every request for the whole `wait` SnapTrade asked for (only the inline retry is
|
||||
/// capped at [`MAX_INLINE_WAIT`], not the pause).
|
||||
fn start_cooldown(wait: Duration) {
|
||||
let until = Instant::now() + wait;
|
||||
let mut c = COOLDOWN.lock().unwrap_or_else(|p| p.into_inner());
|
||||
if c.is_none_or(|t| t < until) {
|
||||
*c = Some(until);
|
||||
}
|
||||
}
|
||||
|
||||
impl SnapTrade {
|
||||
/// Client for `SNAPTRADE_BASE_URL` (tests, proxies) or the production API.
|
||||
pub fn new(creds: Credentials) -> Result<Self> {
|
||||
@@ -157,18 +256,27 @@ impl SnapTrade {
|
||||
Self::with_base(creds, &base)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn with_base(creds: Credentials, base: &str) -> Result<Self> {
|
||||
if !creds.has_keys() {
|
||||
bail!("SnapTrade client ID and consumer key are not set");
|
||||
}
|
||||
let http = reqwest::Client::builder()
|
||||
.user_agent(concat!("terminal-rs/", env!("CARGO_PKG_VERSION")))
|
||||
.user_agent(concat!("terminal/", env!("CARGO_PKG_VERSION")))
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()?;
|
||||
let base = Url::parse(base.trim_end_matches('/')).context("invalid SnapTrade base URL")?;
|
||||
Ok(Self { http, base, creds })
|
||||
}
|
||||
|
||||
/// SnapTrade doesn't answer cross-origin requests, so the browser build can't make a client
|
||||
/// (and so never sends an order).
|
||||
/// TODO(web proxy): call SnapTrade through a same-origin proxy that keeps the keys server-side.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn with_base(_creds: Credentials, _base: &str) -> Result<Self> {
|
||||
bail!("SnapTrade isn't available in the browser yet; use the desktop app for brokerage accounts")
|
||||
}
|
||||
|
||||
pub fn credentials(&self) -> &Credentials {
|
||||
&self.creds
|
||||
}
|
||||
@@ -194,6 +302,9 @@ impl SnapTrade {
|
||||
url
|
||||
}
|
||||
|
||||
/// One API call. At most [`MAX_CONCURRENT`] run at once; a throttled read (GET) waits as
|
||||
/// SnapTrade asks (or backs off exponentially) and is retried up to [`MAX_RETRIES`]
|
||||
/// times. Writes are never retried here: a throttled order is reported, not resent.
|
||||
pub(crate) async fn call(
|
||||
&self,
|
||||
method: Method,
|
||||
@@ -201,6 +312,54 @@ impl SnapTrade {
|
||||
params: &[(&str, String)],
|
||||
body: Option<Value>,
|
||||
user: bool,
|
||||
) -> Result<Value> {
|
||||
self.call_by(method, path, params, body, user, None).await
|
||||
}
|
||||
|
||||
/// [`Self::call`] that is never sent at or after `deadline` (an order preview's expiry):
|
||||
/// a rate-limit pause that would reach it fails the call unsent.
|
||||
pub(crate) async fn call_by(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
params: &[(&str, String)],
|
||||
body: Option<Value>,
|
||||
user: bool,
|
||||
deadline: Option<Instant>,
|
||||
) -> Result<Value> {
|
||||
let retry = method == Method::GET;
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
let result = {
|
||||
let _permit = IN_FLIGHT.acquire().await.expect("the limiter is never closed");
|
||||
// A long pause is reported rather than waited out: nothing was sent, and the
|
||||
// caller (or the user) can try again when it ends.
|
||||
wait_for_cooldown(deadline).await?;
|
||||
if deadline.is_some_and(|d| Instant::now() >= d) {
|
||||
anyhow::bail!(NotSent("the order's preview expired before it could be sent".into()));
|
||||
}
|
||||
self.call_once(method.clone(), path, params, body.clone(), user).await
|
||||
};
|
||||
let Err(e) = result else { return result };
|
||||
let Some(after) = throttle(&e) else { return Err(e) };
|
||||
let wait = after.unwrap_or(Duration::from_millis(1000 << attempt.min(5)));
|
||||
start_cooldown(wait);
|
||||
if !retry || attempt >= MAX_RETRIES || wait > MAX_INLINE_WAIT {
|
||||
return Err(e);
|
||||
}
|
||||
attempt += 1;
|
||||
tracing::debug!("SnapTrade throttled {path}; retry {attempt} in {wait:?}");
|
||||
tokio::time::sleep(wait).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn call_once(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
params: &[(&str, String)],
|
||||
body: Option<Value>,
|
||||
user: bool,
|
||||
) -> Result<Value> {
|
||||
let url = self.url(path, params, user, chrono::Utc::now().timestamp());
|
||||
let sig = signature(self.creds.consumer_key.trim(), url.path(), url.query().unwrap_or(""), body.as_ref());
|
||||
@@ -212,9 +371,20 @@ impl SnapTrade {
|
||||
// `without_url`: the URL carries the user secret and must never reach logs or the UI.
|
||||
let resp = req.send().await.map_err(|e| anyhow!("SnapTrade request failed: {}", e.without_url()))?;
|
||||
let status = resp.status();
|
||||
let header_wait = retry_after_header(resp.headers());
|
||||
let text = resp.text().await.map_err(|e| anyhow!("reading SnapTrade response: {}", e.without_url()))?;
|
||||
if !status.is_success() {
|
||||
return Err(ApiError { status: status.as_u16(), message: error_message(status, &text) }.into());
|
||||
// Some throttles arrive with another status but DRF's "Request was throttled" text.
|
||||
let throttled =
|
||||
if status.as_u16() == 429 { Some(throttle_text(&text).flatten()) } else { throttle_text(&text) };
|
||||
return Err(match throttled {
|
||||
Some(text_wait) => {
|
||||
let retry_after = header_wait.or(text_wait);
|
||||
ApiError { status: 429, message: throttle_message(retry_after), retry_after }
|
||||
}
|
||||
None => ApiError { status: status.as_u16(), message: error_message(status, &text), retry_after: None },
|
||||
}
|
||||
.into());
|
||||
}
|
||||
if text.trim().is_empty() {
|
||||
return Ok(Value::Null);
|
||||
@@ -400,4 +570,19 @@ mod tests {
|
||||
assert_eq!(m, "SnapTrade: Unable to verify signature sent [1076] (check the client ID and consumer key)");
|
||||
assert_eq!(error_message(reqwest::StatusCode::BAD_GATEWAY, "<html>"), "SnapTrade returned HTTP 502");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn throttle_messages_are_parsed_and_friendly() {
|
||||
assert_eq!(
|
||||
throttle_text(r#"{"detail":"Request was throttled. Expected available in 37 seconds."}"#),
|
||||
Some(Some(37))
|
||||
);
|
||||
assert_eq!(throttle_text(r#"{"detail":"Request was throttled."}"#), Some(None));
|
||||
assert_eq!(throttle_text(r#"{"detail":"Unknown symbol"}"#), None);
|
||||
assert_eq!(throttle_message(Some(30)), "SnapTrade is rate-limiting requests; trying again in about 30 s");
|
||||
assert!(throttle_message(Some(600)).contains("10 minutes"));
|
||||
let e: anyhow::Error = ApiError { status: 429, message: throttle_message(None), retry_after: Some(5) }.into();
|
||||
assert_eq!(throttle(&e), Some(Some(Duration::from_secs(5))));
|
||||
assert_eq!(throttle(&anyhow!("other")), None);
|
||||
}
|
||||
}
|
||||
@@ -427,10 +427,11 @@ impl Capability {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-account capabilities from `/accounts` (each account names its `brokerage_authorization`)
|
||||
/// and `/authorizations` (each connection carries its brokerage and access type).
|
||||
pub fn parse_capabilities(accounts: &Value, authorizations: &Value) -> std::collections::HashMap<String, Capability> {
|
||||
let conns: std::collections::HashMap<String, Capability> = authorizations
|
||||
/// Capabilities per brokerage connection (SnapTrade authorization id) from `/authorizations`,
|
||||
/// where each connection carries its brokerage and access type. Accounts map onto these
|
||||
/// through [`crate::Account::connection_id`], so one request covers every account.
|
||||
pub fn parse_connection_capabilities(authorizations: &Value) -> std::collections::HashMap<String, Capability> {
|
||||
authorizations
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -452,7 +453,13 @@ pub fn parse_capabilities(accounts: &Value, authorizations: &Value) -> std::coll
|
||||
},
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Per-account capabilities from `/accounts` (each account names its `brokerage_authorization`)
|
||||
/// and `/authorizations`.
|
||||
pub fn parse_capabilities(accounts: &Value, authorizations: &Value) -> std::collections::HashMap<String, Capability> {
|
||||
let conns = parse_connection_capabilities(authorizations);
|
||||
accounts
|
||||
.as_array()
|
||||
.into_iter()
|
||||
@@ -498,6 +505,9 @@ impl std::fmt::Display for PlaceError {
|
||||
/// Only a client error (4xx) is a definite "not placed"; everything else after sending is unknown.
|
||||
pub fn classify_place_error(e: anyhow::Error) -> PlaceError {
|
||||
let msg = format!("{e:#}");
|
||||
if e.downcast_ref::<crate::snaptrade::NotSent>().is_some() {
|
||||
return PlaceError::NotSent(msg);
|
||||
}
|
||||
match e.downcast_ref::<crate::snaptrade::ApiError>() {
|
||||
Some(api) if (400..500).contains(&api.status) => PlaceError::Rejected(msg),
|
||||
_ => PlaceError::Unknown(msg),
|
||||
@@ -549,10 +559,11 @@ impl SnapTrade {
|
||||
return Err(PlaceError::NotSent("the preview expired; preview the order again".into()));
|
||||
}
|
||||
let id = segment("trade id", &preview.trade_id).map_err(|e| PlaceError::NotSent(e.to_string()))?;
|
||||
match self
|
||||
.call(Method::POST, &format!("/trade/{id}"), &[], Some(json!({ "wait_to_confirm": true })), true)
|
||||
.await
|
||||
{
|
||||
// Never sent once the preview has expired, however long a rate-limit pause lasts.
|
||||
let left = (preview.expires() - Utc::now()).to_std().unwrap_or_default();
|
||||
let deadline = Some(std::time::Instant::now() + left);
|
||||
let body = Some(json!({ "wait_to_confirm": true }));
|
||||
match self.call_by(Method::POST, &format!("/trade/{id}"), &[], body, true, deadline).await {
|
||||
Ok(v) => Ok(parse_placed(&preview.request.account_id, &v)),
|
||||
Err(e) => Err(classify_place_error(e)),
|
||||
}
|
||||
@@ -566,6 +577,11 @@ impl SnapTrade {
|
||||
self.call(Method::POST, &format!("/accounts/{acct}/trading/cancel"), &[], Some(body), true).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// What each brokerage connection can do, in one request however many accounts it holds.
|
||||
pub async fn connection_capabilities(&self) -> Result<std::collections::HashMap<String, Capability>> {
|
||||
Ok(parse_connection_capabilities(&self.call(Method::GET, "/authorizations", &[], None, true).await?))
|
||||
}
|
||||
|
||||
/// What each account can do: brokerage support for trading and the connection's access.
|
||||
pub async fn capabilities(&self) -> Result<std::collections::HashMap<String, Capability>> {
|
||||
let (accounts, auths) = futures::join!(
|
||||
@@ -669,6 +685,7 @@ pub fn simulate_place(pf: &mut Portfolio, preview: &OrderPreview, now: DateTime<
|
||||
placed: Some(now),
|
||||
updated: Some(now),
|
||||
underlying: None,
|
||||
market: None,
|
||||
};
|
||||
if let Some(px) = fill {
|
||||
let signed = if req.side == Side::Buy { units } else { -units };
|
||||
@@ -699,6 +716,7 @@ fn apply_fill(pf: &mut Portfolio, account: &str, symbol: &str, signed: f64, px:
|
||||
currency: "USD".into(),
|
||||
cash_equivalent: false,
|
||||
underlying: None,
|
||||
market: None,
|
||||
});
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -93,7 +93,7 @@ async fn mock(seen: Arc<Mutex<Vec<Seen>>>, failures: Arc<Mutex<Vec<String>>>) ->
|
||||
|| path.starts_with("/connection/")
|
||||
|| path == "/snapTrade/login";
|
||||
if user_scoped
|
||||
&& (param("userId").as_deref() != Some("terminal-rs-test")
|
||||
&& (param("userId").as_deref() != Some("terminal-test")
|
||||
|| param("userSecret").as_deref() != Some("s3cr3t/+="))
|
||||
{
|
||||
fail("missing or wrong user credentials".into());
|
||||
@@ -156,7 +156,7 @@ fn creds() -> Credentials {
|
||||
Credentials {
|
||||
client_id: "TEST-CLIENT".into(),
|
||||
consumer_key: CONSUMER_KEY.into(),
|
||||
user_id: "terminal-rs-test".into(),
|
||||
user_id: "terminal-test".into(),
|
||||
user_secret: "s3cr3t/+=".into(),
|
||||
}
|
||||
}
|
||||
@@ -244,8 +244,8 @@ async fn registers_a_user_opens_the_portal_and_disconnects() {
|
||||
let partner =
|
||||
SnapTrade::with_base(Credentials { user_id: String::new(), user_secret: String::new(), ..creds() }, &base)
|
||||
.unwrap();
|
||||
let (id, secret) = partner.register_user("terminal-rs-test").await.unwrap();
|
||||
assert_eq!((id.as_str(), secret.as_str()), ("terminal-rs-test", "fresh-secret"));
|
||||
let (id, secret) = partner.register_user("terminal-test").await.unwrap();
|
||||
assert_eq!((id.as_str(), secret.as_str()), ("terminal-test", "fresh-secret"));
|
||||
|
||||
let client = SnapTrade::with_base(creds(), &base).unwrap();
|
||||
let url = client.connection_portal_url(true, None).await.unwrap();
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
//! The paper-trading demo account: fills, resting orders, buying power, persistence.
|
||||
|
||||
use chrono::{DateTime, Duration, TimeZone, Utc};
|
||||
use tr_broker::demo::{self, ACCOUNT_ID, DemoBook, Event, STARTING_CASH};
|
||||
use tr_broker::trading::{Amount, OrderRequest, OrderType, Side, TimeInForce};
|
||||
|
||||
fn t0() -> DateTime<Utc> {
|
||||
Utc.with_ymd_and_hms(2026, 10, 12, 15, 0, 0).unwrap()
|
||||
}
|
||||
|
||||
fn order(side: Side, symbol: &str, units: f64) -> OrderRequest {
|
||||
OrderRequest {
|
||||
account_id: ACCOUNT_ID.into(),
|
||||
symbol: symbol.into(),
|
||||
side,
|
||||
order_type: OrderType::Market,
|
||||
time_in_force: TimeInForce::Day,
|
||||
amount: Amount::Units(units),
|
||||
limit: None,
|
||||
stop: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn limit(side: Side, symbol: &str, units: f64, px: f64) -> OrderRequest {
|
||||
OrderRequest { order_type: OrderType::Limit, limit: Some(px), ..order(side, symbol, units) }
|
||||
}
|
||||
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starts_with_virtual_cash() {
|
||||
let b = DemoBook::new(t0());
|
||||
assert_eq!((b.cash, b.buying_power(), b.equity()), (STARTING_CASH, STARTING_CASH, STARTING_CASH));
|
||||
let pf = b.portfolio();
|
||||
assert!(pf.demo && !pf.simulated);
|
||||
assert_eq!(pf.accounts.len(), 1);
|
||||
assert!(pf.accounts[0].is_paper);
|
||||
assert_eq!(pf.accounts[0].id, ACCOUNT_ID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn market_orders_fill_at_the_quote() {
|
||||
let mut b = DemoBook::new(t0());
|
||||
let o = b.submit(&order(Side::Buy, "aapl", 10.0), Some(200.0), t0()).unwrap();
|
||||
assert_eq!((o.status.as_str(), o.execution_price, o.filled), ("EXECUTED", Some(200.0), Some(10.0)));
|
||||
assert_eq!(o.symbol, "AAPL");
|
||||
assert!(close(b.cash, STARTING_CASH - 2000.0));
|
||||
assert_eq!(b.held("AAPL"), 10.0);
|
||||
|
||||
// Average cost on adding, realised P&L on selling.
|
||||
b.submit(&order(Side::Buy, "AAPL", 10.0), Some(220.0), t0()).unwrap();
|
||||
let p = b.position("AAPL").unwrap();
|
||||
assert!(close(p.cost_basis.unwrap(), 210.0));
|
||||
b.submit(&order(Side::Sell, "AAPL", 5.0), Some(230.0), t0()).unwrap();
|
||||
assert!(close(b.realized, 100.0));
|
||||
assert_eq!(b.held("AAPL"), 15.0);
|
||||
assert!(close(b.cash, STARTING_CASH - 2000.0 - 2200.0 + 1150.0));
|
||||
|
||||
// Marking to market moves equity, not cash.
|
||||
let cash = b.cash;
|
||||
b.on_price("AAPL", 250.0, t0());
|
||||
assert!(close(b.equity(), cash + 15.0 * 250.0));
|
||||
assert_eq!(b.activities.len(), 3, "every fill is recorded");
|
||||
assert!(close(b.activities[0].amount.unwrap(), 1150.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notional_orders_buy_fractional_shares() {
|
||||
let mut b = DemoBook::new(t0());
|
||||
let req = OrderRequest { amount: Amount::Notional(500.0), ..order(Side::Buy, "NVDA", 0.0) };
|
||||
let o = b.submit(&req, Some(400.0), t0()).unwrap();
|
||||
assert_eq!(o.filled, Some(1.25));
|
||||
assert!(close(b.cash, STARTING_CASH - 500.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_what_a_cash_account_cannot_do() {
|
||||
let mut b = DemoBook::new(t0());
|
||||
let e = b.submit(&order(Side::Buy, "BRK.A", 1.0), Some(700_000.0), t0()).unwrap_err();
|
||||
assert!(e.contains("Not enough buying power"), "{e}");
|
||||
let e = b.submit(&order(Side::Sell, "TSLA", 1.0), Some(250.0), t0()).unwrap_err();
|
||||
assert!(e.contains("can't sell short"), "{e}");
|
||||
b.submit(&order(Side::Buy, "TSLA", 2.0), Some(250.0), t0()).unwrap();
|
||||
let e = b.submit(&order(Side::Sell, "TSLA", 3.0), Some(250.0), t0()).unwrap_err();
|
||||
assert!(e.contains("up to 2 TSLA"), "{e}");
|
||||
let e = b.submit(&order(Side::Buy, "MSFT", 1.0), None, t0()).unwrap_err();
|
||||
assert!(e.contains("Waiting for a price"), "market orders need a quote: {e}");
|
||||
let other = OrderRequest { account_id: "snaptrade-uuid".into(), ..order(Side::Buy, "MSFT", 1.0) };
|
||||
assert!(b.submit(&other, Some(400.0), t0()).is_err(), "only the demo account");
|
||||
assert!(b.orders.iter().all(|o| o.status == "EXECUTED"), "refused orders leave no trace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limit_orders_rest_until_the_price_crosses() {
|
||||
let mut b = DemoBook::new(t0());
|
||||
let o = b.submit(&limit(Side::Buy, "AAPL", 10.0, 190.0), Some(200.0), t0()).unwrap();
|
||||
assert!(o.is_open());
|
||||
assert!(close(b.buying_power(), STARTING_CASH - 1900.0), "a resting buy sets cash aside");
|
||||
assert_eq!(b.cash, STARTING_CASH);
|
||||
assert_eq!(b.watched(), vec!["AAPL".to_string()]);
|
||||
|
||||
assert!(b.on_price("AAPL", 195.0, t0()).is_empty(), "not there yet");
|
||||
let ev = b.on_price("AAPL", 189.5, t0());
|
||||
assert_eq!(ev.len(), 1);
|
||||
let Event::Filled(f) = &ev[0] else { panic!("{ev:?}") };
|
||||
assert_eq!(f.execution_price, Some(189.5), "fills at the crossing quote, at or better than the limit");
|
||||
assert!(ev[0].message().starts_with("Bought 10 AAPL at $189.50"));
|
||||
assert!(close(b.cash, STARTING_CASH - 1895.0));
|
||||
assert!(close(b.buying_power(), b.cash), "the reservation is released");
|
||||
|
||||
// A sell limit above the market waits for a rise and holds the shares back.
|
||||
let s = b.submit(&limit(Side::Sell, "AAPL", 10.0, 210.0), Some(190.0), t0()).unwrap();
|
||||
assert!(s.is_open());
|
||||
assert_eq!(b.available("AAPL"), 0.0);
|
||||
assert!(b.submit(&order(Side::Sell, "AAPL", 1.0), Some(190.0), t0()).is_err(), "shares are spoken for");
|
||||
b.on_price("AAPL", 211.0, t0());
|
||||
assert_eq!(b.held("AAPL"), 0.0);
|
||||
assert!(b.position("AAPL").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marketable_limits_fill_immediately() {
|
||||
let mut b = DemoBook::new(t0());
|
||||
let o = b.submit(&limit(Side::Buy, "MSFT", 2.0, 420.0), Some(410.0), t0()).unwrap();
|
||||
assert_eq!((o.status.as_str(), o.execution_price), ("EXECUTED", Some(410.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stops_trigger_then_fill() {
|
||||
let mut b = DemoBook::new(t0());
|
||||
b.submit(&order(Side::Buy, "TSLA", 10.0), Some(250.0), t0()).unwrap();
|
||||
let stop = OrderRequest { order_type: OrderType::Stop, stop: Some(240.0), ..order(Side::Sell, "TSLA", 10.0) };
|
||||
assert!(b.submit(&stop, Some(250.0), t0()).unwrap().is_open());
|
||||
assert!(b.on_price("TSLA", 245.0, t0()).is_empty());
|
||||
let ev = b.on_price("TSLA", 238.0, t0());
|
||||
assert!(matches!(&ev[..], [Event::Filled(o)] if o.execution_price == Some(238.0)));
|
||||
|
||||
// Stop-limit: the stop arms the order, then the limit decides the fill.
|
||||
b.submit(&order(Side::Buy, "TSLA", 10.0), Some(238.0), t0()).unwrap();
|
||||
let sl = OrderRequest {
|
||||
order_type: OrderType::StopLimit,
|
||||
stop: Some(230.0),
|
||||
limit: Some(228.0),
|
||||
..order(Side::Sell, "TSLA", 10.0)
|
||||
};
|
||||
b.submit(&sl, Some(238.0), t0()).unwrap();
|
||||
assert!(b.on_price("TSLA", 225.0, t0()).is_empty(), "triggered, but below the sell limit");
|
||||
assert_eq!(b.orders[0].status, "TRIGGERED");
|
||||
assert!(b.orders[0].is_open());
|
||||
let ev = b.on_price("TSLA", 229.0, t0());
|
||||
assert!(matches!(&ev[..], [Event::Filled(o)] if o.execution_price == Some(229.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resting_buys_are_rejected_if_cash_ran_out() {
|
||||
let mut b = DemoBook::new(t0());
|
||||
b.reset(1_000.0, t0());
|
||||
b.submit(&limit(Side::Buy, "AAPL", 5.0, 150.0), Some(200.0), t0()).unwrap();
|
||||
// A later order cannot spend the reserved cash.
|
||||
let e = b.submit(&order(Side::Buy, "F", 30.0), Some(10.0), t0()).unwrap_err();
|
||||
assert!(e.contains("buying power"), "{e}");
|
||||
assert!(b.submit(&order(Side::Buy, "F", 25.0), Some(10.0), t0()).is_ok());
|
||||
let ev = b.on_price("AAPL", 149.0, t0());
|
||||
assert!(matches!(&ev[..], [Event::Filled(_)]), "its own reservation pays for it: {ev:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_ioc_and_day_expiry() {
|
||||
let mut b = DemoBook::new(t0());
|
||||
let o = b.submit(&limit(Side::Buy, "AAPL", 1.0, 100.0), Some(200.0), t0()).unwrap();
|
||||
b.cancel(&o.id, t0()).unwrap();
|
||||
assert_eq!(b.orders[0].status, "CANCELED");
|
||||
assert!(b.cancel(&o.id, t0()).is_err(), "already cancelled");
|
||||
assert_eq!(b.buying_power(), STARTING_CASH);
|
||||
|
||||
let ioc = OrderRequest { time_in_force: TimeInForce::Ioc, ..limit(Side::Buy, "AAPL", 1.0, 100.0) };
|
||||
assert_eq!(b.submit(&ioc, Some(200.0), t0()).unwrap().status, "CANCELED", "IOC never rests");
|
||||
|
||||
let day = b.submit(&limit(Side::Buy, "AAPL", 1.0, 100.0), Some(200.0), t0()).unwrap();
|
||||
let gtc = OrderRequest { time_in_force: TimeInForce::Gtc, ..limit(Side::Buy, "AAPL", 1.0, 100.0) };
|
||||
let gtc = b.submit(>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);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! SnapTrade rate limits: throttled reads wait as asked and retry, throttled orders are never
|
||||
//! resent, and the error the user sees is a friendly line rather than SnapTrade's raw text.
|
||||
//! (Its own test binary: the client's cooldown is process-wide.)
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tr_broker::trading::{Amount, OrderPreview, OrderRequest, OrderType, PlaceError, Side, SymbolMatch, TimeInForce};
|
||||
use tr_broker::{Credentials, SnapTrade};
|
||||
|
||||
const THROTTLED: &str = r#"{"detail":"Request was throttled. Expected available in 1 seconds."}"#;
|
||||
|
||||
/// Answers `/authorizations` with a 429 the first `throttle_reads` times, every
|
||||
/// `/trade/...` with a 429, and records each request line.
|
||||
async fn mock(throttle_reads: usize, seen: Arc<Mutex<Vec<String>>>) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let reads = Arc::new(AtomicUsize::new(0));
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = listener.accept().await else { break };
|
||||
let (seen, reads) = (seen.clone(), reads.clone());
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let n = sock.read(&mut buf).await.unwrap_or(0);
|
||||
let head = String::from_utf8_lossy(&buf[..n]).to_string();
|
||||
let line = head.lines().next().unwrap_or("").to_string();
|
||||
let path = line.split(' ').nth(1).unwrap_or("").split('?').next().unwrap_or("").to_string();
|
||||
let method = line.split(' ').next().unwrap_or("").to_string();
|
||||
seen.lock().unwrap().push(format!("{method} {path}"));
|
||||
let (status, extra, body) = if path == "/authorizations" {
|
||||
if reads.fetch_add(1, Ordering::SeqCst) < throttle_reads {
|
||||
(429, "Retry-After: 1\r\n", THROTTLED.to_string())
|
||||
} else {
|
||||
(200, "", r#"[{"id":"c1","type":"trade","brokerage":{"display_name":"Wealthsimple","allows_trading":true}}]"#.to_string())
|
||||
}
|
||||
} else if path.starts_with("/trade/") {
|
||||
(429, "", THROTTLED.to_string())
|
||||
} else {
|
||||
(404, "", r#"{"detail":"Not found"}"#.to_string())
|
||||
};
|
||||
let reply = format!(
|
||||
"HTTP/1.1 {status} X\r\n{extra}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
let _ = sock.write_all(reply.as_bytes()).await;
|
||||
let _ = sock.shutdown().await;
|
||||
});
|
||||
}
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
fn client(base: &str) -> SnapTrade {
|
||||
let creds = Credentials {
|
||||
client_id: "TEST".into(),
|
||||
consumer_key: "key".into(),
|
||||
user_id: "terminal-test".into(),
|
||||
user_secret: "s".into(),
|
||||
};
|
||||
SnapTrade::with_base(creds, base).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn throttled_reads_retry_and_orders_are_never_resent() {
|
||||
// A read throttled once waits for Retry-After and succeeds.
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let base = mock(1, seen.clone()).await;
|
||||
let start = Instant::now();
|
||||
let caps = client(&base).connection_capabilities().await.unwrap();
|
||||
assert!(caps["c1"].can_trade());
|
||||
assert!(start.elapsed().as_millis() >= 900, "waited for Retry-After");
|
||||
assert_eq!(seen.lock().unwrap().iter().filter(|l| l.ends_with("/authorizations")).count(), 2);
|
||||
|
||||
// A read that stays throttled gives up with a friendly message.
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let base = mock(usize::MAX, seen.clone()).await;
|
||||
let e = client(&base).connection_capabilities().await.unwrap_err();
|
||||
let msg = format!("{e:#}");
|
||||
assert!(tr_broker::snaptrade::throttle(&e).is_some());
|
||||
assert!(msg.contains("rate-limiting") && !msg.contains("Request was throttled"), "{msg}");
|
||||
assert_eq!(seen.lock().unwrap().len(), 1 + tr_broker::snaptrade::MAX_RETRIES as usize);
|
||||
|
||||
// A throttled placement is sent exactly once and reported as not placed.
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let base = mock(0, seen.clone()).await;
|
||||
let req = OrderRequest {
|
||||
account_id: "acct-1".into(),
|
||||
symbol: "AAPL".into(),
|
||||
side: Side::Buy,
|
||||
order_type: OrderType::Market,
|
||||
time_in_force: TimeInForce::Day,
|
||||
amount: Amount::Units(1.0),
|
||||
limit: None,
|
||||
stop: None,
|
||||
};
|
||||
let preview = OrderPreview {
|
||||
trade_id: "139e307a-82f7-4402-b39e-4da7baa87758".into(),
|
||||
request: req,
|
||||
symbol: SymbolMatch {
|
||||
id: "s".into(),
|
||||
symbol: "AAPL".into(),
|
||||
description: String::new(),
|
||||
currency: "USD".into(),
|
||||
exchange: String::new(),
|
||||
},
|
||||
price: None,
|
||||
units: None,
|
||||
commission: None,
|
||||
forex_fees: None,
|
||||
remaining_cash: None,
|
||||
currency: "USD".into(),
|
||||
created: chrono::Utc::now(),
|
||||
simulated: false,
|
||||
};
|
||||
let err = client(&base).place_previewed(&preview).await.unwrap_err();
|
||||
assert!(matches!(err, PlaceError::Rejected(_)), "{err:?}");
|
||||
assert!(err.message().contains("rate-limiting"));
|
||||
assert_eq!(seen.lock().unwrap().iter().filter(|l| l.starts_with("POST /trade/")).count(), 1);
|
||||
|
||||
// A pause that would outlast the preview never sends the order late.
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let base = mock_retry_after(4, seen.clone()).await;
|
||||
let _ = client(&base).connection_capabilities().await; // starts a 4 s pause
|
||||
let mut soon = preview.clone();
|
||||
soon.created = chrono::Utc::now()
|
||||
- chrono::Duration::seconds(tr_broker::trading::PREVIEW_TTL_SECS - tr_broker::trading::PREVIEW_MARGIN_SECS - 2);
|
||||
let start = Instant::now();
|
||||
let e = client(&base).place_previewed(&soon).await.unwrap_err();
|
||||
assert!(start.elapsed().as_secs() < 2, "refused at once, not after the pause");
|
||||
assert!(!e.uncertain(), "{e:?}");
|
||||
assert!(seen.lock().unwrap().iter().all(|l| !l.starts_with("POST /trade/")), "the stale order was never sent");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(4)).await;
|
||||
|
||||
// A long Retry-After pauses every request for its whole length, and nothing is sent
|
||||
// meanwhile: the call fails at once, unsent, with the time left.
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let base = mock_retry_after(120, seen.clone()).await;
|
||||
let e = client(&base).connection_capabilities().await.unwrap_err();
|
||||
assert!(tr_broker::snaptrade::throttle(&e).flatten().is_some_and(|d| d.as_secs() >= 100));
|
||||
let start = Instant::now();
|
||||
let e = client(&base).place_previewed(&preview).await.unwrap_err();
|
||||
assert!(start.elapsed().as_secs() < 5, "an order is never held for minutes");
|
||||
assert!(matches!(e, PlaceError::Rejected(_)) && e.message().contains("minutes"), "{e:?}");
|
||||
assert_eq!(seen.lock().unwrap().len(), 1, "nothing sent during the pause");
|
||||
}
|
||||
|
||||
/// Answers everything with a 429 asking to wait `secs`.
|
||||
async fn mock_retry_after(secs: u64, seen: Arc<Mutex<Vec<String>>>) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut sock, _)) = listener.accept().await else { break };
|
||||
let seen = seen.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let n = sock.read(&mut buf).await.unwrap_or(0);
|
||||
let head = String::from_utf8_lossy(&buf[..n]).to_string();
|
||||
seen.lock().unwrap().push(head.lines().next().unwrap_or("").to_string());
|
||||
let body = r#"{"detail":"Request was throttled."}"#;
|
||||
let reply = format!(
|
||||
"HTTP/1.1 429 X\r\nRetry-After: {secs}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
let _ = sock.write_all(reply.as_bytes()).await;
|
||||
let _ = sock.shutdown().await;
|
||||
});
|
||||
}
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
@@ -80,7 +80,7 @@ async fn mock(seen: Log, failures: Log) -> String {
|
||||
let body: Option<Value> = (!body.is_empty()).then(|| serde_json::from_slice(&body).unwrap());
|
||||
let sig = headers.iter().find(|(k, _)| k == "signature").map(|(_, v)| v.clone()).unwrap_or_default();
|
||||
let ok = sig == signature(CONSUMER_KEY, path, query, body.as_ref())
|
||||
&& param(query, "userId").as_deref() == Some("terminal-rs-test")
|
||||
&& param(query, "userId").as_deref() == Some("terminal-test")
|
||||
&& param(query, "userSecret").as_deref() == Some("s3cr3t");
|
||||
let entry = Seen { method: method.clone(), path: path.to_string(), body: body.clone() };
|
||||
if !ok {
|
||||
@@ -161,7 +161,7 @@ fn client(base: &str) -> SnapTrade {
|
||||
let creds = Credentials {
|
||||
client_id: "TEST-CLIENT".into(),
|
||||
consumer_key: CONSUMER_KEY.into(),
|
||||
user_id: "terminal-rs-test".into(),
|
||||
user_id: "terminal-test".into(),
|
||||
user_secret: "s3cr3t".into(),
|
||||
};
|
||||
SnapTrade::with_base(creds, base).unwrap()
|
||||
|
||||
@@ -6,8 +6,9 @@ edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tr-core.workspace = true
|
||||
egui = "0.36"
|
||||
egui-wgpu = "0.36"
|
||||
egui = { version = "0.36", default-features = false }
|
||||
# GPU backends come from the app (eframe), so the browser build can leave out WebGL.
|
||||
egui-wgpu = { version = "0.36", default-features = false }
|
||||
bytemuck = { version = "1", features = ["derive"] }
|
||||
chrono.workspace = true
|
||||
serde.workspace = true
|
||||
@@ -15,6 +16,8 @@ pollster = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion.workspace = true
|
||||
# Tests, benches and examples render offscreen on a real backend.
|
||||
egui-wgpu = "0.36"
|
||||
|
||||
[[bench]]
|
||||
name = "frame"
|
||||
|
||||
@@ -28,6 +28,32 @@ pub struct Uniforms {
|
||||
pub color: [f32; 4],
|
||||
pub params: [f32; 4],
|
||||
pub extra: [f32; 4],
|
||||
pub anim: [f32; 4],
|
||||
}
|
||||
|
||||
/// Load-in sweep for a pane. Bars right of `front` (a fractional bar index) are hidden
|
||||
/// and the `ramp` bars behind it grow in from their open; lines are drawn up to
|
||||
/// `line_front`. `opacity` fades everything in the pane.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Reveal {
|
||||
pub front: f64,
|
||||
pub ramp: f32,
|
||||
pub line_front: f64,
|
||||
pub opacity: f32,
|
||||
}
|
||||
|
||||
impl Reveal {
|
||||
pub const DONE: Self = Self { front: f64::INFINITY, ramp: 1.0, line_front: f64::INFINITY, opacity: 1.0 };
|
||||
|
||||
/// The sweep at progress `t` (0..1) across `visible_bars` bars starting at `left`.
|
||||
pub fn sweep(t: f32, left: f64, visible_bars: f64) -> Self {
|
||||
if t >= 1.0 {
|
||||
return Self::DONE;
|
||||
}
|
||||
let ramp = (visible_bars * 0.12).max(2.0);
|
||||
let front = left + t as f64 * (visible_bars + ramp);
|
||||
Self { front, ramp: ramp as f32, line_front: front - ramp * 0.5, opacity: 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Pane-level transform shared by every draw in a pane.
|
||||
@@ -60,6 +86,7 @@ pub struct Style {
|
||||
/// Physical pixels per logical point (2.0 on Retina). Pixel-sized strokes are given
|
||||
/// in points and scaled by this; pane sizes in [`PaneTransform`] are physical.
|
||||
pub pixels_per_point: f32,
|
||||
pub reveal: Reveal,
|
||||
}
|
||||
|
||||
impl Default for Style {
|
||||
@@ -77,6 +104,7 @@ impl Default for Style {
|
||||
hist_width_bars: 0.7,
|
||||
baseline: 0.0,
|
||||
pixels_per_point: 1.0,
|
||||
reveal: Reveal::DONE,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,6 +112,9 @@ impl Default for Style {
|
||||
impl Uniforms {
|
||||
pub fn new(t: &PaneTransform, s: &Style) -> Self {
|
||||
let left_hi = t.left.floor();
|
||||
// Relative to `left_hi` for f32 precision on long series.
|
||||
let rel = |x: f64| if x.is_finite() { (x - left_hi) as f32 } else { 1e9 };
|
||||
let r = s.reveal;
|
||||
Self {
|
||||
rect: [t.width_px, t.height_px, left_hi as f32, (t.left - left_hi) as f32],
|
||||
view: [t.visible_bars as f32, t.y_lo as f32, t.y_hi as f32, if t.log { 1.0 } else { 0.0 }],
|
||||
@@ -97,6 +128,7 @@ impl Uniforms {
|
||||
if s.ohlc_bars { 1.0 } else { 0.0 },
|
||||
],
|
||||
extra: [s.vol_frac, s.vol_max, s.hist_width_bars, s.baseline],
|
||||
anim: [rel(r.front), r.ramp.max(1e-3), rel(r.line_front), r.opacity],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,6 +153,10 @@ pub enum LineKind {
|
||||
Line,
|
||||
Area,
|
||||
Hist,
|
||||
/// A square marker at each finite point.
|
||||
Dots,
|
||||
/// A flat dash across each finite point's bar, with no joins between bars.
|
||||
Steps,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -152,6 +188,8 @@ enum Pipe {
|
||||
Line,
|
||||
Area,
|
||||
Hist,
|
||||
Dots,
|
||||
Steps,
|
||||
}
|
||||
|
||||
struct Resolved {
|
||||
@@ -167,6 +205,8 @@ pub struct ChartRenderer {
|
||||
line: wgpu::RenderPipeline,
|
||||
area: wgpu::RenderPipeline,
|
||||
hist: wgpu::RenderPipeline,
|
||||
dots: wgpu::RenderPipeline,
|
||||
steps: wgpu::RenderPipeline,
|
||||
uniform_buf: wgpu::Buffer,
|
||||
uniform_bg: wgpu::BindGroup,
|
||||
storage_bgl: wgpu::BindGroupLayout,
|
||||
@@ -277,6 +317,8 @@ impl ChartRenderer {
|
||||
line: make(&line_mod, "vs_line"),
|
||||
area: make(&line_mod, "vs_area"),
|
||||
hist: make(&line_mod, "vs_hist"),
|
||||
dots: make(&line_mod, "vs_dots"),
|
||||
steps: make(&line_mod, "vs_steps"),
|
||||
uniform_buf,
|
||||
uniform_bg,
|
||||
storage_bgl,
|
||||
@@ -352,6 +394,8 @@ impl ChartRenderer {
|
||||
LineKind::Line => Pipe::Line,
|
||||
LineKind::Area => Pipe::Area,
|
||||
LineKind::Hist => Pipe::Hist,
|
||||
LineKind::Dots => Pipe::Dots,
|
||||
LineKind::Steps => Pipe::Steps,
|
||||
};
|
||||
(pipe, (true, data.key), *range, uniforms)
|
||||
}
|
||||
@@ -376,6 +420,8 @@ impl ChartRenderer {
|
||||
Pipe::Line => (&self.line, 6),
|
||||
Pipe::Area => (&self.area, 6),
|
||||
Pipe::Hist => (&self.hist, 6),
|
||||
Pipe::Dots => (&self.dots, 6),
|
||||
Pipe::Steps => (&self.steps, 6),
|
||||
};
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, &self.uniform_bg, &[d.slot * SLOT as u32]);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! GPU chart rendering for terminal-rs.
|
||||
//! GPU chart rendering for Terminal.
|
||||
//!
|
||||
//! - [`viewport`]: bar-index/price <-> pixel math, pan and zoom.
|
||||
//! - [`lod`]: level-of-detail pyramids that bound per-frame work to the plot width.
|
||||
//! - [`gpu`]: wgpu pipelines (candles, OHLC bars, volume, lines, areas, histograms)
|
||||
//! - [`gpu`]: wgpu pipelines (candles, OHLC bars, volume, lines, areas, histograms, dots)
|
||||
//! and the egui paint-callback adapter.
|
||||
//! - [`scale`]: axis ticks and labels. [`drawings`]: user drawing model and hit testing.
|
||||
|
||||
|
||||
+29
-2
@@ -133,15 +133,25 @@ pub struct LinePyramid {
|
||||
}
|
||||
|
||||
impl LinePyramid {
|
||||
/// Keeps the later point of each pair (its value is the group's close).
|
||||
pub fn build(values: &[f64]) -> Self {
|
||||
Self::reduce(values, |_, later| later)
|
||||
}
|
||||
|
||||
/// For sparse markers: a pair whose later point is blank keeps the earlier one, so
|
||||
/// markers survive zooming out.
|
||||
pub fn build_markers(values: &[f64]) -> Self {
|
||||
Self::reduce(values, |earlier, later| if later[1].is_nan() { earlier } else { later })
|
||||
}
|
||||
|
||||
fn reduce(values: &[f64], merge: impl Fn([f32; 2], [f32; 2]) -> [f32; 2]) -> Self {
|
||||
let mut data: Vec<[f32; 2]> = values.iter().enumerate().map(|(i, v)| [i as f32 + 0.5, *v as f32]).collect();
|
||||
let mut offsets = vec![0];
|
||||
let (mut start, mut n) = (0, values.len());
|
||||
while n > 1 {
|
||||
let next_start = data.len();
|
||||
for j in (0..n).step_by(2) {
|
||||
// Keep the later point of each pair (its value is the group's close).
|
||||
let p = if j + 1 < n { data[start + j + 1] } else { data[start + j] };
|
||||
let p = if j + 1 < n { merge(data[start + j], data[start + j + 1]) } else { data[start + j] };
|
||||
data.push(p);
|
||||
}
|
||||
offsets.push(next_start);
|
||||
@@ -251,4 +261,21 @@ mod tests {
|
||||
assert_eq!((a, b), (2, 7));
|
||||
assert_eq!(p.segment_range(0, 0, 0), (0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_pyramid_keeps_sparse_points() {
|
||||
let mut v = vec![f64::NAN; 16];
|
||||
v[2] = 5.0;
|
||||
v[9] = 7.0;
|
||||
let lines = LinePyramid::build(&v);
|
||||
assert_eq!(lines.extent(1, 0, 16), Some((7.0, 7.0)), "lines keep the later point");
|
||||
let marks = LinePyramid::build_markers(&v);
|
||||
// Both survive every level where they fall in different groups, at their own bars.
|
||||
for k in 0..marks.levels() - 1 {
|
||||
assert_eq!(marks.extent(k, 0, 16), Some((5.0, 7.0)), "level {k}");
|
||||
let level = &marks.data[marks.offsets[k]..marks.offsets[k + 1]];
|
||||
assert!(level.contains(&[2.5, 5.0]) && level.contains(&[9.5, 7.0]), "level {k}");
|
||||
}
|
||||
assert_eq!(marks.data[marks.offsets[marks.levels() - 1]], [9.5, 7.0]);
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,19 @@ pub fn format_volume(v: f64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Indicator values of any magnitude in a few characters: 1.25B, 37.9K, 245.3, 61.42,
|
||||
/// 0.734, 0.0012.
|
||||
pub fn format_value(v: f64) -> String {
|
||||
match v.abs() {
|
||||
x if x >= 1e4 => format_volume(v),
|
||||
x if x >= 100.0 => format!("{v:.1}"),
|
||||
x if x >= 1.0 || x == 0.0 => format!("{v:.2}"),
|
||||
x if x >= 0.01 => format!("{v:.3}"),
|
||||
x if x >= 1e-4 => format!("{v:.4}"),
|
||||
_ => format!("{v:.1e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// How significant a time boundary is; larger boundaries get labels first.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Boundary {
|
||||
@@ -152,6 +165,14 @@ mod tests {
|
||||
fn formatting() {
|
||||
assert_eq!(format_volume(37_881_199.0), "37.88M");
|
||||
assert_eq!(format_volume(101_820.0), "101.8K");
|
||||
assert_eq!(format_value(-1_250_000_000.0), "-1.25B");
|
||||
assert_eq!(format_value(37_900.0), "37.9K");
|
||||
assert_eq!(format_value(245.31), "245.3");
|
||||
assert_eq!(format_value(61.416), "61.42");
|
||||
assert_eq!(format_value(0.0), "0.00");
|
||||
assert_eq!(format_value(0.7341), "0.734");
|
||||
assert_eq!(format_value(-0.00123), "-0.0012");
|
||||
assert_eq!(format_value(0.000012), "1.2e-5");
|
||||
assert_eq!(decimals_for(0.25), 2);
|
||||
assert_eq!(decimals_for(0.001), 3);
|
||||
assert_eq!(decimals_for(10.0), 0);
|
||||
|
||||
+173
-15
@@ -81,6 +81,11 @@ pub fn line_ref(values: &[f64]) -> LineRef {
|
||||
LineRef { key: next_key(), version: 1, pyramid: Arc::new(LinePyramid::build(values)) }
|
||||
}
|
||||
|
||||
/// A [`LineKind::Dots`] series, reduced so sparse markers stay visible when zoomed out.
|
||||
pub fn marker_ref(values: &[f64]) -> LineRef {
|
||||
LineRef { key: next_key(), version: 1, pyramid: Arc::new(LinePyramid::build_markers(values)) }
|
||||
}
|
||||
|
||||
/// A line overlay with its colour.
|
||||
#[derive(Clone)]
|
||||
pub struct Overlay {
|
||||
@@ -143,7 +148,7 @@ pub fn visible_extent(
|
||||
}
|
||||
_ => p.extent(k, lo, hi)?,
|
||||
};
|
||||
for o in overlays.iter().filter(|o| o.kind == LineKind::Line) {
|
||||
for o in overlays.iter().filter(|o| matches!(o.kind, LineKind::Line | LineKind::Dots | LineKind::Steps)) {
|
||||
let kk = level_for(v.bar_px, o.line.pyramid.levels());
|
||||
if let Some((a, b)) = o.line.pyramid.extent(kk, lo, hi) {
|
||||
l = l.min(a * o.scale);
|
||||
@@ -153,6 +158,24 @@ pub fn visible_extent(
|
||||
Some((l, h, vmax))
|
||||
}
|
||||
|
||||
/// The scale `price_pane` uses when `manual_scale` is `None`.
|
||||
pub fn autoscale(p: &PricePane<'_>) -> PriceScale {
|
||||
autoscale_from(p, visible_extent(p.series, p.overlays, p.viewport, p.chart_type))
|
||||
}
|
||||
|
||||
fn autoscale_from(p: &PricePane<'_>, ext: Option<(f64, f64, f64)>) -> PriceScale {
|
||||
let (l, h) = ext.map(|e| (e.0, e.1)).unwrap_or((0.0, 1.0));
|
||||
// Leave room at the bottom for the volume overlay.
|
||||
let mut s = PriceScale::padded(l, h, 0.08, p.log, p.height_px);
|
||||
if p.show_volume {
|
||||
let (a, b) = s.bounds_t();
|
||||
let extra = (b - a) * 0.12;
|
||||
let lo_t = a - extra;
|
||||
s.lo = if s.log { lo_t.exp() } else { lo_t };
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
pub fn price_pane(p: &PricePane<'_>) -> PaneOutput {
|
||||
let v = p.viewport;
|
||||
let pyr = &p.series.bars.pyramid;
|
||||
@@ -160,18 +183,7 @@ pub fn price_pane(p: &PricePane<'_>) -> PaneOutput {
|
||||
let k = level_for(v.bar_px, pyr.levels());
|
||||
let ext = visible_extent(p.series, p.overlays, v, p.chart_type);
|
||||
let vol_max = ext.map(|e| e.2).unwrap_or(1.0);
|
||||
let scale = p.manual_scale.unwrap_or_else(|| {
|
||||
let (l, h) = ext.map(|e| (e.0, e.1)).unwrap_or((0.0, 1.0));
|
||||
// Leave room at the bottom for the volume overlay.
|
||||
let mut s = PriceScale::padded(l, h, 0.08, p.log, p.height_px);
|
||||
if p.show_volume {
|
||||
let (a, b) = s.bounds_t();
|
||||
let extra = (b - a) * 0.12;
|
||||
let lo_t = a - extra;
|
||||
s.lo = if s.log { lo_t.exp() } else { lo_t };
|
||||
}
|
||||
s
|
||||
});
|
||||
let scale = p.manual_scale.unwrap_or_else(|| autoscale_from(p, ext));
|
||||
let t = transform(v, p.height_px, &scale, p.style.pixels_per_point);
|
||||
let mut style = p.style;
|
||||
style.ohlc_bars = p.chart_type == ChartType::Bars;
|
||||
@@ -243,8 +255,9 @@ fn overlay_cmds(overlays: &[Overlay], v: &Viewport, t: &PaneTransform, base: &St
|
||||
}
|
||||
}
|
||||
let range = match o.kind {
|
||||
LineKind::Hist => {
|
||||
s.hist_width_bars = (base.body_frac as f64 * (1u64 << k) as f64) as f32;
|
||||
LineKind::Hist | LineKind::Dots | LineKind::Steps => {
|
||||
let frac = if o.kind == LineKind::Steps { 1.0 } else { base.body_frac as f64 };
|
||||
s.hist_width_bars = (frac * (1u64 << k) as f64) as f32;
|
||||
let n = lp.level_len(k);
|
||||
let off = lp.offsets[k];
|
||||
let a = (lo >> k).min(n);
|
||||
@@ -361,6 +374,68 @@ mod tests {
|
||||
assert_eq!(one.scale, two.scale);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autoscale_is_what_price_pane_uses() {
|
||||
let s = SeriesGpu::build(&sample::generate("X", Timeframe::D1, 500, 1_790_000_000));
|
||||
let v = Viewport::fit_last(500, 120, 600.0);
|
||||
for (show_volume, log) in [(true, false), (false, true)] {
|
||||
let p = PricePane {
|
||||
series: &s,
|
||||
chart_type: ChartType::Candles,
|
||||
show_volume,
|
||||
overlays: &[],
|
||||
viewport: &v,
|
||||
height_px: 300.0,
|
||||
log,
|
||||
manual_scale: None,
|
||||
style: Style::default(),
|
||||
};
|
||||
assert_eq!(autoscale(&p), price_pane(&p).scale);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_sweeps_relative_to_the_left_edge() {
|
||||
use crate::gpu::Reveal;
|
||||
let s = SeriesGpu::build(&sample::generate("X", Timeframe::D1, 500, 1_790_000_000));
|
||||
let v = Viewport::fit_last(500, 100, 400.0);
|
||||
let anim = |reveal: Reveal| {
|
||||
let out = price_pane(&PricePane {
|
||||
series: &s,
|
||||
chart_type: ChartType::Candles,
|
||||
show_volume: true,
|
||||
overlays: &[],
|
||||
viewport: &v,
|
||||
height_px: 300.0,
|
||||
log: false,
|
||||
manual_scale: None,
|
||||
style: Style { reveal, ..Style::default() },
|
||||
});
|
||||
out.cmds
|
||||
.iter()
|
||||
.map(|c| match c {
|
||||
DrawCmd::Candles { uniforms, .. } | DrawCmd::Volume { uniforms, .. } => uniforms.anim,
|
||||
DrawCmd::Line { uniforms, .. } => uniforms.anim,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
// Done: everything drawn, nothing clipped.
|
||||
for a in anim(Reveal::DONE) {
|
||||
assert_eq!(a, [1e9, 1.0, 1e9, 1.0]);
|
||||
}
|
||||
assert_eq!(Reveal::sweep(1.0, v.left(), v.visible_bars()), Reveal::DONE);
|
||||
// Start: the front sits on the left edge, so no bar has grown yet.
|
||||
let start = Reveal::sweep(0.0, v.left(), v.visible_bars());
|
||||
let left_lo = (v.left() - v.left().floor()) as f32;
|
||||
for a in anim(start) {
|
||||
assert!((a[0] - left_lo).abs() < 1e-3, "{a:?}");
|
||||
assert!(a[2] < a[0] && a[1] >= 2.0);
|
||||
}
|
||||
// Midway the front is inside the visible range, lines trailing it.
|
||||
let mid = Reveal::sweep(0.5, v.left(), v.visible_bars());
|
||||
assert!(mid.front > v.left() && mid.front < v.right && mid.line_front < mid.front);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_pane_fixed_and_auto() {
|
||||
let vals: Vec<f64> = (0..100).map(|i| (i as f64 * 0.2).sin()).collect();
|
||||
@@ -371,4 +446,87 @@ mod tests {
|
||||
let fixed = sub_pane(&[o], &v, 100.0, Some((0.0, 100.0)), Style::default());
|
||||
assert_eq!((fixed.scale.lo, fixed.scale.hi), (0.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dots_draw_one_instance_per_point_and_scale_the_price_pane() {
|
||||
let bars = sample::generate("X", Timeframe::D1, 200, 1_790_000_000);
|
||||
let s = SeriesGpu::build(&bars);
|
||||
let v = Viewport::fit_last(200, 200, 800.0);
|
||||
let top = bars.iter().map(|b| b.high).fold(f64::MIN, f64::max) * 2.0;
|
||||
let mut marks = vec![f64::NAN; 200];
|
||||
marks[150] = top;
|
||||
let o = Overlay { line: line_ref(&marks), color: [1.0; 4], kind: LineKind::Dots, scale: 1.0 };
|
||||
let out = price_pane(&PricePane {
|
||||
series: &s,
|
||||
chart_type: ChartType::Candles,
|
||||
show_volume: false,
|
||||
overlays: std::slice::from_ref(&o),
|
||||
viewport: &v,
|
||||
height_px: 400.0,
|
||||
log: false,
|
||||
manual_scale: None,
|
||||
style: Style::default(),
|
||||
});
|
||||
let Some(DrawCmd::Line { kind: LineKind::Dots, range, .. }) = out.cmds.last() else { panic!("no dots drawn") };
|
||||
assert_eq!(range.1 - range.0, 200);
|
||||
assert!(out.scale.hi >= top);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoomed_out_markers_keep_sparse_points() {
|
||||
let bars = sample::generate("X", Timeframe::M1, 4096, 1_790_000_000);
|
||||
let s = SeriesGpu::build(&bars);
|
||||
let v = Viewport { right: 4096.0, bar_px: 0.2, width_px: 800.0 };
|
||||
let top = bars.iter().map(|b| b.high).fold(f64::MIN, f64::max) * 2.0;
|
||||
// A fractal-like marker whose neighbours are blank.
|
||||
let mut marks = vec![f64::NAN; 4096];
|
||||
marks[2002] = top;
|
||||
let pane = |line: LineRef| {
|
||||
let o = Overlay { line, color: [1.0; 4], kind: LineKind::Dots, scale: 1.0 };
|
||||
let out = price_pane(&PricePane {
|
||||
series: &s,
|
||||
chart_type: ChartType::Candles,
|
||||
show_volume: false,
|
||||
overlays: std::slice::from_ref(&o),
|
||||
viewport: &v,
|
||||
height_px: 400.0,
|
||||
log: false,
|
||||
manual_scale: None,
|
||||
style: Style::default(),
|
||||
});
|
||||
let Some(DrawCmd::Line { data, range, .. }) = out.cmds.last() else { panic!("no dots drawn") };
|
||||
let drawn = data.pyramid.data[range.0 as usize..range.1 as usize].contains(&[2002.5, top as f32]);
|
||||
(out.level, drawn, out.scale.hi >= top)
|
||||
};
|
||||
let (level, drawn, scaled) = pane(marker_ref(&marks));
|
||||
assert!(level > 0 && drawn && scaled, "level {level}: drawn {drawn}, scaled {scaled}");
|
||||
assert_eq!(pane(line_ref(&marks)), (level, false, false), "line reduction drops the marker");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steps_draw_one_full_bar_dash_per_point() {
|
||||
let s = SeriesGpu::build(&sample::generate("X", Timeframe::H4, 200, 1_790_000_000));
|
||||
let levels: Vec<f64> = (0..200).map(|i| if i / 2 % 2 == 0 { 1.0 } else { 1e6 }).collect();
|
||||
let o = Overlay { line: line_ref(&levels), color: [1.0; 4], kind: LineKind::Steps, scale: 1.0 };
|
||||
for (bar_px, group) in [(4.0, 1.0), (0.5, 4.0)] {
|
||||
let v = Viewport { right: 200.0, bar_px, width_px: 800.0 };
|
||||
let out = price_pane(&PricePane {
|
||||
series: &s,
|
||||
chart_type: ChartType::Candles,
|
||||
show_volume: false,
|
||||
overlays: std::slice::from_ref(&o),
|
||||
viewport: &v,
|
||||
height_px: 400.0,
|
||||
log: false,
|
||||
manual_scale: None,
|
||||
style: Style::default(),
|
||||
});
|
||||
let Some(DrawCmd::Line { kind: LineKind::Steps, range, uniforms, .. }) = out.cmds.last() else {
|
||||
panic!("no steps drawn")
|
||||
};
|
||||
assert_eq!(uniforms.extra[2], group, "a dash spans its whole group of bars");
|
||||
assert_eq!(range.1 - range.0, 200 / group as u32);
|
||||
assert!(out.scale.hi >= 1e6);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ struct Uniforms {
|
||||
params: vec4<f32>,
|
||||
// vol_frac, vol_max, hist_width_bars, baseline
|
||||
extra: vec4<f32>,
|
||||
// reveal front, ramp (bars), line front (both relative to left_hi), pane opacity
|
||||
anim: vec4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> u: Uniforms;
|
||||
@@ -55,11 +57,24 @@ fn corner(vi: u32) -> vec2<f32> {
|
||||
return c[vi % 6u];
|
||||
}
|
||||
|
||||
// Premultiplied colour with the pane opacity applied.
|
||||
fn shade(color: vec4<f32>) -> vec4<f32> {
|
||||
let a = color.a * u.anim.w;
|
||||
return vec4<f32>(color.rgb * a, a);
|
||||
}
|
||||
|
||||
// How far the bar centred at `idx` has grown in (0..1, eased) behind the reveal front.
|
||||
fn grow(idx: f32) -> f32 {
|
||||
let f = clamp((u.anim.x - (idx - u.rect.z)) / u.anim.y, 0.0, 1.0);
|
||||
let r = 1.0 - f;
|
||||
return 1.0 - r * r * r;
|
||||
}
|
||||
|
||||
fn rect(vi: u32, x0: f32, x1: f32, y0: f32, y1: f32, color: vec4<f32>) -> VsOut {
|
||||
let c = corner(vi);
|
||||
var out: VsOut;
|
||||
out.pos = vec4<f32>(mix(x0, x1, c.x), mix(y0, y1, c.y), 0.0, 1.0);
|
||||
out.color = vec4<f32>(color.rgb * color.a, color.a);
|
||||
out.color = shade(color);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -95,7 +110,16 @@ struct Bar {
|
||||
@vertex
|
||||
fn vs_candle(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VsOut {
|
||||
let b = bars[ii];
|
||||
let color = select(u.down, u.up, b.close >= b.open);
|
||||
let g = grow(b.x + b.w * 0.5);
|
||||
if (g <= 0.0) {
|
||||
return degenerate();
|
||||
}
|
||||
// Growing in: the bar opens out from its open price.
|
||||
let high = mix(b.open, b.high, g);
|
||||
let low = mix(b.open, b.low, g);
|
||||
let close = mix(b.open, b.close, g);
|
||||
var color = select(u.down, u.up, b.close >= b.open);
|
||||
color.a = color.a * clamp(g * 3.0, 0.0, 1.0);
|
||||
let cx = x_ndc(b.x + b.w * 0.5);
|
||||
let half_body = max((b.w * u.params.x * 0.5) / u.view.x * 2.0, px_to_ndc_x(0.5));
|
||||
let half_wick = px_to_ndc_x(u.params.y * 0.5);
|
||||
@@ -103,34 +127,38 @@ fn vs_candle(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -
|
||||
let one_px = px_to_ndc_y(1.0);
|
||||
if (u.params.w < 0.5) {
|
||||
if (r == 0u) {
|
||||
return rect(vi, cx - half_wick, cx + half_wick, y_ndc(b.low), max(y_ndc(b.high), y_ndc(b.low) + one_px), color);
|
||||
return rect(vi, cx - half_wick, cx + half_wick, y_ndc(low), max(y_ndc(high), y_ndc(low) + one_px), color);
|
||||
}
|
||||
if (r == 1u) {
|
||||
let y0 = y_ndc(min(b.open, b.close));
|
||||
let y1 = max(y_ndc(max(b.open, b.close)), y0 + one_px);
|
||||
let y0 = y_ndc(min(b.open, close));
|
||||
let y1 = max(y_ndc(max(b.open, close)), y0 + one_px);
|
||||
return rect(vi, cx - half_body, cx + half_body, y0, y1, color);
|
||||
}
|
||||
return degenerate();
|
||||
}
|
||||
let tick = px_to_ndc_y(max(u.params.y, 1.0) * 0.5);
|
||||
if (r == 0u) {
|
||||
return rect(vi, cx - half_wick, cx + half_wick, y_ndc(b.low), max(y_ndc(b.high), y_ndc(b.low) + one_px), color);
|
||||
return rect(vi, cx - half_wick, cx + half_wick, y_ndc(low), max(y_ndc(high), y_ndc(low) + one_px), color);
|
||||
}
|
||||
if (r == 1u) {
|
||||
let y = y_ndc(b.open);
|
||||
return rect(vi, cx - half_body, cx, y - tick, y + tick, color);
|
||||
}
|
||||
let y = y_ndc(b.close);
|
||||
let y = y_ndc(close);
|
||||
return rect(vi, cx, cx + half_body, y - tick, y + tick, color);
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_volume(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VsOut {
|
||||
let b = bars[ii];
|
||||
let g = grow(b.x + b.w * 0.5);
|
||||
if (g <= 0.0) {
|
||||
return degenerate();
|
||||
}
|
||||
var color = select(u.down, u.up, b.close >= b.open);
|
||||
color.a = color.a * u.color.a;
|
||||
let cx = x_ndc(b.x + b.w * 0.5);
|
||||
let half = max((b.w * u.params.x * 0.5) / u.view.x * 2.0, px_to_ndc_x(0.5));
|
||||
let h = clamp(b.volume / max(u.extra.y, 1e-30), 0.0, 1.0) * u.extra.x * 2.0;
|
||||
let h = clamp(b.volume / max(u.extra.y, 1e-30), 0.0, 1.0) * u.extra.x * 2.0 * g;
|
||||
return rect(vi, cx - half, cx + half, -1.0, -1.0 + h, color);
|
||||
}
|
||||
@@ -15,14 +15,24 @@ fn px_to_ndc(p: vec2<f32>) -> vec2<f32> {
|
||||
return vec2<f32>(p.x / u.rect.x * 2.0 - 1.0, p.y / u.rect.y * 2.0 - 1.0);
|
||||
}
|
||||
|
||||
// Segment end `b` cut back to the line front while the pane is revealing.
|
||||
fn traced(a: vec2<f32>, b: vec2<f32>) -> vec2<f32> {
|
||||
let fa = a.x - u.rect.z;
|
||||
let fb = b.x - u.rect.z;
|
||||
if (fb <= u.anim.z) {
|
||||
return b;
|
||||
}
|
||||
return mix(a, b, clamp((u.anim.z - fa) / max(fb - fa, 1e-6), 0.0, 1.0));
|
||||
}
|
||||
|
||||
// Thick segment between points ii and ii+1, expanded in pixel space.
|
||||
@vertex
|
||||
fn vs_line(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VsOut {
|
||||
let a = pts[ii];
|
||||
let b = pts[ii + 1u];
|
||||
if (!(finite(a.y) && finite(b.y))) {
|
||||
if (!(finite(a.y) && finite(pts[ii + 1u].y)) || a.x - u.rect.z >= u.anim.z) {
|
||||
return degenerate();
|
||||
}
|
||||
let b = traced(a, pts[ii + 1u]);
|
||||
let pa = to_px(a);
|
||||
let pb = to_px(b);
|
||||
var d = pb - pa;
|
||||
@@ -36,7 +46,7 @@ fn vs_line(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) ->
|
||||
let p = along + n * (c.y * 2.0 - 1.0);
|
||||
var out: VsOut;
|
||||
out.pos = vec4<f32>(px_to_ndc(p), 0.0, 1.0);
|
||||
out.color = vec4<f32>(u.color.rgb * u.color.a, u.color.a);
|
||||
out.color = shade(u.color);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -44,19 +54,18 @@ fn vs_line(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) ->
|
||||
@vertex
|
||||
fn vs_area(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VsOut {
|
||||
let a = pts[ii];
|
||||
let b = pts[ii + 1u];
|
||||
if (!(finite(a.y) && finite(b.y))) {
|
||||
if (!(finite(a.y) && finite(pts[ii + 1u].y)) || a.x - u.rect.z >= u.anim.z) {
|
||||
return degenerate();
|
||||
}
|
||||
let b = traced(a, pts[ii + 1u]);
|
||||
let c = corner(vi);
|
||||
let x = mix(x_ndc(a.x), x_ndc(b.x), c.x);
|
||||
let top = mix(y_ndc(a.y), y_ndc(b.y), c.x);
|
||||
let y = mix(-1.0, top, c.y);
|
||||
// Fade towards the bottom.
|
||||
let alpha = u.color.a * mix(0.15, 1.0, c.y);
|
||||
var out: VsOut;
|
||||
out.pos = vec4<f32>(x, y, 0.0, 1.0);
|
||||
out.color = vec4<f32>(u.color.rgb * alpha, alpha);
|
||||
out.color = shade(vec4<f32>(u.color.rgb, u.color.a * mix(0.15, 1.0, c.y)));
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -64,11 +73,45 @@ fn vs_area(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) ->
|
||||
@vertex
|
||||
fn vs_hist(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VsOut {
|
||||
let a = pts[ii];
|
||||
if (!finite(a.y)) {
|
||||
let g = grow(a.x);
|
||||
if (!finite(a.y) || g <= 0.0) {
|
||||
return degenerate();
|
||||
}
|
||||
let color = select(u.down, u.up, a.y >= u.extra.w);
|
||||
let half = max(u.extra.z * 0.5 / u.view.x * 2.0, px_to_ndc_x(0.5));
|
||||
let cx = x_ndc(a.x);
|
||||
return rect(vi, cx - half, cx + half, y_ndc(u.extra.w), y_ndc(a.y), color);
|
||||
return rect(vi, cx - half, cx + half, y_ndc(u.extra.w), y_ndc(mix(u.extra.w, a.y, g)), color);
|
||||
}
|
||||
|
||||
// Flat dash at point ii's level across the bars of its group (extra.z bars wide, one when
|
||||
// not zoomed out). The point is its group's last bar, so the dash runs from the group's
|
||||
// start to that bar; a short final group stays short. Consecutive levels never join, so a
|
||||
// session's pivot starts afresh instead of stepping up from the last one.
|
||||
@vertex
|
||||
fn vs_steps(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VsOut {
|
||||
let a = pts[ii];
|
||||
let x1 = a.x + 0.5;
|
||||
let x0 = floor(a.x / u.extra.z) * u.extra.z;
|
||||
if (!finite(a.y) || x0 - u.rect.z >= u.anim.z) {
|
||||
return degenerate();
|
||||
}
|
||||
let hy = px_to_ndc_y(u.params.z * 0.5);
|
||||
let cy = y_ndc(a.y);
|
||||
return rect(vi, x_ndc(x0), x_ndc(min(x1, u.rect.z + u.anim.z)), cy - hy, cy + hy, u.color);
|
||||
}
|
||||
|
||||
// Square marker centred on point ii (e.g. parabolic SAR), sized from the line thickness.
|
||||
// Markers grow in behind the reveal front.
|
||||
@vertex
|
||||
fn vs_dots(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VsOut {
|
||||
let a = pts[ii];
|
||||
let g = grow(a.x);
|
||||
if (!finite(a.y) || g <= 0.0) {
|
||||
return degenerate();
|
||||
}
|
||||
let hx = px_to_ndc_x(u.params.z * 1.25 * g);
|
||||
let hy = px_to_ndc_y(u.params.z * 1.25 * g);
|
||||
let cx = x_ndc(a.x);
|
||||
let cy = y_ndc(a.y);
|
||||
return rect(vi, cx - hx, cx + hx, cy - hy, cy + hy, u.color);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Renders real frames offscreen (any adapter, including software Vulkan) and checks pixels.
|
||||
|
||||
use tr_chart::gpu::{Frame, LineKind, Style};
|
||||
use tr_chart::gpu::{Frame, LineKind, Reveal, Style};
|
||||
use tr_chart::headless::Headless;
|
||||
use tr_chart::scene::{ChartType, Overlay, PricePane, SeriesGpu, line_ref, price_pane, sub_pane};
|
||||
use tr_chart::viewport::Viewport;
|
||||
@@ -73,3 +73,130 @@ fn renders_candles_volume_lines_and_hist() {
|
||||
assert!(count(&px, |p| p[1] > 150 && p[0] < 100) > 200);
|
||||
assert!(count(&px, |p| p[0] > 200 && p[1] < 100) > 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_sweeps_in_from_the_left_and_fades() {
|
||||
let Some(mut h) = Headless::new(800, 400) else {
|
||||
eprintln!("no wgpu adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let bars = sample::generate("AAPL", Timeframe::D1, 2_000, 1_790_000_000);
|
||||
let series = SeriesGpu::build(&bars);
|
||||
let closes: Vec<f64> = bars.iter().map(|b| b.close).collect();
|
||||
let overlays = [
|
||||
Overlay {
|
||||
line: line_ref(&indicators::sma(&closes, 20)),
|
||||
color: [0.0, 0.6, 1.0, 1.0],
|
||||
kind: LineKind::Line,
|
||||
scale: 1.0,
|
||||
},
|
||||
Overlay {
|
||||
line: line_ref(&indicators::ema(&closes, 50)),
|
||||
color: [1.0, 0.3, 0.3, 1.0],
|
||||
kind: LineKind::Dots,
|
||||
scale: 1.0,
|
||||
},
|
||||
];
|
||||
let v = Viewport::fit_last(bars.len(), 150, 800.0);
|
||||
let mut draw = |reveal: Reveal, pane: u64| {
|
||||
let out = price_pane(&PricePane {
|
||||
series: &series,
|
||||
chart_type: ChartType::Candles,
|
||||
show_volume: true,
|
||||
overlays: &overlays,
|
||||
viewport: &v,
|
||||
height_px: 400.0,
|
||||
log: false,
|
||||
manual_scale: None,
|
||||
style: Style { reveal, ..Style::default() },
|
||||
});
|
||||
h.render(&mut [Frame { pane, frame_no: pane, cmds: out.cmds }]);
|
||||
h.read_pixels()
|
||||
};
|
||||
// Lit pixels (candles, volume or the SMA) in columns [x0, x1).
|
||||
let lit = |px: &[u8], x0: usize, x1: usize| {
|
||||
px.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, p)| (x0..x1).contains(&(i % 800)) && p[..3] != [0, 0, 0])
|
||||
.count()
|
||||
};
|
||||
|
||||
let half = draw(Reveal::sweep(0.45, v.left(), v.visible_bars()), 1);
|
||||
if let Ok(dir) = std::env::var("TR_DUMP_DIR") {
|
||||
dump_ppm(&format!("{dir}/reveal.ppm"), 800, 400, &half);
|
||||
}
|
||||
let (left, right) = (lit(&half, 0, 250), lit(&half, 450, 800));
|
||||
eprintln!("half revealed: left {left} right {right}");
|
||||
assert!(left > 2_000, "bars behind the front are drawn ({left})");
|
||||
assert_eq!(right, 0, "nothing past the front, lines and dots included");
|
||||
|
||||
let full = draw(Reveal::DONE, 2);
|
||||
assert!(lit(&full, 450, 800) > 2_000);
|
||||
|
||||
let faded = draw(Reveal { opacity: 0.0, ..Reveal::DONE }, 3);
|
||||
assert_eq!(lit(&faded, 0, 800), 0, "opacity 0 draws nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steps_draw_short_sessions_without_joining_them() {
|
||||
let Some(mut h) = Headless::new(800, 400) else {
|
||||
eprintln!("no wgpu adapter; skipping");
|
||||
return;
|
||||
};
|
||||
// Two-bar sessions alternating between 25 and 75 (pivots on a 4H equity chart), then a
|
||||
// session of one bar at 50.
|
||||
let mut levels: Vec<f64> = (0..39).map(|i| if i / 2 % 2 == 0 { 25.0 } else { 75.0 }).collect();
|
||||
levels.push(50.0);
|
||||
let v = Viewport::fit_last(levels.len(), levels.len(), 800.0);
|
||||
let mut draw = |kind: LineKind, pane: u64| {
|
||||
let o = [Overlay { line: line_ref(&levels), color: [1.0; 4], kind, scale: 1.0 }];
|
||||
let out = sub_pane(&o, &v, 400.0, Some((0.0, 100.0)), Style::default());
|
||||
h.render(&mut [Frame { pane, frame_no: pane, cmds: out.cmds }]);
|
||||
h.read_pixels()
|
||||
};
|
||||
// Lit pixels in rows [y0, y1); 25 and 75 sit on rows 300 and 100, 50 on row 200.
|
||||
let rows = |px: &[u8], y0: usize, y1: usize| {
|
||||
px.as_chunks::<4>().0.iter().enumerate().filter(|(i, p)| (y0..y1).contains(&(i / 800)) && p[0] > 0).count()
|
||||
};
|
||||
let steps = draw(LineKind::Steps, 1);
|
||||
if let Ok(dir) = std::env::var("TR_DUMP_DIR") {
|
||||
dump_ppm(&format!("{dir}/steps.ppm"), 800, 400, &steps);
|
||||
}
|
||||
let (high, low, one_bar) = (rows(&steps, 95, 105), rows(&steps, 295, 305), rows(&steps, 195, 205));
|
||||
eprintln!("steps: 75 {high}, 25 {low}, 50 {one_bar}");
|
||||
assert!(high > 300 && low > 300, "every session draws its level");
|
||||
assert!(one_bar > 10, "a one-bar session draws");
|
||||
assert_eq!(rows(&steps, 110, 190) + rows(&steps, 210, 290), 0, "sessions are not joined");
|
||||
let lines = draw(LineKind::Line, 2);
|
||||
assert!(rows(&lines, 110, 190) > 0, "a line would join them");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoomed_out_steps_stay_inside_their_group() {
|
||||
let Some(mut h) = Headless::new(800, 400) else {
|
||||
eprintln!("no wgpu adapter; skipping");
|
||||
return;
|
||||
};
|
||||
// 401 bars at one pixel each draw in groups of two; the last group holds only bar 400,
|
||||
// which starts a new session at 75.
|
||||
let mut levels = vec![25.0; 400];
|
||||
levels.push(75.0);
|
||||
let v = Viewport { right: 410.0, bar_px: 1.0, width_px: 800.0 };
|
||||
let o = [Overlay { line: line_ref(&levels), color: [1.0; 4], kind: LineKind::Steps, scale: 1.0 }];
|
||||
let out = sub_pane(&o, &v, 400.0, Some((0.0, 100.0)), Style::default());
|
||||
h.render(&mut [Frame { pane: 1, frame_no: 0, cmds: out.cmds }]);
|
||||
let px = h.read_pixels();
|
||||
// Bar i spans columns [i + 390, i + 391); 75 sits on row 100.
|
||||
let lit = |x0: usize, x1: usize| {
|
||||
px.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, p)| (95..105).contains(&(i / 800)) && (x0..x1).contains(&(i % 800)) && p[0] > 0)
|
||||
.count()
|
||||
};
|
||||
assert!(lit(790, 791) > 0, "the new session draws on its own bar");
|
||||
assert_eq!(lit(0, 790), 0, "and not over the previous session");
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
[package]
|
||||
name = "tr-congress"
|
||||
description = "STOCK Act disclosure ingest (Senate eFD + House Clerk PTRs) for terminal-rs"
|
||||
description = "STOCK Act disclosure ingest (Senate eFD + House Clerk PTRs) for Terminal"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
chrono.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
regex.workspace = true
|
||||
|
||||
# Ingest and storage: desktop only (the browser build has no SQLite and can't reach the sources).
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
thiserror.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
reqwest.workspace = true
|
||||
rusqlite.workspace = true
|
||||
|
||||
@@ -7,6 +7,7 @@ use anyhow::Result;
|
||||
use chrono::{NaiveDate, Utc};
|
||||
use rusqlite::{Connection, OptionalExtension, Row, params};
|
||||
|
||||
pub use crate::model::{Alert, PoliticianSummary};
|
||||
use crate::model::{AmountRange, Chamber, ParsedTrade, Party, Politician, Trade, TxType};
|
||||
|
||||
const SCHEMA: &str = r#"
|
||||
@@ -111,17 +112,6 @@ pub struct FilingRecord {
|
||||
pub is_amendment: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Alert {
|
||||
pub id: i64,
|
||||
pub politician_id: i64,
|
||||
pub politician: String,
|
||||
pub filing_id: String,
|
||||
pub created_at: i64,
|
||||
pub message: String,
|
||||
pub seen: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct Stats {
|
||||
pub politicians: i64,
|
||||
@@ -130,15 +120,6 @@ pub struct Stats {
|
||||
pub scanned: i64,
|
||||
}
|
||||
|
||||
/// A politician with aggregate trade counts, for list views.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PoliticianSummary {
|
||||
pub politician: Politician,
|
||||
pub trades: i64,
|
||||
pub last_filed: Option<NaiveDate>,
|
||||
pub tracked: bool,
|
||||
}
|
||||
|
||||
pub struct Db {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! The browser build's stand-in for the SQLite store: always empty, because the browser
|
||||
//! can't fetch disclosures yet. It keeps the desktop store's method signatures so callers
|
||||
//! compile unchanged.
|
||||
//!
|
||||
//! TODO(web proxy): fill this from a same-origin proxy for Senate eFD and the House Clerk.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
pub use crate::model::{Alert, PoliticianSummary};
|
||||
use crate::model::{Politician, Trade};
|
||||
|
||||
pub struct Db;
|
||||
|
||||
impl Db {
|
||||
pub fn open(_path: impl AsRef<Path>) -> Result<Self> {
|
||||
Ok(Self)
|
||||
}
|
||||
|
||||
pub fn open_in_memory() -> Result<Self> {
|
||||
Ok(Self)
|
||||
}
|
||||
|
||||
pub fn latest_trades(&self, _limit: usize) -> Result<Vec<Trade>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
pub fn trades_for_ticker(&self, _ticker: &str, _limit: usize) -> Result<Vec<Trade>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
pub fn trades_for_politician(&self, _politician_id: i64) -> Result<Vec<Trade>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
pub fn politician(&self, _id: i64) -> Result<Option<Politician>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn search_politicians(&self, _query: &str, _limit: usize) -> Result<Vec<PoliticianSummary>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
pub fn set_tracked(&self, _politician_id: i64, _tracked: bool) -> Result<()> {
|
||||
bail!("tracking needs congressional data, which the browser build can't load yet")
|
||||
}
|
||||
|
||||
pub fn is_tracked(&self, _politician_id: i64) -> Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub fn alerts(&self, _unseen_only: bool, _limit: usize) -> Result<Vec<Alert>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
pub fn mark_alerts_seen(&self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
//! STOCK Act congressional trade disclosures, ingested directly from the
|
||||
//! official sources: the Senate eFD system and the House Clerk's PTR filings.
|
||||
//!
|
||||
//! The browser build (`wasm32`) has no SQLite and can't reach either site (they don't allow
|
||||
//! cross-origin requests), so there [`db`] is an empty stand-in and there is no ingest.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod db;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[path = "db_web.rs"]
|
||||
pub mod db;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod house;
|
||||
pub mod model;
|
||||
pub mod performance;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod refresh;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod roster;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod senate;
|
||||
|
||||
pub use db::Db;
|
||||
|
||||
@@ -243,6 +243,36 @@ pub fn normalize_ticker(raw: &str) -> Option<String> {
|
||||
RE.is_match(&t).then_some(t)
|
||||
}
|
||||
|
||||
/// An alert raised when a tracked politician files new trades.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Alert {
|
||||
pub id: i64,
|
||||
pub politician_id: i64,
|
||||
pub politician: String,
|
||||
pub filing_id: String,
|
||||
pub created_at: i64,
|
||||
pub message: String,
|
||||
pub seen: bool,
|
||||
}
|
||||
|
||||
/// A politician with aggregate trade counts, for list views.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PoliticianSummary {
|
||||
pub politician: Politician,
|
||||
pub trades: i64,
|
||||
pub last_filed: Option<NaiveDate>,
|
||||
pub tracked: bool,
|
||||
}
|
||||
|
||||
/// A new filing by a tracked politician, found during a refresh.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct NewTradeAlert {
|
||||
pub politician_id: i64,
|
||||
pub politician: String,
|
||||
pub filing_id: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -12,6 +12,7 @@ use tracing::{debug, info, warn};
|
||||
|
||||
use crate::db::{Db, FilingRecord, FilingStatus, NewPolitician};
|
||||
use crate::house::{self, IndexEntry, PdfError};
|
||||
pub use crate::model::NewTradeAlert;
|
||||
use crate::model::{Chamber, Party};
|
||||
use crate::roster::{self, Roster};
|
||||
use crate::senate::{self, ReportKind, ReportRef};
|
||||
@@ -58,14 +59,6 @@ impl RefreshConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct NewTradeAlert {
|
||||
pub politician_id: i64,
|
||||
pub politician: String,
|
||||
pub filing_id: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RefreshReport {
|
||||
pub new_filings: usize,
|
||||
@@ -394,7 +387,7 @@ impl HttpSource {
|
||||
pub fn new() -> Result<Self> {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(concat!(
|
||||
"terminal-rs/",
|
||||
"terminal/",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
" (STOCK Act disclosure reader; +https://github.com/)"
|
||||
))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tr-core"
|
||||
description = "Core market types, timeframes, indicators and sample data for terminal-rs"
|
||||
description = "Core market types, timeframes, indicators and sample data for Terminal"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -10,6 +10,7 @@ serde.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
criterion.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[[bench]]
|
||||
name = "indicators"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
use std::hint::black_box;
|
||||
use tr_core::indicators::registry::{self, Input};
|
||||
use tr_core::{Timeframe, indicators, sample};
|
||||
|
||||
fn bench(c: &mut Criterion) {
|
||||
@@ -14,6 +15,19 @@ fn bench(c: &mut Criterion) {
|
||||
g.bench_function("bollinger", |b| b.iter(|| indicators::bollinger(black_box(&closes), 20, 2.0)));
|
||||
g.bench_function("vwap", |b| b.iter(|| indicators::vwap(black_box(&bars))));
|
||||
g.finish();
|
||||
|
||||
// The whole catalogue at default parameters, as the chart worker computes it.
|
||||
let input = Input::new(&bars[..100_000], Timeframe::M1);
|
||||
let mut g = c.benchmark_group("catalog_100k_bars");
|
||||
g.sample_size(10);
|
||||
g.bench_function("every_indicator", |b| {
|
||||
b.iter(|| registry::all().iter().map(|d| d.run(black_box(&input), &d.defaults()).len()).sum::<usize>())
|
||||
});
|
||||
for code in ["ICHIMOKU", "MAMA", "SUPERTREND", "ZIGZAG", "PIVOT", "YZ"] {
|
||||
let d = registry::find(code).expect(code);
|
||||
g.bench_function(code, |b| b.iter(|| d.run(black_box(&input), &d.defaults())));
|
||||
}
|
||||
g.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench);
|
||||
|
||||
+24
-10
@@ -89,18 +89,24 @@ impl Series {
|
||||
i.checked_sub(1)
|
||||
}
|
||||
|
||||
/// Apply one live bar: absorb it into the last bar if it is the same period, append it
|
||||
/// if newer, ignore it if older. Returns true if the series changed.
|
||||
/// Apply one live bar by its `ts`: absorb it into the bar of the same period, append it if
|
||||
/// newer, or insert it where a period is missing (a feed backfilling a gap after an outage).
|
||||
/// Bars older than the whole series are ignored; paging loads those. Returns true if the
|
||||
/// series changed.
|
||||
pub fn apply_live(bars: &mut Vec<Bar>, live: Bar) -> bool {
|
||||
match bars.last_mut() {
|
||||
Some(last) if last.ts == live.ts => {
|
||||
let before = *last;
|
||||
last.absorb(&live);
|
||||
*last != before
|
||||
if bars.last().is_none_or(|last| last.ts < live.ts) {
|
||||
bars.push(live);
|
||||
return true;
|
||||
}
|
||||
match bars.binary_search_by_key(&live.ts, |b| b.ts) {
|
||||
Ok(i) => {
|
||||
let before = bars[i];
|
||||
bars[i].absorb(&live);
|
||||
bars[i] != before
|
||||
}
|
||||
Some(last) if last.ts > live.ts => false,
|
||||
_ => {
|
||||
bars.push(live);
|
||||
Err(0) => false,
|
||||
Err(i) => {
|
||||
bars.insert(i, live);
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -148,6 +154,14 @@ mod tests {
|
||||
assert!(!Series::apply_live(&mut v, Bar::new(0, 1.0, 1.0, 1.0, 1.0, 1.0)));
|
||||
assert!(Series::apply_live(&mut v, Bar::new(120, 11.5, 11.6, 11.4, 11.6, 5.0)));
|
||||
assert_eq!(v.len(), 2);
|
||||
// A feed backfilling after an outage: a missing period is inserted, an older bar it
|
||||
// still holds is completed, and nothing older than the series is added.
|
||||
assert!(Series::apply_live(&mut v, Bar::new(240, 12.0, 12.0, 12.0, 12.0, 1.0)));
|
||||
assert!(Series::apply_live(&mut v, Bar::new(180, 11.6, 11.9, 11.5, 11.8, 7.0)));
|
||||
assert!(Series::apply_live(&mut v, Bar::new(120, 11.5, 11.7, 11.4, 11.65, 6.0)));
|
||||
assert_eq!(v.iter().map(|b| b.ts).collect::<Vec<_>>(), [60, 120, 180, 240]);
|
||||
assert_eq!((v[1].high, v[1].close, v[1].volume), (11.7, 11.65, 6.0));
|
||||
assert!(!Series::apply_live(&mut v, Bar::new(180, 11.6, 11.9, 11.5, 11.8, 7.0)), "no change");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
//! Technical indicators. Every function returns a vector aligned with its
|
||||
//! input; positions without enough history hold `f64::NAN`.
|
||||
//!
|
||||
//! Conventions follow the common charting definitions: EMA uses
|
||||
//! alpha = 2/(n+1) seeded with an SMA, RSI uses Wilder smoothing, Bollinger
|
||||
//! bands use the population standard deviation, and VWAP resets each session.
|
||||
|
||||
use crate::Bar;
|
||||
|
||||
/// Simple moving average over `n` values, computed with a running sum.
|
||||
pub fn sma(values: &[f64], n: usize) -> Vec<f64> {
|
||||
let mut out = vec![f64::NAN; values.len()];
|
||||
if n == 0 || values.len() < n {
|
||||
return out;
|
||||
}
|
||||
let mut sum: f64 = values[..n].iter().sum();
|
||||
out[n - 1] = sum / n as f64;
|
||||
for i in n..values.len() {
|
||||
sum += values[i] - values[i - n];
|
||||
out[i] = sum / n as f64;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Exponential smoothing seeded with the SMA of the first `n` values.
|
||||
fn smooth(values: &[f64], n: usize, alpha: f64) -> Vec<f64> {
|
||||
let mut out = vec![f64::NAN; values.len()];
|
||||
if n == 0 || values.len() < n {
|
||||
return out;
|
||||
}
|
||||
let mut prev = values[..n].iter().sum::<f64>() / n as f64;
|
||||
out[n - 1] = prev;
|
||||
for i in n..values.len() {
|
||||
prev = alpha * values[i] + (1.0 - alpha) * prev;
|
||||
out[i] = prev;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Exponential moving average, alpha = 2/(n+1).
|
||||
pub fn ema(values: &[f64], n: usize) -> Vec<f64> {
|
||||
smooth(values, n, 2.0 / (n as f64 + 1.0))
|
||||
}
|
||||
|
||||
/// Wilder's moving average (RMA), alpha = 1/n.
|
||||
pub fn rma(values: &[f64], n: usize) -> Vec<f64> {
|
||||
smooth(values, n, 1.0 / n as f64)
|
||||
}
|
||||
|
||||
/// Relative Strength Index with Wilder smoothing (first value at index `n`).
|
||||
/// Single pass with no intermediate buffers: on large series the cost is dominated by
|
||||
/// first-touch of output memory, so only the output is allocated.
|
||||
pub fn rsi(closes: &[f64], n: usize) -> Vec<f64> {
|
||||
let mut out = vec![f64::NAN; closes.len()];
|
||||
if n == 0 || closes.len() <= n {
|
||||
return out;
|
||||
}
|
||||
let value = |g: f64, l: f64| {
|
||||
if l == 0.0 { if g == 0.0 { 50.0 } else { 100.0 } } else { 100.0 - 100.0 / (1.0 + g / l) }
|
||||
};
|
||||
let (mut ag, mut al) = (0.0, 0.0);
|
||||
for i in 1..=n {
|
||||
let d = closes[i] - closes[i - 1];
|
||||
ag += d.max(0.0);
|
||||
al += (-d).max(0.0);
|
||||
}
|
||||
ag /= n as f64;
|
||||
al /= n as f64;
|
||||
out[n] = value(ag, al);
|
||||
let alpha = 1.0 / n as f64;
|
||||
for i in (n + 1)..closes.len() {
|
||||
let d = closes[i] - closes[i - 1];
|
||||
ag = alpha * d.max(0.0) + (1.0 - alpha) * ag;
|
||||
al = alpha * (-d).max(0.0) + (1.0 - alpha) * al;
|
||||
out[i] = value(ag, al);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Macd {
|
||||
pub macd: Vec<f64>,
|
||||
pub signal: Vec<f64>,
|
||||
pub histogram: Vec<f64>,
|
||||
}
|
||||
|
||||
/// Streaming EMA seeded with the SMA of its first `n` inputs.
|
||||
struct Ema {
|
||||
n: usize,
|
||||
alpha: f64,
|
||||
seen: usize,
|
||||
acc: f64,
|
||||
}
|
||||
|
||||
impl Ema {
|
||||
fn new(n: usize) -> Self {
|
||||
Self { n, alpha: 2.0 / (n as f64 + 1.0), seen: 0, acc: 0.0 }
|
||||
}
|
||||
|
||||
/// Feed one value; returns the EMA once `n` values have been seen.
|
||||
fn push(&mut self, v: f64) -> Option<f64> {
|
||||
self.seen += 1;
|
||||
if self.seen < self.n {
|
||||
self.acc += v;
|
||||
None
|
||||
} else if self.seen == self.n {
|
||||
self.acc = (self.acc + v) / self.n as f64;
|
||||
Some(self.acc)
|
||||
} else {
|
||||
self.acc = self.alpha * v + (1.0 - self.alpha) * self.acc;
|
||||
Some(self.acc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MACD line (fast EMA - slow EMA), its signal EMA and histogram, in one pass.
|
||||
pub fn macd(closes: &[f64], fast: usize, slow: usize, signal: usize) -> Macd {
|
||||
let len = closes.len();
|
||||
let mut line = vec![f64::NAN; len];
|
||||
let mut sig = vec![f64::NAN; len];
|
||||
let mut histogram = vec![f64::NAN; len];
|
||||
if fast == 0 || slow == 0 || signal == 0 {
|
||||
return Macd { macd: line, signal: sig, histogram };
|
||||
}
|
||||
let (mut ef, mut es, mut eg) = (Ema::new(fast), Ema::new(slow), Ema::new(signal));
|
||||
for (i, &c) in closes.iter().enumerate() {
|
||||
let (f, s) = (ef.push(c), es.push(c));
|
||||
let (Some(f), Some(s)) = (f, s) else { continue };
|
||||
let m = f - s;
|
||||
line[i] = m;
|
||||
// The signal EMA starts once the MACD line itself is defined.
|
||||
if let Some(g) = eg.push(m) {
|
||||
sig[i] = g;
|
||||
histogram[i] = m - g;
|
||||
}
|
||||
}
|
||||
Macd { macd: line, signal: sig, histogram }
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Bands {
|
||||
pub middle: Vec<f64>,
|
||||
pub upper: Vec<f64>,
|
||||
pub lower: Vec<f64>,
|
||||
}
|
||||
|
||||
/// Bollinger bands: SMA(n) +/- k * population standard deviation.
|
||||
pub fn bollinger(closes: &[f64], n: usize, k: f64) -> Bands {
|
||||
let middle = sma(closes, n);
|
||||
let mut upper = vec![f64::NAN; closes.len()];
|
||||
let mut lower = vec![f64::NAN; closes.len()];
|
||||
if n > 0 && closes.len() >= n {
|
||||
let mut sum_sq: f64 = closes[..n].iter().map(|v| v * v).sum();
|
||||
for i in (n - 1)..closes.len() {
|
||||
if i >= n {
|
||||
sum_sq += closes[i] * closes[i] - closes[i - n] * closes[i - n];
|
||||
}
|
||||
let mean = middle[i];
|
||||
let var = (sum_sq / n as f64 - mean * mean).max(0.0);
|
||||
let sd = var.sqrt();
|
||||
upper[i] = mean + k * sd;
|
||||
lower[i] = mean - k * sd;
|
||||
}
|
||||
}
|
||||
Bands { middle, upper, lower }
|
||||
}
|
||||
|
||||
/// Volume-weighted average price over hlc3, reset whenever `session_of(ts)` changes.
|
||||
pub fn vwap_by(bars: &[Bar], session_of: impl Fn(i64) -> i64) -> Vec<f64> {
|
||||
let mut out = Vec::with_capacity(bars.len());
|
||||
let (mut pv, mut vol) = (0.0, 0.0);
|
||||
let mut session = None;
|
||||
for b in bars {
|
||||
let s = session_of(b.ts);
|
||||
if session != Some(s) {
|
||||
session = Some(s);
|
||||
pv = 0.0;
|
||||
vol = 0.0;
|
||||
}
|
||||
pv += b.hlc3() * b.volume;
|
||||
vol += b.volume;
|
||||
out.push(if vol > 0.0 { pv / vol } else { b.hlc3() });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Session VWAP resetting each UTC day. For daily and longer bars this
|
||||
/// degenerates to hlc3, so callers typically anchor it by month instead.
|
||||
pub fn vwap(bars: &[Bar]) -> Vec<f64> {
|
||||
vwap_by(bars, |ts| ts.div_euclid(86_400))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn close_to(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
fn assert_series(got: &[f64], want: &[f64]) {
|
||||
assert_eq!(got.len(), want.len());
|
||||
for (i, (g, w)) in got.iter().zip(want).enumerate() {
|
||||
assert!((g.is_nan() && w.is_nan()) || close_to(*g, *w), "index {i}: got {g}, want {w}");
|
||||
}
|
||||
}
|
||||
|
||||
const N: f64 = f64::NAN;
|
||||
|
||||
#[test]
|
||||
fn sma_basic() {
|
||||
assert_series(&sma(&[1.0, 2.0, 3.0, 4.0, 5.0], 3), &[N, N, 2.0, 3.0, 4.0]);
|
||||
assert!(sma(&[1.0], 3)[0].is_nan());
|
||||
assert!(sma(&[1.0, 2.0], 0).iter().all(|v| v.is_nan()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ema_seeded_with_sma() {
|
||||
// alpha = 0.5; seed = mean(1,2,3) = 2; then 0.5*4+0.5*2 = 3; 0.5*5+0.5*3 = 4.
|
||||
assert_series(&ema(&[1.0, 2.0, 3.0, 4.0, 5.0], 3), &[N, N, 2.0, 3.0, 4.0]);
|
||||
// Constant input stays constant.
|
||||
assert!(close_to(ema(&[7.0; 50], 10)[49], 7.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rsi_reference_values() {
|
||||
// Wilder's original 14-period example (New Concepts in Technical Trading Systems).
|
||||
let closes = [
|
||||
44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08, 45.89, 46.03, 45.61, 46.28, 46.28,
|
||||
46.00, 46.03, 46.41, 46.22, 45.64,
|
||||
];
|
||||
let r = rsi(&closes, 14);
|
||||
assert!(r[13].is_nan());
|
||||
assert!((r[14] - 70.46).abs() < 0.01, "rsi[14] = {}", r[14]);
|
||||
assert!((r[15] - 66.25).abs() < 0.01, "rsi[15] = {}", r[15]);
|
||||
assert!((r[19] - 57.92).abs() < 0.05, "rsi[19] = {}", r[19]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rsi_extremes() {
|
||||
let up: Vec<f64> = (0..30).map(|i| i as f64).collect();
|
||||
assert_eq!(rsi(&up, 14)[29], 100.0);
|
||||
assert_eq!(rsi(&[5.0; 30], 14)[29], 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macd_matches_manual_emas() {
|
||||
let closes: Vec<f64> = (0..60).map(|i| 100.0 + (i as f64 * 0.3).sin() * 5.0 + i as f64 * 0.1).collect();
|
||||
let m = macd(&closes, 12, 26, 9);
|
||||
let f = ema(&closes, 12);
|
||||
let s = ema(&closes, 26);
|
||||
assert!(m.macd[24].is_nan());
|
||||
assert!(close_to(m.macd[25], f[25] - s[25]));
|
||||
// Signal needs 9 MACD values: first at 25 + 8.
|
||||
assert!(m.signal[32].is_nan());
|
||||
let seed: f64 = m.macd[25..34].iter().sum::<f64>() / 9.0;
|
||||
assert!(close_to(m.signal[33], seed));
|
||||
assert!(close_to(m.histogram[40], m.macd[40] - m.signal[40]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bollinger_population_sd() {
|
||||
let b = bollinger(&[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0], 8, 2.0);
|
||||
// mean 5, population sd 2.
|
||||
assert!(close_to(b.middle[7], 5.0));
|
||||
assert!(close_to(b.upper[7], 9.0));
|
||||
assert!(close_to(b.lower[7], 1.0));
|
||||
assert!(b.upper[6].is_nan());
|
||||
// Rolling window stays numerically consistent with a fresh computation.
|
||||
let xs: Vec<f64> = (0..200).map(|i| ((i * 37) % 101) as f64).collect();
|
||||
let roll = bollinger(&xs, 20, 2.0);
|
||||
let fresh = bollinger(&xs[180..], 20, 2.0);
|
||||
assert!(close_to(roll.upper[199], fresh.upper[19]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vwap_resets_per_session() {
|
||||
let day = 86_400;
|
||||
let bars = [
|
||||
Bar::new(0, 0.0, 12.0, 8.0, 10.0, 100.0),
|
||||
Bar::new(60, 0.0, 22.0, 18.0, 20.0, 300.0),
|
||||
Bar::new(day, 0.0, 6.0, 4.0, 5.0, 10.0),
|
||||
];
|
||||
let v = vwap(&bars);
|
||||
assert!(close_to(v[0], 10.0));
|
||||
assert!(close_to(v[1], (10.0 * 100.0 + 20.0 * 300.0) / 400.0));
|
||||
assert!(close_to(v[2], 5.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Price levels: session keys and pivot points.
|
||||
|
||||
use chrono::Datelike;
|
||||
|
||||
use crate::Timeframe;
|
||||
|
||||
use super::{NAN, Ohlcv};
|
||||
|
||||
/// The session a bar at `ts` belongs to on `tf`, for VWAP and pivots: the UTC day
|
||||
/// intraday, the month on daily bars, the year above that.
|
||||
pub fn session_key(tf: Timeframe) -> impl Fn(i64) -> i64 {
|
||||
move |ts| {
|
||||
let d = chrono::DateTime::from_timestamp(ts, 0).unwrap_or_default();
|
||||
match tf {
|
||||
t if t.is_intraday() => ts.div_euclid(86_400),
|
||||
Timeframe::D1 => (d.year() * 12 + d.month() as i32) as i64,
|
||||
_ => d.year() as i64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PivotKind {
|
||||
Classic,
|
||||
Fibonacci,
|
||||
Camarilla,
|
||||
Woodie,
|
||||
Demark,
|
||||
}
|
||||
|
||||
/// Open, high, low and close of one session.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Session {
|
||||
open: f64,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
}
|
||||
|
||||
impl PivotKind {
|
||||
/// Level names, in the order [`pivots`] returns them.
|
||||
pub fn names(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
PivotKind::Classic | PivotKind::Fibonacci | PivotKind::Woodie => &["P", "R1", "S1", "R2", "S2", "R3", "S3"],
|
||||
PivotKind::Camarilla => &["P", "R1", "S1", "R2", "S2", "R3", "S3", "R4", "S4"],
|
||||
PivotKind::Demark => &["P", "R1", "S1"],
|
||||
}
|
||||
}
|
||||
|
||||
/// Levels from the previous session `s`, given the current session's `open`.
|
||||
fn levels(self, s: Session, open: f64) -> Vec<f64> {
|
||||
let Session { open: o, high: h, low: l, close: c } = s;
|
||||
let range = h - l;
|
||||
let classic =
|
||||
|p: f64| vec![p, 2.0 * p - l, 2.0 * p - h, p + range, p - range, h + 2.0 * (p - l), l - 2.0 * (h - p)];
|
||||
match self {
|
||||
PivotKind::Classic => classic((h + l + c) / 3.0),
|
||||
PivotKind::Woodie => classic((h + l + 2.0 * open) / 4.0),
|
||||
PivotKind::Fibonacci => {
|
||||
let p = (h + l + c) / 3.0;
|
||||
vec![
|
||||
p,
|
||||
p + 0.382 * range,
|
||||
p - 0.382 * range,
|
||||
p + 0.618 * range,
|
||||
p - 0.618 * range,
|
||||
p + range,
|
||||
p - range,
|
||||
]
|
||||
}
|
||||
PivotKind::Camarilla => {
|
||||
let p = (h + l + c) / 3.0;
|
||||
let k = 1.1 * range;
|
||||
vec![
|
||||
p,
|
||||
c + k / 12.0,
|
||||
c - k / 12.0,
|
||||
c + k / 6.0,
|
||||
c - k / 6.0,
|
||||
c + k / 4.0,
|
||||
c - k / 4.0,
|
||||
c + k / 2.0,
|
||||
c - k / 2.0,
|
||||
]
|
||||
}
|
||||
PivotKind::Demark => {
|
||||
let x = if c < o {
|
||||
h + 2.0 * l + c
|
||||
} else if c > o {
|
||||
2.0 * h + l + c
|
||||
} else {
|
||||
h + l + 2.0 * c
|
||||
};
|
||||
vec![x / 4.0, x / 2.0 - l, x / 2.0 - h]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pivot levels for each bar from the previous session's prices (`session_of` maps a
|
||||
/// timestamp to its session); the first session has none.
|
||||
pub fn pivots(o: &Ohlcv, session_of: impl Fn(i64) -> i64, kind: PivotKind) -> Vec<(&'static str, Vec<f64>)> {
|
||||
let names = kind.names();
|
||||
let len = o.len();
|
||||
let mut out = vec![vec![NAN; len]; names.len()];
|
||||
let keys: Vec<i64> = o.ts.iter().map(|&t| session_of(t)).collect();
|
||||
let mut cur: Option<Session> = None;
|
||||
let mut levels: Option<Vec<f64>> = None;
|
||||
for i in 0..len {
|
||||
let bar = Session { open: o.open[i], high: o.high[i], low: o.low[i], close: o.close[i] };
|
||||
match cur.as_mut() {
|
||||
Some(s) if keys[i] == keys[i - 1] => {
|
||||
s.high = s.high.max(bar.high);
|
||||
s.low = s.low.min(bar.low);
|
||||
s.close = bar.close;
|
||||
}
|
||||
prev => {
|
||||
levels = prev.map(|p| kind.levels(*p, bar.open));
|
||||
cur = Some(bar);
|
||||
}
|
||||
}
|
||||
if let Some(l) = &levels {
|
||||
for (series, v) in out.iter_mut().zip(l) {
|
||||
series[i] = *v;
|
||||
}
|
||||
}
|
||||
}
|
||||
names.iter().copied().zip(out).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Bar;
|
||||
|
||||
#[test]
|
||||
fn session_keys() {
|
||||
let day = 86_400;
|
||||
let k = session_key(Timeframe::M5);
|
||||
assert_eq!(k(10), k(day - 1));
|
||||
assert_ne!(k(day - 1), k(day));
|
||||
// 2026-01-31 and 2026-02-01 fall in different months on daily bars.
|
||||
let m = session_key(Timeframe::D1);
|
||||
assert_ne!(m(1_769_817_600), m(1_769_904_000));
|
||||
assert_eq!(session_key(Timeframe::W1)(1_769_817_600), 2026);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_pivots_from_the_previous_session() {
|
||||
let day = 86_400;
|
||||
let bars = [
|
||||
Bar::new(0, 10.0, 12.0, 8.0, 11.0, 1.0),
|
||||
Bar::new(60, 11.0, 15.0, 9.0, 13.0, 1.0),
|
||||
Bar::new(day, 13.0, 14.0, 12.0, 13.5, 1.0),
|
||||
Bar::new(day + 60, 13.5, 14.5, 12.5, 14.0, 1.0),
|
||||
Bar::new(2 * day, 14.0, 15.0, 13.0, 14.0, 1.0),
|
||||
];
|
||||
let o = Ohlcv::new(&bars);
|
||||
let p = pivots(&o, |ts| ts.div_euclid(day), PivotKind::Classic);
|
||||
let names: Vec<&str> = p.iter().map(|(n, _)| *n).collect();
|
||||
assert_eq!(names, ["P", "R1", "S1", "R2", "S2", "R3", "S3"]);
|
||||
// Day 1: H 15, L 8, C 13 → P = 12, R1 = 16, S1 = 9.
|
||||
let (pp, r1, s1) = (&p[0].1, &p[1].1, &p[2].1);
|
||||
assert!(pp[0].is_nan() && pp[1].is_nan());
|
||||
assert_eq!((pp[2], r1[2], s1[2]), (12.0, 16.0, 9.0));
|
||||
// Every bar of day 2 carries its levels; day 3 uses H 14.5, L 12, C 14.
|
||||
assert_eq!((pp[3], r1[3], s1[3]), (12.0, 16.0, 9.0));
|
||||
assert!((pp[4] - 40.5 / 3.0).abs() < 1e-12);
|
||||
let dm = pivots(&o, |ts| ts.div_euclid(day), PivotKind::Demark);
|
||||
// Day 1 closed above its open: X = 2H + L + C = 51.
|
||||
assert_eq!((dm[0].1[2], dm[1].1[2], dm[2].1[2]), (12.75, 17.5, 10.5));
|
||||
let w = pivots(&o, |ts| ts.div_euclid(day), PivotKind::Woodie);
|
||||
assert_eq!(w[0].1[2], (15.0 + 8.0 + 2.0 * 13.0) / 4.0);
|
||||
assert_eq!(pivots(&o, |ts| ts.div_euclid(day), PivotKind::Camarilla).len(), 9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
//! Moving averages and adaptive filters, including John Ehlers' DSP smoothers.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use super::stats::{change, highest, lowest, sum, windows};
|
||||
use super::{NAN, Ohlcv, from_first, ratio, zip};
|
||||
|
||||
/// Simple moving average over `n` values.
|
||||
pub fn sma(values: &[f64], n: usize) -> Vec<f64> {
|
||||
let k = n as f64;
|
||||
sum(values, n).into_iter().map(|s| s / k).collect()
|
||||
}
|
||||
|
||||
/// Exponential smoothing with factor `alpha`, seeded with the SMA of the first `n` finite
|
||||
/// values. A non-finite input yields NaN at that bar and leaves the state untouched.
|
||||
pub(super) fn smooth(values: &[f64], n: usize, alpha: f64) -> Vec<f64> {
|
||||
let mut out = vec![NAN; values.len()];
|
||||
if n == 0 {
|
||||
return out;
|
||||
}
|
||||
let (mut seen, mut acc) = (0usize, 0.0);
|
||||
for (i, &v) in values.iter().enumerate() {
|
||||
if !v.is_finite() {
|
||||
continue;
|
||||
}
|
||||
if seen < n {
|
||||
acc += v;
|
||||
seen += 1;
|
||||
if seen < n {
|
||||
continue;
|
||||
}
|
||||
acc /= n as f64;
|
||||
} else {
|
||||
acc = alpha * v + (1.0 - alpha) * acc;
|
||||
}
|
||||
out[i] = acc;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Exponential moving average, alpha = 2/(n+1).
|
||||
pub fn ema(values: &[f64], n: usize) -> Vec<f64> {
|
||||
smooth(values, n, 2.0 / (n as f64 + 1.0))
|
||||
}
|
||||
|
||||
/// Wilder's moving average (RMA, SMMA), alpha = 1/n.
|
||||
pub fn rma(values: &[f64], n: usize) -> Vec<f64> {
|
||||
smooth(values, n, 1.0 / n as f64)
|
||||
}
|
||||
|
||||
/// Linearly weighted moving average (newest weight n, oldest 1), O(1) per bar.
|
||||
pub fn wma(values: &[f64], n: usize) -> Vec<f64> {
|
||||
let mut out = vec![NAN; values.len()];
|
||||
let denom = (n * (n + 1)) as f64 / 2.0;
|
||||
windows(values, n, |i, r, _, w, _| out[i] = w / denom + r);
|
||||
out
|
||||
}
|
||||
|
||||
/// Hull moving average: WMA(2·WMA(n/2) − WMA(n), √n).
|
||||
pub fn hma(values: &[f64], n: usize) -> Vec<f64> {
|
||||
if n < 2 {
|
||||
return wma(values, n);
|
||||
}
|
||||
let diff = zip(&wma(values, n / 2), &wma(values, n), |a, b| 2.0 * a - b);
|
||||
wma(&diff, ((n as f64).sqrt() as usize).max(1))
|
||||
}
|
||||
|
||||
/// Double EMA: 2·EMA − EMA(EMA).
|
||||
pub fn dema(values: &[f64], n: usize) -> Vec<f64> {
|
||||
let e1 = ema(values, n);
|
||||
let e2 = ema(&e1, n);
|
||||
zip(&e1, &e2, |a, b| 2.0 * a - b)
|
||||
}
|
||||
|
||||
/// Triple EMA: 3·E1 − 3·E2 + E3.
|
||||
pub fn tema(values: &[f64], n: usize) -> Vec<f64> {
|
||||
let e1 = ema(values, n);
|
||||
let e2 = ema(&e1, n);
|
||||
let e3 = ema(&e2, n);
|
||||
(0..values.len()).map(|i| 3.0 * e1[i] - 3.0 * e2[i] + e3[i]).collect()
|
||||
}
|
||||
|
||||
/// Triangular moving average: an SMA of an SMA spanning `n` bars in total.
|
||||
pub fn trima(values: &[f64], n: usize) -> Vec<f64> {
|
||||
if n == 0 {
|
||||
return vec![NAN; values.len()];
|
||||
}
|
||||
let (a, b) = if n % 2 == 1 { (n.div_ceil(2), n.div_ceil(2)) } else { (n / 2, n / 2 + 1) };
|
||||
sma(&sma(values, a), b)
|
||||
}
|
||||
|
||||
/// Kaufman adaptive moving average: the smoothing constant moves between the `fast` and
|
||||
/// `slow` EMA constants with the efficiency ratio over `n` bars.
|
||||
pub fn kama(values: &[f64], n: usize, fast: usize, slow: usize) -> Vec<f64> {
|
||||
from_first(values, |v| {
|
||||
let mut out = vec![NAN; v.len()];
|
||||
if n == 0 || v.len() < n {
|
||||
return out;
|
||||
}
|
||||
let (fsc, ssc) = (2.0 / (fast as f64 + 1.0), 2.0 / (slow as f64 + 1.0));
|
||||
let mut k = v[n - 1];
|
||||
out[n - 1] = k;
|
||||
let mut vol: f64 = (1..n.min(v.len())).map(|j| (v[j] - v[j - 1]).abs()).sum();
|
||||
for i in n..v.len() {
|
||||
vol += (v[i] - v[i - 1]).abs();
|
||||
if i > n {
|
||||
vol -= (v[i - n] - v[i - n - 1]).abs();
|
||||
}
|
||||
let er = ratio((v[i] - v[i - n]).abs(), vol, 0.0).clamp(0.0, 1.0);
|
||||
let sc = (er * (fsc - ssc) + ssc).powi(2);
|
||||
k += sc * (v[i] - k);
|
||||
out[i] = k;
|
||||
}
|
||||
out
|
||||
})
|
||||
}
|
||||
|
||||
/// Zero-lag EMA: an EMA of `2·x − x[lag]` with lag (n−1)/2.
|
||||
pub fn zlema(values: &[f64], n: usize) -> Vec<f64> {
|
||||
let lag = n.saturating_sub(1) / 2;
|
||||
let adj: Vec<f64> =
|
||||
(0..values.len()).map(|i| if i >= lag { 2.0 * values[i] - values[i - lag] } else { NAN }).collect();
|
||||
ema(&adj, n)
|
||||
}
|
||||
|
||||
/// Arnaud Legoux moving average: Gaussian weights centred at `offset` (0..1) across the
|
||||
/// window, width n/`sigma`.
|
||||
pub fn alma(values: &[f64], n: usize, offset: f64, sigma: f64) -> Vec<f64> {
|
||||
let mut out = vec![NAN; values.len()];
|
||||
if n == 0 || sigma <= 0.0 {
|
||||
return out;
|
||||
}
|
||||
let m = offset * (n - 1) as f64;
|
||||
let s = n as f64 / sigma;
|
||||
let w: Vec<f64> = (0..n).map(|j| (-(j as f64 - m).powi(2) / (2.0 * s * s)).exp()).collect();
|
||||
let norm: f64 = w.iter().sum();
|
||||
for i in n - 1..values.len() {
|
||||
let win = &values[i + 1 - n..=i];
|
||||
if win.iter().all(|x| x.is_finite()) {
|
||||
out[i] = win.iter().zip(&w).map(|(x, w)| x * w).sum::<f64>() / norm;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Volume-weighted moving average (falls back to the SMA where the window has no volume).
|
||||
pub fn vwma(values: &[f64], volume: &[f64], n: usize) -> Vec<f64> {
|
||||
let pv = zip(values, volume, |c, v| c * v);
|
||||
let (spv, sv, sc) = (sum(&pv, n), sum(volume, n), sma(values, n));
|
||||
(0..values.len()).map(|i| if sv[i] > 0.0 { spv[i] / sv[i] } else { sc[i] }).collect()
|
||||
}
|
||||
|
||||
/// Elastic volume-weighted moving average: each bar moves the average by its share of the
|
||||
/// last `n` bars' volume.
|
||||
pub fn evwma(values: &[f64], volume: &[f64], n: usize) -> Vec<f64> {
|
||||
let sv = sum(volume, n);
|
||||
let mut out = vec![NAN; values.len()];
|
||||
let mut prev = NAN;
|
||||
for i in 0..values.len() {
|
||||
if !sv[i].is_finite() || !values[i].is_finite() {
|
||||
continue;
|
||||
}
|
||||
prev = if prev.is_nan() {
|
||||
values[i]
|
||||
} else if sv[i] > 0.0 {
|
||||
((sv[i] - volume[i]) * prev + volume[i] * values[i]) / sv[i]
|
||||
} else {
|
||||
prev
|
||||
};
|
||||
out[i] = prev;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Tillson T3: six chained EMAs combined with volume factor `v`.
|
||||
pub fn t3(values: &[f64], n: usize, v: f64) -> Vec<f64> {
|
||||
let e1 = ema(values, n);
|
||||
let e2 = ema(&e1, n);
|
||||
let e3 = ema(&e2, n);
|
||||
let e4 = ema(&e3, n);
|
||||
let e5 = ema(&e4, n);
|
||||
let e6 = ema(&e5, n);
|
||||
let (v2, v3) = (v * v, v * v * v);
|
||||
let (c1, c2, c3, c4) = (-v3, 3.0 * v2 + 3.0 * v3, -6.0 * v2 - 3.0 * v - 3.0 * v3, 1.0 + 3.0 * v + v3 + 3.0 * v2);
|
||||
(0..values.len()).map(|i| c1 * e6[i] + c2 * e5[i] + c3 * e4[i] + c4 * e3[i]).collect()
|
||||
}
|
||||
|
||||
/// McGinley Dynamic: an EMA-seeded average whose speed adapts to (price/average)^4. The
|
||||
/// step is capped at the full distance so it never overshoots.
|
||||
pub fn mcginley(values: &[f64], n: usize) -> Vec<f64> {
|
||||
let seed = ema(values, n);
|
||||
let mut out = vec![NAN; values.len()];
|
||||
let mut md = NAN;
|
||||
for i in 0..values.len() {
|
||||
let x = values[i];
|
||||
if md.is_nan() {
|
||||
md = seed[i];
|
||||
} else if x.is_finite() {
|
||||
let d = n as f64 * (x / md).powi(4);
|
||||
md = if md != 0.0 && d.is_finite() { md + (x - md) / d.max(1.0) } else { x };
|
||||
}
|
||||
out[i] = md;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Chande's variable index dynamic average: an EMA whose factor scales with |CMO(`cmo_n`)|.
|
||||
pub fn vidya(values: &[f64], n: usize, cmo_n: usize) -> Vec<f64> {
|
||||
let ch = change(values, 1);
|
||||
let up: Vec<f64> = ch.iter().map(|c| if c.is_nan() { NAN } else { c.max(0.0) }).collect();
|
||||
let dn: Vec<f64> = ch.iter().map(|c| if c.is_nan() { NAN } else { (-c).max(0.0) }).collect();
|
||||
let (su, sd) = (sum(&up, cmo_n), sum(&dn, cmo_n));
|
||||
let alpha = 2.0 / (n as f64 + 1.0);
|
||||
let mut out = vec![NAN; values.len()];
|
||||
let mut v = NAN;
|
||||
for i in 0..values.len() {
|
||||
if !su[i].is_finite() || !sd[i].is_finite() || !values[i].is_finite() {
|
||||
continue;
|
||||
}
|
||||
let k = alpha * ratio(su[i] - sd[i], su[i] + sd[i], 0.0).abs();
|
||||
v = if v.is_nan() { values[i] } else { k * values[i] + (1.0 - k) * v };
|
||||
out[i] = v;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Ehlers' fractal adaptive moving average of the median price over `n` (even) bars.
|
||||
pub fn frama(d: &Ohlcv, n: usize) -> Vec<f64> {
|
||||
let n = n.max(2).next_multiple_of(2);
|
||||
let h = n / 2;
|
||||
let price = d.hl2();
|
||||
let (hh1, ll1) = (highest(&d.high, h), lowest(&d.low, h));
|
||||
let (hh, ll) = (highest(&d.high, n), lowest(&d.low, n));
|
||||
let mut out = vec![NAN; d.len()];
|
||||
let (mut dim, mut filt) = (1.0, NAN);
|
||||
for i in n - 1..d.len() {
|
||||
let n1 = (hh1[i] - ll1[i]) / h as f64;
|
||||
let n2 = (hh1[i - h] - ll1[i - h]) / h as f64;
|
||||
let n3 = (hh[i] - ll[i]) / n as f64;
|
||||
if !(n1.is_finite() && n2.is_finite() && n3.is_finite() && price[i].is_finite()) {
|
||||
continue;
|
||||
}
|
||||
if n1 > 0.0 && n2 > 0.0 && n3 > 0.0 {
|
||||
dim = ((n1 + n2).ln() - n3.ln()) / 2f64.ln();
|
||||
}
|
||||
let alpha = (-4.6 * (dim - 1.0)).exp().clamp(0.01, 1.0);
|
||||
filt = if filt.is_nan() { price[i] } else { alpha * price[i] + (1.0 - alpha) * filt };
|
||||
out[i] = filt;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Ehlers' two-pole super smoother with critical period `n`.
|
||||
pub fn super_smoother(values: &[f64], n: usize) -> Vec<f64> {
|
||||
if n == 0 {
|
||||
return vec![NAN; values.len()];
|
||||
}
|
||||
from_first(values, |v| {
|
||||
let a1 = (-1.414 * PI / n as f64).exp();
|
||||
let c2 = 2.0 * a1 * (1.414 * PI / n as f64).cos();
|
||||
let c3 = -a1 * a1;
|
||||
let c1 = 1.0 - c2 - c3;
|
||||
let mut out = v.to_vec();
|
||||
for i in 2..v.len() {
|
||||
out[i] = c1 * (v[i] + v[i - 1]) / 2.0 + c2 * out[i - 1] + c3 * out[i - 2];
|
||||
}
|
||||
out
|
||||
})
|
||||
}
|
||||
|
||||
/// Ehlers' simple decycler: the series minus its two-pole high-pass component, which
|
||||
/// removes cycles shorter than `n` bars with no lag. Cutoffs under 3 bars put the
|
||||
/// high-pass pole outside the unit circle, so they are blank.
|
||||
pub fn decycler(values: &[f64], n: usize) -> Vec<f64> {
|
||||
if n < 3 {
|
||||
return vec![NAN; values.len()];
|
||||
}
|
||||
from_first(values, |v| {
|
||||
let w = 0.707 * 2.0 * PI / n as f64;
|
||||
let a = (w.cos() + w.sin() - 1.0) / w.cos();
|
||||
let (k1, k2, k3) = ((1.0 - a / 2.0).powi(2), 2.0 * (1.0 - a), (1.0 - a).powi(2));
|
||||
let mut hp = vec![0.0; v.len()];
|
||||
for i in 2..v.len() {
|
||||
hp[i] = k1 * (v[i] - 2.0 * v[i - 1] + v[i - 2]) + k2 * hp[i - 1] - k3 * hp[i - 2];
|
||||
}
|
||||
zip(v, &hp, |x, h| x - h)
|
||||
})
|
||||
}
|
||||
|
||||
fn at(v: &[f64], i: usize, k: usize) -> f64 {
|
||||
if i >= k { v[i - k] } else { 0.0 }
|
||||
}
|
||||
|
||||
/// MESA adaptive moving average and its following average.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Mama {
|
||||
pub mama: Vec<f64>,
|
||||
pub fama: Vec<f64>,
|
||||
}
|
||||
|
||||
/// Ehlers' MESA adaptive moving average: a Hilbert-transform phase rate sets the EMA
|
||||
/// factor between `slow` and `fast` limits.
|
||||
pub fn mama(values: &[f64], fast: f64, slow: f64) -> Mama {
|
||||
let len = values.len();
|
||||
let mut out = Mama { mama: vec![NAN; len], fama: vec![NAN; len] };
|
||||
let Some(start) = values.iter().position(|v| v.is_finite()) else { return out };
|
||||
let p = &values[start..];
|
||||
let m = p.len();
|
||||
let [mut smooth, mut detr, mut i1, mut q1, mut i2, mut q2, mut re, mut im, mut period, mut phase] =
|
||||
std::array::from_fn(|_| vec![0.0; m]);
|
||||
let (mut ma, mut fa) = (p[0], p[0]);
|
||||
for i in 0..m {
|
||||
let pr = |k: usize| if i >= k { p[i - k] } else { p[0] };
|
||||
smooth[i] = (4.0 * pr(0) + 3.0 * pr(1) + 2.0 * pr(2) + pr(3)) / 10.0;
|
||||
let adj = 0.075 * at(&period, i, 1) + 0.54;
|
||||
let hilbert =
|
||||
|v: &[f64]| (0.0962 * v[i] + 0.5769 * at(v, i, 2) - 0.5769 * at(v, i, 4) - 0.0962 * at(v, i, 6)) * adj;
|
||||
detr[i] = hilbert(&smooth);
|
||||
q1[i] = hilbert(&detr);
|
||||
i1[i] = at(&detr, i, 3);
|
||||
let (ji, jq) = (hilbert(&i1), hilbert(&q1));
|
||||
i2[i] = 0.2 * (i1[i] - jq) + 0.8 * at(&i2, i, 1);
|
||||
q2[i] = 0.2 * (q1[i] + ji) + 0.8 * at(&q2, i, 1);
|
||||
let r = i2[i] * at(&i2, i, 1) + q2[i] * at(&q2, i, 1);
|
||||
let j = i2[i] * at(&q2, i, 1) - q2[i] * at(&i2, i, 1);
|
||||
re[i] = 0.2 * r + 0.8 * at(&re, i, 1);
|
||||
im[i] = 0.2 * j + 0.8 * at(&im, i, 1);
|
||||
let prev = at(&period, i, 1);
|
||||
let mut per = prev;
|
||||
if im[i] != 0.0 && re[i] != 0.0 {
|
||||
per = 2.0 * PI / (im[i] / re[i]).atan();
|
||||
}
|
||||
per = per.min(1.5 * prev).max(0.67 * prev).clamp(6.0, 50.0);
|
||||
period[i] = 0.2 * per + 0.8 * prev;
|
||||
phase[i] = if i1[i] != 0.0 { (q1[i] / i1[i]).atan().to_degrees() } else { at(&phase, i, 1) };
|
||||
let delta = (at(&phase, i, 1) - phase[i]).max(1.0);
|
||||
let alpha = (fast / delta).max(slow);
|
||||
ma = alpha * p[i] + (1.0 - alpha) * ma;
|
||||
fa = 0.5 * alpha * ma + (1.0 - 0.5 * alpha) * fa;
|
||||
if i >= 6 {
|
||||
out.mama[start + i] = ma;
|
||||
out.fama[start + i] = fa;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Ehlers' instantaneous trendline and its trigger (2·IT − IT[2]).
|
||||
pub fn itrend(values: &[f64], alpha: f64) -> (Vec<f64>, Vec<f64>) {
|
||||
let it = from_first(values, |p| {
|
||||
let mut it = vec![0.0; p.len()];
|
||||
let a = alpha;
|
||||
for i in 0..p.len() {
|
||||
let pr = |k: usize| if i >= k { p[i - k] } else { p[0] };
|
||||
it[i] = if i < 7 {
|
||||
(pr(0) + 2.0 * pr(1) + pr(2)) / 4.0
|
||||
} else {
|
||||
(a - a * a / 4.0) * p[i] + 0.5 * a * a * p[i - 1] - (a - 0.75 * a * a) * p[i - 2]
|
||||
+ 2.0 * (1.0 - a) * it[i - 1]
|
||||
- (1.0 - a).powi(2) * it[i - 2]
|
||||
};
|
||||
}
|
||||
it
|
||||
});
|
||||
let trigger = (0..it.len()).map(|i| if i >= 2 { 2.0 * it[i] - it[i - 2] } else { NAN }).collect();
|
||||
(it, trigger)
|
||||
}
|
||||
|
||||
/// Symmetrically weighted moving average over 4 bars (weights 1, 2, 2, 1).
|
||||
pub(super) fn swma(values: &[f64]) -> Vec<f64> {
|
||||
(0..values.len())
|
||||
.map(
|
||||
|i| {
|
||||
if i < 3 { NAN } else { (values[i] + 2.0 * values[i - 1] + 2.0 * values[i - 2] + values[i - 3]) / 6.0 }
|
||||
},
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-9 * (1.0 + b.abs())
|
||||
}
|
||||
|
||||
fn wave(n: usize) -> Vec<f64> {
|
||||
(0..n)
|
||||
.map(|i| 100.0 + (i as f64 * 0.21).sin() * 8.0 + (i as f64 * 0.05).cos() * 3.0 + i as f64 * 0.04)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wma_hand_computed_and_rolling() {
|
||||
// (1·1 + 2·2 + 3·3) / 6 = 14/6; (2 + 6 + 12) / 6 = 20/6.
|
||||
let w = wma(&[1.0, 2.0, 3.0, 4.0], 3);
|
||||
assert!(w[1].is_nan() && near(w[2], 14.0 / 6.0) && near(w[3], 20.0 / 6.0));
|
||||
let xs = wave(300);
|
||||
let got = wma(&xs, 9);
|
||||
for i in 8..300 {
|
||||
let want = (0..9).map(|k| (k + 1) as f64 * xs[i - 8 + k]).sum::<f64>() / 45.0;
|
||||
assert!(near(got[i], want), "{i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compositions_follow_their_definitions() {
|
||||
let xs = wave(400);
|
||||
let (e1, e2) = (ema(&xs, 10), ema(&ema(&xs, 10), 10));
|
||||
let d = dema(&xs, 10);
|
||||
assert!(near(d[300], 2.0 * e1[300] - e2[300]));
|
||||
let h = hma(&xs, 16);
|
||||
let diff = zip(&wma(&xs, 8), &wma(&xs, 16), |a, b| 2.0 * a - b);
|
||||
assert!(near(h[300], wma(&diff, 4)[300]));
|
||||
// TRIMA spans n bars: its first value is at n - 1.
|
||||
let t = trima(&xs, 10);
|
||||
assert!(t[8].is_nan() && t[9].is_finite());
|
||||
let t = trima(&xs, 9);
|
||||
assert!(t[7].is_nan() && t[8].is_finite());
|
||||
// T3 coefficients sum to 1, so a constant stays constant.
|
||||
assert!(near(t3(&[5.0; 200], 5, 0.7)[199], 5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adaptive_averages_track_a_constant_and_a_trend() {
|
||||
let flat = vec![42.0; 300];
|
||||
for v in [
|
||||
kama(&flat, 10, 2, 30),
|
||||
zlema(&flat, 20),
|
||||
alma(&flat, 9, 0.85, 6.0),
|
||||
mcginley(&flat, 14),
|
||||
vidya(&flat, 20, 9),
|
||||
super_smoother(&flat, 10),
|
||||
decycler(&flat, 60),
|
||||
mama(&flat, 0.5, 0.05).mama,
|
||||
itrend(&flat, 0.07).0,
|
||||
] {
|
||||
assert!(near(v[299], 42.0), "{}", v[299]);
|
||||
}
|
||||
// With a perfectly efficient trend KAMA uses the fast constant: k += (2/3)^2 (x - k).
|
||||
let line: Vec<f64> = (0..50).map(|i| i as f64).collect();
|
||||
let k = kama(&line, 10, 2, 30);
|
||||
let fsc: f64 = (2.0f64 / 3.0).powi(2);
|
||||
assert!(near(k[10], 9.0 + fsc * (10.0 - 9.0)));
|
||||
// ALMA weights sum to 1 and lean to the recent end with offset 0.85.
|
||||
let a = alma(&line, 9, 0.85, 6.0);
|
||||
assert!(a[20] > sma(&line, 9)[20] && a[20] < 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volume_weighting() {
|
||||
let c = [10.0, 20.0, 30.0];
|
||||
let v = [1.0, 1.0, 2.0];
|
||||
assert!(near(vwma(&c, &v, 3)[2], (10.0 + 20.0 + 60.0) / 4.0));
|
||||
assert!(near(vwma(&c, &[0.0; 3], 3)[2], 20.0), "no volume falls back to the SMA");
|
||||
let e = evwma(&c, &v, 2);
|
||||
assert!(e[0].is_nan() && near(e[1], 20.0));
|
||||
assert!(near(e[2], ((3.0 - 2.0) * 20.0 + 2.0 * 30.0) / 3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frama_moves_between_bounds() {
|
||||
let bars: Vec<crate::Bar> = wave(200)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| crate::Bar::new(i as i64, c, c + 1.0, c - 1.0, c, 1.0))
|
||||
.collect();
|
||||
let f = frama(&Ohlcv::new(&bars), 16);
|
||||
assert!(f[14].is_nan() && f[15].is_finite());
|
||||
// A running blend of prices never leaves the range seen so far.
|
||||
let price: Vec<f64> = bars.iter().map(|b| (b.high + b.low) / 2.0).collect();
|
||||
for i in 15..f.len() {
|
||||
let seen = &price[15..=i];
|
||||
let (lo, hi) = seen.iter().fold((f64::MAX, f64::MIN), |(l, h), v| (l.min(*v), h.max(*v)));
|
||||
assert!(f[i] >= lo - 1e-9 && f[i] <= hi + 1e-9, "frama[{i}] = {} outside {lo}..{hi}", f[i]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decycler_is_stable_from_its_shortest_cutoff() {
|
||||
let xs: Vec<f64> = (0..2000).map(|i| 100.0 + (i as f64 * 1.7).sin()).collect();
|
||||
for n in 3..=10 {
|
||||
let d = decycler(&xs, n);
|
||||
assert!(d.iter().all(|v| (90.0..110.0).contains(v)), "cutoff {n} runs away");
|
||||
}
|
||||
for n in 0..3 {
|
||||
assert!(decycler(&xs, n).iter().all(|v| v.is_nan()), "cutoff {n} is unstable");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! Technical indicators. Every function returns a vector aligned with its input; positions
|
||||
//! without enough history hold `f64::NAN`. Inputs may themselves start with NaN (the output
|
||||
//! of another indicator): smoothing starts at the first defined value, and a gap later on
|
||||
//! blanks only the windows that contain it.
|
||||
//!
|
||||
//! Conventions follow the common charting definitions: EMA uses alpha = 2/(n+1) seeded with
|
||||
//! an SMA, RSI, ATR and ADX use Wilder smoothing, standard deviations are population ones,
|
||||
//! and VWAP and pivot levels reset each session (a day intraday, a month on daily bars, a
|
||||
//! year above that).
|
||||
//!
|
||||
//! The functions are grouped by family; [`registry`] lists every indicator with its
|
||||
//! parameters, placement and outputs, and is what the app's command line, picker and chart
|
||||
//! are driven from.
|
||||
|
||||
use crate::Bar;
|
||||
|
||||
mod levels;
|
||||
mod ma;
|
||||
mod momentum;
|
||||
pub mod registry;
|
||||
mod stats;
|
||||
mod trend;
|
||||
mod volatility;
|
||||
mod volume;
|
||||
|
||||
pub use levels::*;
|
||||
pub use ma::*;
|
||||
pub use momentum::*;
|
||||
pub use stats::*;
|
||||
pub use trend::*;
|
||||
pub use volatility::*;
|
||||
pub use volume::*;
|
||||
|
||||
const NAN: f64 = f64::NAN;
|
||||
|
||||
/// Bars split into columns: the input of the bar-based indicators.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Ohlcv {
|
||||
pub ts: Vec<i64>,
|
||||
pub open: Vec<f64>,
|
||||
pub high: Vec<f64>,
|
||||
pub low: Vec<f64>,
|
||||
pub close: Vec<f64>,
|
||||
pub volume: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Ohlcv {
|
||||
pub fn new(bars: &[Bar]) -> Self {
|
||||
Self {
|
||||
ts: bars.iter().map(|b| b.ts).collect(),
|
||||
open: bars.iter().map(|b| b.open).collect(),
|
||||
high: bars.iter().map(|b| b.high).collect(),
|
||||
low: bars.iter().map(|b| b.low).collect(),
|
||||
close: bars.iter().map(|b| b.close).collect(),
|
||||
volume: bars.iter().map(|b| b.volume).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.close.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.close.is_empty()
|
||||
}
|
||||
|
||||
/// Median price, (high + low) / 2.
|
||||
pub fn hl2(&self) -> Vec<f64> {
|
||||
zip(&self.high, &self.low, |h, l| (h + l) / 2.0)
|
||||
}
|
||||
|
||||
/// Typical price, (high + low + close) / 3.
|
||||
pub fn hlc3(&self) -> Vec<f64> {
|
||||
(0..self.len()).map(|i| (self.high[i] + self.low[i] + self.close[i]) / 3.0).collect()
|
||||
}
|
||||
|
||||
/// Average price, (open + high + low + close) / 4.
|
||||
pub fn ohlc4(&self) -> Vec<f64> {
|
||||
(0..self.len()).map(|i| (self.open[i] + self.high[i] + self.low[i] + self.close[i]) / 4.0).collect()
|
||||
}
|
||||
|
||||
/// Weighted close, (high + low + 2 × close) / 4.
|
||||
pub fn hlcc4(&self) -> Vec<f64> {
|
||||
(0..self.len()).map(|i| (self.high[i] + self.low[i] + 2.0 * self.close[i]) / 4.0).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` on `values` from the first finite one on; leading NaNs stay NaN.
|
||||
fn from_first(values: &[f64], f: impl FnOnce(&[f64]) -> Vec<f64>) -> Vec<f64> {
|
||||
let start = values.iter().position(|v| v.is_finite()).unwrap_or(values.len());
|
||||
let mut out = vec![NAN; start];
|
||||
out.extend(f(&values[start..]));
|
||||
debug_assert_eq!(out.len(), values.len());
|
||||
out
|
||||
}
|
||||
|
||||
fn zip(a: &[f64], b: &[f64], f: impl Fn(f64, f64) -> f64) -> Vec<f64> {
|
||||
a.iter().zip(b).map(|(x, y)| f(*x, *y)).collect()
|
||||
}
|
||||
|
||||
/// `a / b`, or `zero` when `b` is 0.
|
||||
fn ratio(a: f64, b: f64, zero: f64) -> f64 {
|
||||
if b == 0.0 { zero } else { a / b }
|
||||
}
|
||||
|
||||
/// `values[i - k]`, NaN before the start.
|
||||
fn ago(values: &[f64], i: usize, k: usize) -> f64 {
|
||||
if i >= k { values[i - k] } else { NAN }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn close_to(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
fn assert_series(got: &[f64], want: &[f64]) {
|
||||
assert_eq!(got.len(), want.len());
|
||||
for (i, (g, w)) in got.iter().zip(want).enumerate() {
|
||||
assert!((g.is_nan() && w.is_nan()) || close_to(*g, *w), "index {i}: got {g}, want {w}");
|
||||
}
|
||||
}
|
||||
|
||||
const N: f64 = f64::NAN;
|
||||
|
||||
#[test]
|
||||
fn sma_basic() {
|
||||
assert_series(&sma(&[1.0, 2.0, 3.0, 4.0, 5.0], 3), &[N, N, 2.0, 3.0, 4.0]);
|
||||
assert!(sma(&[1.0], 3)[0].is_nan());
|
||||
assert!(sma(&[1.0, 2.0], 0).iter().all(|v| v.is_nan()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ema_seeded_with_sma() {
|
||||
// alpha = 0.5; seed = mean(1,2,3) = 2; then 0.5*4+0.5*2 = 3; 0.5*5+0.5*3 = 4.
|
||||
assert_series(&ema(&[1.0, 2.0, 3.0, 4.0, 5.0], 3), &[N, N, 2.0, 3.0, 4.0]);
|
||||
// Constant input stays constant.
|
||||
assert!(close_to(ema(&[7.0; 50], 10)[49], 7.0));
|
||||
// Leading NaNs (another indicator's warm-up) delay the seed instead of poisoning it.
|
||||
assert_series(&ema(&[N, N, 1.0, 2.0, 3.0, 4.0], 3), &[N, N, N, N, 2.0, 3.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rsi_reference_values() {
|
||||
// Wilder's original 14-period example (New Concepts in Technical Trading Systems).
|
||||
let closes = [
|
||||
44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08, 45.89, 46.03, 45.61, 46.28, 46.28,
|
||||
46.00, 46.03, 46.41, 46.22, 45.64,
|
||||
];
|
||||
let r = rsi(&closes, 14);
|
||||
assert!(r[13].is_nan());
|
||||
assert!((r[14] - 70.46).abs() < 0.01, "rsi[14] = {}", r[14]);
|
||||
assert!((r[15] - 66.25).abs() < 0.01, "rsi[15] = {}", r[15]);
|
||||
assert!((r[19] - 57.92).abs() < 0.05, "rsi[19] = {}", r[19]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rsi_extremes() {
|
||||
let up: Vec<f64> = (0..30).map(|i| i as f64).collect();
|
||||
assert_eq!(rsi(&up, 14)[29], 100.0);
|
||||
assert_eq!(rsi(&[5.0; 30], 14)[29], 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macd_matches_manual_emas() {
|
||||
let closes: Vec<f64> = (0..60).map(|i| 100.0 + (i as f64 * 0.3).sin() * 5.0 + i as f64 * 0.1).collect();
|
||||
let m = macd(&closes, 12, 26, 9);
|
||||
let f = ema(&closes, 12);
|
||||
let s = ema(&closes, 26);
|
||||
assert!(m.macd[24].is_nan());
|
||||
assert!(close_to(m.macd[25], f[25] - s[25]));
|
||||
// Signal needs 9 MACD values: first at 25 + 8.
|
||||
assert!(m.signal[32].is_nan());
|
||||
let seed: f64 = m.macd[25..34].iter().sum::<f64>() / 9.0;
|
||||
assert!(close_to(m.signal[33], seed));
|
||||
assert!(close_to(m.histogram[40], m.macd[40] - m.signal[40]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bollinger_population_sd() {
|
||||
let b = bollinger(&[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0], 8, 2.0);
|
||||
// mean 5, population sd 2.
|
||||
assert!(close_to(b.middle[7], 5.0));
|
||||
assert!(close_to(b.upper[7], 9.0));
|
||||
assert!(close_to(b.lower[7], 1.0));
|
||||
assert!(b.upper[6].is_nan());
|
||||
// Rolling window stays numerically consistent with a fresh computation.
|
||||
let xs: Vec<f64> = (0..200).map(|i| ((i * 37) % 101) as f64).collect();
|
||||
let roll = bollinger(&xs, 20, 2.0);
|
||||
let fresh = bollinger(&xs[180..], 20, 2.0);
|
||||
assert!(close_to(roll.upper[199], fresh.upper[19]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vwap_resets_per_session() {
|
||||
let day = 86_400;
|
||||
let bars = [
|
||||
Bar::new(0, 0.0, 12.0, 8.0, 10.0, 100.0),
|
||||
Bar::new(60, 0.0, 22.0, 18.0, 20.0, 300.0),
|
||||
Bar::new(day, 0.0, 6.0, 4.0, 5.0, 10.0),
|
||||
];
|
||||
let v = vwap(&bars);
|
||||
assert!(close_to(v[0], 10.0));
|
||||
assert!(close_to(v[1], (10.0 * 100.0 + 20.0 * 300.0) / 400.0));
|
||||
assert!(close_to(v[2], 5.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
//! Oscillators and momentum: RSI and its variants, stochastics, MACD-style differences,
|
||||
//! rate of change, and the cycle oscillators.
|
||||
|
||||
use super::ma::{ema, rma, sma, smooth, swma, wma};
|
||||
use super::stats::{change, highest, linreg, lowest, percent_rank, sum};
|
||||
use super::volatility::true_range;
|
||||
use super::{NAN, Ohlcv, ago, from_first, ratio, zip};
|
||||
|
||||
/// Relative Strength Index with Wilder smoothing (first value at index `n`).
|
||||
/// Single pass with no intermediate buffers: on large series the cost is dominated by
|
||||
/// first-touch of output memory, so only the output is allocated.
|
||||
pub fn rsi(closes: &[f64], n: usize) -> Vec<f64> {
|
||||
from_first(closes, |closes| {
|
||||
let mut out = vec![NAN; closes.len()];
|
||||
if n == 0 || closes.len() <= n {
|
||||
return out;
|
||||
}
|
||||
let value = |g: f64, l: f64| {
|
||||
if l == 0.0 { if g == 0.0 { 50.0 } else { 100.0 } } else { 100.0 - 100.0 / (1.0 + g / l) }
|
||||
};
|
||||
let (mut ag, mut al) = (0.0, 0.0);
|
||||
for i in 1..=n {
|
||||
let d = closes[i] - closes[i - 1];
|
||||
ag += d.max(0.0);
|
||||
al += (-d).max(0.0);
|
||||
}
|
||||
ag /= n as f64;
|
||||
al /= n as f64;
|
||||
out[n] = value(ag, al);
|
||||
let alpha = 1.0 / n as f64;
|
||||
for i in (n + 1)..closes.len() {
|
||||
let d = closes[i] - closes[i - 1];
|
||||
ag = alpha * d.max(0.0) + (1.0 - alpha) * ag;
|
||||
al = alpha * (-d).max(0.0) + (1.0 - alpha) * al;
|
||||
out[i] = value(ag, al);
|
||||
}
|
||||
out
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Macd {
|
||||
pub macd: Vec<f64>,
|
||||
pub signal: Vec<f64>,
|
||||
pub histogram: Vec<f64>,
|
||||
}
|
||||
|
||||
/// Streaming EMA seeded with the SMA of its first `n` inputs.
|
||||
struct Ema {
|
||||
n: usize,
|
||||
alpha: f64,
|
||||
seen: usize,
|
||||
acc: f64,
|
||||
}
|
||||
|
||||
impl Ema {
|
||||
fn new(n: usize) -> Self {
|
||||
Self { n, alpha: 2.0 / (n as f64 + 1.0), seen: 0, acc: 0.0 }
|
||||
}
|
||||
|
||||
/// Feed one value; returns the EMA once `n` values have been seen.
|
||||
fn push(&mut self, v: f64) -> Option<f64> {
|
||||
self.seen += 1;
|
||||
if self.seen < self.n {
|
||||
self.acc += v;
|
||||
None
|
||||
} else if self.seen == self.n {
|
||||
self.acc = (self.acc + v) / self.n as f64;
|
||||
Some(self.acc)
|
||||
} else {
|
||||
self.acc = self.alpha * v + (1.0 - self.alpha) * self.acc;
|
||||
Some(self.acc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MACD line (fast EMA - slow EMA), its signal EMA and histogram, in one pass.
|
||||
pub fn macd(closes: &[f64], fast: usize, slow: usize, signal: usize) -> Macd {
|
||||
let len = closes.len();
|
||||
let mut line = vec![f64::NAN; len];
|
||||
let mut sig = vec![f64::NAN; len];
|
||||
let mut histogram = vec![f64::NAN; len];
|
||||
if fast == 0 || slow == 0 || signal == 0 {
|
||||
return Macd { macd: line, signal: sig, histogram };
|
||||
}
|
||||
let (mut ef, mut es, mut eg) = (Ema::new(fast), Ema::new(slow), Ema::new(signal));
|
||||
for (i, &c) in closes.iter().enumerate() {
|
||||
let (f, s) = (ef.push(c), es.push(c));
|
||||
let (Some(f), Some(s)) = (f, s) else { continue };
|
||||
let m = f - s;
|
||||
line[i] = m;
|
||||
// The signal EMA starts once the MACD line itself is defined.
|
||||
if let Some(g) = eg.push(m) {
|
||||
sig[i] = g;
|
||||
histogram[i] = m - g;
|
||||
}
|
||||
}
|
||||
Macd { macd: line, signal: sig, histogram }
|
||||
}
|
||||
|
||||
/// A line with an EMA signal and their difference.
|
||||
fn with_signal(line: Vec<f64>, n: usize) -> Macd {
|
||||
let signal = ema(&line, n);
|
||||
let histogram = zip(&line, &signal, |a, b| a - b);
|
||||
Macd { macd: line, signal, histogram }
|
||||
}
|
||||
|
||||
/// Percentage price oscillator: (fast EMA − slow EMA) / slow EMA × 100, with signal.
|
||||
pub fn ppo(values: &[f64], fast: usize, slow: usize, signal: usize) -> Macd {
|
||||
let line = zip(&ema(values, fast), &ema(values, slow), |f, s| ratio(f - s, s, 0.0) * 100.0);
|
||||
with_signal(line, signal)
|
||||
}
|
||||
|
||||
/// Absolute price oscillator: fast EMA − slow EMA.
|
||||
pub fn apo(values: &[f64], fast: usize, slow: usize) -> Vec<f64> {
|
||||
zip(&ema(values, fast), &ema(values, slow), |f, s| f - s)
|
||||
}
|
||||
|
||||
/// Rate of change in percent over `n` bars.
|
||||
pub fn roc(values: &[f64], n: usize) -> Vec<f64> {
|
||||
(0..values.len())
|
||||
.map(|i| if i >= n { ratio(values[i] - values[i - n], values[i - n], NAN) * 100.0 } else { NAN })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Momentum: the change over `n` bars.
|
||||
pub fn momentum(values: &[f64], n: usize) -> Vec<f64> {
|
||||
change(values, n)
|
||||
}
|
||||
|
||||
/// %K and %D lines of a stochastic.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Stoch {
|
||||
pub k: Vec<f64>,
|
||||
pub d: Vec<f64>,
|
||||
}
|
||||
|
||||
/// Raw stochastic: where `close` sits in the `n`-bar high/low range, 0..100 (50 when flat).
|
||||
fn stoch_raw(high: &[f64], low: &[f64], close: &[f64], n: usize) -> Vec<f64> {
|
||||
let (hh, ll) = (highest(high, n), lowest(low, n));
|
||||
(0..close.len()).map(|i| 100.0 * ratio(close[i] - ll[i], hh[i] - ll[i], 0.5)).collect()
|
||||
}
|
||||
|
||||
/// Stochastic oscillator: %K = SMA(raw, `smooth_k`), %D = SMA(%K, `d`).
|
||||
pub fn stochastic(o: &Ohlcv, n: usize, smooth_k: usize, d: usize) -> Stoch {
|
||||
let k = sma(&stoch_raw(&o.high, &o.low, &o.close, n), smooth_k);
|
||||
let d = sma(&k, d);
|
||||
Stoch { k, d }
|
||||
}
|
||||
|
||||
/// Stochastic RSI: a stochastic of RSI(`rsi_n`) over `stoch_n` bars.
|
||||
pub fn stoch_rsi(values: &[f64], rsi_n: usize, stoch_n: usize, smooth_k: usize, d: usize) -> Stoch {
|
||||
let r = rsi(values, rsi_n);
|
||||
let k = sma(&stoch_raw(&r, &r, &r, stoch_n), smooth_k);
|
||||
let d = sma(&k, d);
|
||||
Stoch { k, d }
|
||||
}
|
||||
|
||||
/// Commodity channel index of `values` (usually the typical price) over `n` bars.
|
||||
pub fn cci(values: &[f64], n: usize) -> Vec<f64> {
|
||||
let m = sma(values, n);
|
||||
(0..values.len())
|
||||
.map(|i| {
|
||||
if m[i].is_nan() {
|
||||
return NAN;
|
||||
}
|
||||
let md = values[i + 1 - n..=i].iter().map(|v| (v - m[i]).abs()).sum::<f64>() / n as f64;
|
||||
ratio(values[i] - m[i], 0.015 * md, 0.0)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Williams %R: −100 (at the `n`-bar low) to 0 (at the high).
|
||||
pub fn williams_r(o: &Ohlcv, n: usize) -> Vec<f64> {
|
||||
stoch_raw(&o.high, &o.low, &o.close, n).into_iter().map(|k| k - 100.0).collect()
|
||||
}
|
||||
|
||||
/// Ultimate oscillator: buying pressure over true range on three horizons, weighted 4:2:1.
|
||||
pub fn ultimate(o: &Ohlcv, a: usize, b: usize, c: usize) -> Vec<f64> {
|
||||
let prev = |i: usize| ago(&o.close, i, 1);
|
||||
let bp: Vec<f64> = (0..o.len()).map(|i| o.close[i] - o.low[i].min(prev(i))).collect();
|
||||
let tr: Vec<f64> = (0..o.len()).map(|i| o.high[i].max(prev(i)) - o.low[i].min(prev(i))).collect();
|
||||
let avg = |n| zip(&sum(&bp, n), &sum(&tr, n), |p, t| ratio(p, t, 0.0));
|
||||
let (x, y, z) = (avg(a), avg(b), avg(c));
|
||||
(0..o.len()).map(|i| 100.0 * (4.0 * x[i] + 2.0 * y[i] + z[i]) / 7.0).collect()
|
||||
}
|
||||
|
||||
/// Awesome oscillator: SMA(median price, `fast`) − SMA(median price, `slow`).
|
||||
pub fn awesome(o: &Ohlcv, fast: usize, slow: usize) -> Vec<f64> {
|
||||
let hl2 = o.hl2();
|
||||
zip(&sma(&hl2, fast), &sma(&hl2, slow), |a, b| a - b)
|
||||
}
|
||||
|
||||
/// Accelerator oscillator: AO − SMA(AO, `n`).
|
||||
pub fn accelerator(o: &Ohlcv, fast: usize, slow: usize, n: usize) -> Vec<f64> {
|
||||
let ao = awesome(o, fast, slow);
|
||||
zip(&ao, &sma(&ao, n), |a, b| a - b)
|
||||
}
|
||||
|
||||
/// True strength index: double-smoothed momentum over double-smoothed |momentum|, × 100.
|
||||
pub fn tsi(values: &[f64], long: usize, short: usize) -> Vec<f64> {
|
||||
let pc = change(values, 1);
|
||||
let apc: Vec<f64> = pc.iter().map(|v| v.abs()).collect();
|
||||
let num = ema(&ema(&pc, long), short);
|
||||
let den = ema(&ema(&apc, long), short);
|
||||
zip(&num, &den, |a, b| ratio(a, b, 0.0) * 100.0)
|
||||
}
|
||||
|
||||
/// TRIX: one-bar percent change of a triple-smoothed EMA.
|
||||
pub fn trix(values: &[f64], n: usize) -> Vec<f64> {
|
||||
let e3 = ema(&ema(&ema(values, n), n), n);
|
||||
roc(&e3, 1)
|
||||
}
|
||||
|
||||
/// Pring's Know Sure Thing: weighted sum of four smoothed rates of change, with a signal.
|
||||
pub fn kst(values: &[f64], roc_n: [usize; 4], sma_n: [usize; 4], signal: usize) -> Macd {
|
||||
let mut line = vec![0.0; values.len()];
|
||||
for (w, (r, s)) in roc_n.iter().zip(sma_n).enumerate() {
|
||||
let part = sma(&roc(values, *r), s);
|
||||
for (l, p) in line.iter_mut().zip(&part) {
|
||||
*l += (w + 1) as f64 * p;
|
||||
}
|
||||
}
|
||||
let signal = sma(&line, signal);
|
||||
let histogram = zip(&line, &signal, |a, b| a - b);
|
||||
Macd { macd: line, signal, histogram }
|
||||
}
|
||||
|
||||
/// Up and down moves (`max(Δ, 0)`, `max(−Δ, 0)`) of `values` over `k` bars.
|
||||
fn moves(values: &[f64], k: usize) -> (Vec<f64>, Vec<f64>) {
|
||||
let ch = change(values, k);
|
||||
let up = ch.iter().map(|c| if c.is_nan() { NAN } else { c.max(0.0) }).collect();
|
||||
let dn = ch.iter().map(|c| if c.is_nan() { NAN } else { (-c).max(0.0) }).collect();
|
||||
(up, dn)
|
||||
}
|
||||
|
||||
/// Chande momentum oscillator, −100..100.
|
||||
pub fn cmo(values: &[f64], n: usize) -> Vec<f64> {
|
||||
let (up, dn) = moves(values, 1);
|
||||
zip(&sum(&up, n), &sum(&dn, n), |u, d| 100.0 * ratio(u - d, u + d, 0.0))
|
||||
}
|
||||
|
||||
/// Detrended price oscillator: close − SMA(n) from n/2 + 1 bars ago.
|
||||
pub fn dpo(values: &[f64], n: usize) -> Vec<f64> {
|
||||
let m = sma(values, n);
|
||||
let back = n / 2 + 1;
|
||||
(0..values.len()).map(|i| values[i] - ago(&m, i, back)).collect()
|
||||
}
|
||||
|
||||
/// Coppock curve: WMA(ROC(long) + ROC(short), `n`).
|
||||
pub fn coppock(values: &[f64], n: usize, long: usize, short: usize) -> Vec<f64> {
|
||||
wma(&zip(&roc(values, long), &roc(values, short), |a, b| a + b), n)
|
||||
}
|
||||
|
||||
/// Relative vigor index: close − open over high − low, summed over `n`, with an SWMA signal.
|
||||
pub fn rvgi(o: &Ohlcv, n: usize) -> Macd {
|
||||
let num = swma(&zip(&o.close, &o.open, |c, op| c - op));
|
||||
let den = swma(&zip(&o.high, &o.low, |h, l| h - l));
|
||||
let line = zip(&sum(&num, n), &sum(&den, n), |a, b| ratio(a, b, 0.0));
|
||||
let signal = swma(&line);
|
||||
let histogram = zip(&line, &signal, |a, b| a - b);
|
||||
Macd { macd: line, signal, histogram }
|
||||
}
|
||||
|
||||
/// Ehlers' Fisher transform of the median price's position in its `n`-bar range, with the
|
||||
/// previous value as trigger.
|
||||
pub fn fisher(o: &Ohlcv, n: usize) -> (Vec<f64>, Vec<f64>) {
|
||||
let hl2 = o.hl2();
|
||||
let (hh, ll) = (highest(&hl2, n), lowest(&hl2, n));
|
||||
let mut fish = vec![NAN; o.len()];
|
||||
let (mut v, mut f) = (0.0, 0.0);
|
||||
for i in 0..o.len() {
|
||||
if hh[i].is_nan() {
|
||||
continue;
|
||||
}
|
||||
v = (0.66 * (ratio(hl2[i] - ll[i], hh[i] - ll[i], 0.5) - 0.5) + 0.67 * v).clamp(-0.999, 0.999);
|
||||
f = 0.5 * ((1.0 + v) / (1.0 - v)).ln() + 0.5 * f;
|
||||
fish[i] = f;
|
||||
}
|
||||
let trigger = (0..fish.len()).map(|i| ago(&fish, i, 1)).collect();
|
||||
(fish, trigger)
|
||||
}
|
||||
|
||||
/// Blau's stochastic momentum index: distance from the midpoint of the `n`-bar range,
|
||||
/// double-smoothed over `d`, as a percentage of half the range; with an EMA signal.
|
||||
pub fn smi(o: &Ohlcv, n: usize, d: usize, signal: usize) -> Macd {
|
||||
let (hh, ll) = (highest(&o.high, n), lowest(&o.low, n));
|
||||
let rel: Vec<f64> = (0..o.len()).map(|i| o.close[i] - (hh[i] + ll[i]) / 2.0).collect();
|
||||
let range = zip(&hh, &ll, |h, l| h - l);
|
||||
let num = ema(&ema(&rel, d), d);
|
||||
let den = ema(&ema(&range, d), d);
|
||||
with_signal(zip(&num, &den, |a, b| ratio(a, b / 2.0, 0.0) * 100.0), signal)
|
||||
}
|
||||
|
||||
/// SMI ergodic indicator: TSI(`long`, `short`) with an EMA signal and their difference.
|
||||
pub fn smi_ergodic(values: &[f64], short: usize, long: usize, signal: usize) -> Macd {
|
||||
with_signal(tsi(values, long, short), signal)
|
||||
}
|
||||
|
||||
/// Connors RSI: the mean of RSI(`rsi_n`), RSI(`streak_n`) of the up/down streak length, and
|
||||
/// the percent rank of the one-bar change over `rank_n` bars.
|
||||
pub fn connors_rsi(values: &[f64], rsi_n: usize, streak_n: usize, rank_n: usize) -> Vec<f64> {
|
||||
let mut streak = vec![0.0; values.len()];
|
||||
for i in 1..values.len() {
|
||||
let s = streak[i - 1];
|
||||
streak[i] = if values[i] > values[i - 1] {
|
||||
if s > 0.0 { s + 1.0 } else { 1.0 }
|
||||
} else if values[i] < values[i - 1] {
|
||||
if s < 0.0 { s - 1.0 } else { -1.0 }
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
let (a, b, c) = (rsi(values, rsi_n), rsi(&streak, streak_n), percent_rank(&roc(values, 1), rank_n));
|
||||
(0..values.len()).map(|i| (a[i] + b[i] + c[i]) / 3.0).collect()
|
||||
}
|
||||
|
||||
/// Ehlers' Laguerre RSI with damping `gamma`, 0..1.
|
||||
pub fn laguerre_rsi(values: &[f64], gamma: f64) -> Vec<f64> {
|
||||
from_first(values, |p| {
|
||||
let g = gamma;
|
||||
let mut l = [p.first().copied().unwrap_or_default(); 4];
|
||||
p.iter()
|
||||
.map(|&x| {
|
||||
let prev = l;
|
||||
l[0] = (1.0 - g) * x + g * prev[0];
|
||||
l[1] = -g * l[0] + prev[0] + g * prev[1];
|
||||
l[2] = -g * l[1] + prev[1] + g * prev[2];
|
||||
l[3] = -g * l[2] + prev[2] + g * prev[3];
|
||||
let (mut cu, mut cd) = (0.0, 0.0);
|
||||
for w in l.windows(2) {
|
||||
let d = w[0] - w[1];
|
||||
if d >= 0.0 { cu += d } else { cd -= d }
|
||||
}
|
||||
ratio(cu, cu + cd, 0.0)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Relative momentum index: an RSI of the change over `m` bars, Wilder-smoothed over `n`.
|
||||
pub fn rmi(values: &[f64], n: usize, m: usize) -> Vec<f64> {
|
||||
let (up, dn) = moves(values, m);
|
||||
zip(&rma(&up, n), &rma(&dn, n), |u, d| 100.0 * ratio(u, u + d, 0.5))
|
||||
}
|
||||
|
||||
/// DecisionPoint price momentum oscillator: the one-bar ROC smoothed twice with the custom
|
||||
/// 2/n factor (×10 after the first), with an EMA signal.
|
||||
pub fn pmo(values: &[f64], a: usize, b: usize, signal: usize) -> Macd {
|
||||
let r = roc(values, 1);
|
||||
let s1: Vec<f64> = smooth(&r, a, 2.0 / a as f64).into_iter().map(|v| v * 10.0).collect();
|
||||
with_signal(smooth(&s1, b, 2.0 / b as f64), signal)
|
||||
}
|
||||
|
||||
/// Balance of power: SMA of (close − open) / (high − low).
|
||||
pub fn bop(o: &Ohlcv, n: usize) -> Vec<f64> {
|
||||
let raw: Vec<f64> = (0..o.len()).map(|i| ratio(o.close[i] - o.open[i], o.high[i] - o.low[i], 0.0)).collect();
|
||||
sma(&raw, n)
|
||||
}
|
||||
|
||||
/// Elder ray: bull power (high − EMA) and bear power (low − EMA).
|
||||
pub fn elder_ray(o: &Ohlcv, n: usize) -> (Vec<f64>, Vec<f64>) {
|
||||
let e = ema(&o.close, n);
|
||||
(zip(&o.high, &e, |h, e| h - e), zip(&o.low, &e, |l, e| l - e))
|
||||
}
|
||||
|
||||
/// Bull bear power: (high − EMA) + (low − EMA).
|
||||
pub fn bull_bear_power(o: &Ohlcv, n: usize) -> Vec<f64> {
|
||||
let (bull, bear) = elder_ray(o, n);
|
||||
zip(&bull, &bear, |a, b| a + b)
|
||||
}
|
||||
|
||||
/// Chande's Qstick: SMA of close − open.
|
||||
pub fn qstick(o: &Ohlcv, n: usize) -> Vec<f64> {
|
||||
sma(&zip(&o.close, &o.open, |c, op| c - op), n)
|
||||
}
|
||||
|
||||
/// Psychological line: the percentage of up closes over `n` bars.
|
||||
pub fn psychological(values: &[f64], n: usize) -> Vec<f64> {
|
||||
let up: Vec<f64> =
|
||||
(0..values.len()).map(|i| if i == 0 { NAN } else { f64::from(values[i] > values[i - 1]) }).collect();
|
||||
sum(&up, n).into_iter().map(|s| 100.0 * s / n as f64).collect()
|
||||
}
|
||||
|
||||
/// Chande forecast oscillator: percent distance of the close from its `n`-bar regression.
|
||||
pub fn cfo(values: &[f64], n: usize) -> Vec<f64> {
|
||||
zip(values, &linreg(values, n), |c, l| 100.0 * ratio(c - l, c, 0.0))
|
||||
}
|
||||
|
||||
/// Intraday momentum index: an RSI of open-to-close moves, 0..100.
|
||||
pub fn imi(o: &Ohlcv, n: usize) -> Vec<f64> {
|
||||
let gains: Vec<f64> = (0..o.len()).map(|i| (o.close[i] - o.open[i]).max(0.0)).collect();
|
||||
let losses: Vec<f64> = (0..o.len()).map(|i| (o.open[i] - o.close[i]).max(0.0)).collect();
|
||||
zip(&sum(&gains, n), &sum(&losses, n), |g, l| 100.0 * ratio(g, g + l, 0.5))
|
||||
}
|
||||
|
||||
/// Schaff trend cycle: a twice-stochasticised, half-smoothed MACD(`fast`, `slow`) over a
|
||||
/// `cycle`-bar window, 0..100.
|
||||
pub fn stc(values: &[f64], cycle: usize, fast: usize, slow: usize) -> Vec<f64> {
|
||||
let m = apo(values, fast, slow);
|
||||
let stage = |x: &[f64]| {
|
||||
let (lo, hi) = (lowest(x, cycle), highest(x, cycle));
|
||||
let mut out = vec![NAN; x.len()];
|
||||
let (mut raw, mut sm) = (NAN, NAN);
|
||||
for i in 0..x.len() {
|
||||
if lo[i].is_nan() {
|
||||
continue;
|
||||
}
|
||||
let range = hi[i] - lo[i];
|
||||
if range > 0.0 {
|
||||
raw = (x[i] - lo[i]) / range * 100.0;
|
||||
} else if raw.is_nan() {
|
||||
raw = 0.0;
|
||||
}
|
||||
sm = if sm.is_nan() { raw } else { sm + 0.5 * (raw - sm) };
|
||||
out[i] = sm;
|
||||
}
|
||||
out
|
||||
};
|
||||
stage(&stage(&m))
|
||||
}
|
||||
|
||||
/// Ehlers' center of gravity over `n` bars, with the previous value as signal.
|
||||
pub fn center_of_gravity(values: &[f64], n: usize) -> (Vec<f64>, Vec<f64>) {
|
||||
let mut cg = vec![NAN; values.len()];
|
||||
for i in (n.max(1) - 1..values.len()).filter(|_| n > 0) {
|
||||
let (mut num, mut den) = (0.0, 0.0);
|
||||
for k in 0..n {
|
||||
num += (k + 1) as f64 * values[i - k];
|
||||
den += values[i - k];
|
||||
}
|
||||
cg[i] = if den != 0.0 { -num / den + (n as f64 + 1.0) / 2.0 } else { 0.0 };
|
||||
}
|
||||
let signal = (0..cg.len()).map(|i| ago(&cg, i, 1)).collect();
|
||||
(cg, signal)
|
||||
}
|
||||
|
||||
/// Inverse Fisher transform of RSI(`rsi_n`), WMA-smoothed over `n`, −1..1.
|
||||
pub fn ift_rsi(values: &[f64], rsi_n: usize, n: usize) -> Vec<f64> {
|
||||
let v: Vec<f64> = rsi(values, rsi_n).into_iter().map(|r| 0.1 * (r - 50.0)).collect();
|
||||
wma(&v, n).into_iter().map(f64::tanh).collect()
|
||||
}
|
||||
|
||||
/// Pretty good oscillator: distance from the SMA in units of the average true range.
|
||||
pub fn pgo(o: &Ohlcv, n: usize) -> Vec<f64> {
|
||||
let m = sma(&o.close, n);
|
||||
let atr = ema(&true_range(o), n);
|
||||
(0..o.len()).map(|i| ratio(o.close[i] - m[i], atr[i], 0.0)).collect()
|
||||
}
|
||||
|
||||
/// QQE: RSI smoothed over `smooth` bars and a trailing level `factor` times its smoothed
|
||||
/// volatility away, flipping sides when crossed.
|
||||
pub fn qqe(values: &[f64], rsi_n: usize, smooth: usize, factor: f64) -> (Vec<f64>, Vec<f64>) {
|
||||
let line = ema(&rsi(values, rsi_n), smooth);
|
||||
let wilders = (2 * rsi_n).saturating_sub(1).max(1);
|
||||
let atr: Vec<f64> = (0..line.len()).map(|i| (line[i] - ago(&line, i, 1)).abs()).collect();
|
||||
let dar: Vec<f64> = ema(&ema(&atr, wilders), wilders).into_iter().map(|v| v * factor).collect();
|
||||
let mut trail = vec![NAN; line.len()];
|
||||
let (mut long, mut short, mut trend) = (NAN, NAN, 1);
|
||||
let mut prev = NAN;
|
||||
for i in 0..line.len() {
|
||||
let (r, d) = (line[i], dar[i]);
|
||||
if r.is_nan() || d.is_nan() {
|
||||
continue;
|
||||
}
|
||||
let (nl, ns) = (r - d, r + d);
|
||||
let (pl, ps) = (long, short);
|
||||
long = if !pl.is_nan() && prev > pl && r > pl { pl.max(nl) } else { nl };
|
||||
short = if !ps.is_nan() && prev < ps && r < ps { ps.min(ns) } else { ns };
|
||||
if !ps.is_nan() && prev <= ps && r > ps {
|
||||
trend = 1;
|
||||
} else if !pl.is_nan() && prev >= pl && r < pl {
|
||||
trend = -1;
|
||||
}
|
||||
trail[i] = if trend == 1 { long } else { short };
|
||||
prev = r;
|
||||
}
|
||||
(line, trail)
|
||||
}
|
||||
|
||||
/// WaveTrend (LazyBear): a CCI-like channel index of the typical price, EMA-smoothed, with
|
||||
/// a 4-bar SMA signal.
|
||||
pub fn wavetrend(o: &Ohlcv, n1: usize, n2: usize) -> (Vec<f64>, Vec<f64>) {
|
||||
let ap = o.hlc3();
|
||||
let esa = ema(&ap, n1);
|
||||
let dev = ema(&zip(&ap, &esa, |a, e| (a - e).abs()), n1);
|
||||
let ci: Vec<f64> = (0..ap.len()).map(|i| ratio(ap[i] - esa[i], 0.015 * dev[i], 0.0)).collect();
|
||||
let wt1 = ema(&ci, n2);
|
||||
let wt2 = sma(&wt1, 4);
|
||||
(wt1, wt2)
|
||||
}
|
||||
|
||||
/// Ehlers' cyber cycle with smoothing `alpha`, and its previous value as trigger.
|
||||
pub fn cyber_cycle(values: &[f64], alpha: f64) -> (Vec<f64>, Vec<f64>) {
|
||||
let cycle = from_first(values, |p| {
|
||||
let a = alpha;
|
||||
let pr = |i: usize, k: usize| if i >= k { p[i - k] } else { p[0] };
|
||||
let sm: Vec<f64> =
|
||||
(0..p.len()).map(|i| (pr(i, 0) + 2.0 * pr(i, 1) + 2.0 * pr(i, 2) + pr(i, 3)) / 6.0).collect();
|
||||
let mut c = vec![0.0; p.len()];
|
||||
for i in 0..p.len() {
|
||||
c[i] = if i < 7 {
|
||||
(pr(i, 0) - 2.0 * pr(i, 1) + pr(i, 2)) / 4.0
|
||||
} else {
|
||||
(1.0 - 0.5 * a).powi(2) * (sm[i] - 2.0 * sm[i - 1] + sm[i - 2]) + 2.0 * (1.0 - a) * c[i - 1]
|
||||
- (1.0 - a).powi(2) * c[i - 2]
|
||||
};
|
||||
}
|
||||
c
|
||||
});
|
||||
let trigger = (0..cycle.len()).map(|i| ago(&cycle, i, 1)).collect();
|
||||
(cycle, trigger)
|
||||
}
|
||||
|
||||
/// Disparity index: percent distance of the close from its `n`-bar SMA.
|
||||
pub fn disparity(values: &[f64], n: usize) -> Vec<f64> {
|
||||
zip(values, &sma(values, n), |c, m| 100.0 * ratio(c - m, m, 0.0))
|
||||
}
|
||||
|
||||
/// Polarized fractal efficiency: straight-line distance over path length across `n` bars,
|
||||
/// signed by direction and EMA-smoothed, −100..100.
|
||||
pub fn pfe(values: &[f64], n: usize, smoothing: usize) -> Vec<f64> {
|
||||
let k = n as f64;
|
||||
let step: Vec<f64> = (0..values.len())
|
||||
.map(|i| if i == 0 { NAN } else { ((values[i] - values[i - 1]).powi(2) + 1.0).sqrt() })
|
||||
.collect();
|
||||
let path = sum(&step, n);
|
||||
let raw: Vec<f64> = (0..values.len())
|
||||
.map(|i| {
|
||||
if i < n || path[i].is_nan() {
|
||||
return NAN;
|
||||
}
|
||||
let d = values[i] - values[i - n];
|
||||
let p = 100.0 * (d * d + k * k).sqrt() / path[i];
|
||||
if d > 0.0 { p } else { -p }
|
||||
})
|
||||
.collect();
|
||||
ema(&raw, smoothing)
|
||||
}
|
||||
|
||||
/// Kaufman's efficiency ratio: |net change| over the sum of |bar changes|, 0..1.
|
||||
pub fn efficiency_ratio(values: &[f64], n: usize) -> Vec<f64> {
|
||||
let step: Vec<f64> = change(values, 1).into_iter().map(f64::abs).collect();
|
||||
let path = sum(&step, n);
|
||||
(0..values.len())
|
||||
.map(|i| if i >= n { ratio((values[i] - values[i - n]).abs(), path[i], 0.0) } else { NAN })
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Bar;
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-9 * (1.0 + b.abs())
|
||||
}
|
||||
|
||||
fn ohlcv(rows: &[(f64, f64, f64, f64)]) -> Ohlcv {
|
||||
let bars: Vec<Bar> =
|
||||
rows.iter().enumerate().map(|(i, r)| Bar::new(i as i64, r.0, r.1, r.2, r.3, 100.0)).collect();
|
||||
Ohlcv::new(&bars)
|
||||
}
|
||||
|
||||
fn wave(n: usize) -> Ohlcv {
|
||||
let rows: Vec<(f64, f64, f64, f64)> = (0..n)
|
||||
.map(|i| {
|
||||
let c = 100.0 + (i as f64 * 0.17).sin() * 6.0 + i as f64 * 0.02;
|
||||
(c - 0.3, c + 1.0 + (i % 3) as f64 * 0.2, c - 1.1, c)
|
||||
})
|
||||
.collect();
|
||||
ohlcv(&rows)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stochastic_and_williams_hand_computed() {
|
||||
// 3-bar window: HH 12, LL 8, close 11 → raw 75, %R −25.
|
||||
let o = ohlcv(&[(9.0, 10.0, 8.0, 9.0), (10.0, 12.0, 9.0, 11.0), (11.0, 11.5, 10.0, 11.0)]);
|
||||
let s = stochastic(&o, 3, 1, 1);
|
||||
assert!(near(s.k[2], 75.0) && near(s.d[2], 75.0));
|
||||
assert!(near(williams_r(&o, 3)[2], -25.0));
|
||||
// A flat window sits in the middle instead of dividing by zero.
|
||||
let flat = ohlcv(&[(5.0, 5.0, 5.0, 5.0); 4]);
|
||||
assert_eq!(stochastic(&flat, 3, 1, 1).k[3], 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cci_hand_computed() {
|
||||
// Typical prices 1, 2, 3: mean 2, mean deviation 2/3 → (3 − 2) / (0.015 · 2/3) = 100.
|
||||
assert!(near(cci(&[1.0, 2.0, 3.0], 3)[2], 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn differences_match_their_parts() {
|
||||
let o = wave(300);
|
||||
let c = &o.close;
|
||||
let p = ppo(c, 12, 26, 9);
|
||||
let (f, s) = (ema(c, 12), ema(c, 26));
|
||||
assert!(near(p.macd[200], (f[200] - s[200]) / s[200] * 100.0));
|
||||
assert!(near(apo(c, 12, 26)[200], f[200] - s[200]));
|
||||
assert!(near(roc(c, 9)[50], (c[50] / c[41] - 1.0) * 100.0));
|
||||
assert!(near(momentum(c, 10)[50], c[50] - c[40]));
|
||||
let hl2 = o.hl2();
|
||||
assert!(near(awesome(&o, 5, 34)[100], sma(&hl2, 5)[100] - sma(&hl2, 34)[100]));
|
||||
let ao = awesome(&o, 5, 34);
|
||||
assert!(near(accelerator(&o, 5, 34, 5)[100], ao[100] - sma(&ao, 5)[100]));
|
||||
let b = bull_bear_power(&o, 13);
|
||||
let (bull, bear) = elder_ray(&o, 13);
|
||||
assert!(near(b[90], bull[90] + bear[90]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_oscillators_stay_in_range() {
|
||||
let o = wave(500);
|
||||
let c = &o.close;
|
||||
let checks: Vec<(Vec<f64>, f64, f64)> = vec![
|
||||
(cmo(c, 9), -100.0, 100.0),
|
||||
(stoch_rsi(c, 14, 14, 3, 3).k, 0.0, 100.0),
|
||||
(ultimate(&o, 7, 14, 28), 0.0, 100.0),
|
||||
(connors_rsi(c, 3, 2, 100), 0.0, 100.0),
|
||||
(laguerre_rsi(c, 0.5), 0.0, 1.0),
|
||||
(rmi(c, 20, 5), 0.0, 100.0),
|
||||
(imi(&o, 14), 0.0, 100.0),
|
||||
(psychological(c, 12), 0.0, 100.0),
|
||||
(stc(c, 12, 26, 50), 0.0, 100.0),
|
||||
(ift_rsi(c, 5, 9), -1.0, 1.0),
|
||||
(qqe(c, 14, 5, 4.236).0, 0.0, 100.0),
|
||||
(pfe(c, 10, 5), -100.0, 100.0),
|
||||
(efficiency_ratio(c, 10), 0.0, 1.0),
|
||||
(smi(&o, 10, 3, 10).macd, -100.0, 100.0),
|
||||
];
|
||||
for (k, (v, lo, hi)) in checks.iter().enumerate() {
|
||||
let finite: Vec<f64> = v.iter().copied().filter(|x| x.is_finite()).collect();
|
||||
assert!(finite.len() > 300, "check {k} has {} values", finite.len());
|
||||
assert!(finite.iter().all(|x| *x >= lo - 1e-9 && *x <= hi + 1e-9), "check {k} out of range");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_shapes() {
|
||||
let up: Vec<f64> = (0..60).map(|i| 10.0 + i as f64).collect();
|
||||
assert_eq!(cmo(&up, 9)[30], 100.0);
|
||||
assert_eq!(efficiency_ratio(&up, 10)[30], 1.0);
|
||||
assert_eq!(psychological(&up, 12)[30], 100.0);
|
||||
assert!(near(tsi(&up, 25, 13)[59], 100.0));
|
||||
// Streaks: three rises then a fall.
|
||||
let c = connors_rsi(&[1.0, 2.0, 3.0, 4.0, 3.0], 2, 2, 2);
|
||||
assert!(c[4].is_finite());
|
||||
let (cg, sig) = center_of_gravity(&[1.0; 20], 10);
|
||||
assert!(near(cg[15], -(55.0 / 10.0) + 5.5) && near(sig[16], cg[15]));
|
||||
// DPO compares with the SMA from n/2 + 1 bars back.
|
||||
let d = dpo(&up, 10);
|
||||
assert!(near(d[30], up[30] - sma(&up, 10)[24]));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,372 @@
|
||||
//! Rolling statistics: sums, extremes, dispersion, least-squares fits and ranks. Running
|
||||
//! sums are recomputed exactly once per window length, so drift stays bounded over
|
||||
//! millions of bars at O(1) amortised cost per bar.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use super::NAN;
|
||||
|
||||
/// Calls `f(i, r, s, w, s2)` for every full window of `n` values ending at `i` that holds
|
||||
/// only finite values, where, over `y - r` (`r` = the first finite value, for precision),
|
||||
/// `s` = Σy, `w` = Σk·y with k = 1 for the oldest … n for the newest, and `s2` = Σy².
|
||||
pub(super) fn windows(values: &[f64], n: usize, mut f: impl FnMut(usize, f64, f64, f64, f64)) {
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let r = values.iter().copied().find(|v| v.is_finite()).unwrap_or(0.0);
|
||||
let y = |i: usize| if values[i].is_finite() { values[i] - r } else { 0.0 };
|
||||
let (mut s, mut w, mut s2, mut bad) = (0.0, 0.0, 0.0, 0usize);
|
||||
for i in 0..values.len() {
|
||||
if !values[i].is_finite() {
|
||||
bad += 1;
|
||||
}
|
||||
let v = y(i);
|
||||
if i >= n {
|
||||
if !values[i - n].is_finite() {
|
||||
bad -= 1;
|
||||
}
|
||||
let o = y(i - n);
|
||||
w -= s;
|
||||
s -= o;
|
||||
s2 -= o * o;
|
||||
w += n as f64 * v;
|
||||
} else {
|
||||
w += (i + 1) as f64 * v;
|
||||
}
|
||||
s += v;
|
||||
s2 += v * v;
|
||||
if i + 1 >= n {
|
||||
if (i + 1) % n == 0 {
|
||||
(s, w, s2) = (0.0, 0.0, 0.0);
|
||||
for (k, j) in (i + 1 - n..=i).enumerate() {
|
||||
let v = y(j);
|
||||
s += v;
|
||||
w += (k + 1) as f64 * v;
|
||||
s2 += v * v;
|
||||
}
|
||||
}
|
||||
if bad == 0 {
|
||||
f(i, r, s, w, s2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rolling sum of `n` values. Windows holding a non-finite value are NaN.
|
||||
pub fn sum(values: &[f64], n: usize) -> Vec<f64> {
|
||||
let mut out = vec![NAN; values.len()];
|
||||
windows(values, n, |i, r, s, _, _| out[i] = s + r * n as f64);
|
||||
out
|
||||
}
|
||||
|
||||
/// Population variance over `n` values.
|
||||
pub fn variance(values: &[f64], n: usize) -> Vec<f64> {
|
||||
let mut out = vec![NAN; values.len()];
|
||||
let k = n as f64;
|
||||
windows(values, n, |i, _, s, _, s2| {
|
||||
let m = s / k;
|
||||
out[i] = (s2 / k - m * m).max(0.0);
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Population standard deviation over `n` values.
|
||||
pub fn stdev(values: &[f64], n: usize) -> Vec<f64> {
|
||||
variance(values, n).into_iter().map(f64::sqrt).collect()
|
||||
}
|
||||
|
||||
/// Distance from the `n`-value mean in standard deviations (0 when flat).
|
||||
pub fn zscore(values: &[f64], n: usize) -> Vec<f64> {
|
||||
let mut out = vec![NAN; values.len()];
|
||||
let k = n as f64;
|
||||
windows(values, n, |i, r, s, _, s2| {
|
||||
let m = s / k;
|
||||
let sd = (s2 / k - m * m).max(0.0).sqrt();
|
||||
out[i] = if sd > 0.0 { (values[i] - r - m) / sd } else { 0.0 };
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Index of the highest (`max`) or lowest value in each window of `n` (the most recent one
|
||||
/// on ties), or `usize::MAX` while the window is short or holds a non-finite value.
|
||||
pub(super) fn extreme_at(values: &[f64], n: usize, max: bool) -> Vec<usize> {
|
||||
let mut out = vec![usize::MAX; values.len()];
|
||||
if n == 0 {
|
||||
return out;
|
||||
}
|
||||
let mut dq: VecDeque<usize> = VecDeque::new();
|
||||
let mut bad = 0usize;
|
||||
for (i, &v) in values.iter().enumerate() {
|
||||
if v.is_finite() {
|
||||
while let Some(&b) = dq.back() {
|
||||
if (max && values[b] <= v) || (!max && values[b] >= v) {
|
||||
dq.pop_back();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
dq.push_back(i);
|
||||
} else {
|
||||
bad += 1;
|
||||
}
|
||||
if i >= n && !values[i - n].is_finite() {
|
||||
bad -= 1;
|
||||
}
|
||||
while dq.front().is_some_and(|&f| f + n <= i) {
|
||||
dq.pop_front();
|
||||
}
|
||||
if i + 1 >= n
|
||||
&& bad == 0
|
||||
&& let Some(&f) = dq.front()
|
||||
{
|
||||
out[i] = f;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn pick(values: &[f64], idx: Vec<usize>) -> Vec<f64> {
|
||||
idx.into_iter().map(|j| if j == usize::MAX { NAN } else { values[j] }).collect()
|
||||
}
|
||||
|
||||
/// Highest value over the last `n` (monotonic deque, O(1) amortised).
|
||||
pub fn highest(values: &[f64], n: usize) -> Vec<f64> {
|
||||
pick(values, extreme_at(values, n, true))
|
||||
}
|
||||
|
||||
/// Lowest value over the last `n`.
|
||||
pub fn lowest(values: &[f64], n: usize) -> Vec<f64> {
|
||||
pick(values, extreme_at(values, n, false))
|
||||
}
|
||||
|
||||
/// `values[i] - values[i - k]`.
|
||||
pub fn change(values: &[f64], k: usize) -> Vec<f64> {
|
||||
(0..values.len()).map(|i| if i >= k { values[i] - values[i - k] } else { NAN }).collect()
|
||||
}
|
||||
|
||||
/// Displace a series by `k` bars: forward (into the future, extending the output past the
|
||||
/// last bar) when positive, back when negative.
|
||||
pub fn displace(values: &[f64], k: isize) -> Vec<f64> {
|
||||
let len = values.len();
|
||||
if k >= 0 {
|
||||
let k = k as usize;
|
||||
let mut out = vec![NAN; len + k];
|
||||
out[k..].copy_from_slice(values);
|
||||
out
|
||||
} else {
|
||||
let k = k.unsigned_abs().min(len);
|
||||
let mut out = vec![NAN; len];
|
||||
out[..len - k].copy_from_slice(&values[k..]);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Least-squares line through one window, with x = 0 for the oldest value.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Fit {
|
||||
pub slope: f64,
|
||||
pub intercept: f64,
|
||||
/// Coefficient of determination (0 for a flat window).
|
||||
pub r2: f64,
|
||||
/// Standard error of the estimate, sqrt(SSE / (n - 2)).
|
||||
pub se: f64,
|
||||
}
|
||||
|
||||
impl Fit {
|
||||
pub fn at(&self, x: f64) -> f64 {
|
||||
self.intercept + self.slope * x
|
||||
}
|
||||
}
|
||||
|
||||
/// Map every rolling `n`-value least-squares fit through `f` (O(1) per bar).
|
||||
pub fn linreg_map(values: &[f64], n: usize, f: impl Fn(Fit) -> f64) -> Vec<f64> {
|
||||
let mut out = vec![NAN; values.len()];
|
||||
let k = n as f64;
|
||||
let sx = k * (k - 1.0) / 2.0;
|
||||
let sxx = (k - 1.0) * k * (2.0 * k - 1.0) / 6.0;
|
||||
let den = k * sxx - sx * sx;
|
||||
windows(values, n, |i, r, s, w, s2| {
|
||||
let sxy = w - s;
|
||||
let slope = if den != 0.0 { (k * sxy - sx * s) / den } else { 0.0 };
|
||||
let b = (s - slope * sx) / k;
|
||||
let sse = (s2 - b * s - slope * sxy).max(0.0);
|
||||
let sst = s2 - s * s / k;
|
||||
let r2 = if sst > 1e-12 * s2.max(1e-300) { (1.0 - sse / sst).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let se = if n > 2 { (sse / (k - 2.0)).sqrt() } else { 0.0 };
|
||||
out[i] = f(Fit { slope, intercept: b + r, r2, se });
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Linear regression value at the newest bar (least squares moving average, LSMA).
|
||||
pub fn linreg(values: &[f64], n: usize) -> Vec<f64> {
|
||||
linreg_map(values, n, |f| f.at(n as f64 - 1.0))
|
||||
}
|
||||
|
||||
/// Slope of the rolling regression line, per bar.
|
||||
pub fn linreg_slope(values: &[f64], n: usize) -> Vec<f64> {
|
||||
linreg_map(values, n, |f| f.slope)
|
||||
}
|
||||
|
||||
/// R² of the rolling regression line.
|
||||
pub fn r_squared(values: &[f64], n: usize) -> Vec<f64> {
|
||||
linreg_map(values, n, |f| f.r2)
|
||||
}
|
||||
|
||||
/// Time series forecast: the regression line extended one bar ahead.
|
||||
pub fn tsf(values: &[f64], n: usize) -> Vec<f64> {
|
||||
linreg_map(values, n, |f| f.at(n as f64))
|
||||
}
|
||||
|
||||
/// Percentage of the previous `n` values less than or equal to the current one.
|
||||
pub fn percent_rank(values: &[f64], n: usize) -> Vec<f64> {
|
||||
let mut out = vec![NAN; values.len()];
|
||||
if n == 0 {
|
||||
return out;
|
||||
}
|
||||
for i in n..values.len() {
|
||||
let v = values[i];
|
||||
let w = &values[i - n..i];
|
||||
if v.is_finite() && w.iter().all(|x| x.is_finite()) {
|
||||
out[i] = 100.0 * w.iter().filter(|&&x| x <= v).count() as f64 / n as f64;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Rolling median of `n` values (sorted window, O(n) per bar).
|
||||
pub fn median(values: &[f64], n: usize) -> Vec<f64> {
|
||||
let mut out = vec![NAN; values.len()];
|
||||
if n == 0 {
|
||||
return out;
|
||||
}
|
||||
let mut sorted: Vec<f64> = Vec::with_capacity(n);
|
||||
let mut bad = 0usize;
|
||||
let pos = |s: &[f64], v: f64| s.partition_point(|x| *x < v);
|
||||
for i in 0..values.len() {
|
||||
let v = values[i];
|
||||
if v.is_finite() {
|
||||
let p = pos(&sorted, v);
|
||||
sorted.insert(p, v);
|
||||
} else {
|
||||
bad += 1;
|
||||
}
|
||||
if i >= n {
|
||||
let o = values[i - n];
|
||||
if o.is_finite() {
|
||||
let p = pos(&sorted, o);
|
||||
sorted.remove(p);
|
||||
} else {
|
||||
bad -= 1;
|
||||
}
|
||||
}
|
||||
if i + 1 >= n && bad == 0 {
|
||||
out[i] = if n % 2 == 1 { sorted[n / 2] } else { (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0 };
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn noisy(n: usize) -> Vec<f64> {
|
||||
(0..n).map(|i| 100.0 + ((i * 7919) % 101) as f64 * 0.37 - (i as f64 * 0.11).sin() * 9.0).collect()
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a.is_nan() && b.is_nan()) || (a - b).abs() <= 1e-7 * (1.0 + b.abs())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_sums_match_brute_force() {
|
||||
let xs = noisy(500);
|
||||
for n in [1, 2, 7, 20] {
|
||||
let s = sum(&xs, n);
|
||||
let var = variance(&xs, n);
|
||||
for i in 0..xs.len() {
|
||||
if i + 1 < n {
|
||||
assert!(s[i].is_nan());
|
||||
continue;
|
||||
}
|
||||
let w = &xs[i + 1 - n..=i];
|
||||
let m = w.iter().sum::<f64>() / n as f64;
|
||||
assert!(near(s[i], w.iter().sum()), "sum n={n} i={i}");
|
||||
let v = w.iter().map(|x| (x - m).powi(2)).sum::<f64>() / n as f64;
|
||||
assert!(near(var[i], v), "var n={n} i={i}: {} vs {v}", var[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gaps_only_blank_the_windows_that_hold_them() {
|
||||
let mut xs = noisy(40);
|
||||
xs[10] = NAN;
|
||||
let s = sum(&xs, 5);
|
||||
assert!(s[9].is_finite());
|
||||
assert!((10..15).all(|i| s[i].is_nan()));
|
||||
assert!(near(s[15], xs[11..16].iter().sum()));
|
||||
assert!(highest(&xs, 5)[14].is_nan() && highest(&xs, 5)[15].is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extremes_match_brute_force() {
|
||||
let xs = noisy(400);
|
||||
for n in [1, 3, 14, 52] {
|
||||
let (h, l) = (highest(&xs, n), lowest(&xs, n));
|
||||
for i in n - 1..xs.len() {
|
||||
let w = &xs[i + 1 - n..=i];
|
||||
assert_eq!(h[i], w.iter().cloned().fold(f64::MIN, f64::max));
|
||||
assert_eq!(l[i], w.iter().cloned().fold(f64::MAX, f64::min));
|
||||
}
|
||||
}
|
||||
// Ties resolve to the most recent bar.
|
||||
assert_eq!(extreme_at(&[1.0, 3.0, 2.0, 3.0], 4, true)[3], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regression_on_a_line_is_exact() {
|
||||
let xs: Vec<f64> = (0..50).map(|i| 3.0 + 0.5 * i as f64).collect();
|
||||
let lr = linreg(&xs, 10);
|
||||
let sl = linreg_slope(&xs, 10);
|
||||
let r2 = r_squared(&xs, 10);
|
||||
let f = tsf(&xs, 10);
|
||||
for i in 9..50 {
|
||||
assert!(near(lr[i], xs[i]), "{i}");
|
||||
assert!(near(sl[i], 0.5));
|
||||
assert!(near(r2[i], 1.0));
|
||||
assert!(near(f[i], xs[i] + 0.5));
|
||||
}
|
||||
// Noisy data: compare with a direct fit.
|
||||
let ys = noisy(120);
|
||||
let n = 25;
|
||||
let got = linreg_map(&ys, n, |f| f.slope + f.se);
|
||||
let i = 101;
|
||||
let w = &ys[i + 1 - n..=i];
|
||||
let k = n as f64;
|
||||
let mx = (k - 1.0) / 2.0;
|
||||
let my = w.iter().sum::<f64>() / k;
|
||||
let sxy: f64 = w.iter().enumerate().map(|(j, y)| (j as f64 - mx) * (y - my)).sum();
|
||||
let sxx: f64 = (0..n).map(|j| (j as f64 - mx).powi(2)).sum();
|
||||
let b = sxy / sxx;
|
||||
let a = my - b * mx;
|
||||
let sse: f64 = w.iter().enumerate().map(|(j, y)| (y - a - b * j as f64).powi(2)).sum();
|
||||
assert!(near(got[i], b + (sse / (k - 2.0)).sqrt()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_median_and_shift() {
|
||||
let xs = [5.0, 1.0, 4.0, 2.0, 3.0, 6.0];
|
||||
assert_eq!(percent_rank(&xs, 4)[4], 50.0);
|
||||
assert_eq!(percent_rank(&xs, 4)[5], 100.0);
|
||||
assert_eq!(median(&xs, 3)[2], 4.0);
|
||||
assert_eq!(median(&xs, 4)[5], 3.5);
|
||||
assert_eq!(displace(&[1.0, 2.0], 2)[3], 2.0);
|
||||
let back = displace(&[1.0, 2.0, 3.0], -1);
|
||||
assert_eq!(&back[..2], &[2.0, 3.0]);
|
||||
assert!(back[2].is_nan());
|
||||
assert_eq!(change(&[1.0, 4.0, 9.0], 2)[2], 8.0);
|
||||
assert!(zscore(&[2.0; 10], 5)[9] == 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
//! Trend: directional movement, trailing stops, Ichimoku, Bill Williams' tools, zigzag and
|
||||
//! trend-strength filters.
|
||||
|
||||
use super::ma::{rma, sma};
|
||||
use super::stats::{change, displace, extreme_at, highest, linreg, lowest, sum};
|
||||
use super::volatility::{atr, bollinger, true_range};
|
||||
use super::{NAN, Ohlcv, ago, ratio, zip};
|
||||
|
||||
/// Wilder's directional movement index.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Dmi {
|
||||
pub plus: Vec<f64>,
|
||||
pub minus: Vec<f64>,
|
||||
pub adx: Vec<f64>,
|
||||
}
|
||||
|
||||
/// +DI and −DI over `n` bars and ADX smoothed over `adx_n`, all Wilder-smoothed.
|
||||
pub fn dmi(o: &Ohlcv, n: usize, adx_n: usize) -> Dmi {
|
||||
let len = o.len();
|
||||
let (up, down) = (change(&o.high, 1), change(&o.low, 1));
|
||||
let plus_dm: Vec<f64> = (0..len)
|
||||
.map(|i| {
|
||||
if up[i].is_nan() {
|
||||
NAN
|
||||
} else if up[i] > -down[i] && up[i] > 0.0 {
|
||||
up[i]
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let minus_dm: Vec<f64> = (0..len)
|
||||
.map(|i| {
|
||||
if down[i].is_nan() {
|
||||
NAN
|
||||
} else if -down[i] > up[i] && -down[i] > 0.0 {
|
||||
-down[i]
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut tr = true_range(o);
|
||||
if let Some(t) = tr.first_mut() {
|
||||
*t = NAN;
|
||||
}
|
||||
let tr = rma(&tr, n);
|
||||
let plus = zip(&rma(&plus_dm, n), &tr, |d, t| 100.0 * ratio(d, t, 0.0));
|
||||
let minus = zip(&rma(&minus_dm, n), &tr, |d, t| 100.0 * ratio(d, t, 0.0));
|
||||
let dx: Vec<f64> = (0..len).map(|i| ratio((plus[i] - minus[i]).abs(), plus[i] + minus[i], 0.0)).collect();
|
||||
let adx = rma(&dx, adx_n).into_iter().map(|v| 100.0 * v).collect();
|
||||
Dmi { plus, minus, adx }
|
||||
}
|
||||
|
||||
/// Aroon up and down: how recently the `n`-bar high and low were set, 0..100.
|
||||
pub fn aroon(o: &Ohlcv, n: usize) -> (Vec<f64>, Vec<f64>) {
|
||||
let k = n as f64;
|
||||
let since = |idx: Vec<usize>| -> Vec<f64> {
|
||||
idx.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, j)| if j == usize::MAX { NAN } else { 100.0 * (k - (i - j) as f64) / k })
|
||||
.collect()
|
||||
};
|
||||
(since(extreme_at(&o.high, n + 1, true)), since(extreme_at(&o.low, n + 1, false)))
|
||||
}
|
||||
|
||||
/// Vortex indicator: +VI and −VI over `n` bars.
|
||||
pub fn vortex(o: &Ohlcv, n: usize) -> (Vec<f64>, Vec<f64>) {
|
||||
let vp: Vec<f64> = (0..o.len()).map(|i| (o.high[i] - ago(&o.low, i, 1)).abs()).collect();
|
||||
let vm: Vec<f64> = (0..o.len()).map(|i| (o.low[i] - ago(&o.high, i, 1)).abs()).collect();
|
||||
let tr = sum(&true_range(o), n);
|
||||
(zip(&sum(&vp, n), &tr, |v, t| ratio(v, t, 0.0)), zip(&sum(&vm, n), &tr, |v, t| ratio(v, t, 0.0)))
|
||||
}
|
||||
|
||||
/// A stop-and-reverse level and, per bar, whether it trails an uptrend (below price).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Trail {
|
||||
pub value: Vec<f64>,
|
||||
pub up: Vec<bool>,
|
||||
}
|
||||
|
||||
impl Trail {
|
||||
fn new(len: usize) -> Self {
|
||||
Trail { value: vec![NAN; len], up: vec![false; len] }
|
||||
}
|
||||
|
||||
/// The level split into its uptrend and downtrend stretches (NaN elsewhere), so each
|
||||
/// side draws as its own broken line.
|
||||
pub fn split(&self) -> (Vec<f64>, Vec<f64>) {
|
||||
let side = |want: bool| -> Vec<f64> {
|
||||
self.value.iter().zip(&self.up).map(|(v, u)| if *u == want { *v } else { NAN }).collect()
|
||||
};
|
||||
(side(true), side(false))
|
||||
}
|
||||
}
|
||||
|
||||
/// Wilder's parabolic SAR with acceleration `start`, step `inc` and cap `max`.
|
||||
pub fn psar(o: &Ohlcv, start: f64, inc: f64, max: f64) -> Trail {
|
||||
let len = o.len();
|
||||
let mut t = Trail::new(len);
|
||||
if len < 2 {
|
||||
return t;
|
||||
}
|
||||
let (h, l, c) = (&o.high, &o.low, &o.close);
|
||||
let mut below = c[1] > c[0];
|
||||
let (mut ext, mut sar) = if below { (h[1], l[0]) } else { (l[1], h[0]) };
|
||||
let mut af = start;
|
||||
for i in 1..len {
|
||||
let mut fresh = i == 1;
|
||||
sar += af * (ext - sar);
|
||||
if below && sar > l[i] {
|
||||
(fresh, below, sar, ext, af) = (true, false, h[i].max(ext), l[i], start);
|
||||
} else if !below && sar < h[i] {
|
||||
(fresh, below, sar, ext, af) = (true, true, l[i].min(ext), h[i], start);
|
||||
}
|
||||
if !fresh && ((below && h[i] > ext) || (!below && l[i] < ext)) {
|
||||
ext = if below { h[i] } else { l[i] };
|
||||
af = (af + inc).min(max);
|
||||
}
|
||||
if below {
|
||||
sar = sar.min(l[i - 1]);
|
||||
if i > 1 {
|
||||
sar = sar.min(l[i - 2]);
|
||||
}
|
||||
} else {
|
||||
sar = sar.max(h[i - 1]);
|
||||
if i > 1 {
|
||||
sar = sar.max(h[i - 2]);
|
||||
}
|
||||
}
|
||||
t.value[i] = sar;
|
||||
t.up[i] = below;
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// SuperTrend: median price ± `factor` × ATR(`n`), ratcheting with the trend.
|
||||
pub fn supertrend(o: &Ohlcv, n: usize, factor: f64) -> Trail {
|
||||
let (a, mid) = (atr(o, n), o.hl2());
|
||||
let mut t = Trail::new(o.len());
|
||||
let (mut lb_prev, mut ub_prev, mut was_up) = (NAN, NAN, None);
|
||||
for i in 0..o.len() {
|
||||
if a[i].is_nan() {
|
||||
continue;
|
||||
}
|
||||
let (mut lb, mut ub) = (mid[i] - factor * a[i], mid[i] + factor * a[i]);
|
||||
let pc = ago(&o.close, i, 1);
|
||||
if !lb_prev.is_nan() && !(lb > lb_prev || pc < lb_prev) {
|
||||
lb = lb_prev;
|
||||
}
|
||||
if !ub_prev.is_nan() && !(ub < ub_prev || pc > ub_prev) {
|
||||
ub = ub_prev;
|
||||
}
|
||||
let up = match was_up {
|
||||
None => false,
|
||||
Some(false) => o.close[i] > ub,
|
||||
Some(true) => o.close[i] >= lb,
|
||||
};
|
||||
t.value[i] = if up { lb } else { ub };
|
||||
t.up[i] = up;
|
||||
(lb_prev, ub_prev, was_up) = (lb, ub, Some(up));
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Volatility stop: a close-based trailing stop `factor` × ATR(`n`) from the extreme close
|
||||
/// of the current swing.
|
||||
pub fn volatility_stop(o: &Ohlcv, n: usize, factor: f64) -> Trail {
|
||||
let (a, tr) = (atr(o, n), true_range(o));
|
||||
let c = &o.close;
|
||||
let mut t = Trail::new(c.len());
|
||||
let Some(&first) = c.first() else { return t };
|
||||
let (mut hi, mut lo, mut up, mut stop) = (first, first, true, 0.0f64);
|
||||
for i in 0..c.len() {
|
||||
let src = c[i];
|
||||
let m = if a[i].is_nan() { tr[i] } else { a[i] * factor };
|
||||
hi = hi.max(src);
|
||||
lo = lo.min(src);
|
||||
stop = if up { stop.max(hi - m) } else { stop.min(lo + m) };
|
||||
if stop.is_nan() {
|
||||
stop = src;
|
||||
}
|
||||
let now_up = src - stop >= 0.0;
|
||||
if now_up != up {
|
||||
(hi, lo) = (src, src);
|
||||
stop = if now_up { hi - m } else { lo + m };
|
||||
}
|
||||
up = now_up;
|
||||
t.value[i] = stop;
|
||||
t.up[i] = up;
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Gann HiLo activator: the SMA of lows under an uptrend, of highs over a downtrend; the
|
||||
/// trend flips when the close crosses the other side's previous value.
|
||||
pub fn gann_hilo(o: &Ohlcv, high_n: usize, low_n: usize) -> Trail {
|
||||
let (sh, sl) = (sma(&o.high, high_n), sma(&o.low, low_n));
|
||||
let mut t = Trail::new(o.len());
|
||||
let mut up = None;
|
||||
for i in 0..o.len() {
|
||||
if o.close[i] > ago(&sh, i, 1) {
|
||||
up = Some(true);
|
||||
} else if o.close[i] < ago(&sl, i, 1) {
|
||||
up = Some(false);
|
||||
}
|
||||
if let Some(u) = up {
|
||||
t.value[i] = if u { sl[i] } else { sh[i] };
|
||||
t.up[i] = u;
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Ichimoku {
|
||||
pub conversion: Vec<f64>,
|
||||
pub base: Vec<f64>,
|
||||
/// Leading spans, displaced into the future (longer than the input).
|
||||
pub span_a: Vec<f64>,
|
||||
pub span_b: Vec<f64>,
|
||||
/// The close displaced into the past.
|
||||
pub lagging: Vec<f64>,
|
||||
}
|
||||
|
||||
/// Ichimoku Kinko Hyo: Tenkan-sen, Kijun-sen, Senkou spans A and B and Chikou span.
|
||||
pub fn ichimoku(o: &Ohlcv, conversion: usize, base: usize, span_b: usize, displacement: usize) -> Ichimoku {
|
||||
let mid = |n| zip(&highest(&o.high, n), &lowest(&o.low, n), |h, l| (h + l) / 2.0);
|
||||
let (conv, base) = (mid(conversion), mid(base));
|
||||
let shift = displacement.saturating_sub(1) as isize;
|
||||
let span_a = displace(&zip(&conv, &base, |a, b| (a + b) / 2.0), shift);
|
||||
let span_b = displace(&mid(span_b), shift);
|
||||
let lagging = displace(&o.close, -shift);
|
||||
Ichimoku { conversion: conv, base, span_a, span_b, lagging }
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Alligator {
|
||||
pub jaw: Vec<f64>,
|
||||
pub teeth: Vec<f64>,
|
||||
pub lips: Vec<f64>,
|
||||
}
|
||||
|
||||
/// Bill Williams' alligator: smoothed (Wilder) median price over `lengths`, each displaced
|
||||
/// forward by its `offsets`.
|
||||
pub fn alligator(o: &Ohlcv, lengths: [usize; 3], offsets: [usize; 3]) -> Alligator {
|
||||
let hl2 = o.hl2();
|
||||
let line = |k: usize| displace(&rma(&hl2, lengths[k]), offsets[k] as isize);
|
||||
Alligator { jaw: line(0), teeth: line(1), lips: line(2) }
|
||||
}
|
||||
|
||||
/// Gator oscillator: |jaw − teeth| above zero and −|teeth − lips| below, at each bar.
|
||||
pub fn gator(o: &Ohlcv, lengths: [usize; 3], offsets: [usize; 3]) -> (Vec<f64>, Vec<f64>) {
|
||||
let a = alligator(o, lengths, offsets);
|
||||
let len = o.len();
|
||||
let upper = zip(&a.jaw[..len], &a.teeth[..len], |j, t| (j - t).abs());
|
||||
let lower = zip(&a.teeth[..len], &a.lips[..len], |t, l| -(t - l).abs());
|
||||
(upper, lower)
|
||||
}
|
||||
|
||||
/// Bill Williams' fractals: bars whose high (low) is strictly beyond the `n` bars on each
|
||||
/// side, marked at that high (low). A fractal is only known `n` bars later.
|
||||
pub fn fractals(o: &Ohlcv, n: usize) -> (Vec<f64>, Vec<f64>) {
|
||||
let len = o.len();
|
||||
let (mut up, mut down) = (vec![NAN; len], vec![NAN; len]);
|
||||
for i in n..len.saturating_sub(n) {
|
||||
let mut around = (i - n..i).chain(i + 1..=i + n);
|
||||
if around.clone().all(|j| o.high[j] < o.high[i]) {
|
||||
up[i] = o.high[i];
|
||||
}
|
||||
if around.all(|j| o.low[j] > o.low[i]) {
|
||||
down[i] = o.low[i];
|
||||
}
|
||||
}
|
||||
(up, down)
|
||||
}
|
||||
|
||||
/// Zigzag through swing highs and lows that reverse by at least `deviation` percent,
|
||||
/// interpolated linearly between pivots. The last leg runs to the latest extreme and
|
||||
/// repaints until the reversal is confirmed.
|
||||
pub fn zigzag(o: &Ohlcv, deviation: f64) -> Vec<f64> {
|
||||
let len = o.len();
|
||||
let mut out = vec![NAN; len];
|
||||
if len == 0 {
|
||||
return out;
|
||||
}
|
||||
let (h, l) = (&o.high, &o.low);
|
||||
let (up_by, down_by) = (1.0 + deviation / 100.0, 1.0 - deviation / 100.0);
|
||||
let mut pivots: Vec<(usize, f64)> = Vec::new();
|
||||
let (mut dir, mut hi, mut lo) = (0i8, 0usize, 0usize);
|
||||
for i in 1..len {
|
||||
match dir {
|
||||
1 if h[i] >= h[hi] => hi = i,
|
||||
1 if l[i] <= h[hi] * down_by => {
|
||||
pivots.push((hi, h[hi]));
|
||||
(dir, lo) = (-1, i);
|
||||
}
|
||||
-1 if l[i] <= l[lo] => lo = i,
|
||||
-1 if h[i] >= l[lo] * up_by => {
|
||||
pivots.push((lo, l[lo]));
|
||||
(dir, hi) = (1, i);
|
||||
}
|
||||
0 => {
|
||||
if h[i] > h[hi] {
|
||||
hi = i;
|
||||
}
|
||||
if l[i] < l[lo] {
|
||||
lo = i;
|
||||
}
|
||||
if hi > lo && h[hi] >= l[lo] * up_by {
|
||||
pivots.push((lo, l[lo]));
|
||||
dir = 1;
|
||||
} else if lo > hi && l[lo] <= h[hi] * down_by {
|
||||
pivots.push((hi, h[hi]));
|
||||
dir = -1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match dir {
|
||||
1 => pivots.push((hi, h[hi])),
|
||||
-1 => pivots.push((lo, l[lo])),
|
||||
_ => {}
|
||||
}
|
||||
for w in pivots.windows(2) {
|
||||
let ((i0, v0), (i1, v1)) = (w[0], w[1]);
|
||||
for (k, slot) in out.iter_mut().enumerate().take(i1 + 1).skip(i0) {
|
||||
*slot = v0 + (v1 - v0) * (k - i0) as f64 / (i1 - i0).max(1) as f64;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Choppiness index: 100 × log10(ΣTR / (highest high − lowest low)) / log10(n). High values
|
||||
/// mean a sideways market, low values a trend.
|
||||
pub fn choppiness(o: &Ohlcv, n: usize) -> Vec<f64> {
|
||||
let tr = sum(&true_range(o), n);
|
||||
let (hh, ll) = (highest(&o.high, n), lowest(&o.low, n));
|
||||
let k = (n as f64).log10();
|
||||
(0..o.len()).map(|i| 100.0 * ratio(tr[i], hh[i] - ll[i], n as f64).log10() / k).collect()
|
||||
}
|
||||
|
||||
/// Vertical horizontal filter: the `n`-bar close range over the sum of bar-to-bar moves.
|
||||
pub fn vhf(values: &[f64], n: usize) -> Vec<f64> {
|
||||
let (hh, ll) = (highest(values, n), lowest(values, n));
|
||||
let path = sum(&change(values, 1).into_iter().map(f64::abs).collect::<Vec<_>>(), n);
|
||||
(0..values.len()).map(|i| ratio(hh[i] - ll[i], path[i], 0.0)).collect()
|
||||
}
|
||||
|
||||
/// Random walk index: the `n`-bar move against the range a random walk would cover.
|
||||
pub fn rwi(o: &Ohlcv, n: usize) -> (Vec<f64>, Vec<f64>) {
|
||||
let a = atr(o, n);
|
||||
let scale = (n as f64).sqrt();
|
||||
let high = (0..o.len()).map(|i| ratio(o.high[i] - ago(&o.low, i, n), a[i] * scale, 0.0)).collect();
|
||||
let low = (0..o.len()).map(|i| ratio(ago(&o.high, i, n) - o.low[i], a[i] * scale, 0.0)).collect();
|
||||
(high, low)
|
||||
}
|
||||
|
||||
/// Trend intensity index: the share of positive deviations from SMA(`ma_n`) over the last
|
||||
/// `n` bars, 0..100.
|
||||
pub fn tii(values: &[f64], ma_n: usize, n: usize) -> Vec<f64> {
|
||||
let dev = zip(values, &sma(values, ma_n), |c, m| c - m);
|
||||
let pos: Vec<f64> = dev.iter().map(|d| if d.is_nan() { NAN } else { d.max(0.0) }).collect();
|
||||
let neg: Vec<f64> = dev.iter().map(|d| if d.is_nan() { NAN } else { (-d).max(0.0) }).collect();
|
||||
zip(&sum(&pos, n), &sum(&neg, n), |p, q| 100.0 * ratio(p, p + q, 0.5))
|
||||
}
|
||||
|
||||
/// Chande Kroll stop: ATR-offset extremes over `p` bars, then their extremes over `q`.
|
||||
/// Returns (stop for shorts, stop for longs).
|
||||
pub fn chande_kroll(o: &Ohlcv, p: usize, x: f64, q: usize) -> (Vec<f64>, Vec<f64>) {
|
||||
let a = atr(o, p);
|
||||
let first_high = zip(&highest(&o.high, p), &a, |h, a| h - x * a);
|
||||
let first_low = zip(&lowest(&o.low, p), &a, |l, a| l + x * a);
|
||||
(highest(&first_high, q), lowest(&first_low, q))
|
||||
}
|
||||
|
||||
/// Chandelier exits: `mult` × ATR(`n`) below the `n`-bar high (longs) and above the low
|
||||
/// (shorts).
|
||||
pub fn chandelier(o: &Ohlcv, n: usize, mult: f64) -> (Vec<f64>, Vec<f64>) {
|
||||
let a = atr(o, n);
|
||||
(zip(&highest(&o.high, n), &a, |h, a| h - mult * a), zip(&lowest(&o.low, n), &a, |l, a| l + mult * a))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Squeeze {
|
||||
/// Linear-regression momentum of the close against its channel midpoint.
|
||||
pub momentum: Vec<f64>,
|
||||
/// Bollinger bands inside the Keltner channels.
|
||||
pub on: Vec<bool>,
|
||||
/// Bollinger bands outside the Keltner channels.
|
||||
pub off: Vec<bool>,
|
||||
}
|
||||
|
||||
/// TTM-style squeeze momentum: Bollinger(`bb_n`, `bb_mult`) against Keltner(`kc_n`,
|
||||
/// `kc_mult`, SMA of true range), with linear-regression momentum.
|
||||
pub fn squeeze(o: &Ohlcv, bb_n: usize, bb_mult: f64, kc_n: usize, kc_mult: f64) -> Squeeze {
|
||||
let bb = bollinger(&o.close, bb_n, bb_mult);
|
||||
let (ma, range) = (sma(&o.close, kc_n), sma(&true_range(o), kc_n));
|
||||
let len = o.len();
|
||||
let (mut on, mut off) = (vec![false; len], vec![false; len]);
|
||||
for i in 0..len {
|
||||
let (ku, kl) = (ma[i] + kc_mult * range[i], ma[i] - kc_mult * range[i]);
|
||||
on[i] = bb.lower[i] > kl && bb.upper[i] < ku;
|
||||
off[i] = bb.lower[i] < kl && bb.upper[i] > ku;
|
||||
}
|
||||
let (hh, ll) = (highest(&o.high, kc_n), lowest(&o.low, kc_n));
|
||||
let rel: Vec<f64> = (0..len).map(|i| o.close[i] - ((hh[i] + ll[i]) / 2.0 + ma[i]) / 2.0).collect();
|
||||
Squeeze { momentum: linreg(&rel, kc_n), on, off }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Bar;
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-9 * (1.0 + b.abs())
|
||||
}
|
||||
|
||||
fn ohlcv(rows: &[(f64, f64, f64, f64)]) -> Ohlcv {
|
||||
let bars: Vec<Bar> =
|
||||
rows.iter().enumerate().map(|(i, r)| Bar::new(i as i64, r.0, r.1, r.2, r.3, 100.0)).collect();
|
||||
Ohlcv::new(&bars)
|
||||
}
|
||||
|
||||
fn line(n: usize, slope: f64) -> Ohlcv {
|
||||
let rows: Vec<(f64, f64, f64, f64)> =
|
||||
(0..n).map(|i| 100.0 + slope * i as f64).map(|c| (c - slope / 2.0, c + 0.5, c - 0.5, c)).collect();
|
||||
ohlcv(&rows)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dmi_in_a_steady_uptrend() {
|
||||
let d = dmi(&line(100, 1.0), 14, 14);
|
||||
assert!(d.plus[13].is_nan() && d.plus[14].is_finite());
|
||||
assert!(d.minus[99] == 0.0 && d.plus[99] > 0.0);
|
||||
assert!(near(d.adx[99], 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aroon_counts_bars_since_extremes() {
|
||||
let o = line(30, 1.0);
|
||||
let (up, down) = aroon(&o, 10);
|
||||
assert!(up[9].is_nan());
|
||||
assert_eq!((up[20], down[20]), (100.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trails_follow_the_trend() {
|
||||
let o = line(80, 1.0);
|
||||
for t in
|
||||
[psar(&o, 0.02, 0.02, 0.2), supertrend(&o, 10, 3.0), volatility_stop(&o, 20, 2.0), gann_hilo(&o, 13, 21)]
|
||||
{
|
||||
assert!(t.up[79], "should trail an uptrend");
|
||||
assert!(t.value[79] < o.low[79]);
|
||||
let (u, d) = t.split();
|
||||
assert!(u[79].is_finite() && d[79].is_nan());
|
||||
}
|
||||
let falling = line(80, -1.0);
|
||||
let st = supertrend(&falling, 10, 3.0);
|
||||
assert!(!st.up[79] && st.value[79] > falling.high[79]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psar_flips_on_reversal() {
|
||||
let mut rows: Vec<(f64, f64, f64, f64)> =
|
||||
(0..20).map(|i| 100.0 + i as f64).map(|c| (c, c + 0.5, c - 0.5, c)).collect();
|
||||
rows.extend((0..20).map(|i| 119.0 - 2.0 * i as f64).map(|c| (c, c + 0.5, c - 0.5, c)));
|
||||
let p = psar(&ohlcv(&rows), 0.02, 0.02, 0.2);
|
||||
assert!(p.up[15] && !p.up[39]);
|
||||
assert!(p.value[39] > rows[39].1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ichimoku_displacement() {
|
||||
let o = line(100, 1.0);
|
||||
let k = ichimoku(&o, 9, 26, 52, 26);
|
||||
assert_eq!(k.span_a.len(), 125);
|
||||
assert!(near(k.span_a[124], (k.conversion[99] + k.base[99]) / 2.0));
|
||||
assert!(near(k.lagging[0], o.close[25]));
|
||||
assert!(k.lagging[99].is_nan());
|
||||
let a = alligator(&o, [13, 8, 5], [8, 5, 3]);
|
||||
assert_eq!(a.jaw.len(), 108);
|
||||
let (up, low) = gator(&o, [13, 8, 5], [8, 5, 3]);
|
||||
assert!(up[99] > 0.0 && low[99] < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractals_and_zigzag_find_swings() {
|
||||
// Up 10%, down 10%, up again.
|
||||
let path = [100.0, 104.0, 110.0, 105.0, 99.0, 103.0, 108.0];
|
||||
let rows: Vec<(f64, f64, f64, f64)> = path.iter().map(|&c| (c, c, c, c)).collect();
|
||||
let o = ohlcv(&rows);
|
||||
let (up, down) = fractals(&o, 2);
|
||||
assert_eq!(up[2], 110.0);
|
||||
assert_eq!(down[4], 99.0);
|
||||
assert!(up[3].is_nan());
|
||||
let z = zigzag(&o, 5.0);
|
||||
assert_eq!((z[0], z[2], z[4], z[6]), (100.0, 110.0, 99.0, 108.0));
|
||||
assert!(near(z[1], 105.0) && near(z[3], 104.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trend_filters() {
|
||||
let o = line(100, 1.0);
|
||||
assert!(near(vhf(&o.close, 28)[99], 27.0 / 28.0));
|
||||
assert!(near(tii(&o.close, 60, 30)[99], 100.0));
|
||||
let c = choppiness(&o, 14)[99];
|
||||
assert!(c > 0.0 && c < 50.0, "trending market is not choppy: {c}");
|
||||
let (hi, lo) = rwi(&o, 14);
|
||||
assert!(hi[99] > lo[99]);
|
||||
let (sp_short, sp_long) = chande_kroll(&o, 10, 1.0, 9);
|
||||
assert!(sp_short[99].is_finite() && sp_long[99].is_finite());
|
||||
let (long_exit, short_exit) = chandelier(&o, 22, 3.0);
|
||||
assert!(near(long_exit[99], o.high[99] - 3.0 * atr(&o, 22)[99]));
|
||||
assert!(near(short_exit[99], lowest(&o.low, 22)[99] + 3.0 * atr(&o, 22)[99]));
|
||||
let s = squeeze(&o, 20, 2.0, 20, 1.5);
|
||||
assert!(s.momentum[99] > 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
//! Ranges, bands and channels, and volatility estimators.
|
||||
|
||||
use crate::Timeframe;
|
||||
|
||||
use super::ma::{ema, rma, sma};
|
||||
use super::momentum::roc;
|
||||
use super::stats::{change, highest, linreg_map, lowest, stdev, sum, variance};
|
||||
use super::{NAN, Ohlcv, ago, ratio, zip};
|
||||
|
||||
/// True range: the bar's range extended to the previous close (high − low on the first bar).
|
||||
pub fn true_range(o: &Ohlcv) -> Vec<f64> {
|
||||
(0..o.len())
|
||||
.map(|i| {
|
||||
let p = ago(&o.close, i, 1);
|
||||
if p.is_nan() { o.high[i] - o.low[i] } else { o.high[i].max(p) - o.low[i].min(p) }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Average true range (Wilder).
|
||||
pub fn atr(o: &Ohlcv, n: usize) -> Vec<f64> {
|
||||
rma(&true_range(o), n)
|
||||
}
|
||||
|
||||
/// ATR as a percentage of the close.
|
||||
pub fn natr(o: &Ohlcv, n: usize) -> Vec<f64> {
|
||||
zip(&atr(o, n), &o.close, |a, c| 100.0 * ratio(a, c, NAN))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Bands {
|
||||
pub upper: Vec<f64>,
|
||||
pub middle: Vec<f64>,
|
||||
pub lower: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Bands {
|
||||
/// `middle ± width`.
|
||||
fn around(middle: Vec<f64>, width: &[f64]) -> Self {
|
||||
let upper = zip(&middle, width, |m, w| m + w);
|
||||
let lower = zip(&middle, width, |m, w| m - w);
|
||||
Bands { upper, middle, lower }
|
||||
}
|
||||
}
|
||||
|
||||
/// Bollinger bands: SMA ± `k` population standard deviations.
|
||||
pub fn bollinger(closes: &[f64], n: usize, k: f64) -> Bands {
|
||||
let sd: Vec<f64> = stdev(closes, n).into_iter().map(|s| k * s).collect();
|
||||
Bands::around(sma(closes, n), &sd)
|
||||
}
|
||||
|
||||
/// Bollinger %B: where the close sits between the bands (0 at the lower, 1 at the upper).
|
||||
pub fn percent_b(closes: &[f64], n: usize, k: f64) -> Vec<f64> {
|
||||
let b = bollinger(closes, n, k);
|
||||
(0..closes.len()).map(|i| ratio(closes[i] - b.lower[i], b.upper[i] - b.lower[i], 0.5)).collect()
|
||||
}
|
||||
|
||||
/// Bollinger bandwidth: band width as a percentage of the middle band.
|
||||
pub fn bandwidth(closes: &[f64], n: usize, k: f64) -> Vec<f64> {
|
||||
let b = bollinger(closes, n, k);
|
||||
(0..closes.len()).map(|i| 100.0 * ratio(b.upper[i] - b.lower[i], b.middle[i], 0.0)).collect()
|
||||
}
|
||||
|
||||
/// Keltner channels: EMA(close, `n`) ± `mult` × ATR(`atr_n`).
|
||||
pub fn keltner(o: &Ohlcv, n: usize, mult: f64, atr_n: usize) -> Bands {
|
||||
let w: Vec<f64> = atr(o, atr_n).into_iter().map(|a| mult * a).collect();
|
||||
Bands::around(ema(&o.close, n), &w)
|
||||
}
|
||||
|
||||
/// Donchian channels: the `n`-bar highest high and lowest low, and their midpoint.
|
||||
pub fn donchian(o: &Ohlcv, n: usize) -> Bands {
|
||||
let (upper, lower) = (highest(&o.high, n), lowest(&o.low, n));
|
||||
let middle = zip(&upper, &lower, |h, l| (h + l) / 2.0);
|
||||
Bands { upper, middle, lower }
|
||||
}
|
||||
|
||||
/// Moving average envelopes: SMA ± `pct` percent.
|
||||
pub fn envelopes(values: &[f64], n: usize, pct: f64) -> Bands {
|
||||
let middle = sma(values, n);
|
||||
let w: Vec<f64> = middle.iter().map(|m| m * pct / 100.0).collect();
|
||||
Bands::around(middle, &w)
|
||||
}
|
||||
|
||||
/// Headley's acceleration bands: SMAs of the high and low pushed out by `factor` times the
|
||||
/// bar's relative range.
|
||||
pub fn acceleration_bands(o: &Ohlcv, n: usize, factor: f64) -> Bands {
|
||||
let rel: Vec<f64> = (0..o.len()).map(|i| ratio(o.high[i] - o.low[i], o.high[i] + o.low[i], 0.0)).collect();
|
||||
let up: Vec<f64> = (0..o.len()).map(|i| o.high[i] * (1.0 + factor * rel[i])).collect();
|
||||
let dn: Vec<f64> = (0..o.len()).map(|i| o.low[i] * (1.0 - factor * rel[i])).collect();
|
||||
Bands { upper: sma(&up, n), middle: sma(&o.close, n), lower: sma(&dn, n) }
|
||||
}
|
||||
|
||||
/// STARC bands: SMA(close, `n`) ± `mult` × ATR(`atr_n`).
|
||||
pub fn starc(o: &Ohlcv, n: usize, atr_n: usize, mult: f64) -> Bands {
|
||||
let w: Vec<f64> = atr(o, atr_n).into_iter().map(|a| mult * a).collect();
|
||||
Bands::around(sma(&o.close, n), &w)
|
||||
}
|
||||
|
||||
/// Standard error bands: the `n`-bar regression value ± `k` standard errors, both smoothed
|
||||
/// with an SMA over `smooth` bars.
|
||||
pub fn standard_error_bands(values: &[f64], n: usize, smooth: usize, k: f64) -> Bands {
|
||||
let mid = sma(&linreg_map(values, n, |f| f.at(n as f64 - 1.0)), smooth);
|
||||
let se: Vec<f64> = sma(&linreg_map(values, n, |f| f.se), smooth).into_iter().map(|s| k * s).collect();
|
||||
Bands::around(mid, &se)
|
||||
}
|
||||
|
||||
/// Bars per year on `tf`, for annualising volatility (US equity hours intraday).
|
||||
pub fn periods_per_year(tf: Timeframe) -> f64 {
|
||||
match tf {
|
||||
Timeframe::D1 => 252.0,
|
||||
Timeframe::W1 => 52.0,
|
||||
Timeframe::MN1 => 12.0,
|
||||
intraday => 252.0 * 23_400.0 / intraday.seconds() as f64,
|
||||
}
|
||||
}
|
||||
|
||||
/// `ln(a / b)` for positive prices, NaN otherwise.
|
||||
fn ln_ratio(a: f64, b: f64) -> f64 {
|
||||
if a > 0.0 && b > 0.0 { (a / b).ln() } else { NAN }
|
||||
}
|
||||
|
||||
/// Annualised percentage volatility from a per-bar variance; rounding can leave tiny
|
||||
/// negative variances, which read as zero. Missing variances stay NaN.
|
||||
fn annualised(variance: f64, ppy: f64) -> f64 {
|
||||
if variance.is_finite() { 100.0 * (variance.max(0.0) * ppy).sqrt() } else { NAN }
|
||||
}
|
||||
|
||||
/// Annualised percentage volatility from a rolling mean of per-bar variance terms.
|
||||
fn annualise(terms: &[f64], n: usize, ppy: f64) -> Vec<f64> {
|
||||
sma(terms, n).into_iter().map(|v| annualised(v, ppy)).collect()
|
||||
}
|
||||
|
||||
/// Historical (close-to-close) volatility: annualised standard deviation of log returns, %.
|
||||
pub fn historical_volatility(values: &[f64], n: usize, ppy: f64) -> Vec<f64> {
|
||||
let r: Vec<f64> = (0..values.len()).map(|i| ln_ratio(values[i], ago(values, i, 1))).collect();
|
||||
stdev(&r, n).into_iter().map(|s| 100.0 * s * ppy.sqrt()).collect()
|
||||
}
|
||||
|
||||
/// Parkinson's high-low volatility estimator, annualised %.
|
||||
pub fn parkinson(o: &Ohlcv, n: usize, ppy: f64) -> Vec<f64> {
|
||||
let k = 1.0 / (4.0 * std::f64::consts::LN_2);
|
||||
let terms: Vec<f64> = (0..o.len()).map(|i| k * ln_ratio(o.high[i], o.low[i]).powi(2)).collect();
|
||||
annualise(&terms, n, ppy)
|
||||
}
|
||||
|
||||
/// Garman-Klass OHLC volatility estimator, annualised %.
|
||||
pub fn garman_klass(o: &Ohlcv, n: usize, ppy: f64) -> Vec<f64> {
|
||||
let c = 2.0 * std::f64::consts::LN_2 - 1.0;
|
||||
let terms: Vec<f64> = (0..o.len())
|
||||
.map(|i| 0.5 * ln_ratio(o.high[i], o.low[i]).powi(2) - c * ln_ratio(o.close[i], o.open[i]).powi(2))
|
||||
.collect();
|
||||
annualise(&terms, n, ppy)
|
||||
}
|
||||
|
||||
/// Per-bar Rogers-Satchell variance term (drift independent).
|
||||
fn rs_terms(o: &Ohlcv) -> Vec<f64> {
|
||||
(0..o.len())
|
||||
.map(|i| {
|
||||
let (h, l, op, c) = (o.high[i], o.low[i], o.open[i], o.close[i]);
|
||||
ln_ratio(h, c) * ln_ratio(h, op) + ln_ratio(l, c) * ln_ratio(l, op)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Rogers-Satchell volatility estimator, annualised %.
|
||||
pub fn rogers_satchell(o: &Ohlcv, n: usize, ppy: f64) -> Vec<f64> {
|
||||
annualise(&rs_terms(o), n, ppy)
|
||||
}
|
||||
|
||||
/// Yang-Zhang volatility: overnight, open-to-close and Rogers-Satchell variances combined
|
||||
/// to minimise estimator variance, annualised %.
|
||||
pub fn yang_zhang(o: &Ohlcv, n: usize, ppy: f64) -> Vec<f64> {
|
||||
let overnight: Vec<f64> = (0..o.len()).map(|i| ln_ratio(o.open[i], ago(&o.close, i, 1))).collect();
|
||||
let intraday: Vec<f64> = (0..o.len()).map(|i| ln_ratio(o.close[i], o.open[i])).collect();
|
||||
let k = n as f64;
|
||||
let sample = if n > 1 { k / (k - 1.0) } else { 1.0 };
|
||||
let w = 0.34 / (1.34 + (k + 1.0) / (k - 1.0).max(1.0));
|
||||
let (vo, vc, rs) = (variance(&overnight, n), variance(&intraday, n), sma(&rs_terms(o), n));
|
||||
(0..o.len()).map(|i| annualised(sample * vo[i] + w * sample * vc[i] + (1.0 - w) * rs[i], ppy)).collect()
|
||||
}
|
||||
|
||||
/// Ulcer index: root mean square of the percentage drawdown from the `n`-bar high.
|
||||
pub fn ulcer(values: &[f64], n: usize) -> Vec<f64> {
|
||||
let hh = highest(values, n);
|
||||
let dd: Vec<f64> = (0..values.len()).map(|i| (100.0 * ratio(values[i] - hh[i], hh[i], 0.0)).powi(2)).collect();
|
||||
sma(&dd, n).into_iter().map(f64::sqrt).collect()
|
||||
}
|
||||
|
||||
/// Chaikin volatility: the `roc_n`-bar rate of change of the EMA of the high-low range.
|
||||
pub fn chaikin_volatility(o: &Ohlcv, n: usize, roc_n: usize) -> Vec<f64> {
|
||||
roc(&ema(&zip(&o.high, &o.low, |h, l| h - l), n), roc_n)
|
||||
}
|
||||
|
||||
/// Dorsey's mass index: the `sum_n`-bar sum of EMA(range) / EMA(EMA(range)).
|
||||
pub fn mass_index(o: &Ohlcv, n: usize, sum_n: usize) -> Vec<f64> {
|
||||
let e1 = ema(&zip(&o.high, &o.low, |h, l| h - l), n);
|
||||
let e2 = ema(&e1, n);
|
||||
sum(&zip(&e1, &e2, |a, b| ratio(a, b, 1.0)), sum_n)
|
||||
}
|
||||
|
||||
/// Dorsey's relative volatility index: an RSI of the `n`-bar standard deviation, split by
|
||||
/// the direction of the close, EMA-smoothed over `smooth`.
|
||||
pub fn relative_volatility_index(values: &[f64], n: usize, smooth: usize) -> Vec<f64> {
|
||||
let sd = stdev(values, n);
|
||||
let ch = change(values, 1);
|
||||
let side = |up: bool| -> Vec<f64> {
|
||||
(0..values.len()).map(|i| if sd[i].is_nan() || (ch[i] > 0.0) == up { sd[i] } else { 0.0 }).collect()
|
||||
};
|
||||
zip(&ema(&side(true), smooth), &ema(&side(false), smooth), |u, d| 100.0 * ratio(u, u + d, 0.5))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Bar;
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-9 * (1.0 + b.abs())
|
||||
}
|
||||
|
||||
fn ohlcv(rows: &[(f64, f64, f64, f64)]) -> Ohlcv {
|
||||
let bars: Vec<Bar> =
|
||||
rows.iter().enumerate().map(|(i, r)| Bar::new(i as i64, r.0, r.1, r.2, r.3, 100.0)).collect();
|
||||
Ohlcv::new(&bars)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn true_range_uses_previous_close() {
|
||||
let o = ohlcv(&[(10.0, 11.0, 9.0, 10.0), (12.0, 13.0, 12.0, 12.5), (9.0, 10.0, 8.0, 9.0)]);
|
||||
assert_eq!(true_range(&o), vec![2.0, 3.0, 4.5]);
|
||||
let a = atr(&o, 2);
|
||||
assert!(a[0].is_nan() && near(a[1], 2.5) && near(a[2], 0.5 * 4.5 + 0.5 * 2.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bands_hand_computed() {
|
||||
let o = ohlcv(&[(1.0, 2.0, 0.0, 1.0), (2.0, 4.0, 1.0, 3.0), (3.0, 3.5, 2.5, 3.0)]);
|
||||
let d = donchian(&o, 2);
|
||||
assert!(near(d.upper[2], 4.0) && near(d.lower[2], 1.0) && near(d.middle[2], 2.5));
|
||||
let e = envelopes(&o.close, 2, 10.0);
|
||||
assert!(near(e.upper[2], 3.3) && near(e.lower[2], 2.7));
|
||||
let b = bollinger(&[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0], 8, 2.0);
|
||||
assert!(near(percent_b(&[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0], 8, 2.0)[7], (9.0 - 1.0) / 8.0));
|
||||
assert!(near(bandwidth(&[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0], 8, 2.0)[7], 800.0 / 5.0));
|
||||
assert!(near(b.middle[7], 5.0));
|
||||
// Flat prices: zero-width bands put %B in the middle.
|
||||
assert_eq!(percent_b(&[3.0; 10], 5, 2.0)[9], 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimators_agree_on_constant_ranges() {
|
||||
// Every bar spans a fixed log range and opens/closes at the extremes alternately:
|
||||
// the range-based estimators then all see the same per-bar variance.
|
||||
let rows: Vec<(f64, f64, f64, f64)> = (0..200)
|
||||
.map(|i| {
|
||||
let lo = 100.0 * (1.0 + 0.001 * (i % 7) as f64);
|
||||
let hi = lo * 1.02;
|
||||
if i % 2 == 0 { (lo, hi, lo, hi) } else { (hi, hi, lo, lo) }
|
||||
})
|
||||
.collect();
|
||||
let o = ohlcv(&rows);
|
||||
let r = (1.02f64).ln();
|
||||
let ppy = 252.0;
|
||||
// Rogers-Satchell is zero when bars open and close at their extremes.
|
||||
assert!(near(rogers_satchell(&o, 20, ppy)[100], 0.0));
|
||||
let p = parkinson(&o, 20, ppy)[100];
|
||||
assert!(near(p, 100.0 * (r * r / (4.0 * std::f64::consts::LN_2) * ppy).sqrt()));
|
||||
let gk = garman_klass(&o, 20, ppy)[100];
|
||||
let c = 2.0 * std::f64::consts::LN_2 - 1.0;
|
||||
assert!(near(gk, 100.0 * ((0.5 * r * r - c * r * r) * ppy).sqrt()));
|
||||
assert!(yang_zhang(&o, 20, ppy)[100].is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimators_are_blank_until_their_window_fills() {
|
||||
let rows: Vec<(f64, f64, f64, f64)> = (0..40).map(|i| (100.0, 101.0 + (i % 3) as f64, 99.0, 100.5)).collect();
|
||||
let o = ohlcv(&rows);
|
||||
for (name, v, first) in [
|
||||
("parkinson", parkinson(&o, 20, 252.0), 19),
|
||||
("garman-klass", garman_klass(&o, 20, 252.0), 19),
|
||||
("rogers-satchell", rogers_satchell(&o, 20, 252.0), 19),
|
||||
("yang-zhang", yang_zhang(&o, 20, 252.0), 20),
|
||||
] {
|
||||
assert!(v[..first].iter().all(|x| x.is_nan()), "{name} reads before its window fills");
|
||||
assert!(v[first..].iter().all(|x| x.is_finite() && *x > 0.0), "{name} is blank after warm-up");
|
||||
}
|
||||
// A bar with a non-positive price has no log range; its windows stay blank.
|
||||
let mut rows = rows;
|
||||
rows[30].2 = 0.0;
|
||||
assert!(parkinson(&ohlcv(&rows), 20, 252.0)[30..].iter().all(|x| x.is_nan()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_volatility_of_constant_growth_is_zero() {
|
||||
let g: Vec<f64> = (0..50).map(|i| 100.0 * 1.01f64.powi(i)).collect();
|
||||
assert!(historical_volatility(&g, 20, 252.0)[49].abs() < 1e-6);
|
||||
assert!(ulcer(&g, 14)[49].abs() < 1e-9);
|
||||
assert_eq!(periods_per_year(Timeframe::H1), 252.0 * 6.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mass_index_of_constant_range() {
|
||||
let rows: Vec<(f64, f64, f64, f64)> =
|
||||
(0..100).map(|i| (i as f64, i as f64 + 2.0, i as f64, i as f64 + 1.0)).collect();
|
||||
assert!(near(mass_index(&ohlcv(&rows), 9, 25)[99], 25.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
//! Volume: cumulative flows, money flow, volume oscillators and session VWAP.
|
||||
|
||||
use crate::Bar;
|
||||
|
||||
use super::ma::{ema, rma, sma};
|
||||
use super::momentum::{Macd, ppo, roc};
|
||||
use super::stats::{change, sum};
|
||||
use super::volatility::Bands;
|
||||
use super::{NAN, Ohlcv, ago, ratio, zip};
|
||||
|
||||
/// VWAP resetting whenever `session_of(ts)` changes; falls back to the typical price
|
||||
/// while the session has no volume.
|
||||
pub fn vwap_by(bars: &[Bar], session_of: impl Fn(i64) -> i64) -> Vec<f64> {
|
||||
let mut out = Vec::with_capacity(bars.len());
|
||||
let (mut pv, mut vol) = (0.0, 0.0);
|
||||
let mut session = None;
|
||||
for b in bars {
|
||||
let s = session_of(b.ts);
|
||||
if session != Some(s) {
|
||||
session = Some(s);
|
||||
pv = 0.0;
|
||||
vol = 0.0;
|
||||
}
|
||||
pv += b.hlc3() * b.volume;
|
||||
vol += b.volume;
|
||||
out.push(if vol > 0.0 { pv / vol } else { b.hlc3() });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Session VWAP resetting each UTC day. For daily and longer bars this
|
||||
/// degenerates to hlc3, so callers typically anchor it by month instead.
|
||||
pub fn vwap(bars: &[Bar]) -> Vec<f64> {
|
||||
vwap_by(bars, |ts| ts.div_euclid(86_400))
|
||||
}
|
||||
|
||||
/// Session VWAP with bands `k` volume-weighted standard deviations of the typical price
|
||||
/// away.
|
||||
pub fn vwap_bands(o: &Ohlcv, session_of: impl Fn(i64) -> i64, k: f64) -> Bands {
|
||||
let tp = o.hlc3();
|
||||
let len = o.len();
|
||||
let (mut middle, mut width) = (vec![NAN; len], vec![NAN; len]);
|
||||
let (mut pv, mut pv2, mut vol, mut session) = (0.0, 0.0, 0.0, None);
|
||||
for i in 0..len {
|
||||
let s = session_of(o.ts[i]);
|
||||
if session != Some(s) {
|
||||
(pv, pv2, vol, session) = (0.0, 0.0, 0.0, Some(s));
|
||||
}
|
||||
let v = o.volume[i];
|
||||
pv += tp[i] * v;
|
||||
pv2 += tp[i] * tp[i] * v;
|
||||
vol += v;
|
||||
if vol > 0.0 {
|
||||
let m = pv / vol;
|
||||
middle[i] = m;
|
||||
width[i] = k * (pv2 / vol - m * m).max(0.0).sqrt();
|
||||
} else {
|
||||
(middle[i], width[i]) = (tp[i], 0.0);
|
||||
}
|
||||
}
|
||||
let upper = zip(&middle, &width, |m, w| m + w);
|
||||
let lower = zip(&middle, &width, |m, w| m - w);
|
||||
Bands { upper, middle, lower }
|
||||
}
|
||||
|
||||
/// Running total of `f(i)` from the first bar.
|
||||
fn cumulative(len: usize, mut f: impl FnMut(usize) -> f64) -> Vec<f64> {
|
||||
let mut acc = 0.0;
|
||||
(0..len)
|
||||
.map(|i| {
|
||||
acc += f(i);
|
||||
acc
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `values[i] - values[i - 1]` with 0 on the first bar.
|
||||
fn step(values: &[f64], i: usize) -> f64 {
|
||||
if i == 0 { 0.0 } else { values[i] - values[i - 1] }
|
||||
}
|
||||
|
||||
/// On-balance volume: volume added on up closes, subtracted on down closes.
|
||||
pub fn obv(o: &Ohlcv) -> Vec<f64> {
|
||||
cumulative(o.len(), |i| {
|
||||
let d = step(&o.close, i);
|
||||
if d > 0.0 {
|
||||
o.volume[i]
|
||||
} else if d < 0.0 {
|
||||
-o.volume[i]
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Close location value × volume: the money flow volume of each bar.
|
||||
fn money_flow_volume(o: &Ohlcv) -> Vec<f64> {
|
||||
(0..o.len())
|
||||
.map(|i| {
|
||||
let (h, l, c) = (o.high[i], o.low[i], o.close[i]);
|
||||
ratio((c - l) - (h - c), h - l, 0.0) * o.volume[i]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Chaikin's accumulation/distribution line.
|
||||
pub fn adl(o: &Ohlcv) -> Vec<f64> {
|
||||
let mfv = money_flow_volume(o);
|
||||
cumulative(o.len(), |i| mfv[i])
|
||||
}
|
||||
|
||||
/// Chaikin money flow: money flow volume over volume across `n` bars, −1..1.
|
||||
pub fn cmf(o: &Ohlcv, n: usize) -> Vec<f64> {
|
||||
zip(&sum(&money_flow_volume(o), n), &sum(&o.volume, n), |m, v| ratio(m, v, 0.0))
|
||||
}
|
||||
|
||||
/// Chaikin oscillator: EMA(ADL, `fast`) − EMA(ADL, `slow`).
|
||||
pub fn chaikin_osc(o: &Ohlcv, fast: usize, slow: usize) -> Vec<f64> {
|
||||
let a = adl(o);
|
||||
zip(&ema(&a, fast), &ema(&a, slow), |f, s| f - s)
|
||||
}
|
||||
|
||||
/// Money flow index: a volume-weighted RSI of the typical price, 0..100.
|
||||
pub fn mfi(o: &Ohlcv, n: usize) -> Vec<f64> {
|
||||
let tp = o.hlc3();
|
||||
let flow = |up: bool| -> Vec<f64> {
|
||||
(0..o.len())
|
||||
.map(|i| {
|
||||
let d = tp[i] - ago(&tp, i, 1);
|
||||
if d.is_nan() {
|
||||
NAN
|
||||
} else if (up && d > 0.0) || (!up && d < 0.0) {
|
||||
tp[i] * o.volume[i]
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
zip(&sum(&flow(true), n), &sum(&flow(false), n), |p, q| 100.0 * ratio(p, p + q, 0.5))
|
||||
}
|
||||
|
||||
/// Elder's force index: EMA of the close change × volume.
|
||||
pub fn force_index(o: &Ohlcv, n: usize) -> Vec<f64> {
|
||||
ema(&zip(&change(&o.close, 1), &o.volume, |d, v| d * v), n)
|
||||
}
|
||||
|
||||
/// Arms' ease of movement: SMA of the median-price move per unit of volume, scaled by
|
||||
/// `divisor`. Bars without volume contribute nothing.
|
||||
pub fn ease_of_movement(o: &Ohlcv, n: usize, divisor: f64) -> Vec<f64> {
|
||||
let hl2 = o.hl2();
|
||||
let raw: Vec<f64> = (0..o.len())
|
||||
.map(|i| ratio(divisor * (hl2[i] - ago(&hl2, i, 1)) * (o.high[i] - o.low[i]), o.volume[i], 0.0))
|
||||
.collect();
|
||||
sma(&raw, n)
|
||||
}
|
||||
|
||||
/// Price volume trend: cumulative volume × percentage close change.
|
||||
pub fn pvt(o: &Ohlcv) -> Vec<f64> {
|
||||
cumulative(o.len(), |i| if i == 0 { 0.0 } else { ratio(step(&o.close, i), o.close[i - 1], 0.0) * o.volume[i] })
|
||||
}
|
||||
|
||||
/// A volume index starting at 1000 that compounds the close's percentage change only on
|
||||
/// bars where `pick(volume, previous volume)` holds.
|
||||
fn volume_index(o: &Ohlcv, pick: impl Fn(f64, f64) -> bool) -> Vec<f64> {
|
||||
let mut v = 1000.0;
|
||||
(0..o.len())
|
||||
.map(|i| {
|
||||
if i > 0 && pick(o.volume[i], o.volume[i - 1]) {
|
||||
v *= 1.0 + ratio(step(&o.close, i), o.close[i - 1], 0.0);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Negative volume index (moves on volume declines) with an EMA signal.
|
||||
pub fn nvi(o: &Ohlcv, signal: usize) -> (Vec<f64>, Vec<f64>) {
|
||||
let line = volume_index(o, |v, p| v < p);
|
||||
let sig = ema(&line, signal);
|
||||
(line, sig)
|
||||
}
|
||||
|
||||
/// Positive volume index (moves on volume increases) with an EMA signal.
|
||||
pub fn pvi(o: &Ohlcv, signal: usize) -> (Vec<f64>, Vec<f64>) {
|
||||
let line = volume_index(o, |v, p| v > p);
|
||||
let sig = ema(&line, signal);
|
||||
(line, sig)
|
||||
}
|
||||
|
||||
/// Klinger volume oscillator (simplified): EMA difference of volume signed by the
|
||||
/// typical-price trend, with an EMA signal.
|
||||
pub fn klinger(o: &Ohlcv, fast: usize, slow: usize, signal: usize) -> Macd {
|
||||
let tp = o.hlc3();
|
||||
let sv: Vec<f64> = (0..o.len())
|
||||
.map(|i| {
|
||||
let d = tp[i] - ago(&tp, i, 1);
|
||||
if d.is_nan() {
|
||||
NAN
|
||||
} else if d >= 0.0 {
|
||||
o.volume[i]
|
||||
} else {
|
||||
-o.volume[i]
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let line = zip(&ema(&sv, fast), &ema(&sv, slow), |f, s| f - s);
|
||||
let signal = ema(&line, signal);
|
||||
let histogram = zip(&line, &signal, |a, b| a - b);
|
||||
Macd { macd: line, signal, histogram }
|
||||
}
|
||||
|
||||
/// Volume oscillator: percentage difference of fast and slow volume EMAs.
|
||||
pub fn volume_osc(o: &Ohlcv, fast: usize, slow: usize) -> Vec<f64> {
|
||||
zip(&ema(&o.volume, fast), &ema(&o.volume, slow), |f, s| 100.0 * ratio(f - s, s, 0.0))
|
||||
}
|
||||
|
||||
/// Percentage volume oscillator: PPO of volume, with signal.
|
||||
pub fn pvo(o: &Ohlcv, fast: usize, slow: usize, signal: usize) -> Macd {
|
||||
ppo(&o.volume, fast, slow, signal)
|
||||
}
|
||||
|
||||
/// Volume rate of change in percent over `n` bars.
|
||||
pub fn vroc(o: &Ohlcv, n: usize) -> Vec<f64> {
|
||||
roc(&o.volume, n)
|
||||
}
|
||||
|
||||
/// Relative volume: this bar's volume over the average of the previous `n` bars.
|
||||
pub fn relative_volume(o: &Ohlcv, n: usize) -> Vec<f64> {
|
||||
let avg = sma(&o.volume, n);
|
||||
(0..o.len()).map(|i| ratio(o.volume[i], ago(&avg, i, 1), NAN)).collect()
|
||||
}
|
||||
|
||||
/// Twiggs money flow: Wilder-smoothed true-range money flow volume over smoothed volume.
|
||||
pub fn twiggs_mf(o: &Ohlcv, n: usize) -> Vec<f64> {
|
||||
let adv: Vec<f64> = (0..o.len())
|
||||
.map(|i| {
|
||||
let p = ago(&o.close, i, 1);
|
||||
let (th, tl) = (o.high[i].max(p), o.low[i].min(p));
|
||||
ratio((o.close[i] - tl) - (th - o.close[i]), th - tl, 0.0) * o.volume[i]
|
||||
})
|
||||
.collect();
|
||||
zip(&rma(&adv, n), &rma(&o.volume, n), |a, v| ratio(a, v, 0.0))
|
||||
}
|
||||
|
||||
/// Williams accumulation/distribution: cumulative close moves measured from the true range
|
||||
/// extreme on the opposite side.
|
||||
pub fn williams_ad(o: &Ohlcv) -> Vec<f64> {
|
||||
cumulative(o.len(), |i| {
|
||||
if i == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let (p, c) = (o.close[i - 1], o.close[i]);
|
||||
if c > p {
|
||||
c - o.low[i].min(p)
|
||||
} else if c < p {
|
||||
c - o.high[i].max(p)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Wilder's accumulative swing index with limit move `limit`.
|
||||
pub fn asi(o: &Ohlcv, limit: f64) -> Vec<f64> {
|
||||
cumulative(o.len(), |i| {
|
||||
if i == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let (h, l, c, op) = (o.high[i], o.low[i], o.close[i], o.open[i]);
|
||||
let (pc, po) = (o.close[i - 1], o.open[i - 1]);
|
||||
let (a, b, cc, d) = ((h - pc).abs(), (l - pc).abs(), h - l, (pc - po).abs());
|
||||
let r = if a >= b && a >= cc {
|
||||
a - 0.5 * b + 0.25 * d
|
||||
} else if b >= a && b >= cc {
|
||||
b - 0.5 * a + 0.25 * d
|
||||
} else {
|
||||
cc + 0.25 * d
|
||||
};
|
||||
let k = a.max(b);
|
||||
ratio(50.0 * (c - pc + 0.5 * (c - op) + 0.25 * (pc - po)) * k, r * limit, 0.0)
|
||||
})
|
||||
}
|
||||
|
||||
/// Bill Williams' market facilitation index: range per unit of volume.
|
||||
pub fn bw_mfi(o: &Ohlcv) -> Vec<f64> {
|
||||
(0..o.len()).map(|i| ratio(o.high[i] - o.low[i], o.volume[i], 0.0)).collect()
|
||||
}
|
||||
|
||||
/// Net volume: volume signed by the close's direction.
|
||||
pub fn net_volume(o: &Ohlcv) -> Vec<f64> {
|
||||
(0..o.len())
|
||||
.map(|i| {
|
||||
let d = step(&o.close, i);
|
||||
if d > 0.0 {
|
||||
o.volume[i]
|
||||
} else if d < 0.0 {
|
||||
-o.volume[i]
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Volume zone oscillator: EMA of direction-signed volume over EMA of volume, −100..100.
|
||||
pub fn vzo(o: &Ohlcv, n: usize) -> Vec<f64> {
|
||||
zip(&ema(&net_volume(o), n), &ema(&o.volume, n), |s, v| 100.0 * ratio(s, v, 0.0))
|
||||
}
|
||||
|
||||
/// Time segmented volume: the `n`-bar sum of close change × volume.
|
||||
pub fn tsv(o: &Ohlcv, n: usize) -> Vec<f64> {
|
||||
sum(&zip(&change(&o.close, 1), &o.volume, |d, v| d * v), n)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-9 * (1.0 + b.abs())
|
||||
}
|
||||
|
||||
fn bars(rows: &[(f64, f64, f64, f64, f64)]) -> Ohlcv {
|
||||
let b: Vec<Bar> =
|
||||
rows.iter().enumerate().map(|(i, r)| Bar::new(i as i64 * 60, r.0, r.1, r.2, r.3, r.4)).collect();
|
||||
Ohlcv::new(&b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cumulative_flows_hand_computed() {
|
||||
let o = bars(&[
|
||||
(10.0, 12.0, 8.0, 11.0, 100.0),
|
||||
(11.0, 13.0, 10.0, 10.0, 200.0),
|
||||
(10.0, 11.0, 9.0, 10.0, 50.0),
|
||||
(10.0, 12.0, 10.0, 12.0, 300.0),
|
||||
]);
|
||||
assert_eq!(obv(&o), vec![0.0, -200.0, -200.0, 100.0]);
|
||||
// Close location: (11−8 − (12−11))/4 = 0.5, (0 − 3)/3 = −1, 0, 1.
|
||||
assert_eq!(adl(&o), vec![50.0, -150.0, -150.0, 150.0]);
|
||||
assert!(near(cmf(&o, 4)[3], 150.0 / 650.0));
|
||||
assert!(near(pvt(&o)[1], -200.0 / 11.0));
|
||||
assert_eq!(net_volume(&o), vec![0.0, -200.0, 0.0, 300.0]);
|
||||
assert_eq!(williams_ad(&o), vec![0.0, 0.0 + (10.0 - 13.0), -3.0, -3.0 + (12.0 - 10.0)]);
|
||||
let (n, _) = nvi(&o, 3);
|
||||
let (p, _) = pvi(&o, 3);
|
||||
assert!(near(n[2], 1000.0) && near(p[1], 1000.0 * (1.0 - 1.0 / 11.0)));
|
||||
assert!(near(n[3], 1000.0) && near(p[3], p[1] * 1.2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn money_flow_index_bounds() {
|
||||
let rising: Vec<(f64, f64, f64, f64, f64)> =
|
||||
(0..30).map(|i| i as f64 + 10.0).map(|c| (c, c + 1.0, c - 1.0, c, 1000.0)).collect();
|
||||
assert_eq!(mfi(&bars(&rising), 14)[29], 100.0);
|
||||
let flat: Vec<(f64, f64, f64, f64, f64)> = (0..30).map(|_| (5.0, 5.0, 5.0, 5.0, 0.0)).collect();
|
||||
let o = bars(&flat);
|
||||
assert_eq!(mfi(&o, 14)[29], 50.0);
|
||||
assert_eq!(ease_of_movement(&o, 14, 10_000.0)[29], 0.0);
|
||||
assert_eq!(cmf(&o, 20)[29], 0.0);
|
||||
assert!(relative_volume(&o, 20)[29].is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vwap_bands_weight_by_volume() {
|
||||
let raw = [Bar::new(0, 0.0, 10.0, 10.0, 10.0, 1.0), Bar::new(60, 0.0, 20.0, 20.0, 20.0, 3.0)];
|
||||
let b = vwap_bands(&Ohlcv::new(&raw), |_| 0, 1.0);
|
||||
// Mean 17.5; variance (1·56.25 + 3·6.25) / 4 = 18.75.
|
||||
assert!(near(b.middle[1], 17.5));
|
||||
assert!(near(b.upper[1] - b.middle[1], 18.75f64.sqrt()));
|
||||
assert_eq!(b.upper[0], b.lower[0]);
|
||||
assert!(near(vwap_by(&raw, |_| 0)[1], b.middle[1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oscillators_relate_to_their_parts() {
|
||||
let rows: Vec<(f64, f64, f64, f64, f64)> = (0..120)
|
||||
.map(|i| {
|
||||
let c = 50.0 + (i as f64 * 0.2).sin() * 4.0;
|
||||
(c - 0.2, c + 0.6, c - 0.7, c, 1000.0 + (i % 5) as f64 * 150.0)
|
||||
})
|
||||
.collect();
|
||||
let o = bars(&rows);
|
||||
let v = &o.volume;
|
||||
assert!(near(volume_osc(&o, 5, 10)[60], 100.0 * (ema(v, 5)[60] - ema(v, 10)[60]) / ema(v, 10)[60]));
|
||||
assert!(near(relative_volume(&o, 20)[60], v[60] / sma(v, 20)[59]));
|
||||
let z = vzo(&o, 14);
|
||||
assert!(z.iter().filter(|x| x.is_finite()).all(|x| x.abs() <= 100.0));
|
||||
let k = klinger(&o, 34, 55, 13);
|
||||
assert!(near(k.histogram[100], k.macd[100] - k.signal[100]));
|
||||
assert!(twiggs_mf(&o, 21)[100].abs() <= 1.0);
|
||||
assert!(asi(&o, 10_000.0)[119].is_finite());
|
||||
assert!(near(tsv(&o, 13)[50], (38..=50).map(|i| (o.close[i] - o.close[i - 1]) * v[i]).sum::<f64>()));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
//! Core market types shared across terminal-rs: bars, timeframes, securities and
|
||||
//! Core market types shared across Terminal: bars, timeframes, securities and
|
||||
//! market keys, the security catalog, technical indicators and sample data.
|
||||
|
||||
pub mod bar;
|
||||
pub mod catalog;
|
||||
pub mod indicators;
|
||||
pub mod options;
|
||||
pub mod perps;
|
||||
pub mod sample;
|
||||
pub mod security;
|
||||
pub mod timeframe;
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
//! Hyperliquid HIP-3 perpetuals, such as `xyz:AAPL`: their market keys, the venue label every
|
||||
//! surface shows with them, and the reviewed table that links an instrument the app already
|
||||
//! has (`AAPL US Equity`) to the perp on the same underlying.
|
||||
//!
|
||||
//! A perp is its own instrument. `AAPL US Equity` stays the listed stock, with its own quotes
|
||||
//! and chart; the table only lets that view point at `xyz:AAPL`. Which perps exist, and which
|
||||
//! are live, comes from the Terminal server at run time, so a row here never makes a perp
|
||||
//! appear by itself.
|
||||
//!
|
||||
//! A row needs all of these (docs/architecture/server.md §7.3):
|
||||
//! - the venue's own docs say the perp references the underlying the app key means;
|
||||
//! - the perp is live (not `isDelisted`) on Hyperliquid;
|
||||
//! - the `xyz` dex, unless xyz has no market on that underlying and another dex's match is
|
||||
//! unambiguous;
|
||||
//! - its price tracks the app instrument's.
|
||||
|
||||
/// A perp linked to an app instrument with the same underlying.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PerpLink {
|
||||
/// The perp's market key, as Hyperliquid names the coin.
|
||||
pub coin: &'static str,
|
||||
/// The app market key with the same underlying.
|
||||
pub underlying: &'static str,
|
||||
/// Display name.
|
||||
pub name: &'static str,
|
||||
/// What the perp references, in the venue's words.
|
||||
pub reference: &'static str,
|
||||
}
|
||||
|
||||
const fn link(coin: &'static str, underlying: &'static str, name: &'static str, reference: &'static str) -> PerpLink {
|
||||
PerpLink { coin, underlying, name, reference }
|
||||
}
|
||||
|
||||
/// Checked on 2026-11-02 against trade.xyz's specification index, Hyperliquid's `meta` (all
|
||||
/// live) and the app instrument's price (within 0.6%; oil differs by its contract month).
|
||||
///
|
||||
/// Left out on purpose: `xyz:XYZ100` and `xyz:JP225` track the Nasdaq-100 and the Nikkei 225
|
||||
/// closely, but the venue's docs name neither index. `xyz:HO` tracks HO1, but the docs describe
|
||||
/// the diesel market under another name and never tie it to `HO`. `xyz:KR200` is the KOSPI 200,
|
||||
/// which the app doesn't have.
|
||||
pub const LINKS: &[PerpLink] = &[
|
||||
link("xyz:AAPL", "AAPL US Equity", "Apple Inc perp", "1 share of Apple Inc. common stock (Nasdaq: AAPL)"),
|
||||
link(
|
||||
"xyz:MSFT",
|
||||
"MSFT US Equity",
|
||||
"Microsoft Corp perp",
|
||||
"1 share of Microsoft Corporation common stock (Nasdaq: MSFT)",
|
||||
),
|
||||
link("xyz:NVDA", "NVDA US Equity", "NVIDIA Corp perp", "1 share of NVIDIA Corporation common stock (Nasdaq: NVDA)"),
|
||||
link(
|
||||
"xyz:AMZN",
|
||||
"AMZN US Equity",
|
||||
"Amazon.com Inc perp",
|
||||
"1 share of Amazon.com, Inc. common stock (Nasdaq: AMZN)",
|
||||
),
|
||||
link(
|
||||
"xyz:GOOGL",
|
||||
"GOOGL US Equity",
|
||||
"Alphabet Inc Class A perp",
|
||||
"1 share of Alphabet Inc. Class A common stock (Nasdaq: GOOGL)",
|
||||
),
|
||||
link(
|
||||
"xyz:META",
|
||||
"META US Equity",
|
||||
"Meta Platforms Inc perp",
|
||||
"1 share of Meta Platforms, Inc. Class A common stock (Nasdaq: META)",
|
||||
),
|
||||
link("xyz:TSLA", "TSLA US Equity", "Tesla Inc perp", "1 share of Tesla, Inc. common stock (Nasdaq: TSLA)"),
|
||||
link(
|
||||
"xyz:LLY",
|
||||
"LLY US Equity",
|
||||
"Eli Lilly & Co perp",
|
||||
"1 share of Eli Lilly and Company common stock (NYSE: LLY)",
|
||||
),
|
||||
link("xyz:AVGO", "AVGO US Equity", "Broadcom Inc perp", "1 share of Broadcom Inc. common stock (Nasdaq: AVGO)"),
|
||||
link(
|
||||
"xyz:AMD",
|
||||
"AMD US Equity",
|
||||
"Advanced Micro Devices perp",
|
||||
"1 share of Advanced Micro Devices, Inc. common stock (Nasdaq: AMD)",
|
||||
),
|
||||
link("xyz:NFLX", "NFLX US Equity", "Netflix Inc perp", "1 share of Netflix, Inc. common stock (Nasdaq: NFLX)"),
|
||||
link(
|
||||
"xyz:COIN",
|
||||
"COIN US Equity",
|
||||
"Coinbase Global Inc perp",
|
||||
"1 share of Coinbase Global, Inc. Class A common stock (Nasdaq: COIN)",
|
||||
),
|
||||
link(
|
||||
"xyz:MSTR",
|
||||
"MSTR US Equity",
|
||||
"Strategy Inc perp",
|
||||
"1 share of Strategy Inc Class A common stock (Nasdaq: MSTR)",
|
||||
),
|
||||
link(
|
||||
"xyz:TLT",
|
||||
"TLT US Equity",
|
||||
"iShares 20+ Year Treasury Bond ETF perp",
|
||||
"1 share of the iShares 20+ Year Treasury Bond ETF (Nasdaq: TLT)",
|
||||
),
|
||||
link(
|
||||
"xyz:SMH",
|
||||
"SMH US Equity",
|
||||
"VanEck Semiconductor ETF perp",
|
||||
"1 share of the VanEck Semiconductor ETF (Nasdaq: SMH)",
|
||||
),
|
||||
link(
|
||||
"xyz:XLE",
|
||||
"XLE US Equity",
|
||||
"Energy Select Sector SPDR perp",
|
||||
"1 share of the Energy Select Sector SPDR ETF (NYSE Arca: XLE)",
|
||||
),
|
||||
link("xyz:SP500", "SPX Index", "S&P 500 perp", "the S&P 500 Index"),
|
||||
link(
|
||||
"xyz:CL",
|
||||
"CL1 Comdty",
|
||||
"WTI crude oil perp",
|
||||
"1 barrel of WTI Light Sweet Crude Oil, from a futures contract it rolls monthly",
|
||||
),
|
||||
link(
|
||||
"xyz:BRENTOIL",
|
||||
"CO1 Comdty",
|
||||
"Brent crude oil perp",
|
||||
"1 barrel of Brent crude oil, from a futures contract it rolls monthly",
|
||||
),
|
||||
link(
|
||||
"xyz:NATGAS",
|
||||
"NG1 Comdty",
|
||||
"Natural gas perp",
|
||||
"1 MMBtu of Henry Hub natural gas, from a futures contract it rolls monthly",
|
||||
),
|
||||
link(
|
||||
"xyz:COPPER",
|
||||
"HG1 Comdty",
|
||||
"Copper perp",
|
||||
"1 pound of high-grade copper, from a futures contract it rolls monthly",
|
||||
),
|
||||
link("xyz:GOLD", "GC1 Comdty", "Gold perp", "the spot price of 1 troy ounce of gold (XAU/USD), not the future"),
|
||||
link(
|
||||
"xyz:SILVER",
|
||||
"SI1 Comdty",
|
||||
"Silver perp",
|
||||
"the spot price of 1 troy ounce of silver (XAG/USD), not the future",
|
||||
),
|
||||
link(
|
||||
"xyz:PLATINUM",
|
||||
"PL1 Comdty",
|
||||
"Platinum perp",
|
||||
"the spot price of 1 troy ounce of platinum (XPT/USD), not the future",
|
||||
),
|
||||
link("xyz:EUR", "EURUSD Curncy", "EUR/USD perp", "the EUR/USD spot rate, US dollars per euro"),
|
||||
link("xyz:GBP", "GBPUSD Curncy", "GBP/USD perp", "the GBP/USD spot rate, US dollars per pound"),
|
||||
link("xyz:JPY", "USDJPY Curncy", "USD/JPY perp", "the USD/JPY spot rate, yen per US dollar"),
|
||||
];
|
||||
|
||||
/// The canonical key for a perp written in any case: `XYZ:aapl` is `xyz:AAPL`. `None` unless
|
||||
/// it looks like `{dex}:{coin}`: a lowercase-able dex name of letters, then letters and digits.
|
||||
pub fn normalize(input: &str) -> Option<String> {
|
||||
let (dex, coin) = input.trim().split_once(':')?;
|
||||
let ok = |s: &str, digits: bool| {
|
||||
!s.is_empty() && s.len() <= 16 && s.chars().all(|c| c.is_ascii_alphabetic() || (digits && c.is_ascii_digit()))
|
||||
};
|
||||
(ok(dex, false) && ok(coin, true)).then(|| format!("{}:{}", dex.to_ascii_lowercase(), coin.to_ascii_uppercase()))
|
||||
}
|
||||
|
||||
/// Whether `key` names a HIP-3 perp.
|
||||
pub fn is_perp(key: &str) -> bool {
|
||||
normalize(key).is_some()
|
||||
}
|
||||
|
||||
/// The dex a perp trades on: `xyz` for `xyz:AAPL`.
|
||||
pub fn venue(key: &str) -> Option<&str> {
|
||||
key.split_once(':').map(|(dex, _)| dex).filter(|_| is_perp(key))
|
||||
}
|
||||
|
||||
/// The label every surface shows next to a perp, so it never passes for the listed
|
||||
/// instrument: `PERP · HL xyz`.
|
||||
pub fn badge(key: &str) -> String {
|
||||
match venue(key) {
|
||||
Some(dex) => format!("PERP · HL {}", dex.to_ascii_lowercase()),
|
||||
None => "PERP · HL".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The perp linked to an app market key.
|
||||
pub fn perp_for(key: &str) -> Option<&'static PerpLink> {
|
||||
LINKS.iter().find(|l| l.underlying == key)
|
||||
}
|
||||
|
||||
/// The link for a perp, by its key in any case.
|
||||
pub fn link_for(key: &str) -> Option<&'static PerpLink> {
|
||||
let k = normalize(key)?;
|
||||
LINKS.iter().find(|l| l.coin == k)
|
||||
}
|
||||
|
||||
/// The perps among `keys` that match every word of `query` in their key, coin or linked name,
|
||||
/// best first. `PERP` in the query matches every perp; a yellow key (`GOLD CMDTY`) asks for
|
||||
/// listed instruments, so it matches none.
|
||||
pub fn search<'a>(query: &str, keys: impl IntoIterator<Item = &'a str>, limit: usize) -> Vec<&'a str> {
|
||||
let words: Vec<String> = query.split_whitespace().map(str::to_ascii_uppercase).collect();
|
||||
if words.is_empty() || words.iter().any(|w| crate::YellowKey::parse(w).is_some()) {
|
||||
return Vec::new();
|
||||
}
|
||||
let words: Vec<&String> = words.iter().filter(|w| !matches!(w.as_str(), "PERP" | "PERPS")).collect();
|
||||
let mut scored: Vec<(i32, &'a str)> = keys
|
||||
.into_iter()
|
||||
.filter_map(|key| {
|
||||
let up = key.to_ascii_uppercase();
|
||||
let coin = up.split_once(':').map_or(up.as_str(), |(_, c)| c);
|
||||
let name = link_for(key).map(|l| l.name.to_ascii_uppercase()).unwrap_or_default();
|
||||
let mut score = 1;
|
||||
for w in &words {
|
||||
score += if coin == w.as_str() {
|
||||
1000
|
||||
} else if coin.starts_with(w.as_str()) {
|
||||
600 - 10 * (coin.len() - w.len()) as i32
|
||||
} else if up.starts_with(w.as_str()) {
|
||||
500
|
||||
} else if name.split(|c: char| c.is_whitespace() || c == '/').any(|n| n.starts_with(w.as_str())) {
|
||||
300
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
}
|
||||
Some((score, key))
|
||||
})
|
||||
.collect();
|
||||
scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(b.1)));
|
||||
scored.into_iter().take(limit).map(|(_, k)| k).collect()
|
||||
}
|
||||
|
||||
/// Where `PERP` takes a pane on `key`: from a linked perp to its instrument, or from an
|
||||
/// instrument to its perp while `live` says the server lists that perp.
|
||||
pub fn switch_target(key: &str, live: impl Fn(&str) -> bool) -> Option<&'static str> {
|
||||
match link_for(key) {
|
||||
Some(l) => Some(l.underlying),
|
||||
None => perp_for(key).map(|l| l.coin).filter(|c| live(c)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The other side of a link: an instrument's perp, or a perp's instrument.
|
||||
pub fn counterpart(key: &str) -> Option<&'static str> {
|
||||
match link_for(key) {
|
||||
Some(l) => Some(l.underlying),
|
||||
None => perp_for(key).map(|l| l.coin),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Security, catalog};
|
||||
|
||||
#[test]
|
||||
fn keys_normalise_and_label_their_venue() {
|
||||
assert_eq!(normalize("XYZ:aapl").as_deref(), Some("xyz:AAPL"));
|
||||
assert_eq!(normalize(" xyz:XYZ100 ").as_deref(), Some("xyz:XYZ100"));
|
||||
assert_eq!(normalize("para:10Y").as_deref(), Some("para:10Y"));
|
||||
for not in ["AAPL", "xyz:", ":AAPL", "x1:AAPL", "xyz:AA-PL", "xyz:AAPL:B", "AAPL US Equity"] {
|
||||
assert_eq!(normalize(not), None, "{not}");
|
||||
}
|
||||
assert_eq!(venue("xyz:AAPL"), Some("xyz"));
|
||||
assert_eq!(venue("BTC"), None);
|
||||
assert_eq!(badge("xyz:AAPL"), "PERP · HL xyz");
|
||||
assert_eq!(badge("para:10Y"), "PERP · HL para");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_point_at_app_instruments_and_back() {
|
||||
assert_eq!(perp_for("AAPL US Equity").map(|l| l.coin), Some("xyz:AAPL"));
|
||||
assert_eq!(perp_for("SPX Index").map(|l| l.coin), Some("xyz:SP500"));
|
||||
assert_eq!(perp_for("USDJPY Curncy").map(|l| l.coin), Some("xyz:JPY"));
|
||||
assert_eq!(perp_for("CL1 Comdty").map(|l| l.coin), Some("xyz:CL"));
|
||||
assert_eq!(link_for("XYZ:eur").map(|l| l.underlying), Some("EURUSD Curncy"));
|
||||
// Not linked: the docs don't name these indices, or the app lacks the instrument.
|
||||
for unlinked in ["NDX Index", "NKY Index", "HO1 Comdty", "KOSPI Index", "JPM US Equity", "BTCUSD Curncy"] {
|
||||
assert_eq!(perp_for(unlinked), None, "{unlinked}");
|
||||
}
|
||||
assert_eq!(link_for("xyz:XYZ100"), None);
|
||||
assert_eq!(counterpart("AAPL US Equity"), Some("xyz:AAPL"));
|
||||
assert_eq!(counterpart("xyz:aapl"), Some("AAPL US Equity"));
|
||||
assert_eq!(counterpart("xyz:SKHX"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_follows_links_to_live_perps_only() {
|
||||
let live = |c: &str| c == "xyz:AAPL";
|
||||
assert_eq!(switch_target("AAPL US Equity", live), Some("xyz:AAPL"));
|
||||
assert_eq!(switch_target("GC1 Comdty", live), None, "xyz:GOLD isn't listed live here");
|
||||
assert_eq!(switch_target("xyz:GOLD", live), Some("GC1 Comdty"), "back to the instrument always works");
|
||||
assert_eq!(switch_target("XYZ:aapl", |_| false), Some("AAPL US Equity"));
|
||||
assert_eq!(switch_target("xyz:SKHX", |_| true), None, "unlinked");
|
||||
assert_eq!(switch_target("NDX Index", |_| true), None, "no link");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_finds_perps_by_coin_key_or_name() {
|
||||
let live = ["xyz:AAPL", "xyz:GOLD", "xyz:SKHX", "xyz:SP500", "para:10Y", "xyz:AMD", "xyz:AMZN"];
|
||||
let s = |q: &str| search(q, live, 10);
|
||||
assert_eq!(s("aapl"), ["xyz:AAPL"]);
|
||||
assert_eq!(s("xyz:aa"), ["xyz:AAPL"]);
|
||||
assert_eq!(s("am")[..2], ["xyz:AMD", "xyz:AMZN"], "coin prefixes, shorter first");
|
||||
assert_eq!(s("apple"), ["xyz:AAPL"], "a linked perp's name");
|
||||
assert_eq!(s("gold perp"), ["xyz:GOLD"]);
|
||||
assert_eq!(s("s&p"), ["xyz:SP500"]);
|
||||
assert_eq!(s("skhx"), ["xyz:SKHX"], "unlinked perps by coin");
|
||||
assert_eq!(s("10y"), ["para:10Y"]);
|
||||
assert_eq!(s("perp").len(), live.len());
|
||||
assert!(s("gold cmdty").is_empty(), "a yellow key asks for the listed instrument");
|
||||
assert!(s("").is_empty() && s("zzzz").is_empty());
|
||||
assert_eq!(search("a", live, 2).len(), 2);
|
||||
}
|
||||
|
||||
/// Every row names a real app instrument, by its canonical key, on one side, and a
|
||||
/// well-formed xyz perp on the other, each at most once.
|
||||
#[test]
|
||||
fn the_table_is_well_formed() {
|
||||
let mut coins: Vec<&str> = LINKS.iter().map(|l| l.coin).collect();
|
||||
let mut keys: Vec<&str> = LINKS.iter().map(|l| l.underlying).collect();
|
||||
coins.sort();
|
||||
coins.dedup();
|
||||
keys.sort();
|
||||
keys.dedup();
|
||||
assert_eq!((coins.len(), keys.len()), (LINKS.len(), LINKS.len()), "one row per perp and per instrument");
|
||||
for l in LINKS {
|
||||
assert_eq!(normalize(l.coin).as_deref(), Some(l.coin), "{} is canonical", l.coin);
|
||||
assert_eq!(venue(l.coin), Some("xyz"), "{} prefers xyz", l.coin);
|
||||
let sec = Security::parse(l.underlying).expect("parses");
|
||||
assert_eq!(sec.key(), l.underlying, "{} is a canonical key", l.underlying);
|
||||
assert!(catalog::by_security(&sec).is_some(), "{} is in the catalog", l.underlying);
|
||||
assert!(l.name.ends_with(" perp") && !l.reference.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Securities across asset classes, addressed with Bloomberg-style market keys:
|
||||
//! `AAPL US Equity`, `SPX Index`, `BTCUSD Curncy`, `EURUSD Curncy`, `US10Y Govt`, `CL1 Comdty`.
|
||||
//! Hyperliquid HIP-3 perps keep the venue's own name, `xyz:AAPL` (see [`crate::perps`]).
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::catalog;
|
||||
use crate::{catalog, perps};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum AssetClass {
|
||||
@@ -16,6 +17,8 @@ pub enum AssetClass {
|
||||
Crypto,
|
||||
Govt,
|
||||
Cmdty,
|
||||
/// A Hyperliquid HIP-3 perpetual (`xyz:AAPL`): its own instrument, never the listed one.
|
||||
Perp,
|
||||
}
|
||||
|
||||
/// The Bloomberg "yellow key" a security lives under.
|
||||
@@ -26,11 +29,14 @@ pub enum YellowKey {
|
||||
Curncy,
|
||||
Govt,
|
||||
Comdty,
|
||||
/// HIP-3 perps, which Bloomberg has no key for. Nothing typed parses to it: a perp is named
|
||||
/// by its venue key (`xyz:AAPL`), and `PERP` after a security is a function.
|
||||
Perp,
|
||||
}
|
||||
|
||||
impl YellowKey {
|
||||
pub const ALL: [YellowKey; 5] =
|
||||
[YellowKey::Equity, YellowKey::Index, YellowKey::Curncy, YellowKey::Govt, YellowKey::Comdty];
|
||||
pub const ALL: [YellowKey; 6] =
|
||||
[YellowKey::Equity, YellowKey::Index, YellowKey::Curncy, YellowKey::Govt, YellowKey::Comdty, YellowKey::Perp];
|
||||
|
||||
/// Accepts `<CRNCY>`, `CURNCY`, `crncy`, `CMDTY`, `COMDTY`, `GOVT`, `INDEX`, `EQUITY`, `EQ`, `ETF`.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
@@ -52,6 +58,7 @@ impl YellowKey {
|
||||
YellowKey::Curncy => "Curncy",
|
||||
YellowKey::Govt => "Govt",
|
||||
YellowKey::Comdty => "Comdty",
|
||||
YellowKey::Perp => "Perp",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +71,7 @@ impl AssetClass {
|
||||
AssetClass::Fx | AssetClass::Crypto => YellowKey::Curncy,
|
||||
AssetClass::Govt => YellowKey::Govt,
|
||||
AssetClass::Cmdty => YellowKey::Comdty,
|
||||
AssetClass::Perp => YellowKey::Perp,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +84,7 @@ impl AssetClass {
|
||||
AssetClass::Crypto => "Crypto",
|
||||
AssetClass::Govt => "Govt",
|
||||
AssetClass::Cmdty => "Comdty",
|
||||
AssetClass::Perp => "Perp",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,13 +96,14 @@ impl AssetClass {
|
||||
AssetClass::Fx => 0.08,
|
||||
AssetClass::Crypto => 0.60,
|
||||
AssetClass::Govt => 0.20,
|
||||
AssetClass::Cmdty => 0.30,
|
||||
AssetClass::Cmdty | AssetClass::Perp => 0.30,
|
||||
}
|
||||
}
|
||||
|
||||
/// Exchanges trade crypto around the clock; everything else has sessions.
|
||||
/// Exchanges trade crypto around the clock, and HIP-3 perps too (even while the market they
|
||||
/// reference is closed); everything else has sessions.
|
||||
pub fn trades_24_7(self) -> bool {
|
||||
self == AssetClass::Crypto
|
||||
matches!(self, AssetClass::Crypto | AssetClass::Perp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,9 +132,15 @@ impl Security {
|
||||
Self::new(ticker, AssetClass::Equity)
|
||||
}
|
||||
|
||||
/// Canonical market key, e.g. `AAPL US Equity`, `BTCUSD Curncy`.
|
||||
/// A HIP-3 perp from its venue key in any case (`XYZ:aapl` is `xyz:AAPL`).
|
||||
pub fn perp(key: &str) -> Option<Self> {
|
||||
Some(Self { ticker: perps::normalize(key)?, class: AssetClass::Perp })
|
||||
}
|
||||
|
||||
/// Canonical market key, e.g. `AAPL US Equity`, `BTCUSD Curncy`, `xyz:AAPL`.
|
||||
pub fn key(&self) -> String {
|
||||
match self.class {
|
||||
AssetClass::Perp => self.ticker.clone(),
|
||||
AssetClass::Equity | AssetClass::Etf => format!("{} US Equity", self.ticker),
|
||||
c => format!("{} {}", self.ticker, c.yellow_key().label()),
|
||||
}
|
||||
@@ -134,6 +150,9 @@ impl Security {
|
||||
/// Returns the security and how many input tokens it consumed.
|
||||
pub fn resolve_tokens(tokens: &[&str]) -> Option<(Self, usize)> {
|
||||
let (&head, rest) = tokens.split_first()?;
|
||||
if let Some(perp) = Self::perp(head) {
|
||||
return Some((perp, 1));
|
||||
}
|
||||
// `BTC/USD` is a pair; `BRK/B` is a share class.
|
||||
let up = head.trim().to_ascii_uppercase();
|
||||
let ticker = match up.split_once('/') {
|
||||
@@ -187,7 +206,7 @@ impl Security {
|
||||
AssetClass::Crypto
|
||||
}
|
||||
}
|
||||
Some(YellowKey::Equity) => AssetClass::Equity,
|
||||
Some(YellowKey::Equity) | Some(YellowKey::Perp) => AssetClass::Equity,
|
||||
None if ticker.len() == 6 && is_fiat(&ticker[..3]) && is_fiat(&ticker[3..]) => AssetClass::Fx,
|
||||
None => AssetClass::Equity,
|
||||
};
|
||||
@@ -196,6 +215,9 @@ impl Security {
|
||||
|
||||
/// Display name from the catalog, if known.
|
||||
pub fn name(&self) -> Option<&'static str> {
|
||||
if self.class == AssetClass::Perp {
|
||||
return perps::link_for(&self.ticker).map(|l| l.name);
|
||||
}
|
||||
catalog::lookup(&self.ticker, Some(self.class.yellow_key())).filter(|l| l.class == self.class).map(|l| l.name)
|
||||
}
|
||||
|
||||
@@ -218,6 +240,10 @@ impl Security {
|
||||
AssetClass::Govt => 3,
|
||||
AssetClass::Fx if self.ticker.ends_with("JPY") => 3,
|
||||
AssetClass::Fx => 4,
|
||||
// Hyperliquid quotes five significant figures, at most six decimals.
|
||||
AssetClass::Perp if price.is_finite() && price != 0.0 => {
|
||||
(4 - price.abs().log10().floor() as i64).clamp(0, 6) as usize
|
||||
}
|
||||
_ => match price.abs() {
|
||||
p if p >= 1000.0 => 2,
|
||||
p if p >= 1.0 => 2,
|
||||
@@ -291,6 +317,27 @@ mod tests {
|
||||
assert_eq!(Security::resolve_tokens(&t).unwrap().1, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hip3_perps_are_their_own_instruments() {
|
||||
let p = r("xyz:AAPL");
|
||||
assert_eq!(p, Security { ticker: "xyz:AAPL".into(), class: AssetClass::Perp });
|
||||
assert_eq!(r("XYZ:aapl"), p, "any case");
|
||||
assert_eq!(p.key(), "xyz:AAPL");
|
||||
assert_eq!(r("AAPL"), Security::new("AAPL", AssetClass::Equity), "AAPL stays the listed stock");
|
||||
assert_eq!(r("AAPL US Equity").key(), "AAPL US Equity");
|
||||
assert_eq!(Security::resolve_tokens(&["xyz:AAPL", "5"]).unwrap().1, 1);
|
||||
assert_eq!(p.name(), Some("Apple Inc perp"));
|
||||
assert_eq!(r("xyz:SKHX").name(), None, "unlinked perps have no catalog name");
|
||||
assert_eq!((p.class.label(), p.class.yellow_key().label()), ("Perp", "Perp"));
|
||||
assert!(p.class.trades_24_7() && !p.has_congress_trades() && p.pair().is_none());
|
||||
// Prices show the five significant figures Hyperliquid quotes.
|
||||
assert_eq!(r("xyz:EUR").decimals(1.1208), 4);
|
||||
assert_eq!(r("xyz:JPY").decimals(157.74), 2);
|
||||
assert_eq!(r("xyz:GOLD").decimals(3958.05), 1);
|
||||
assert_eq!(r("xyz:XYZ100").decimals(28390.5), 0);
|
||||
assert_eq!(r("para:ANSEM").decimals(0.041887), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_and_helpers() {
|
||||
assert_eq!(Security::new("BTCUSD", AssetClass::Crypto).key(), "BTCUSD Curncy");
|
||||
|
||||
+17
-2
@@ -6,19 +6,34 @@ edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tr-core.workspace = true
|
||||
tr-proto.workspace = true
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
reqwest.workspace = true
|
||||
rusqlite.workspace = true
|
||||
tracing.workspace = true
|
||||
async-trait = "0.1"
|
||||
futures = "0.3"
|
||||
web-time = "1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
tokio.workspace = true
|
||||
rusqlite.workspace = true
|
||||
tokio-tungstenite = { version = "0.30", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std"] }
|
||||
|
||||
# The browser build: no threads, SQLite or raw sockets. Fetch and WebSocket come from the
|
||||
# browser, and chrono reads the clock through JavaScript.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
tokio = { version = "1", default-features = false, features = ["sync", "macros"] }
|
||||
chrono = { workspace = true, features = ["wasmbind"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "WebSocket", "MessageEvent", "CloseEvent", "Event", "BinaryType"] }
|
||||
send_wrapper = { version = "0.6", features = ["futures"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std"] }
|
||||
+36
-32
@@ -6,13 +6,12 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use chrono::DateTime;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tr_core::{Bar, Security, Timeframe, catalog};
|
||||
|
||||
use crate::{LiveFeed, Quote};
|
||||
use crate::ws::Socket;
|
||||
use crate::{LiveFeed, Quote, rt};
|
||||
|
||||
const BINANCE_REST: [&str; 2] = ["https://data-api.binance.vision", "https://api.binance.com"];
|
||||
const BINANCE_WS: [&str; 2] = ["wss://data-stream.binance.vision/ws", "wss://stream.binance.com:9443/ws"];
|
||||
@@ -186,25 +185,37 @@ pub fn parse_coinbase_book(json: &str, symbol: &str, depth: usize) -> Result<cra
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
pub struct CryptoProvider {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl CryptoProvider {
|
||||
pub fn new() -> Result<Self> {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(concat!("terminal-rs/", env!("CARGO_PKG_VERSION")))
|
||||
.timeout(Duration::from_secs(20))
|
||||
.build()?;
|
||||
Ok(Self { client })
|
||||
let client = reqwest::Client::builder();
|
||||
// Browsers set their own User-Agent, and fetch takes its timeout per request.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let client = client.user_agent(concat!("terminal/", env!("CARGO_PKG_VERSION"))).timeout(TIMEOUT);
|
||||
Ok(Self { client: client.build()? })
|
||||
}
|
||||
|
||||
async fn get(&self, url: &str, query: &[(&str, String)]) -> Result<String> {
|
||||
let resp = self.client.get(url).query(query).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
bail!("GET {url}: HTTP {}", resp.status());
|
||||
}
|
||||
Ok(resp.text().await?)
|
||||
let req = self.client.get(url).query(query);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let req = req.timeout(TIMEOUT);
|
||||
let fetch = async move {
|
||||
let resp = req.send().await?;
|
||||
if !resp.status().is_success() {
|
||||
bail!("GET {url}: HTTP {}", resp.status());
|
||||
}
|
||||
Ok(resp.text().await?)
|
||||
};
|
||||
// A browser fetch holds JavaScript values, which are not `Send`. The web build has
|
||||
// one thread, so asserting it is sound and keeps the provider traits shared.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let fetch = send_wrapper::SendWrapper::new(fetch);
|
||||
fetch.await
|
||||
}
|
||||
|
||||
async fn binance_history(
|
||||
@@ -363,7 +374,7 @@ impl CryptoProvider {
|
||||
let (tx, rx) = mpsc::channel(256);
|
||||
let stream = format!("{}@kline_{}", binance_symbol(sec).to_ascii_lowercase(), binance_interval(tf));
|
||||
let product = coinbase_product(sec);
|
||||
tokio::spawn(async move {
|
||||
rt::spawn(async move {
|
||||
let mut backoff = 1;
|
||||
let mut attempt = 0usize;
|
||||
while !tx.is_closed() {
|
||||
@@ -378,7 +389,7 @@ impl CryptoProvider {
|
||||
tracing::debug!("live feed {stream}: {e:#}");
|
||||
}
|
||||
attempt += 1;
|
||||
tokio::time::sleep(Duration::from_secs(backoff)).await;
|
||||
rt::sleep(Duration::from_secs(backoff)).await;
|
||||
backoff = (backoff * 2).min(30);
|
||||
}
|
||||
});
|
||||
@@ -387,32 +398,25 @@ impl CryptoProvider {
|
||||
}
|
||||
|
||||
async fn binance_ws(url: &str, tx: &mpsc::Sender<Bar>) -> Result<()> {
|
||||
let (mut ws, _) = tokio_tungstenite::connect_async(url).await.context("binance websocket")?;
|
||||
while let Some(msg) = ws.next().await {
|
||||
match msg? {
|
||||
Message::Text(t) => {
|
||||
if let Some(bar) = parse_binance_ws(t.as_str())
|
||||
&& tx.send(bar).await.is_err()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => {}
|
||||
let mut ws = Socket::connect(url).await.context("binance websocket")?;
|
||||
while let Some(text) = ws.next().await {
|
||||
if let Some(bar) = parse_binance_ws(&text?)
|
||||
&& tx.send(bar).await.is_err()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
bail!("binance websocket closed")
|
||||
}
|
||||
|
||||
async fn coinbase_ws(product: &str, tf: Timeframe, tx: &mpsc::Sender<Bar>) -> Result<()> {
|
||||
let (mut ws, _) = tokio_tungstenite::connect_async(COINBASE_WS).await.context("coinbase websocket")?;
|
||||
let mut ws = Socket::connect(COINBASE_WS).await.context("coinbase websocket")?;
|
||||
let sub = serde_json::json!({"type": "subscribe", "product_ids": [product], "channels": ["ticker"]});
|
||||
ws.send(Message::Text(sub.to_string().into())).await?;
|
||||
ws.send(sub.to_string()).await?;
|
||||
// Aggregate ticks into the current bar; the chart merges partial bars into history.
|
||||
let mut bar: Option<Bar> = None;
|
||||
while let Some(msg) = ws.next().await {
|
||||
let Message::Text(t) = msg? else { continue };
|
||||
let Some((ts, px, size)) = parse_coinbase_ws(t.as_str()) else { continue };
|
||||
while let Some(text) = ws.next().await {
|
||||
let Some((ts, px, size)) = parse_coinbase_ws(&text?) else { continue };
|
||||
let bucket = tf.bucket_start(ts);
|
||||
let b = match bar.as_mut() {
|
||||
Some(b) if b.ts == bucket => {
|
||||
|
||||
+123
-9
@@ -11,14 +11,33 @@
|
||||
//!
|
||||
//! The router is wrapped in [`Fallback`], which serves sample data if a source is
|
||||
//! unreachable, so the terminal always has something to draw.
|
||||
//!
|
||||
//! `TR_SERVER_URL` (`ws://` or `wss://`) opts in to the Terminal data server
|
||||
//! ([`remote::RemoteMarkets`]): crypto markets then come from Hyperliquid perps through the
|
||||
//! server, and everything else, or anything the server can't serve, from the sources above.
|
||||
//!
|
||||
//! 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. When the page was built with a Terminal server URL
|
||||
//! ([`web::server_url`]), crypto comes through the server first, as on the desktop.
|
||||
|
||||
#[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 remote;
|
||||
pub mod rss;
|
||||
pub mod rt;
|
||||
pub mod sample;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod treasury;
|
||||
pub mod web;
|
||||
/// The websocket client the providers use (the app's Hyperliquid user stream uses it too).
|
||||
pub mod ws;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod yahoo;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -29,6 +48,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::mpsc;
|
||||
use tr_core::{AssetClass, Bar, Security, Timeframe, catalog};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use cache::BarCache;
|
||||
|
||||
/// Latest quote for a symbol.
|
||||
@@ -137,6 +157,17 @@ impl OrderBook {
|
||||
}
|
||||
}
|
||||
|
||||
/// One trade on a venue's tape.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TradePrint {
|
||||
/// The venue's trade time (Unix milliseconds).
|
||||
pub ts_ms: i64,
|
||||
pub px: f64,
|
||||
pub sz: f64,
|
||||
/// The buyer was the aggressor.
|
||||
pub buy: bool,
|
||||
}
|
||||
|
||||
/// One listed option.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OptionContract {
|
||||
@@ -183,7 +214,9 @@ pub struct OptionChain {
|
||||
}
|
||||
|
||||
/// Live bar updates for one symbol and timeframe: each message is the latest state of
|
||||
/// the current (possibly still forming) bar. The feed stops when the receiver is dropped.
|
||||
/// the current (possibly still forming) bar, or, after an outage, an older bar that fills the
|
||||
/// gap ([`tr_core::Series::apply_live`] slots it in). The feed stops when the receiver is
|
||||
/// dropped.
|
||||
pub type LiveFeed = mpsc::Receiver<Bar>;
|
||||
|
||||
/// Pick curve snapshots from per-date rows: latest, nearest at or before -30d and -365d.
|
||||
@@ -254,6 +287,17 @@ pub trait MarketData: Send + Sync {
|
||||
anyhow::bail!("no public order book for {symbol}")
|
||||
}
|
||||
|
||||
/// The latest `n` trades, oldest first, where a public tape exists.
|
||||
async fn trades(&self, symbol: &str, _n: usize) -> Result<Vec<TradePrint>> {
|
||||
anyhow::bail!("no public trades for {symbol}")
|
||||
}
|
||||
|
||||
/// The HIP-3 perps (`xyz:AAPL`) the Terminal server lists as live right now. An error
|
||||
/// when there is no server or it can't be reached.
|
||||
async fn perp_markets(&self) -> Result<Vec<String>> {
|
||||
anyhow::bail!("no Terminal server")
|
||||
}
|
||||
|
||||
/// The US Treasury curve. The default assembles it from each tenor's daily history.
|
||||
async fn yield_curves(&self) -> Result<YieldCurves> {
|
||||
let tenors = catalog::treasury_curve();
|
||||
@@ -306,8 +350,8 @@ impl<P: MarketData> MarketData for Fallback<P> {
|
||||
async fn history(&self, symbol: &str, tf: Timeframe, end: Option<i64>, limit: usize) -> Result<Vec<Bar>> {
|
||||
match self.primary.history(symbol, tf, end, limit).await {
|
||||
Ok(b) => Ok(b),
|
||||
// Paging older history: never splice sample bars onto real ones.
|
||||
Err(e) if end.is_some() => Err(e),
|
||||
// Paging older history: never splice sample bars onto real ones. Perps never get them.
|
||||
Err(e) if end.is_some() || is_perp(symbol) => Err(e),
|
||||
Err(e) => {
|
||||
tracing::warn!("{} history {symbol} {tf} failed, using sample data: {e:#}", self.name);
|
||||
self.sample.history(symbol, tf, end, limit).await
|
||||
@@ -318,10 +362,11 @@ impl<P: MarketData> MarketData for Fallback<P> {
|
||||
async fn quotes(&self, symbols: &[String]) -> Result<Vec<Quote>> {
|
||||
match self.primary.quotes(symbols).await {
|
||||
// If the primary produced nothing at all (offline), serve sample quotes rather
|
||||
// than an empty panel; partial results are never mixed with synthetic prices.
|
||||
// than an empty panel; partial results are never mixed with synthetic prices. Perps
|
||||
// are never filled in.
|
||||
Ok(mut q) => {
|
||||
let missing: Vec<String> =
|
||||
symbols.iter().filter(|s| !q.iter().any(|x| &x.symbol == *s)).cloned().collect();
|
||||
symbols.iter().filter(|s| !is_perp(s) && !q.iter().any(|x| &x.symbol == *s)).cloned().collect();
|
||||
if !missing.is_empty() && q.is_empty() {
|
||||
q.extend(self.sample.quotes(&missing).await.unwrap_or_default());
|
||||
}
|
||||
@@ -329,7 +374,8 @@ impl<P: MarketData> MarketData for Fallback<P> {
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("{} quotes failed, using sample data: {e:#}", self.name);
|
||||
self.sample.quotes(symbols).await
|
||||
let listed: Vec<String> = symbols.iter().filter(|s| !is_perp(s)).cloned().collect();
|
||||
self.sample.quotes(&listed).await
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,6 +395,7 @@ impl<P: MarketData> MarketData for Fallback<P> {
|
||||
async fn options(&self, symbol: &str, expiration: Option<i64>) -> Result<OptionChain> {
|
||||
match self.primary.options(symbol, expiration).await {
|
||||
Ok(c) => Ok(c),
|
||||
Err(e) if is_perp(symbol) => Err(e),
|
||||
Err(e) => {
|
||||
tracing::warn!("{} options {symbol}: {e:#}; simulating", self.name);
|
||||
self.sample.options(symbol, expiration).await
|
||||
@@ -360,6 +407,7 @@ impl<P: MarketData> MarketData for Fallback<P> {
|
||||
async fn order_book(&self, symbol: &str, depth: usize) -> Result<OrderBook> {
|
||||
match self.primary.order_book(symbol, depth).await {
|
||||
Ok(b) => Ok(b),
|
||||
Err(e) if is_perp(symbol) => Err(e),
|
||||
Err(e) => {
|
||||
tracing::debug!("{} order book {symbol}: {e:#}; simulating", self.name);
|
||||
self.sample.order_book(symbol, depth).await
|
||||
@@ -367,6 +415,15 @@ impl<P: MarketData> MarketData for Fallback<P> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Real trades only: nothing simulates a tape.
|
||||
async fn trades(&self, symbol: &str, n: usize) -> Result<Vec<TradePrint>> {
|
||||
self.primary.trades(symbol, n).await
|
||||
}
|
||||
|
||||
async fn perp_markets(&self) -> Result<Vec<String>> {
|
||||
self.primary.perp_markets().await
|
||||
}
|
||||
|
||||
async fn yield_curves(&self) -> Result<YieldCurves> {
|
||||
match self.primary.yield_curves().await {
|
||||
Ok(c) => Ok(c),
|
||||
@@ -383,7 +440,19 @@ pub fn security(symbol: &str) -> Security {
|
||||
Security::parse(symbol).unwrap_or_else(|| Security::equity(symbol))
|
||||
}
|
||||
|
||||
/// Whether `symbol` is a HIP-3 perp (`xyz:AAPL`).
|
||||
pub fn is_perp(symbol: &str) -> bool {
|
||||
tr_core::perps::is_perp(symbol)
|
||||
}
|
||||
|
||||
/// What a source other than the Terminal server says about a HIP-3 perp. Only the server
|
||||
/// carries them, and nothing stands in for them, not even sample data.
|
||||
pub fn perp_needs_server(symbol: &str) -> anyhow::Error {
|
||||
anyhow::anyhow!("{symbol} is a HIP-3 perp, which only the Terminal server provides")
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -398,12 +467,39 @@ pub fn from_env() -> Provider {
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
match markets::Markets::new(alpaca) {
|
||||
Ok(m) => Arc::new(Fallback::new(m)),
|
||||
Err(_) => Arc::new(sample::SampleProvider::default().with_live_ticks()),
|
||||
let markets = match markets::Markets::new(alpaca) {
|
||||
Ok(m) => Fallback::new(m),
|
||||
Err(_) => return Arc::new(sample::SampleProvider::default().with_live_ticks()),
|
||||
};
|
||||
match var("TR_SERVER_URL") {
|
||||
Some(url) => {
|
||||
// The URL itself stays out of the logs.
|
||||
tracing::info!("TR_SERVER_URL is set: crypto markets come through the Terminal server");
|
||||
Arc::new(remote::RemoteMarkets::new(url.trim(), markets))
|
||||
}
|
||||
None => Arc::new(markets),
|
||||
}
|
||||
}
|
||||
|
||||
/// The browser build's provider: sample data only when `offline`, otherwise live crypto
|
||||
/// with sample data for everything else. With a Terminal `server` URL, crypto comes through
|
||||
/// that server, and from Binance and Coinbase whenever the server can't serve it.
|
||||
pub fn for_browser(offline: bool, server: Option<&str>) -> Provider {
|
||||
if !offline {
|
||||
match web::WebMarkets::new() {
|
||||
Ok(m) => {
|
||||
let m = Fallback::new(m);
|
||||
return match server {
|
||||
Some(url) => Arc::new(remote::RemoteMarkets::new(url, m)),
|
||||
None => Arc::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 {
|
||||
@@ -474,6 +570,24 @@ mod tests {
|
||||
assert_eq!(p.name(), "broken");
|
||||
}
|
||||
|
||||
/// A HIP-3 perp has no source but the Terminal server, so nothing stands in for it: no
|
||||
/// sample bars, quotes or books when a source fails, and none in offline sample mode.
|
||||
#[tokio::test]
|
||||
async fn perps_never_get_stand_in_data() {
|
||||
assert!(is_perp("xyz:AAPL") && is_perp("XYZ:aapl") && !is_perp("AAPL US Equity") && !is_perp("BTCUSD Curncy"));
|
||||
let p = Fallback::new(Broken);
|
||||
assert!(p.history("xyz:AAPL", Timeframe::D1, None, 10).await.is_err());
|
||||
assert!(p.order_book("xyz:AAPL", 10).await.is_err());
|
||||
let q = p.quotes(&["xyz:AAPL".into(), "AAPL US Equity".into()]).await.unwrap();
|
||||
assert_eq!(q.iter().map(|q| q.symbol.as_str()).collect::<Vec<_>>(), ["AAPL US Equity"]);
|
||||
let s = sample::SampleProvider::default().with_live_ticks();
|
||||
let e = s.history("xyz:AAPL", Timeframe::D1, None, 10).await.unwrap_err();
|
||||
assert!(format!("{e:#}").contains("Terminal server"), "{e:#}");
|
||||
assert!(s.order_book("xyz:AAPL", 10).await.is_err() && s.live("xyz:AAPL", Timeframe::M1).is_none());
|
||||
let q = s.quotes(&["xyz:AAPL".into(), "AAPL US Equity".into()]).await.unwrap();
|
||||
assert_eq!(q.iter().map(|q| q.symbol.as_str()).collect::<Vec<_>>(), ["AAPL US Equity"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_yield_curve_from_history() {
|
||||
let c = sample::SampleProvider::at(1_790_000_000, 1_000).yield_curves().await.unwrap();
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
//! | Govt | Treasury.gov daily curve (FRED fallback); Yahoo `^TNX` etc. intraday | Treasury.gov | — |
|
||||
//! | Equity/ETF | Alpaca if keyed, else Yahoo | Yahoo spark | — |
|
||||
//! | FX/Index/Comdty | Yahoo (`EURUSD=X`, `^GSPC`, `CL=F`) | Yahoo spark | — |
|
||||
//! | HIP-3 perps (`xyz:AAPL`) | — | — | — |
|
||||
//!
|
||||
//! HIP-3 perps come only from the Terminal server ([`crate::remote::RemoteMarkets`]).
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use async_trait::async_trait;
|
||||
@@ -15,7 +18,9 @@ use crate::alpaca::AlpacaProvider;
|
||||
use crate::crypto::CryptoProvider;
|
||||
use crate::treasury::TreasuryProvider;
|
||||
use crate::yahoo::{YahooProvider, yahoo_symbol};
|
||||
use crate::{LiveFeed, MarketData, NewsItem, OptionChain, OrderBook, Quote, SearchHit, YieldCurves, security};
|
||||
use crate::{
|
||||
LiveFeed, MarketData, NewsItem, OptionChain, OrderBook, Quote, SearchHit, YieldCurves, perp_needs_server, security,
|
||||
};
|
||||
|
||||
pub struct Markets {
|
||||
yahoo: YahooProvider,
|
||||
@@ -47,6 +52,7 @@ impl MarketData for Markets {
|
||||
async fn history(&self, symbol: &str, tf: Timeframe, end: Option<i64>, limit: usize) -> Result<Vec<Bar>> {
|
||||
let sec = security(symbol);
|
||||
match sec.class {
|
||||
AssetClass::Perp => Err(perp_needs_server(symbol)),
|
||||
AssetClass::Crypto => self.crypto.history(&sec, tf, end, limit).await,
|
||||
AssetClass::Govt if tf.is_intraday() => match catalog::by_security(&sec).and_then(|l| l.yahoo) {
|
||||
Some(y) => self.yahoo.history(y, tf, end, limit).await,
|
||||
@@ -69,6 +75,7 @@ impl MarketData for Markets {
|
||||
for key in symbols {
|
||||
let sec = security(key);
|
||||
match sec.class {
|
||||
AssetClass::Perp => {}
|
||||
AssetClass::Crypto => crypto.push((key.clone(), sec)),
|
||||
AssetClass::Govt => govt.push((key.clone(), sec)),
|
||||
_ => other.push((key.clone(), sec)),
|
||||
@@ -123,6 +130,11 @@ impl MarketData for Markets {
|
||||
Some(sec) => match sec.class {
|
||||
// Yahoo has no crypto headlines; use CoinDesk's feed.
|
||||
AssetClass::Crypto => return self.crypto.news(&sec).await,
|
||||
// A linked perp shares its underlying's headlines.
|
||||
AssetClass::Perp => match tr_core::perps::link_for(&sec.ticker) {
|
||||
Some(l) => yahoo_symbol(&security(l.underlying)),
|
||||
None => return Ok(Vec::new()),
|
||||
},
|
||||
AssetClass::Govt => catalog::by_security(&sec).and_then(|l| l.yahoo).unwrap_or("^TNX").to_string(),
|
||||
_ => yahoo_symbol(&sec),
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,132 @@
|
||||
//! 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 a `setTimeout` set on the first poll and cleared if the sleep is
|
||||
/// dropped before it fires.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn sleep(d: Duration) -> impl Future<Output = ()> + Send {
|
||||
struct Timer(Option<i32>);
|
||||
impl Drop for Timer {
|
||||
fn drop(&mut self) {
|
||||
if let (Some(id), Some(w)) = (self.0, web_sys::window()) {
|
||||
w.clear_timeout_with_handle(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let ms = d.as_millis().min(i32::MAX as u128) as i32;
|
||||
// A promise isn't `Send`. The web build has one thread, so wrapping it is sound, and the provider
|
||||
// traits' `Send` futures can then wait on it.
|
||||
send_wrapper::SendWrapper::new(async move {
|
||||
let mut timer = Timer(None);
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
timer.0 = web_sys::window()
|
||||
.and_then(|w| w.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms).ok());
|
||||
});
|
||||
let _ = wasm_bindgen_futures::JsFuture::from(promise).await;
|
||||
timer.0 = None; // it fired
|
||||
})
|
||||
}
|
||||
|
||||
/// `fut` ran past its deadline.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Elapsed;
|
||||
|
||||
impl std::fmt::Display for Elapsed {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("deadline has elapsed")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Elapsed {}
|
||||
|
||||
/// Run `fut` for at most `d`.
|
||||
pub async fn timeout<F: Future>(d: Duration, fut: F) -> Result<F::Output, Elapsed> {
|
||||
let (fut, deadline) = (std::pin::pin!(fut), std::pin::pin!(sleep(d)));
|
||||
match futures::future::select(fut, deadline).await {
|
||||
futures::future::Either::Left((out, _)) => Ok(out),
|
||||
futures::future::Either::Right(_) => Err(Elapsed),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeout_stops_at_the_deadline() {
|
||||
assert_eq!(timeout(Duration::from_secs(5), async { 7 }).await, Ok(7));
|
||||
let t0 = Instant::now();
|
||||
assert_eq!(timeout(Duration::from_millis(20), std::future::pending::<()>()).await, Err(Elapsed));
|
||||
assert!(t0.elapsed() >= Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use async_trait::async_trait;
|
||||
use tokio::sync::mpsc;
|
||||
use tr_core::{Bar, Timeframe, sample};
|
||||
|
||||
use crate::{LiveFeed, MarketData, OptionChain, OrderBook, security};
|
||||
use crate::{LiveFeed, MarketData, OptionChain, OrderBook, Quote, is_perp, perp_needs_server, rt, security};
|
||||
|
||||
/// Deterministic synthetic data anchored at a fixed "now" per process, so paging is
|
||||
/// consistent: page N+1 is exactly the bars preceding page N. Levels and volatility
|
||||
@@ -48,25 +48,48 @@ impl MarketData for SampleProvider {
|
||||
"sample"
|
||||
}
|
||||
|
||||
/// HIP-3 perps have no sample data: only the Terminal server provides them.
|
||||
async fn history(&self, symbol: &str, tf: Timeframe, end: Option<i64>, limit: usize) -> Result<Vec<Bar>> {
|
||||
if is_perp(symbol) {
|
||||
return Err(perp_needs_server(symbol));
|
||||
}
|
||||
Ok(crate::clip(self.bars(symbol, tf), end, limit))
|
||||
}
|
||||
|
||||
/// From daily history, like the default, but leaving out perps instead of failing on them.
|
||||
async fn quotes(&self, symbols: &[String]) -> Result<Vec<Quote>> {
|
||||
let mut out = Vec::with_capacity(symbols.len());
|
||||
for s in symbols.iter().filter(|s| !is_perp(s)) {
|
||||
let bars = self.history(s, Timeframe::D1, None, 2).await?;
|
||||
if let Some(last) = bars.last() {
|
||||
let prev = if bars.len() > 1 { bars[bars.len() - 2].close } else { last.open };
|
||||
out.push(Quote { symbol: s.clone(), last: last.close, prev_close: prev, ts: last.ts, spark: vec![] });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A synthetic chain priced with Black-Scholes around the last price.
|
||||
async fn options(&self, symbol: &str, expiration: Option<i64>) -> Result<OptionChain> {
|
||||
if is_perp(symbol) {
|
||||
return Err(perp_needs_server(symbol));
|
||||
}
|
||||
let spot = self.bars(symbol, Timeframe::D1).last().map(|b| b.close).unwrap_or(100.0);
|
||||
Ok(simulated_chain(symbol, spot, expiration, chrono::Utc::now().timestamp()))
|
||||
}
|
||||
|
||||
/// A synthetic book around the last price; sizes vary with the clock so it moves.
|
||||
async fn order_book(&self, symbol: &str, depth: usize) -> Result<OrderBook> {
|
||||
if is_perp(symbol) {
|
||||
return Err(perp_needs_server(symbol));
|
||||
}
|
||||
let last = self.bars(symbol, Timeframe::M1).last().map(|b| b.close).unwrap_or(100.0);
|
||||
Ok(simulated_book(symbol, last, depth, chrono::Utc::now().timestamp_millis() as u64 / 500))
|
||||
}
|
||||
|
||||
/// Random-walk ticks on the last bar every 400 ms, rolling into new bars on schedule.
|
||||
fn live(&self, symbol: &str, tf: Timeframe) -> Option<LiveFeed> {
|
||||
if !self.live_ticks {
|
||||
if !self.live_ticks || is_perp(symbol) {
|
||||
return None;
|
||||
}
|
||||
let mut bar = *self.bars(symbol, tf).last()?;
|
||||
@@ -74,8 +97,8 @@ impl MarketData for SampleProvider {
|
||||
let (tx, rx) = mpsc::channel(64);
|
||||
let tick_vol = sec.class.sample_vol() * (0.4 / (365.0 * 86_400.0f64)).sqrt() * 8.0;
|
||||
let mut rng = sample::Rng::new(sample::seed_for(symbol) ^ 0x5eed);
|
||||
tokio::spawn(async move {
|
||||
let mut iv = tokio::time::interval(Duration::from_millis(400));
|
||||
rt::spawn(async move {
|
||||
let mut iv = rt::interval(Duration::from_millis(400));
|
||||
loop {
|
||||
iv.tick().await;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
@@ -113,7 +113,7 @@ pub struct TreasuryProvider {
|
||||
impl TreasuryProvider {
|
||||
pub fn new() -> Result<Self> {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("Mozilla/5.0 (compatible; terminal-rs)")
|
||||
.user_agent("Mozilla/5.0 (compatible; terminal)")
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()?;
|
||||
Ok(Self { client, years: Mutex::new(HashMap::new()) })
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! 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. HIP-3 perps come only from
|
||||
//! the Terminal server, so here they have no data at all.
|
||||
//!
|
||||
//! When the page has a Terminal server ([`server_url`]), [`crate::for_browser`] puts
|
||||
//! [`crate::remote::RemoteMarkets`] in front of these, so crypto comes from the server and
|
||||
//! from Binance and Coinbase only while the server can't serve it.
|
||||
//!
|
||||
//! 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, is_perp, perp_needs_server, security};
|
||||
|
||||
pub struct WebMarkets {
|
||||
crypto: CryptoProvider,
|
||||
sample: SampleProvider,
|
||||
}
|
||||
|
||||
impl WebMarkets {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(Self { crypto: CryptoProvider::new()?, sample: SampleProvider::default() })
|
||||
}
|
||||
}
|
||||
|
||||
/// The Terminal server a page uses for crypto markets: `built`, the URL set when the page was
|
||||
/// built, unless the page's `?server=` parameter (`requested`) turns it off (`off`) or names a
|
||||
/// server on the same machine, such as `ws://localhost:8787/ws`. Other servers are refused, so a
|
||||
/// link can't point someone's page at a server that isn't ours.
|
||||
pub fn server_url(built: Option<&str>, requested: Option<&str>) -> Option<String> {
|
||||
let built = built.map(str::trim).filter(|u| !u.is_empty()).map(str::to_string);
|
||||
let Some(requested) = requested.map(str::trim) else { return built };
|
||||
if requested.eq_ignore_ascii_case("off") {
|
||||
return None;
|
||||
}
|
||||
if is_local(requested) {
|
||||
return Some(requested.to_string());
|
||||
}
|
||||
tracing::warn!("?server= takes a server on this machine (ws://localhost:8787/ws) or off; ignoring it");
|
||||
built
|
||||
}
|
||||
|
||||
/// A `ws://` or `wss://` URL whose host is this machine. Anything unusual (user info, other
|
||||
/// spellings of the address) counts as not local.
|
||||
fn is_local(url: &str) -> bool {
|
||||
let Some(rest) = url.strip_prefix("ws://").or_else(|| url.strip_prefix("wss://")) else { return false };
|
||||
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
|
||||
if authority.contains('@') {
|
||||
return false;
|
||||
}
|
||||
let host = match authority.strip_prefix('[') {
|
||||
Some(v6) => v6.split(']').next().unwrap_or_default(),
|
||||
None => authority.split(':').next().unwrap_or_default(),
|
||||
};
|
||||
host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
|
||||
}
|
||||
|
||||
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_perp(symbol) {
|
||||
return Err(perp_needs_server(symbol));
|
||||
}
|
||||
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 listed = symbols.iter().filter(|s| !is_perp(s)).cloned();
|
||||
let (crypto, other): (Vec<String>, Vec<String>) = listed.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"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn perps_need_the_server() {
|
||||
let m = WebMarkets::new().unwrap();
|
||||
assert!(m.history("xyz:AAPL", Timeframe::D1, None, 50).await.is_err());
|
||||
assert!(m.order_book("xyz:AAPL", 10).await.is_err());
|
||||
let q = m.quotes(&["xyz:AAPL".into(), "AAPL US Equity".into()]).await.unwrap();
|
||||
assert_eq!(q.iter().map(|q| q.symbol.as_str()).collect::<Vec<_>>(), ["AAPL US Equity"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_url_from_the_build_or_a_local_override() {
|
||||
let prod = Some("wss://terminal-gateway.style.dev/ws");
|
||||
assert_eq!(server_url(prod, None).as_deref(), prod);
|
||||
assert_eq!(server_url(Some(" "), None), None, "an empty build setting means no server");
|
||||
assert_eq!(server_url(None, None), None);
|
||||
assert_eq!(server_url(prod, Some("off")), None);
|
||||
for local in ["ws://localhost:8787/ws", "ws://127.0.0.1:9", "wss://[::1]:8787/ws", "ws://LOCALHOST/ws"] {
|
||||
assert_eq!(server_url(prod, Some(local)).as_deref(), Some(local));
|
||||
assert_eq!(server_url(None, Some(local)).as_deref(), Some(local));
|
||||
}
|
||||
for refused in [
|
||||
"wss://evil.example/ws",
|
||||
"ws://localhost.evil.example/ws",
|
||||
"ws://[email protected]/ws",
|
||||
"ws://evil.example:80@localhost/ws",
|
||||
"http://localhost:8787/ws",
|
||||
"",
|
||||
] {
|
||||
assert_eq!(server_url(prod, Some(refused)).as_deref(), prod, "{refused}");
|
||||
assert_eq!(server_url(None, Some(refused)), None, "{refused}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_server_goes_in_front_of_the_browser_sources() {
|
||||
assert_eq!(crate::for_browser(false, None).name(), "binance+sample");
|
||||
assert_eq!(crate::for_browser(false, Some("ws://127.0.0.1:9/ws")).name(), "server+binance+sample");
|
||||
assert_eq!(crate::for_browser(true, Some("ws://127.0.0.1:9/ws")).name(), "sample");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! A websocket client: tokio-tungstenite on the desktop, the browser's `WebSocket` on the web.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use native::Socket;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use web::Socket;
|
||||
|
||||
/// A data message. Control frames stay inside the socket.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Message {
|
||||
Text(String),
|
||||
Binary(Vec<u8>),
|
||||
}
|
||||
|
||||
impl Socket {
|
||||
pub async fn send(&mut self, text: String) -> Result<()> {
|
||||
self.send_message(Message::Text(text)).await
|
||||
}
|
||||
|
||||
/// The next text message, skipping binary ones; `None` once the server closes the connection.
|
||||
pub async fn next(&mut self) -> Option<Result<String>> {
|
||||
loop {
|
||||
match self.next_message().await? {
|
||||
Ok(Message::Text(t)) => return Some(Ok(t)),
|
||||
Ok(Message::Binary(_)) => {}
|
||||
Err(e) => return Some(Err(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native {
|
||||
use anyhow::{Context, Result};
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
|
||||
|
||||
use super::Message;
|
||||
|
||||
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_message(&mut self, m: Message) -> Result<()> {
|
||||
let m = match m {
|
||||
Message::Text(t) => WsMessage::Text(t.into()),
|
||||
Message::Binary(b) => WsMessage::Binary(b.into()),
|
||||
};
|
||||
Ok(self.0.send(m).await?)
|
||||
}
|
||||
|
||||
/// The next data message; `None` once the server closes the connection.
|
||||
pub async fn next_message(&mut self) -> Option<Result<Message>> {
|
||||
while let Some(msg) = self.0.next().await {
|
||||
match msg {
|
||||
Ok(WsMessage::Text(t)) => return Some(Ok(Message::Text(t.as_str().to_owned()))),
|
||||
Ok(WsMessage::Binary(b)) => return Some(Ok(Message::Binary(b.into()))),
|
||||
Ok(WsMessage::Close(_)) => return None,
|
||||
Ok(_) => {}
|
||||
Err(e) => return Some(Err(e.into())),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Send a close frame.
|
||||
pub async fn close(&mut self) {
|
||||
let _ = self.0.close(None).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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::{BinaryType, CloseEvent, MessageEvent, WebSocket};
|
||||
|
||||
use super::Message;
|
||||
|
||||
enum Event {
|
||||
Open,
|
||||
Message(Message),
|
||||
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:?}"))?;
|
||||
// Binary frames arrive as ArrayBuffers rather than Blobs, which can only be read asynchronously.
|
||||
ws.set_binary_type(BinaryType::Arraybuffer);
|
||||
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| {
|
||||
let data = e.data();
|
||||
let msg = if let Some(text) = data.as_string() {
|
||||
Message::Text(text)
|
||||
} else if let Some(buf) = data.dyn_ref::<js_sys::ArrayBuffer>() {
|
||||
Message::Binary(js_sys::Uint8Array::new(buf).to_vec())
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let _ = t.send(Event::Message(msg));
|
||||
});
|
||||
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_message(&mut self, m: Message) -> Result<()> {
|
||||
match m {
|
||||
Message::Text(t) => self.ws.send_with_str(&t),
|
||||
Message::Binary(b) => self.ws.send_with_u8_array(&b),
|
||||
}
|
||||
.map_err(|e| anyhow!("websocket send: {e:?}"))
|
||||
}
|
||||
|
||||
/// The next data message; `None` once the server closes the connection.
|
||||
pub async fn next_message(&mut self) -> Option<Result<Message>> {
|
||||
match self.rx.recv().await? {
|
||||
Event::Message(m) => Some(Ok(m)),
|
||||
Event::Error => Some(Err(anyhow!("websocket error"))),
|
||||
Event::Open | Event::Closed => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a close frame.
|
||||
pub async fn close(&mut self) {
|
||||
let _ = self.ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ impl YahooProvider {
|
||||
pub fn new() -> Result<Self> {
|
||||
let client = reqwest::Client::builder()
|
||||
// Yahoo rejects requests without a browser-like user agent.
|
||||
.user_agent("Mozilla/5.0 (compatible; terminal-rs)")
|
||||
.user_agent("Mozilla/5.0 (compatible; terminal)")
|
||||
.timeout(Duration::from_secs(20))
|
||||
.cookie_store(true)
|
||||
.build()?;
|
||||
@@ -412,7 +412,7 @@ pub fn yahoo_symbol(sec: &Security) -> String {
|
||||
Some((b, q)) => format!("{b}-{}", if q == "USDT" { "USD" } else { q }),
|
||||
None => sec.ticker.clone(),
|
||||
},
|
||||
AssetClass::Govt | AssetClass::Cmdty => sec.ticker.clone(),
|
||||
AssetClass::Govt | AssetClass::Cmdty | AssetClass::Perp => sec.ticker.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "tr-hl"
|
||||
description = "Hyperliquid order entry on testnet: L1 and user-signed signing, actions, asset ids, rounding, hard caps, info/exchange clients and the user stream"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
reqwest.workspace = true
|
||||
tracing.workspace = true
|
||||
alloy-primitives = { version = "1", default-features = false, features = ["std", "k256"] }
|
||||
alloy-sol-types = { version = "1", default-features = false, features = ["std", "eip712-serde"] }
|
||||
k256 = { version = "0.13", default-features = false, features = ["std", "ecdsa"] }
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
rmp-serde = "1"
|
||||
zeroize = "1"
|
||||
web-time = "1"
|
||||
futures = "0.3"
|
||||
|
||||
# Key generation on the web reads the browser's crypto.getRandomValues.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
# The built-in wallet's approval, end to end against Turnkey's local stand-in.
|
||||
tr-turnkey = { workspace = true, features = ["mock"] }
|
||||
tokio = { workspace = true, features = ["macros", "rt", "net", "io-util"] }
|
||||
tracing-subscriber.workspace = true
|
||||
@@ -0,0 +1,247 @@
|
||||
//! A live check of this crate's signatures against Hyperliquid **testnet**, with throwaway
|
||||
//! keys. Never run in CI. It only uses the crate's public API, so every order goes through
|
||||
//! the same caps as the app.
|
||||
//!
|
||||
//! ```text
|
||||
//! # keys: a 0600 JSON file outside the repo, {"main": "0x…", "agent": "0x…"}
|
||||
//! cargo run -p tr-hl --example testnet_check -- --generate /tmp/hl-throwaway/keys.json
|
||||
//! cargo run -p tr-hl --example testnet_check -- /tmp/hl-throwaway/keys.json
|
||||
//! ```
|
||||
//!
|
||||
//! It never prints a key or a signature, and masks every address (`0x1234…abcd`). Without
|
||||
//! test USDC the account doesn't exist on Hyperliquid, so the expected answers are business
|
||||
//! rejections. Hyperliquid reports a signature it can't match as "User or API Wallet 0x… does
|
||||
//! not exist", where 0x… is the address it recovered: a match with the signer means the
|
||||
//! signature is right. A deliberately corrupted signature is sent as the control.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use tr_hl::SigningKey;
|
||||
use tr_hl::actions::{Tif, cloid};
|
||||
use tr_hl::client::{Client, ExchangeResponse, SendError, recovered_address};
|
||||
use tr_hl::key::{AgentKey, MainKey, mask_address};
|
||||
use tr_hl::nonce::{NonceManager, now_ms};
|
||||
use tr_hl::trader::*;
|
||||
|
||||
/// Replace every `0x` + 40 hex digits with its masked form.
|
||||
fn mask(text: &str) -> String {
|
||||
tr_hl::mask_addresses(text)
|
||||
}
|
||||
|
||||
fn generate(path: &Path) -> anyhow::Result<()> {
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
let dir = path.parent().unwrap();
|
||||
std::fs::create_dir_all(dir)?;
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
let (main, agent) = (AgentKey::generate(), AgentKey::generate());
|
||||
let text = zeroize::Zeroizing::new(json!({"main": *main.to_hex(), "agent": *agent.to_hex()}).to_string());
|
||||
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(path)?;
|
||||
f.write_all(text.as_bytes())?;
|
||||
println!(
|
||||
"wrote throwaway keys (0600) for main {} and agent {}",
|
||||
mask_address(&main.address()),
|
||||
mask_address(&agent.address())
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Check {
|
||||
client: Client,
|
||||
out: Vec<String>,
|
||||
}
|
||||
|
||||
impl Check {
|
||||
fn say(&mut self, s: impl AsRef<str>) {
|
||||
let line = mask(s.as_ref());
|
||||
println!("{line}");
|
||||
self.out.push(line);
|
||||
}
|
||||
|
||||
async fn info(&mut self, label: &str, body: Value) -> Value {
|
||||
match self.client.info(body).await {
|
||||
Ok(v) => {
|
||||
self.say(format!("info {label}: {v}"));
|
||||
v
|
||||
}
|
||||
Err(e) => {
|
||||
self.say(format!("info {label}: ERROR {e:#}"));
|
||||
Value::Null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send, and say whom Hyperliquid recovered if it names an address.
|
||||
fn report(&mut self, label: &str, signer: &alloy_primitives::Address, r: &Result<ExchangeResponse, SendError>) {
|
||||
match r {
|
||||
Ok(resp) => {
|
||||
self.say(format!("{label}: {}", resp.summary()));
|
||||
if let Some(rec) = resp.first_error().and_then(recovered_address) {
|
||||
let verdict = if &rec == signer { "MATCHES the signer" } else { "does NOT match the signer" };
|
||||
self.say(format!(" recovered {} {verdict} {}", mask_address(&rec), mask_address(signer)));
|
||||
}
|
||||
}
|
||||
Err(e) => self.say(format!("{label}: send error: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.first().map(String::as_str) == Some("--generate") {
|
||||
return generate(Path::new(args.get(1).expect("path to the keys file")));
|
||||
}
|
||||
let path = args.first().expect("path to the keys file");
|
||||
let keys: Value = serde_json::from_str(&zeroize::Zeroizing::new(std::fs::read_to_string(path)?))?;
|
||||
let main = MainKey::from_hex(keys["main"].as_str().unwrap())?;
|
||||
let agent = AgentKey::from_hex(keys["agent"].as_str().unwrap())?;
|
||||
drop(keys);
|
||||
let (m, a) = (main.address(), agent.address());
|
||||
let mut c = Check { client: Client::new()?, out: Vec::new() };
|
||||
c.say(format!("testnet check at {} UTC ms {}; main {m:#x}, agent {a:#x}", chrono_like(now_ms()), now_ms()));
|
||||
|
||||
// 1. Before approving.
|
||||
c.info("userRole(main) before", json!({"type": "userRole", "user": format!("{m:#x}")})).await;
|
||||
c.info("userRole(agent) before", json!({"type": "userRole", "user": format!("{a:#x}")})).await;
|
||||
c.info("extraAgents(main) before", json!({"type": "extraAgents", "user": format!("{m:#x}")})).await;
|
||||
|
||||
// 2. approveAgent, signed by the main key.
|
||||
let approve = approve_agent(&a, "check", DEFAULT_AGENT_DAYS, now_ms());
|
||||
c.say(format!(
|
||||
"approveAgent name {:?}, chain {}, signatureChainId {}",
|
||||
approve.agent_name, approve.hyperliquid_chain, approve.signature_chain_id
|
||||
));
|
||||
let sig = tr_hl::signing::sign_approve_agent(&main, &approve)?;
|
||||
let r = c.client.send_user_signed(&user_signed_body(&approve, &sig)).await;
|
||||
c.report("approveAgent (main key)", &m, &r);
|
||||
|
||||
// 3. After.
|
||||
c.info("userRole(main) after", json!({"type": "userRole", "user": format!("{m:#x}")})).await;
|
||||
c.info("userRole(agent) after", json!({"type": "userRole", "user": format!("{a:#x}")})).await;
|
||||
c.info("extraAgents(main) after", json!({"type": "extraAgents", "user": format!("{m:#x}")})).await;
|
||||
|
||||
// 4. Markets, leverage and testnet mids.
|
||||
let reg = c.client.registry().await?;
|
||||
c.say(format!(
|
||||
"registry: {} markets; xyz is perpDexs index {:?}; BTC id {}; xyz:AAPL id {}",
|
||||
reg.len(),
|
||||
reg.dex_index("xyz"),
|
||||
reg.resolve("BTC").map(|x| x.id).unwrap_or(0),
|
||||
reg.resolve("xyz:AAPL").map(|x| x.id).unwrap_or(0)
|
||||
));
|
||||
let (agent_nonces, main_nonces) = (NonceManager::new(), NonceManager::new());
|
||||
for coin in ["BTC", "xyz:AAPL"] {
|
||||
let asset = reg.resolve(coin).map_err(anyhow::Error::msg)?.clone();
|
||||
let dex = asset.dex.clone();
|
||||
let mid = c.client.mids(&dex).await?.get(coin).copied();
|
||||
let data = c.client.asset_data(&m, coin).await?;
|
||||
let lev = data.as_ref().and_then(|d| d.leverage);
|
||||
c.say(format!("{coin}: testnet mid {mid:?}, account leverage {lev:?}"));
|
||||
|
||||
// 5. Leverage to 3× (agent), as the ticket does before a first order.
|
||||
let s = sign_update_leverage(&agent, &agent_nonces, &asset, !asset.only_isolated, 3, now_ms())?;
|
||||
let r = c.client.send(&s).await;
|
||||
c.report(&format!("updateLeverage {coin} 3× (agent)"), &a, &r);
|
||||
let lev = c.client.asset_data(&m, coin).await?.and_then(|d| d.leverage);
|
||||
c.say(format!("{coin}: account leverage now {lev:?}"));
|
||||
|
||||
// 6. A small limit far below the market, by the agent and by the main key.
|
||||
let mid = mid.expect("testnet mid");
|
||||
let px = mid * 0.6;
|
||||
let size = 12.0 / px;
|
||||
let intent = OrderIntent {
|
||||
coin: coin.into(),
|
||||
is_buy: true,
|
||||
size,
|
||||
kind: OrderKind::Limit { px, tif: Tif::Gtc },
|
||||
reduce_only: false,
|
||||
};
|
||||
let ctx = MarketCtx { mid: Some(mid), leverage: lev };
|
||||
for (who, signer) in [("agent", a), ("main key", m)] {
|
||||
let id = cloid(now_ms() as u128);
|
||||
match plan_order(®, &intent, &ctx, &id) {
|
||||
Ok(plan) => {
|
||||
c.say(format!(
|
||||
"{coin} order plan: BUY {} @ {} (notional ${:.2}), cloid {}",
|
||||
plan.size(),
|
||||
plan.price(),
|
||||
plan.notional(),
|
||||
plan.cloid()
|
||||
));
|
||||
let s = if who == "agent" {
|
||||
sign_order(&agent, &agent_nonces, &plan, &ctx, now_ms())?
|
||||
} else {
|
||||
sign_order(&main, &main_nonces, &plan, &ctx, now_ms())?
|
||||
};
|
||||
let r = c.client.send(&s).await;
|
||||
c.report(&format!("order {coin} limit buy (signed by {who})"), &signer, &r);
|
||||
}
|
||||
Err(refusal) => {
|
||||
c.say(format!("{coin} order (signed by {who}): REFUSED before signing: {refusal}"));
|
||||
// A reduce-only sell far above the market passes the caps whatever the
|
||||
// leverage, and still exercises the order signature.
|
||||
let ro = OrderIntent {
|
||||
is_buy: false,
|
||||
reduce_only: true,
|
||||
kind: OrderKind::Limit { px: mid * 1.4, tif: Tif::Gtc },
|
||||
size: 12.0 / (mid * 1.4),
|
||||
..intent.clone()
|
||||
};
|
||||
let plan = plan_order(®, &ro, &ctx, &id).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
c.say(format!(
|
||||
"{coin} reduce-only plan: SELL {} @ {} (notional ${:.2})",
|
||||
plan.size(),
|
||||
plan.price(),
|
||||
plan.notional()
|
||||
));
|
||||
let s = if who == "agent" {
|
||||
sign_order(&agent, &agent_nonces, &plan, &ctx, now_ms())?
|
||||
} else {
|
||||
sign_order(&main, &main_nonces, &plan, &ctx, now_ms())?
|
||||
};
|
||||
let r = c.client.send(&s).await;
|
||||
c.report(&format!("order {coin} reduce-only limit sell (signed by {who})"), &signer, &r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. The control: a damaged agent signature recovers someone else.
|
||||
let noop = sign_noop(&agent, &agent_nonces, now_ms())?;
|
||||
let r = c.client.send(&noop.corrupted()).await;
|
||||
c.report("noop with a CORRUPTED agent signature (control)", &a, &r);
|
||||
|
||||
// 8. Cancel an order that doesn't exist, and a noop.
|
||||
let btc = reg.resolve("BTC").map_err(anyhow::Error::msg)?.clone();
|
||||
let s = sign_cancel(&agent, &agent_nonces, &btc, 1, now_ms())?;
|
||||
let r = c.client.send(&s).await;
|
||||
c.report("cancel BTC oid 1 (agent)", &a, &r);
|
||||
let r = c.client.send(&sign_noop(&agent, &agent_nonces, now_ms())?).await;
|
||||
c.report("noop (agent)", &a, &r);
|
||||
let r = c.client.send(&sign_noop(&main, &main_nonces, now_ms())?).await;
|
||||
c.report("noop (main key)", &m, &r);
|
||||
|
||||
if let Some(out) = args.get(1) {
|
||||
std::fs::write(out, c.out.join("\n") + "\n")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `2026-11-03T01:02:03Z` without a date crate.
|
||||
fn chrono_like(ms: u64) -> String {
|
||||
let s = ms / 1000;
|
||||
let (days, rem) = (s / 86_400, s % 86_400);
|
||||
// Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
|
||||
let z = days as i64 + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + i64::from(mo <= 2);
|
||||
format!("{y:04}-{mo:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60)
|
||||
}
|
||||
Loaded 100 of 285 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user