Compare commits

..
31 Commits
Author SHA1 Message Date
Michael ShimelesandClaude Opus 5.5 a12b5d14e4 feat(hl): built-in Turnkey wallets on testnet (#38)
* docs: design for Hyperliquid trading (crypto + HIP-3 stock perps)

Design only, no feature code. Covers custody (client-side API wallet
signing, recommended), agent approve/revoke flow, asset routing for
core/spot/HIP-3, order ticket, user streams for POS/ORD/ACTV, safety
rails, testnet-only test plan, phases, and open questions including the
Hyperliquid ToS restriction on Ontario.

* docs(hl-trading): plan the market key so HL perp rows reprice and trade

Greptile: market_key() returns None for Future and live_price/row
actions depend on it. Add Position.market and the portfolio_view work.

* docs(hl-trading): keep testnet and mainnet quotes in separate maps

Greptile: same market keys on both networks would let one overwrite the
other in the shared quote map. Plan a network-aware live_price and
mainnet-only gateway requests, with interleaved-update tests.

* feat(hl): add tr-hl, a testnet-only Hyperliquid signing and trading crate

tr-hl signs and sends Hyperliquid actions from the device. It covers
L1 phantom-agent signing and user-signed approveAgent, the order,
cancel, cancelByCloid, modify, updateLeverage, scheduleCancel and noop
actions, asset ids read from perpDexs and meta, tick and lot rounding,
info and exchange clients, a user-stream reducer and a nonce manager.

Only testnet exists in this crate. The endpoints are the testnet
hosts, the phantom agent source is always "b", hyperliquidChain is
always "Testnet", and Network has one variant. A test greps the crate
for mainnet hosts.

The caps are constants and the trader enforces them before it signs.
Orders are capped at $100 notional, leverage at 3x and market slippage
at 1%. The builder field is never sent. Signatures match the Python
SDK's published vectors, plus an approveAgent vector generated with
SDK 0.24.0. The crate builds for wasm32.

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

* feat: Hyperliquid testnet trading in the app

Hyperliquid joins LIVE next to SnapTrade, with its own opt-in in
Settings > Trading > Hyperliquid and an amber TESTNET badge on the
toolbar, the account, the ticket and every confirmation.

- Connect an address read-only. The account's positions, orders,
  fills and funding then show in PORT, POS, ORD and ACTV, from
  Hyperliquid's websocket and never through Terminal's server.
- Approve an agent on the desktop by pasting a throwaway testnet main
  key once. Terminal wipes it after signing and never stores it. In
  the browser the wallet signs instead (EIP-1193). The agent key lives
  in the macOS Keychain or the Linux Secret Service, and in browser
  memory or, by opt-in, IndexedDB under a non-extractable WebCrypto
  key. There is no file fallback.
- The ticket's Hyperliquid variant covers market (IOC, 1% cap), limit
  (GTC, IOC, post-only), stop loss and take profit, reduce-only and
  "Set 3x" leverage. HIP-3 rows read "PERP · HL xyz (perpetual, not a
  share)".
- Every order, cancel, cancel-all and leverage change asks for one
  confirmation. The worker re-reads the testnet mid and leverage and
  checks the caps again right before it signs.
- KILL and a button in ORD cancel every open order and turn
  Hyperliquid orders off. The dead man's switch is opt-in.
- Testnet rows price only from testnet mids. Position.market now
  carries the exact key, and live_price picks the map by network.

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

* fix(hl): mask addresses in Hyperliquid messages, toasts and logs

Hyperliquid's errors name full addresses ("User or API Wallet 0x… does
not exist", "Must deposit… User: 0x…"). mask_addresses turns each one
into 0x1234…abcd. Exchange summaries, the worker's logs and every app
message that shows a Hyperliquid error now go through it.

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

* fix(hl): keep Hyperliquid out of SnapTrade's settings sections

The offscreen render showed the Hyperliquid account under SnapTrade's
Brokerage access, with SnapTrade's "reconnect with trading access" advice.
That section and the default-account picker now list SnapTrade accounts
only. Hyperliquid has its own section.

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

* docs(hl-trading): record the v1 decisions and add screenshots and a wasm CI check

Section 15 of the design now records Micky's twelve decisions as
"Decided (2026-11-02)". The sections they change are updated: testnet
only, Secret Service with no file fallback on Linux, a pasted throwaway
key on the desktop for now, hard caps, an opt-in dead man's switch and
the kill switch. A new section 16 lists what v1 builds and what is still
missing.

docs/img/hl-*.png holds the before (origin/master) and after
screenshots. CI now builds and lints tr-hl for wasm32.

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

* fix(hl): read the agent key from the store once per account

When the store had no key for the approved agent, the app asked again
on every frame, which meant a Keychain or Secret Service read per frame.
It now remembers the account it read and asks again only after a
connect, an approval or a revoke.

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

* fix(hl): fix the kill switch, uncertain sends, approvals and cap valuation

Fixes from Greptile's first review of #37:

- Totals value a margined position (a perp) by its P&L, not its
  notional. Hyperliquid's cash is its collateral (equity less
  unrealised P&L), so a reprice moves equity only by the P&L change.
- KILL reads the open orders fresh on every dex, cancels them, and
  reads again (three rounds at most) before it reports what is left.
  Signed actions now run one at a time, and an order confirmed before
  a KILL that hasn't been signed yet is refused.
- An approval answer applies only to the account that asked for it,
  and Remove is disabled while one is in flight. The app also never
  signs with a key that isn't the approved agent's.
- An uncertain send blocks new orders until orderStatus finds its
  cloid, or until its 30 s expiresAfter window has passed without it.
- Every order needs a current mid before it is valued against the $100
  cap. Prices that round to zero are refused, and rounding zero no
  longer overflows.
- The keyring check runs in the worker, not on the UI thread.
- A cancel-all skips a market the registry doesn't know instead of
  failing, and the pasted key field's undo history is wiped once the
  key has signed.

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

* fix(hl): keep the dead man's switch armed while an order is unresolved

From Greptile's second review of #37: an order with no clear answer
blocked new sends through the same guard the dead man's switch reads,
so the switch cleared its scheduled cancel just when a possibly
accepted order needed it.

The guard is now split. Whether this device can sign arms the switch,
and allows cancels and leverage changes. Only new orders and modifies
also wait for an unresolved order. KILL always goes ahead.

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

* fix(hl): size dollar amounts at the mid and let KILL skip stalled requests

Fixes from Greptile's fourth review of #37 (at f636b7c):

- A dollar amount on the Hyperliquid ticket (BUY xyz:INTC $10) became
  that many contracts. The ticket now sizes it at the testnet mid,
  rounded down to the market's size step. With no mid yet, or under one
  step, it stays a dollar amount and the ticket can't send it. Changing
  the market sizes the amount again.
- Every /info and /exchange request has its own 10 s deadline. The
  browser's fetch had none, so a stalled request could hold the
  worker's only turn forever.
- KILL no longer waits for that turn. It cancels at once, and an order
  confirmed before it gives up, even one still reading the market. If
  an order was already being sent, KILL cancels again once its answer
  comes, or once its expiresAfter has passed if the send got no clear
  answer. Single cancels and the dead man's switch don't wait for
  orders either. Orders, modifies and leverage changes still go one at
  a time, and an order with no clear answer still blocks new ones until
  it is found or expires.

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

* fix(hl): size dollars for the market named now and keep timed cancels in order

Fixes from Greptile's review of f1ff1b3:

- The ticket reads its market at the start of a frame, before an edit
  to the market field. A dollar amount could then be sized at the old
  market's mid ($10 at a $2 mid is 5 units, worth $20 on a $4 market).
  settle_size now sizes only with the market the ticket names, so the
  next frame sizes it for the new one. The ticket's estimate also uses
  that market.
- Dead man's switch changes ran side by side, so a slow clear could
  land after a newer schedule and remove it. They now go out one at a
  time in the order the app made them, on a lock of their own that
  never waits for orders. A change that reaches the worker after a
  newer one is dropped. After a change with no clear answer, the next
  waits until it can no longer land.

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

* fix(hl): give each dead man's switch refresh a fresh deadline

From Greptile's review of 2f7cab6: after a change with no clear answer,
every later change waited until that one could no longer land. Refreshes
queue every 30 s, so with lost replies they went out every 35 s or so,
each with the deadline it got when queued, and the timer could fire
while Terminal was still running.

- ScheduleCancel now carries how far ahead to cancel. The worker adds
  it to the time of signing, so a refresh that waited still gets a
  fresh deadline.
- A newer change replaces one still waiting for its turn, so a backlog
  collapses into the newest.
- Only a change of the other kind waits out a lost one: a timer after a
  clear, or a clear after a timer. A refresh that lands late only moves
  the timer back by one refresh, so refreshes don't wait for each other.

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

* fix(hl): make every dead man's switch change wait out a lost one

From Greptile's review of 73b71b8: letting a refresh go out before an
earlier timer with a lost reply could no longer land was not safe. That
timer could land last (the clocks may differ by seconds) and pull the
deadline in, so the switch could fire while Terminal was running.

Every change now waits until a lost earlier change can no longer land,
whichever kind either one is. That is safe now that each change is
signed, with its deadline, when it goes out, and a newer change
replaces any still waiting, so a backlog can't build up stale
deadlines.

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

* feat(hl): sign approveAgent through a built-in Turnkey wallet, testnet only

Add tr-turnkey, the client side of Terminal's built-in wallets. The device
makes a P-256 key, and every Turnkey request carries a stamp made with it.
The session token Turnkey issues for that key lasts 15 minutes, and Terminal
accepts it only if it is bound to its own key. The client reads whoami and
the wallet's Ethereum account, and signs one digest with the request
alloy-signer-turnkey sends (SIGN_RAW_PAYLOAD_V2, hex payload, no hashing).
On the desktop the browser returns the session to a listener on 127.0.0.1
that checks a 256-bit state. A mock feature adds a local stand-in for
Turnkey's API and sign-in page for tests and screenshots.

tr-hl gets sign_approve_agent_with, the only way a remote wallet's
signature enters the crate. The signer sees only the digest of a testnet
approveAgent, and the answer is used only if it recovers the wallet.
approve_agent_hash now also refuses every signatureChainId except 0x66eee.

The mock wallet holds the Python SDK's public test key and reproduces the
SDK's approveAgent vector. Mainnet and 0xa4b1 approvals never reach the
wallet, and the new code names no mainnet host.

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

* docs(hl): design built-in Turnkey wallets for testnet trading

Record Micky's decision of 2026-11-04 in §15: built-in wallets only, no
browser wallet and no pasted key. The new §4.5 covers who holds which key,
the browser sign-in that hands a session to a key made on the device, why
a loopback redirect beats a terminal:// link, what Turnkey signs and how
often, and the export through Turnkey's iframe. It explains why no
Terminal server needs a Turnkey key (the Auth Proxy holds the one that
creates sub-organizations) and sketches a scoped signup function as an
unbuilt fallback. Agents now last 180 days by default, since each approval
is a paid Turnkey signature.

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

* feat(hl): replace the browser wallet and pasted key with the built-in wallet

Settings › Trading › Hyperliquid now starts with "Create Wallet / Sign In".
On the desktop it opens Terminal's sign-in page in the system browser and
waits on a 127.0.0.1 loopback for the session; the web build opens the page
in a popup and takes its postMessage. Terminal reads the wallet's address
from Turnkey with requests stamped by its own key, keeps the 15-minute
session in the OS secret store (memory on the web) and deletes it once it
expires.

"Approve Trading Key" makes the agent key on the device and has the wallet
sign approveAgent through Turnkey, signing in first if there is no session.
Revoke goes the same way. Agents now last 180 days by default, since each
approval is a paid Turnkey signature. Orders are still signed only by the
agent key.

Deposit shows the address in full with a QR code and a copy button, the
testnet perp account value and withdrawable from the stream, and spot USDC
(new spotClearinghouseState read). Back Up Wallet warns, then opens the
page's export mode.

The web's EIP-1193 Connect Wallet and the desktop's pasted testnet key are
gone. MainKey is left for tests and the testnet_check example. Watching an
address read-only stays.

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

* feat(site): add the wallet sign-in and backup page

/wallet/ signs the user in with an email code or a passkey through
@turnkey/core and Turnkey's Auth Proxy. A new user gets a sub-organization
with one Ethereum wallet. The page then calls stamp_login for the app's
public key, so the app gets a 15-minute session only its own key can use,
and logs itself out. Desktop answers go to the loopback port, web answers
to the opener with postMessage on the same origin.

Its parameters come from the URL fragment, which never reaches a server,
analytics or a Referer, and the page clears it on load. In export mode
the page shows the recovery phrase in Turnkey's export iframe, so the
phrase never touches the page's code or any server.

It needs two public IDs in the Vercel project env and says so until they
are set. @turnkey/core is pinned to 2.13.1 and @turnkey/iframe-stamper to
2.11.1.

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

* ci: build and lint tr-turnkey for wasm32

The browser build uses the wallet crate too, so check it the way tr-hl is
checked.

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

* fix(hl): keep the trading key when the wallet doesn't sign a revoke

Revoke used to forget the agent before Turnkey signed anything, so a failed
or expired sign-in left the app without its agent while the message said
nothing changed. Orders now turn off at once, and the agent is forgotten
only when the revoke goes through. An approval the wallet didn't sign now
says so instead of reading as Hyperliquid's refusal. The mock-URL warnings
no longer print the URL.

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

* fix(hl): link the sign-in page at /wallet, its canonical URL

The site serves the page at /wallet and redirects /wallet/ there. Browsers
keep the fragment across that redirect, but there is no reason to rely on
it.

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

* fix(site): only sign in, never sign up, when backing up a wallet

Export mode hid nothing: it offered "Create with a passkey", and the email
code path would create a new wallet for an unknown address. Backing up now
logs in to an existing wallet only.

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

* docs: add before and after screenshots for built-in wallets

Desktop shots are offscreen renders of the running app against a local
Turnkey stand-in. Web shots come from headless Chromium on a local build.

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

* fix(hl): keep sign-in answers and wallet actions on the right wallet

From Greptile on #38:

- Removing the wallet reset the request counter, so a cancelled sign-in's
  late answer could carry the next sign-in's id and end it. Ids now keep
  counting, and removing the wallet cancels a sign-in in progress.
- A revoke or approval that needed a sign-in acted on whichever wallet the
  user signed in to. The sign-in now remembers the wallet the action is
  for, and refuses another one without changing anything.
- The browser build's sign-in never timed out. Both builds now give up
  after 10 minutes, the limit the waiting line shows.
- The sign-in page stayed on "Connecting your wallet" if Turnkey didn't
  issue the device's session. It now goes back to the sign-in controls
  with the error, so the user can retry or cancel.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 08:38:08 -05:00
Michael ShimelesandClaude Opus 5.5 08f54643d9 feat: Hyperliquid trading on testnet (v1) (#37)
* docs: design for Hyperliquid trading (crypto + HIP-3 stock perps)

Design only, no feature code. Covers custody (client-side API wallet
signing, recommended), agent approve/revoke flow, asset routing for
core/spot/HIP-3, order ticket, user streams for POS/ORD/ACTV, safety
rails, testnet-only test plan, phases, and open questions including the
Hyperliquid ToS restriction on Ontario.

* docs(hl-trading): plan the market key so HL perp rows reprice and trade

Greptile: market_key() returns None for Future and live_price/row
actions depend on it. Add Position.market and the portfolio_view work.

* docs(hl-trading): keep testnet and mainnet quotes in separate maps

Greptile: same market keys on both networks would let one overwrite the
other in the shared quote map. Plan a network-aware live_price and
mainnet-only gateway requests, with interleaved-update tests.

* feat(hl): add tr-hl, a testnet-only Hyperliquid signing and trading crate

tr-hl signs and sends Hyperliquid actions from the device. It covers
L1 phantom-agent signing and user-signed approveAgent, the order,
cancel, cancelByCloid, modify, updateLeverage, scheduleCancel and noop
actions, asset ids read from perpDexs and meta, tick and lot rounding,
info and exchange clients, a user-stream reducer and a nonce manager.

Only testnet exists in this crate. The endpoints are the testnet
hosts, the phantom agent source is always "b", hyperliquidChain is
always "Testnet", and Network has one variant. A test greps the crate
for mainnet hosts.

The caps are constants and the trader enforces them before it signs.
Orders are capped at $100 notional, leverage at 3x and market slippage
at 1%. The builder field is never sent. Signatures match the Python
SDK's published vectors, plus an approveAgent vector generated with
SDK 0.24.0. The crate builds for wasm32.

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

* feat: Hyperliquid testnet trading in the app

Hyperliquid joins LIVE next to SnapTrade, with its own opt-in in
Settings > Trading > Hyperliquid and an amber TESTNET badge on the
toolbar, the account, the ticket and every confirmation.

- Connect an address read-only. The account's positions, orders,
  fills and funding then show in PORT, POS, ORD and ACTV, from
  Hyperliquid's websocket and never through Terminal's server.
- Approve an agent on the desktop by pasting a throwaway testnet main
  key once. Terminal wipes it after signing and never stores it. In
  the browser the wallet signs instead (EIP-1193). The agent key lives
  in the macOS Keychain or the Linux Secret Service, and in browser
  memory or, by opt-in, IndexedDB under a non-extractable WebCrypto
  key. There is no file fallback.
- The ticket's Hyperliquid variant covers market (IOC, 1% cap), limit
  (GTC, IOC, post-only), stop loss and take profit, reduce-only and
  "Set 3x" leverage. HIP-3 rows read "PERP · HL xyz (perpetual, not a
  share)".
- Every order, cancel, cancel-all and leverage change asks for one
  confirmation. The worker re-reads the testnet mid and leverage and
  checks the caps again right before it signs.
- KILL and a button in ORD cancel every open order and turn
  Hyperliquid orders off. The dead man's switch is opt-in.
- Testnet rows price only from testnet mids. Position.market now
  carries the exact key, and live_price picks the map by network.

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

* fix(hl): mask addresses in Hyperliquid messages, toasts and logs

Hyperliquid's errors name full addresses ("User or API Wallet 0x… does
not exist", "Must deposit… User: 0x…"). mask_addresses turns each one
into 0x1234…abcd. Exchange summaries, the worker's logs and every app
message that shows a Hyperliquid error now go through it.

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

* fix(hl): keep Hyperliquid out of SnapTrade's settings sections

The offscreen render showed the Hyperliquid account under SnapTrade's
Brokerage access, with SnapTrade's "reconnect with trading access" advice.
That section and the default-account picker now list SnapTrade accounts
only. Hyperliquid has its own section.

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

* docs(hl-trading): record the v1 decisions and add screenshots and a wasm CI check

Section 15 of the design now records Micky's twelve decisions as
"Decided (2026-11-02)". The sections they change are updated: testnet
only, Secret Service with no file fallback on Linux, a pasted throwaway
key on the desktop for now, hard caps, an opt-in dead man's switch and
the kill switch. A new section 16 lists what v1 builds and what is still
missing.

docs/img/hl-*.png holds the before (origin/master) and after
screenshots. CI now builds and lints tr-hl for wasm32.

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

* fix(hl): read the agent key from the store once per account

When the store had no key for the approved agent, the app asked again
on every frame, which meant a Keychain or Secret Service read per frame.
It now remembers the account it read and asks again only after a
connect, an approval or a revoke.

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

* fix(hl): fix the kill switch, uncertain sends, approvals and cap valuation

Fixes from Greptile's first review of #37:

- Totals value a margined position (a perp) by its P&L, not its
  notional. Hyperliquid's cash is its collateral (equity less
  unrealised P&L), so a reprice moves equity only by the P&L change.
- KILL reads the open orders fresh on every dex, cancels them, and
  reads again (three rounds at most) before it reports what is left.
  Signed actions now run one at a time, and an order confirmed before
  a KILL that hasn't been signed yet is refused.
- An approval answer applies only to the account that asked for it,
  and Remove is disabled while one is in flight. The app also never
  signs with a key that isn't the approved agent's.
- An uncertain send blocks new orders until orderStatus finds its
  cloid, or until its 30 s expiresAfter window has passed without it.
- Every order needs a current mid before it is valued against the $100
  cap. Prices that round to zero are refused, and rounding zero no
  longer overflows.
- The keyring check runs in the worker, not on the UI thread.
- A cancel-all skips a market the registry doesn't know instead of
  failing, and the pasted key field's undo history is wiped once the
  key has signed.

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

* fix(hl): keep the dead man's switch armed while an order is unresolved

From Greptile's second review of #37: an order with no clear answer
blocked new sends through the same guard the dead man's switch reads,
so the switch cleared its scheduled cancel just when a possibly
accepted order needed it.

The guard is now split. Whether this device can sign arms the switch,
and allows cancels and leverage changes. Only new orders and modifies
also wait for an unresolved order. KILL always goes ahead.

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

* fix(hl): size dollar amounts at the mid and let KILL skip stalled requests

Fixes from Greptile's fourth review of #37 (at f636b7c):

- A dollar amount on the Hyperliquid ticket (BUY xyz:INTC $10) became
  that many contracts. The ticket now sizes it at the testnet mid,
  rounded down to the market's size step. With no mid yet, or under one
  step, it stays a dollar amount and the ticket can't send it. Changing
  the market sizes the amount again.
- Every /info and /exchange request has its own 10 s deadline. The
  browser's fetch had none, so a stalled request could hold the
  worker's only turn forever.
- KILL no longer waits for that turn. It cancels at once, and an order
  confirmed before it gives up, even one still reading the market. If
  an order was already being sent, KILL cancels again once its answer
  comes, or once its expiresAfter has passed if the send got no clear
  answer. Single cancels and the dead man's switch don't wait for
  orders either. Orders, modifies and leverage changes still go one at
  a time, and an order with no clear answer still blocks new ones until
  it is found or expires.

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

* fix(hl): size dollars for the market named now and keep timed cancels in order

Fixes from Greptile's review of f1ff1b3:

- The ticket reads its market at the start of a frame, before an edit
  to the market field. A dollar amount could then be sized at the old
  market's mid ($10 at a $2 mid is 5 units, worth $20 on a $4 market).
  settle_size now sizes only with the market the ticket names, so the
  next frame sizes it for the new one. The ticket's estimate also uses
  that market.
- Dead man's switch changes ran side by side, so a slow clear could
  land after a newer schedule and remove it. They now go out one at a
  time in the order the app made them, on a lock of their own that
  never waits for orders. A change that reaches the worker after a
  newer one is dropped. After a change with no clear answer, the next
  waits until it can no longer land.

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

* fix(hl): give each dead man's switch refresh a fresh deadline

From Greptile's review of 2f7cab6: after a change with no clear answer,
every later change waited until that one could no longer land. Refreshes
queue every 30 s, so with lost replies they went out every 35 s or so,
each with the deadline it got when queued, and the timer could fire
while Terminal was still running.

- ScheduleCancel now carries how far ahead to cancel. The worker adds
  it to the time of signing, so a refresh that waited still gets a
  fresh deadline.
- A newer change replaces one still waiting for its turn, so a backlog
  collapses into the newest.
- Only a change of the other kind waits out a lost one: a timer after a
  clear, or a clear after a timer. A refresh that lands late only moves
  the timer back by one refresh, so refreshes don't wait for each other.

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

* fix(hl): make every dead man's switch change wait out a lost one

From Greptile's review of 73b71b8: letting a refresh go out before an
earlier timer with a lost reply could no longer land was not safe. That
timer could land last (the clocks may differ by seconds) and pull the
deadline in, so the switch could fire while Terminal was running.

Every change now waits until a lost earlier change can no longer land,
whichever kind either one is. That is safe now that each change is
signed, with its deadline, when it goes out, and a newer change
replaces any still waiting, so a backlog can't build up stale
deadlines.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-03 12:06:27 -05:00
Michael Shimeles b7efcf5a3a docs: design for Hyperliquid trading (crypto + HIP-3 stock perps) (#36)
* docs: design for Hyperliquid trading (crypto + HIP-3 stock perps)

Design only, no feature code. Covers custody (client-side API wallet
signing, recommended), agent approve/revoke flow, asset routing for
core/spot/HIP-3, order ticket, user streams for POS/ORD/ACTV, safety
rails, testnet-only test plan, phases, and open questions including the
Hyperliquid ToS restriction on Ontario.

* docs(hl-trading): plan the market key so HL perp rows reprice and trade

Greptile: market_key() returns None for Future and live_price/row
actions depend on it. Add Position.market and the portfolio_view work.

* docs(hl-trading): keep testnet and mainnet quotes in separate maps

Greptile: same market keys on both networks would let one overwrite the
other in the shared quote map. Plan a network-aware live_price and
mainnet-only gateway requests, with interleaved-update tests.
2026-11-03 09:45:18 -05:00
Michael Shimeles 5bcf3a50bc Refresh order books about every 0.5 s instead of every 5 s (#34)
* Refresh order books about every 0.5 s instead of every 5 s

Hyperliquid's default l2Book arrives about every 5.4 s. The fast mode
arrives about every 0.5 s but has only 5 levels a side. A book now
subscribes to both, and the gateway puts the fast levels on top of the
default book's deeper ones. If the subscription budget only has room
for the default feed, the book keeps its old 5 s cadence.

The app now asks for a visible book every 400 ms instead of every
second, with one request out at a time.

Measured for BTC over 60 s:
- gateway updates: p50 5353 ms, p95 6283 ms before (live server);
  p50 532 ms, p95 704 ms after (local build on the public feed)
- what a book pane gets: p50 5014 ms before; p50 503 ms, p95 1006 ms
  after

The cli's watch command now prints how far apart a topic's updates are.

* Drop a stalled fast book, and match book answers to asks

- A fast book older than 3 s no longer sits on top of a newer default
  book; the default book is shown alone until fast updates resume.
- Each book request carries a number, and only the answer to the
  latest one clears the pane's pending flag, so a late answer for an
  old symbol can't start overlapping requests.
- cli: update intervals are measured per subscription, so `load` with
  several clients reports each client's cadence.

* Tell the two book feeds apart by Hyperliquid's fast flag, not by depth

A thin market's default book can be 5 levels a side. Classed as fast, it
left the default feed's older, deeper book cached, so removed levels stayed
on the merged book. Fast messages carry "fast":true and default ones leave
it out (checked on the live feed for BTC and xyz:AAPL), so parse reads that,
and the pool no longer remaps thin books.
2026-11-03 09:44:44 -05:00
Michael Shimeles 0690de8c6c Harden the gateway: optional token for /stats and /metrics, per-address cap, flood close, sandboxed unit (#33)
* Gate /stats and /metrics behind an optional token and harden the gateway

- TR_METRICS_TOKEN: when set, /stats and /metrics answer 404 unless the
  request sends it as a bearer token. /healthz and /readyz stay public.
  The token never shows in Debug output or errors.
- TR_MAX_CLIENTS_PER_IP caps connections per address (IPv6 per /64), off
  by default. TR_CLIENT_IP_HEADER takes the address from the edge's
  header. The first connection logs whether x-forwarded-for was there,
  never its value, so the cap can be checked behind Freestyle's edge.
- A client that keeps sending after a budget's worth of RateLimited
  replies is closed with 1008 instead of being answered forever.
- cli stats sends TR_METRICS_TOKEN when set.
- vm-setup.sh: optional /etc/tr-gateway/env, stop timeout, memory and
  task caps, and systemd sandboxing (security score 8.3 to 3.1).

* Let the site's server URL come from a repository variable

deploy-site.yml passes the TR_SERVER_URL repository variable to
build-web.sh when it's set, so moving the site to the gateway's custom
domain is a variable and a redeploy, not a code change.

* Accept the bearer scheme in any case

HTTP auth scheme names are case-insensitive, so "bearer <token>" now
works too. The token itself is still compared exactly.
2026-11-02 15:39:07 -05:00
Michael ShimelesandClaude Opus 5.5 1b7ab3400d Add Hyperliquid HIP-3 perps, from the Terminal data server (#35)
* Add Hyperliquid HIP-3 perps as their own instruments

A HIP-3 perp keeps Hyperliquid's coin name as its market key (xyz:AAPL,
typed in any case) and a new Perp asset class. AAPL US Equity stays the
listed stock. A reviewed table in tr-core links 27 app instruments to the
perp on the same underlying, checked against trade.xyz's spec index, live
meta and price. XYZ100, JP225 and HO stay unlinked because the docs don't
name the index or tie the coin to the product.

The gateway stops serving delisted markets. Hyperliquid keeps publishing
mids for every delisted perp (56 core, 58 HIP-3 on 2026-11-02), so the
market list, key resolution, subscriptions and history now check meta's
isDelisted. A dex with nothing live gets no mids feed, as before.

Perps come only from the Terminal server. Yahoo, the browser sources,
sample mode and the Fallback wrapper refuse them instead of filling in.

In the app, `AAPL PERP` opens the linked perp, and `PERP` or Shift+P
switches the focused pane back and forth. Charts, books, tabs, the
watchlist, the palette and SECF label perps "PERP · HL xyz". A chart on a
linked instrument shows a switch chip once the server lists its perp as
live, and a stock's simulated book offers the perp's real book, titled as
the perp's.

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

* Show a perp's trades beside its book, and record the HIP-3 decision

The order book pane now lists recent trades when its book comes from
the Terminal server. They go beside the ladder in a wide pane and below
it in a tall one, and a perp's list is titled with its venue. They come
from the server's trades topic through MarketData::trades, which no
other source fills in.

Fixes from reviewing the first commit:

- With no server configured, perp_markets fails instead of returning an
  empty list. AAPL PERP then opens the perp, and its pane says why it
  can't load, instead of calling the perp not live.
- The chart's switch chip draws only where it fits beside the bar count.
- The PERP switching rule moved into tr-core (perps::switch_target),
  with a test.
- The browser's source chip says "Crypto + perps live" when a server is
  in front, and its tooltip mentions HIP-3 perps.

docs/architecture/server.md section 7 now records the decision and
closes open question 9: the 27 links, the perps left unlinked and why,
and how delisted markets are handled. The READMEs say where perps come
from.

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

* Say when a chart's cached bars couldn't refresh, and add the screenshots

A chart paints from the disk cache, then refreshes from the network. If
the refresh failed, it used to keep the cached bars and drop the error,
so a perp whose server was down looked live. The range row now reads
"5000 cached bars · the Terminal server is not reachable" in red. A
failed page of older bars leaves the shown ones as they are.

The book and trades say "the Terminal server is not reachable" when the
connection is down, instead of a timeout's "deadline has elapsed".

docs/img gets before and after shots for the desktop (an equity and a
commodity with their perps, and the server down) and for the web build.

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

* Keep delistings through failed refreshes, and stop showing old perp data

From Greptile's review of #35:

- A dex whose meta call fails now keeps what the last refresh listed.
  Before, its entries vanished from the registry, so its delisted
  markets resolved again.
- Delisted markets' mids never enter the mids table. A market delisted
  while clients watch it loses its book, trades and bars streams: each
  subscriber gets Unavailable "... is delisted", and the upstream
  subscription goes.
- A perp chart whose load failed asks the server again every 10 s while
  the pane shows it. Nothing else would reload it, since no live feed
  starts after a failed load.
- The watchlist drops a perp's old quote when an answer leaves it out,
  instead of showing it as current.
- A book or trades feed whose server lost Hyperliquid says "the
  Terminal server lost its Hyperliquid feed" until fresh data arrives.
  Crypto books fall back to Binance and Coinbase meanwhile, as they do
  when the server itself is away.

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

* Update the delisted set and the mids table under one lock

From Greptile's second review of #35: set_delisted dropped a market's
mid, released the table, and only then recorded the market as delisted.
A mids message in between could put the market back, and quote
subscribers would keep getting its price. Both now change under the
table's lock, taken in the same order as apply. apply also drops any
delisted market it finds in the table, so the next message from the
venue clears one that got in anyway.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-02 15:33:34 -05:00
Michael Shimeles 94bb329d68 Let a chart page again after it reloads from another source (#32) 2026-10-31 22:14:51 -04:00
Michael ShimelesandClaude Opus 5.5 dcf11c5755 Get the web build's crypto markets from the Terminal data server (#31)
* Get the web build's crypto markets from the Terminal data server

The browser build now gets BTCUSD Curncy and the other crypto keys from
the Terminal server, the same Hyperliquid perps the desktop gets with
TR_SERVER_URL: history, live bars, quotes and the order book. While the
server is unreachable, Binance and Coinbase serve them as before.

- ws.rs carries binary frames, so the browser's WebSocket speaks postcard.
- rt.rs gets timeout(), and the browser's sleep() is Send, so remote.rs
  runs on the page's event loop as well as on tokio.
- for_browser() puts RemoteMarkets in front of the browser's sources when
  the page has a server URL. scripts/build-web.sh builds in TR_SERVER_URL,
  wss://terminal-gateway.style.dev/ws unless set (empty for none). A page
  can turn it off with ?server=off or use a server on the same machine
  with ?server=ws://localhost:8787/ws; other hosts are refused.
- When the connection drops, the order book falls back instead of showing
  the server's last book until it comes back. The desktop had this bug too.
- The status chip's tooltip names the Terminal server in the browser.
- build-web.sh reads the wasm from CARGO_TARGET_DIR when it is set.

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

* Hand charts off to the fallback while the server is away, and clear browser timers

A chart loaded from the Terminal server froze when the server went away:
its live feed stayed open and waited for the server. Now the feed ends
once the connection has been down for 10 s, and the worker reloads the
chart, which comes from Binance or Coinbase while the server is
unreachable. A chart on the fallback for a server-routed market reloads
from the server once the connection is back up. The worker waits at
least 5 s between a feed starting and a reload. Shorter outages keep the
server's chart and backfill the gap as before.

The browser's sleep() now sets its setTimeout on the first poll and
clears it if dropped early, so a finished timeout() no longer leaves its
deadline timer pending.

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

* Move charts back to the server on any newer connection, and drop stale pages

A chart could stay on the fallback after the server returned: if the
connection came back while the fallback's history was loading, the
relay saw the server already up and waited for another change.
RemoteMarkets now records which connection was up when a chart fell
back (none, or the one that failed the request), and the fallback's
feed ends as soon as a newer connection is up, including one that came
up before the feed started. ConnState::Up counts the client's
connections for this, since server session ids restart with the server.

A page of older bars requested before a source switch could finish
after the reload and join the old source's bars to the new one's,
because the reload keeps the pane's generation. The worker now counts
source switches per pane and drops a page that spans one.

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

* Skip paging while a chart reloads for another source

A page of older bars requested while a source-change reload was running
read the count the reload had already bumped, so it could still prepend
the old source's bars to the new source's history. The count now goes
up when the reload starts and again when it's done. MoreHistory skips
paging while the count is odd, and re-checks it under the series lock
before it merges a page.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-31 21:02:36 -04:00
Michael ShimelesandClaude Opus 5.5 75ffb50a4a Deploy the data server on Freestyle, and keep subscriptions made while it starts (#30)
* deploy(server): run the data server on a Freestyle VM

deploy/freestyle/vm-setup.sh builds tr-gateway from a git ref on the VM
and runs it under systemd on 0.0.0.0:8080, behind Freestyle's TLS edge.
deploy.sh creates the VM and the TLS rule if they're missing, runs
vm-setup.sh and waits for /readyz through the public URL.

Grok Bot wrote both and deployed the running VM with them. This version
waits for /healthz and /readyz instead of one check after 3 s, keeps
Rust on the latest stable like CI, never stops restarting the service,
and pins the Freestyle CLI to 0.2.21.

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

* fix(server): keep subscriptions made while the gateway starts

A client that reconnected while the gateway was still loading its
instrument list and mids got Unavailable { Upstream } on every
subscription after 10 s, and the client never asked again. Live testing
on the Freestyle VM hit this after a reboot.

- The hub is ready once the first instrument list is in and every venue
  on it has sent mids or been refused, or 10 s after that list with the
  mids it has. /readyz uses this.
- A subscription made before then gets Stale, waits, then gets Live and
  its snapshot. A request waits up to 10 s, then fails with Upstream
  rather than UnknownMarket.
- The client subscribes again, with backoff from 1 s to 30 s, to
  anything a server ended with Unavailable { Upstream }, so it also
  recovers from older gateways.

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

* fix(server): don't call markets on a late venue unknown

Greptile review. If a venue outlasts venue_grace, the hub goes ready
without it, and its markets used to come back as UnknownMarket, which
clients never retry.

- Until every venue from the first instrument list has reported, a key
  that doesn't resolve fails as Upstream, for subscriptions and requests
  alike, so the client retries it.
- A quotes subscription takes in keys that resolve once their venue
  reports, with their first delta.
- The health probes in deploy.sh and vm-setup.sh time out each request
  and stop at an overall deadline instead of hanging on a server that
  accepts and never answers.

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

* fix(server): let subscriptions wait for a late venue

Greptile review. Ending a subscription with Upstream while its venue
was late left recovery to the client's retry, so an older client still
lost it for good.

A subscription now waits, marked stale, while the hub isn't ready or its
market's venue is still loading, the same as one made during startup,
and goes live when the market is known. Requests still fail as Upstream
while a venue is loading.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-31 19:20:37 -04:00
Michael ShimelesandClaude Opus 5.5 e012597059 Add the Terminal data server, on Hyperliquid's public feed for now (#29)
* Add the Terminal data server on Hyperliquid's public feed

First build step of docs/architecture/server.md, on the interim source
Micky approved while a node box costs too much: Hyperliquid's public
websocket and info endpoint.

- crates/proto (tr-proto): the client protocol from section 4. postcard
  by default, JSON when a client asks. Builds for wasm32, and CI now
  checks that.
- server/ (tr-gateway): the section 3.1 Provider trait, HlPublicProvider,
  the hot store, client sessions, /healthz, /readyz, /metrics and /stats,
  a CLI example, and tests against a mock upstream.
- tr-data::remote: the client, and RemoteMarkets behind TR_SERVER_URL so
  the desktop app can take crypto markets from the server.
- The design doc gets a short "Interim: public feed" section.

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

* Page from the source a chart loaded from, and rate-limit clients

- RemoteMarkets sends older pages to the server only for charts whose
  history came from it. A chart that fell back to Binance no longer stops
  paging at the server's first empty page.
- The gateway ignores client messages past 200 per 10 s on a connection
  and answers with a new RateLimited error code.
- Before and after screenshots of the order book pane, for the PR.

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

* Fix the six issues from the first Greptile review

- Quotes track delivery per requested key, so BTC and BTCUSD Curncy in
  one subscription both keep updating.
- The mids table keeps each venue's feed state. Quote subscribers get
  Stale during an outage, then Live, Resync and a fresh Snapshot.
- Requests run in a per-connection set: at most 8 at once, 30 s each,
  aborted when the connection closes.
- After a Resync or reconnect, the desktop's live chart feed fetches the
  bars it missed before taking new ones. History for a market with a live
  stream skips cached bars older than the stream's last restart.
- Gateway::shutdown ends the registry loop and closes every session and
  upstream connection.
- A coin missing from its venue's allMids message (each one carries the
  full set) leaves the mids table, so delisted markets stop resolving.

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

* Retry chart backfill until it works, without holding live bars

Greptile's second review: when the history request that fills an outage
gap failed, the bars in the gap were never fetched again.

- RemoteMarkets sends live bars on at once and fetches the gap, from the
  last bar before the outage up to the first one after, in a background
  task that retries with backoff until it works or the feed is dropped.
- Series::apply_live now slots a bar into a missing period, or completes
  an older bar it already holds, instead of ignoring anything older than
  the last bar. Bars older than the whole series are still ignored.
- The backfill test fails the first history request and checks that the
  live bar arrives first and the chart ends up with no hole.

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

* One backfill per chart feed, and refresh compare lines after a gap fill

From Greptile's third review.

- RemoteMarkets keeps one coordinator per live feed: outstanding gaps
  merge into one range, one history request runs at a time, failures
  retry with backoff, and it all stops when the worker drops the feed.
  Before, each outage started its own retry loop.
- When a live bar lands before the latest one (a gap being filled), the
  worker sends that update as a refresh, so the chart rebuilds its
  compare line and indicators instead of keeping index-aligned values
  that the insert shifted.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-30 00:55:50 -04:00
Michael Shimeles f4643de354 docs(architecture): design the Terminal data server (#28)
* docs(architecture): design the Terminal data server

* docs(architecture): address review on bar closes, SnapTrade modes, doc-op idempotency

* docs(architecture): ingest from our own non-validator node from day one

Live data now comes only from our Tokyo hl-node outputs and local info server: own L4/L2 book builder, candles at every interval with no cap, mids/marks/oracle/funding sourcing, node ops (spec, logs, monitoring, catch-up, failover, cost). Public API is limited to a one-time backfill.

* docs(architecture): Tokyo bare-metal hosting options, rule out Freestyle, colocate gateway with node

* docs(architecture): single-writer gateway failover with synchronous ack and epoch fence
2026-10-29 14:44:39 -04:00
Michael ShimelesandClaude Opus 5.5 7d999a8e19 Rename the project from terminal-rs to terminal (#27)
* chore: rename the project from terminal-rs to terminal

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

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

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

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

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

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

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

The PR body links them at the previous commit.

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

* docs: browser build screenshots

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

* docs(readme): final-build throttled timing

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

* docs(readme): rewrap browser timings

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

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

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

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

---------

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

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

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

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

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

---------

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

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

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

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

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

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

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

---------

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

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

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

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

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

## Changes Made

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

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

## Implementation Details

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

## Framework Adaptation

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

## Verification

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

## Next Steps

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

The analytics will be automatically active once deployed to Vercel.

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

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

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

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

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

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

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

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

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

* docs: document the indicator library

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

* Use the candlestick logo

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

* Make theme-color follow the theme the page shows

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

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

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

---------

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

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

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

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

* Remove the amber glow behind the hero

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

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

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

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

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

* Let CHROME name a command on PATH

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

* fix(app): palette review fixes

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

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

---------

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

No files matched your search

+16 -9
View File
@@ -22,7 +22,7 @@ jobs:
arch: x86_64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
@@ -40,9 +40,9 @@ jobs:
TR_PROVIDER: sample
TR_CONGRESS_REFRESH_MIN: "0"
TR_SMOKE_SECS: "8"
TR_DATA_DIR: ${{ runner.temp }}/terminal-rs
TR_DATA_DIR: ${{ runner.temp }}/terminal
run: |
./target/release/terminal-rs > smoke.log 2>&1 &
./target/release/terminal > smoke.log 2>&1 &
pid=$!
sleep 6
screencapture -x screenshot-${{ matrix.arch }}.png || true
@@ -56,9 +56,9 @@ jobs:
env:
TR_SMOKE_SECS: "12"
TR_CONGRESS_REFRESH_MIN: "0"
TR_DATA_DIR: ${{ runner.temp }}/terminal-rs-live
TR_DATA_DIR: ${{ runner.temp }}/terminal-live
run: |
./target/release/terminal-rs > live.log 2>&1 &
./target/release/terminal > live.log 2>&1 &
pid=$!
sleep 10
screencapture -x screenshot-live-${{ matrix.arch }}.png || true
@@ -67,7 +67,7 @@ jobs:
grep -q "smoke:" live.log
- name: Bundle .app
run: ./scripts/bundle-macos.sh
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: always()
with:
name: macos-${{ matrix.arch }}
@@ -75,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
+128
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+15 -2
View File
@@ -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
+133 -36
View File
@@ -1,4 +1,4 @@
# terminal-rs
# Terminal
A fast, keyboard-driven market terminal in pure Rust: TradingView-class GPU charting with a
Bloomberg Terminal look (dense tiled panes, monospace numbers, a command line like `AAPL <GO>`)
@@ -9,7 +9,7 @@ systems** and **your own brokerage accounts through [SnapTrade](https://snaptrad
to Solarized, and a fully dockable
workspace with saved layouts.
![terminal-rs: AAPL chart with congress trade markers and RSI, the Congress trades feed, world markets and a multi-asset watchlist](docs/img/overview.png)
![Terminal: AAPL chart with congress trade markers and RSI, the Congress trades feed, world markets and a multi-asset watchlist](docs/img/overview.png)
> **Built from reverse-engineering research.** Chart UX was studied behaviourally on
> tradingview.com with [REA](https://github.com/morluto/rea) in website mode (passive captures of
@@ -48,9 +48,21 @@ gets the native title bar with traffic lights, window vibrancy behind the sideba
matches across all asset classes (catalog instantly, plus Yahoo search after a short pause);
↑/↓, Tab, Enter; history with ↑/↓. `SECF gold cmdty` opens a full search panel.
- **Charts** — GPU-rendered candles, OHLC bars, line and area; 1m, 5m, 15m, 30m, 1H, 4H, 1D, 1W,
1M plus quick ranges (1D … ALL); volume; SMA, EMA, VWAP, Bollinger overlays; RSI and MACD
sub-panes; `CMP <SYM>` compare; log scale; crosshair with OHLC legend; smooth pan and zoom over
a million bars. Live crypto bars from the Binance websocket (Coinbase fallback).
1M plus quick ranges (1D … ALL); volume; 151 indicators; `CMP <SYM>` compare; log scale;
crosshair with OHLC legend; smooth pan and zoom over a million bars. Live crypto bars from the
Binance websocket (Coinbase fallback).
- **Indicators** — 151 built-in studies: 23 moving averages (SMA, EMA, WMA, Hull, DEMA/TEMA, KAMA,
ALMA, T3, McGinley, VIDYA, FRAMA, MAMA/FAMA, Ehlers filters, GMMA…), 8 bands and channels
(Bollinger, Keltner, Donchian, STARC, standard-error and VWAP bands…), 43 momentum oscillators
(RSI, MACD, Stochastic, StochRSI, CCI, Williams %R, TSI, KST, Fisher, Schaff, QQE, WaveTrend,
Connors RSI…), 22 trend tools (ADX/DMI, Aroon, Vortex, Parabolic SAR, SuperTrend, Ichimoku,
Alligator, ZigZag, fractals, Chandelier and Chande Kroll stops…), 16 volatility measures (ATR,
close-to-close and Parkinson, Garman-Klass, Rogers-Satchell and Yang-Zhang estimators, Ulcer,
Mass index, squeeze momentum…), 23 volume studies (OBV, A/D, CMF, MFI, Klinger, NVI/PVI, VZO…),
11 rolling statistics and five pivot-point families. Type a code with optional parameters
(`RSI`, `SMA 200`, `BB 20 2.5`), browse and search with `IND`, or right-click the chart.
Oscillators get their own panes (up to six) with reference levels; moving averages can be added
several times; indicators are saved with the layout.
- **Drawings** — trendline (Alt+T), horizontal (Alt+H), vertical (Alt+V) and fib retracement
(Alt+F), stored in (time, price) so they survive zoom and timeframe changes; magnet (Alt+M) snaps
to OHLC; click to select, Del to remove.
@@ -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.
![The browser build in Chrome: a live BTCUSD chart from Binance beside a demo-account position](docs/img/web-live-crypto.png)
What the browser build can and can't do yet:
- Crypto is live, because Binance and Coinbase allow cross-origin requests. History, quotes, order
books and websocket bars all come from them. Yahoo, Treasury.gov, Alpaca and CoinDesk don't allow
them, so every other market shows sample data and the toolbar says "Crypto live". A same-origin
proxy can fix that later.
- 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
View File
@@ -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
View File
@@ -1,7 +1,86 @@
//! Spring animations (SwiftUI-like), integrated per frame and keyed by egui `Id`.
//! Spring animations (SwiftUI-like), integrated per frame and keyed by egui `Id`, plus
//! timed transitions and easing curves. Everything snaps when Reduce Motion is on.
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use egui::{Context, Id};
static REDUCED: AtomicBool = AtomicBool::new(false);
static KEYS: AtomicU64 = AtomicU64::new(1);
/// A key no other [`progress`] transition has used, so replacing an object (a pane
/// reloaded under the same id) never mistakes its transition for a finished one.
pub fn fresh_key() -> u64 {
KEYS.fetch_add(1, Ordering::Relaxed)
}
/// Whether the system asks for reduced motion.
pub fn reduced() -> bool {
REDUCED.load(Ordering::Relaxed)
}
pub fn set_reduced(on: bool) {
REDUCED.store(on, Ordering::Relaxed);
}
pub fn ease_out(t: f32) -> f32 {
1.0 - (1.0 - t.clamp(0.0, 1.0)).powi(3)
}
/// Ease out with a slight overshoot past 1 before settling.
pub fn ease_out_back(t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
let (c1, c3) = (1.70158, 2.70158);
1.0 + c3 * (t - 1.0).powi(3) + c1 * (t - 1.0).powi(2)
}
/// `t` rescaled so `a..b` maps to 0..1 (clamped), for staggering parts of one transition.
pub fn span(t: f32, a: f32, b: f32) -> f32 {
((t - a) / (b - a)).clamp(0.0, 1.0)
}
/// Linear progress (0..1) of a `secs`-long transition that restarts whenever `key`
/// changes. The first call for an `id` starts it too. Requests repaints until done.
pub fn progress(ctx: &Context, id: Id, key: u64, secs: f32) -> f32 {
if reduced() {
return 1.0;
}
let now = ctx.input(|i| i.time);
let (k, start) = ctx.data_mut(|d| *d.get_temp_mut_or_insert_with(id, || (key, now)));
let start = if k == key {
start
} else {
ctx.data_mut(|d| d.insert_temp(id, (key, now)));
now
};
since(ctx, now - start, secs)
}
/// Entrance progress (0..1) of something drawn while a condition holds (a popover, an
/// overlay): it restarts whenever `id` was not shown on the previous frame.
pub fn appear(ctx: &Context, id: Id, secs: f32) -> f32 {
let frame = ctx.cumulative_frame_nr();
let now = ctx.input(|i| i.time);
let start = match ctx.data(|d| d.get_temp::<(u64, f64)>(id)) {
Some((last, start)) if last + 1 >= frame => start,
_ => now,
};
ctx.data_mut(|d| d.insert_temp(id, (frame, start)));
since(ctx, now - start, secs)
}
/// Progress of a transition that began `elapsed` seconds ago.
pub fn since(ctx: &Context, elapsed: f64, secs: f32) -> f32 {
if reduced() {
return 1.0;
}
let t = (elapsed / secs.max(1e-3) as f64).clamp(0.0, 1.0) as f32;
if t < 1.0 {
ctx.request_repaint();
}
t
}
/// A damped spring moving `pos` toward a target.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Spring {
@@ -50,7 +129,7 @@ pub fn spring(ctx: &Context, id: Id, target: f32) -> f32 {
pub fn spring_with(ctx: &Context, id: Id, target: f32, response: f32, damping: f32) -> f32 {
let dt = ctx.input(|i| i.stable_dt).clamp(0.0, 0.1);
let mut s = ctx.data_mut(|d| *d.get_temp_mut_or_insert_with(id, || Spring::at(target)));
if s.settled(target, target) {
if reduced() || s.settled(target, target) {
s = Spring::at(target);
} else {
s.step(target, dt, response, damping);
@@ -99,6 +178,59 @@ mod tests {
assert!(s.pos.is_finite() && s.pos.abs() < 2.0);
}
#[test]
fn easings_hit_their_ends() {
for f in [ease_out, ease_out_back] {
assert!(f(0.0).abs() < 1e-6);
assert!((f(1.0) - 1.0).abs() < 1e-6);
}
let peak = (0..=100).map(|i| ease_out_back(i as f32 / 100.0)).fold(0.0, f32::max);
assert!(peak > 1.05 && peak < 1.15, "overshoot {peak}");
assert!(ease_out(0.5) > 0.5);
assert_eq!(span(0.5, 0.25, 0.75), 0.5);
assert_eq!(span(0.1, 0.25, 0.75), 0.0);
}
#[test]
fn progress_restarts_when_the_key_changes() {
let ctx = Context::default();
let id = Id::new("p");
let frame = |key: u64, t: f64| {
let input = egui::RawInput { time: Some(t), ..Default::default() };
let mut out = 0.0;
ctx.run_ui(input, |ui| out = progress(ui.ctx(), id, key, 0.5)).textures_delta.clear();
out
};
assert_eq!(frame(1, 10.0), 0.0);
assert!((frame(1, 10.25) - 0.5).abs() < 1e-4);
assert_eq!(frame(1, 11.0), 1.0);
assert_eq!(frame(2, 12.0), 0.0);
assert!((frame(2, 12.1) - 0.2).abs() < 1e-4);
}
#[test]
fn appear_restarts_after_a_hidden_frame() {
let ctx = Context::default();
let id = Id::new("a");
let frame = |t: f64, show: bool| {
let input = egui::RawInput { time: Some(t), ..Default::default() };
let mut out = None;
ctx.run_ui(input, |ui| {
if show {
out = Some(appear(ui.ctx(), id, 0.2));
}
})
.textures_delta
.clear();
out
};
assert_eq!(frame(1.0, true), Some(0.0));
assert!((frame(1.1, true).unwrap() - 0.5).abs() < 1e-4);
assert_eq!(frame(1.3, true), Some(1.0));
frame(1.4, false);
assert_eq!(frame(1.5, true), Some(0.0), "reopened");
}
#[test]
fn context_helper_snaps_then_animates() {
let ctx = Context::default();
+248 -19
View File
@@ -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
View File
@@ -3,9 +3,10 @@
//! refresh scheduling and the `PORT`/`ACCT`/`POS`/`ORD`/`ACTV` commands. Order entry is in
//! `trading`.
use std::time::{Duration, Instant};
use std::time::Duration;
use tr_broker::{Credentials, Portfolio, Position, SecretStore};
use web_time::Instant;
use super::{PaneKind, TerminalApp};
use crate::command::PortView;
@@ -100,9 +101,15 @@ pub struct BrokerState {
impl BrokerState {
pub fn load(offline: bool) -> Self {
let store = SecretStore::platform(&data_dir());
Self::with_store(SecretStore::platform(&data_dir()), offline)
}
/// [`Self::load`] with credentials from `store` instead of the platform's.
pub fn with_store(store: SecretStore, offline: bool) -> Self {
let mut message = None;
let stored = match store.load() {
// The browser build can't reach SnapTrade, and keys don't belong in page storage.
let stored = if cfg!(target_arch = "wasm32") { Ok(None) } else { store.load() };
let stored = match stored {
Ok(c) => c.unwrap_or_default(),
Err(e) => {
tracing::warn!("SnapTrade credentials unavailable: {e:#}");
@@ -112,7 +119,9 @@ impl BrokerState {
};
let mut creds = stored.clone();
let from_env = creds.apply_env(|k| std::env::var(k).ok());
let simulate = offline || std::env::var("TR_BROKER").is_ok_and(|v| v.eq_ignore_ascii_case("sample"));
let simulate = offline
|| cfg!(target_arch = "wasm32")
|| std::env::var("TR_BROKER").is_ok_and(|v| v.eq_ignore_ascii_case("sample"));
Self {
store,
saved: stored,
@@ -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(&quotes, &mids, &testnet).is_some(), mids.contains_key("xyz:AAPL"));
mids.insert("xyz:AAPL".to_string(), test);
assert_eq!(live_price(&quotes, &mids, &testnet), Some(test), "the testnet row reads the testnet mid");
assert_eq!(live_price(&quotes, &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(&quotes, &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
View File
@@ -1,13 +1,14 @@
//! Window chrome in the style of a SwiftUI macOS app: a unified toolbar holding the
//! command line, a translucent (vibrancy) sidebar, rounded pane cards and a status bar.
use std::time::Instant;
use std::time::Duration;
use egui::{
Align2, Color32, CornerRadius, CursorIcon, Id, Key, Modifiers, Rect, RichText, Sense, Shadow, Stroke, StrokeKind,
Ui, UiBuilder, ViewportCommand, pos2, vec2,
};
use tr_data::SearchHit;
use web_time::Instant;
use super::{PaneKind, TerminalApp};
use crate::anim;
@@ -101,14 +102,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));
}
}
+284
View File
@@ -0,0 +1,284 @@
//! The demo (paper-trading) account inside the app: loading and saving the book, feeding it
//! the terminal's quotes so resting orders fill, and switching between the demo and live
//! accounts.
//!
//! The demo account is selected on first launch and stays selected until the user switches
//! to live accounts, which takes a confirmation and is remembered in the preferences. Every
//! demo change is saved at once to `demo-account.json` in the app data folder.
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Context;
use tr_broker::demo::{DemoBook, Event, STARTING_CASH};
use tr_broker::trading::OrderRequest;
use tr_broker::{InstrumentKind, Order, Portfolio};
use web_time::Instant;
use super::TerminalApp;
use crate::prefs::AccountMode;
use crate::worker::{Request, data_dir};
/// How often quotes are requested for demo positions and open demo orders.
const QUOTES_EVERY: Duration = Duration::from_secs(10);
pub struct DemoState {
pub book: DemoBook,
/// `book.portfolio()`, rebuilt after every change so panes can borrow it.
pub pf: Box<Portfolio>,
path: PathBuf,
last_quotes: Option<Instant>,
/// Settings › Trading: the reset confirmation is open, with this starting cash.
pub confirm_reset: bool,
pub reset_cash: f64,
/// The last save failed (disk full, folder unwritable): shown until a save succeeds,
/// and retried periodically and on exit.
pub save_error: Option<String>,
last_save_try: Option<Instant>,
}
pub fn demo_path() -> PathBuf {
data_dir().join("demo-account.json")
}
/// Terminal market key for a demo symbol (`AAPL` → `AAPL US Equity`).
pub fn market_key(symbol: &str) -> Option<String> {
tr_broker::model::market_key(InstrumentKind::Stock, symbol, "USD")
}
impl DemoState {
/// Load the saved book. A damaged file is moved aside (never overwritten) and a fresh
/// account starts; the returned message says so.
pub fn load(path: PathBuf) -> (Self, Option<String>) {
let now = chrono::Utc::now();
let saved = match crate::storage::read(&path) {
Ok(bytes) => DemoBook::from_json(&bytes),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(DemoBook::new(now)),
Err(e) => Err(anyhow::Error::new(e).context("reading the demo account")),
};
let (book, note) = match saved {
Ok(b) => (b, None),
Err(e) => {
let aside = path.with_extension(format!("damaged-{}.json", now.format("%Y%m%d%H%M%S")));
let moved = crate::storage::rename(&path, &aside).is_ok();
tracing::warn!("demo account: {e:#}");
let note = if moved {
format!(
"The demo account file was damaged and was kept as {}; a new demo account started.",
aside.display()
)
} else {
format!("The demo account file could not be read ({e:#}); a new demo account started.")
};
(DemoBook::new(now), Some(note))
}
};
let pf = Box::new(book.portfolio());
(
Self {
book,
pf,
path,
last_quotes: None,
confirm_reset: false,
reset_cash: STARTING_CASH,
save_error: None,
last_save_try: None,
},
note,
)
}
/// The book changed: rebuild the view and save.
fn changed(&mut self) {
*self.pf = self.book.portfolio();
self.save();
}
/// Save now. Returns whether the book is on disk.
pub fn save(&mut self) -> bool {
self.last_save_try = Some(Instant::now());
let saved = self
.book
.to_json()
.and_then(|json| crate::storage::write_atomic(&self.path, &json).context("saving the demo account"));
match saved {
Ok(()) => {
self.save_error = None;
true
}
Err(e) => {
tracing::warn!("saving the demo account: {e:#}");
self.save_error = Some(format!("{e:#}"));
false
}
}
}
/// Retry a failed save every few seconds.
fn retry_save(&mut self) {
if self.save_error.is_some() && self.last_save_try.is_none_or(|t| t.elapsed() > Duration::from_secs(5)) {
self.save();
}
}
}
impl TerminalApp {
pub(super) fn demo_mode(&self) -> bool {
self.prefs.trading.account_mode == AccountMode::Demo
}
/// The portfolio the panes show: the demo account, or the live (or sample) brokerage.
pub(super) fn shown_portfolio(&self) -> Option<&Portfolio> {
if self.demo_mode() { Some(&self.demo.pf) } else { self.broker.portfolio.as_deref() }
}
/// Keep quotes coming for demo positions and open orders, and expire Day orders.
pub(super) fn demo_periodic(&mut self) {
let had_error = self.demo.save_error.is_some();
self.demo.retry_save();
if had_error && self.demo.save_error.is_none() {
self.trade_toast("Demo account saved", true);
}
let now = chrono::Utc::now();
let expired = self.demo.book.expire(now);
if !expired.is_empty() {
self.demo.changed();
self.demo_events(expired);
}
let watched = self.demo.book.watched();
let wanted = self.demo_mode() || self.demo.book.orders.iter().any(Order::is_open);
if !wanted || watched.is_empty() || self.demo.last_quotes.is_some_and(|t| t.elapsed() < QUOTES_EVERY) {
return;
}
let keys: Vec<String> = watched.iter().filter_map(|s| market_key(s)).collect();
if !keys.is_empty() {
self.worker.send(Request::Quotes(keys));
}
self.demo.last_quotes = Some(Instant::now());
}
/// New quotes arrived: mark demo positions and fill resting demo orders they cross.
pub(super) fn demo_on_quotes(&mut self, keys: &[String]) {
let now = chrono::Utc::now();
// Expire overdue Day orders before any quote can fill them.
let mut events = self.demo.book.expire(now);
let mut touched = !events.is_empty();
for sym in self.demo.book.watched() {
let Some(k) = market_key(&sym).filter(|k| keys.contains(k)) else { continue };
let Some(q) = self.quotes.get(&k).map(|q| q.last).filter(|x| x.is_finite() && *x > 0.0) else { continue };
events.extend(self.demo.book.on_price(&sym, q, now));
touched = true;
}
if touched {
self.demo.changed();
}
self.demo_events(events);
}
fn demo_events(&mut self, events: Vec<Event>) {
for e in events {
let ok = matches!(e, Event::Filled(_));
self.trade_toast(format!("Demo: {}{}", e.message(), self.unsaved_note()), ok);
}
}
/// ` (not saved yet: …)` while the demo book cannot be written.
fn unsaved_note(&self) -> String {
self.demo.save_error.as_ref().map_or(String::new(), |e| format!(" — not saved yet ({e}); retrying"))
}
/// Place a demo order now (one click: no preview, no confirmation).
pub(super) fn demo_submit(&mut self, req: &OrderRequest, quote: Option<f64>) -> Result<Order, String> {
let result = self.demo.book.submit(req, quote, chrono::Utc::now());
match &result {
Ok(o) => {
self.demo.changed();
let msg = match o.status.as_str() {
"EXECUTED" => tr_broker::demo::fill_message(o),
"CANCELED" => format!("{} cancelled (not fillable now)", tr_broker::demo::order_line(o)),
_ => format!("{} working", tr_broker::demo::order_line(o)),
};
let saved = self.demo.save_error.is_none();
self.trade_toast(format!("Demo: {msg}{}", self.unsaved_note()), saved && o.status != "CANCELED");
}
Err(e) => self.trade_toast(format!("Demo order not placed: {e}"), false),
}
result
}
pub(super) fn demo_cancel(&mut self, id: &str) {
match self.demo.book.cancel(id, chrono::Utc::now()) {
Ok(o) => {
self.demo.changed();
let note = self.unsaved_note();
self.trade_toast(format!("Demo: cancelled {}{note}", tr_broker::demo::order_line(&o)), note.is_empty());
}
Err(e) => self.trade_toast(format!("Could not cancel: {e}"), false),
}
}
/// Sell a whole demo position at market.
pub(super) fn demo_close(&mut self, symbol: &str) {
let Some(req) = self.demo.book.close_request(symbol) else {
return self.trade_toast(format!("No {symbol} shares to close (check open sell orders)"), false);
};
let quote = market_key(symbol).and_then(|k| self.quotes.get(&k)).map(|q| q.last);
let _ = self.demo_submit(&req, quote);
}
pub(super) fn reset_demo(&mut self, cash: f64) {
self.demo.book.reset(cash, chrono::Utc::now());
self.demo.changed();
self.demo.confirm_reset = false;
if let Some(t) = &mut self.trading.ticket {
t.error = None;
t.done = None;
}
self.trade_toast(
format!("Demo account reset to {}", super::portfolio_view::money(self.demo.book.cash, "USD")),
true,
);
}
/// Switch accounts. Going live asks first (see `live_dialog`); going back to the demo
/// account is immediate. The choice is saved.
pub(super) fn set_account_mode(&mut self, mode: AccountMode, confirmed: bool) {
if mode == self.prefs.trading.account_mode {
return;
}
if mode == AccountMode::Live && !confirmed {
self.trading.live_prompt = true;
return;
}
self.prefs.trading.account_mode = mode;
self.trading.live_prompt = false;
if let Err(e) = self.prefs.save(&super::prefs_path()) {
self.status = format!("Could not save preferences: {e}");
}
// The ticket's account belongs to the other mode now.
if let Some(t) = &mut self.trading.ticket {
t.account = None;
t.preview = None;
t.confirming = false;
t.review = false;
t.error = None;
t.done = None;
}
match mode {
AccountMode::Demo => {
self.trade_toast("Switched to the DEMO account: orders are simulated with virtual cash", true);
if let Some(t) = &mut self.trading.ticket {
t.account = Some(tr_broker::demo::ACCOUNT_ID.into());
}
}
AccountMode::Live => {
self.trade_toast("Switched to LIVE accounts: orders go to your real brokerage", true);
if self.broker.portfolio.as_ref().is_none_or(|p| p.demo) && !self.broker.loading {
self.refresh_portfolio();
}
self.ensure_capabilities(false);
}
}
}
}
+1426
View File
File diff suppressed because it is too large. Load diff
+810
View File
@@ -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()
}
}
+206
View File
@@ -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));
}
});
}
}
+40 -4
View File
@@ -1,8 +1,10 @@
//! Native keyboard-shortcut table (click a binding to record a new one) and the Help pane.
use egui::{Align2, Color32, Id, Rect, RichText, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use tr_core::indicators::registry::{self, Category};
use super::TerminalApp;
use crate::command::IndicatorSpec;
use crate::prefs::{Action, Keymap};
use crate::theme::{self, amber, dp, mono, muted, text, ui_font};
@@ -35,13 +37,15 @@ fn para(ui: &mut Ui, s: &str) {
ui.label(RichText::new(s).font(ui_font(12.5)).color(muted()));
}
fn chip(ui: &mut Ui, code: &str) -> egui::Response {
ui.label(RichText::new(format!(" {code} ")).font(mono(12.0)).color(amber()).background_color(theme::control()))
}
fn chips(ui: &mut Ui, codes: &[&str]) {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
for c in codes {
ui.label(
RichText::new(format!(" {c} ")).font(mono(12.0)).color(amber()).background_color(theme::control()),
);
chip(ui, c);
}
});
}
@@ -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,
+10 -1
View File
@@ -6,6 +6,7 @@ use tr_core::options::Greeks;
use tr_data::OptionContract;
use super::OptionsPane;
use crate::anim;
use crate::congress_view::Action;
use crate::theme::{self, amber, dp, mono, muted, text, ui_font};
use crate::widgets;
@@ -245,16 +246,24 @@ pub fn options_chain(ui: &mut Ui, o: &mut OptionsPane) -> (Option<Action>, bool)
let spot = ch.spot;
let itm_wash = theme::amber().gamma_multiply(0.07);
// Rows cascade in when the chain comes into view or a chain for another expiration
// arrives (not when one is requested: the old rows stay until then).
let cascade = anim::appear(ui.ctx(), ui.id().with(("omon-rows", &o.symbol, ch.expiration)), 0.9) * 0.9;
egui::ScrollArea::vertical().id_salt(("omon", &o.symbol)).auto_shrink([false, false]).show_rows(
ui,
row_h,
rows.len(),
|ui, range| {
let mut spot_line = None;
let first = range.start;
for ri in range {
let row = &rows[ri];
let (r, resp) = ui.allocate_exact_size(vec2(w, row_h), Sense::click());
let p = ui.painter_at(r);
let delay = ((ri - first) as f32 * 0.03).min(0.45);
let e = anim::ease_out(anim::span(cascade, delay, delay + 0.35));
let r = r.translate(vec2(0.0, 6.0 * (1.0 - e)));
let mut p = ui.painter_at(r);
p.multiply_opacity(e);
let call_side = Rect::from_min_max(pos2(strike_x - side_w, r.top()), pos2(strike_x, r.bottom()));
let put_side = Rect::from_min_max(
pos2(strike_x + strike_w, r.top()),
+197 -28
View File
@@ -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
View File
@@ -1,6 +1,6 @@
//! Pane content for every panel kind.
use std::time::Instant;
use web_time::Instant;
use egui::{Align2, Color32, Rect, RichText, Sense, Stroke, Ui, UiBuilder, pos2, vec2};
use tr_data::OrderBook;
@@ -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
View File
@@ -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, &quotes);
let _ = portfolio(ui, &mut pane, &b, None, &quotes, &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, &quotes);
let _ = portfolio(ui, &mut pane, &b, None, &quotes, &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), &quotes, &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, &quotes);
let _ = portfolio(ui, &mut pane, &b, None, &quotes, &HashMap::new());
});
out.textures_delta.clear();
}
+4 -6
View File
@@ -471,7 +471,7 @@ impl TerminalApp {
match self.theme_store.save(&self.themes[i]) {
Ok(path) => {
if let Some(f) = old_file.filter(|f| *f != path) {
let _ = std::fs::remove_file(f);
let _ = crate::storage::remove(&f);
}
self.themes[i].source = Some(path);
}
@@ -519,7 +519,7 @@ impl TerminalApp {
expand(given).with_extension(op)
};
self.status = match themes::ThemeStore::export(&self.themes[idx], &path) {
Ok(()) => format!("Exported {name:?} to {}", path.display()),
Ok(to) => format!("Exported {name:?} to {to}"),
Err(e) => format!("Export failed: {e:#}"),
};
}
@@ -551,7 +551,7 @@ impl TerminalApp {
}
"import" => {
let path = expand(self.theme_io.trim());
match themes::load_file(&path) {
match themes::import_file(&path) {
Ok(mut t) => {
t.name = themes::unique_name(&self.themes, &t.name);
t.source = None;
@@ -566,9 +566,7 @@ impl TerminalApp {
}
}
"folder" => {
let _ = std::fs::create_dir_all(&self.theme_store.dir);
let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
if let Err(e) = std::process::Command::new(opener).arg(&self.theme_store.dir).spawn() {
if let Err(e) = crate::storage::reveal(&self.theme_store.dir) {
self.status = format!("Could not open {}: {e}", self.theme_store.dir.display());
}
}
+2 -1
View File
@@ -2,11 +2,12 @@
//! configured dark/light pair), cycle, the THEME command, copy-on-write edits of built-ins,
//! and debounced saving of edited custom themes.
use std::time::{Duration, Instant};
use std::time::Duration;
use super::TerminalApp;
use crate::prefs::{Density, UiFont};
use crate::themes::{self, SYSTEM, Theme};
use web_time::Instant;
impl TerminalApp {
/// Index of the theme currently in effect.
+1076 -368
View File
File diff suppressed because it is too large. Load diff
+464
View File
@@ -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");
}
}
+374
View File
@@ -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
View File
@@ -37,6 +37,109 @@ pub const PANELS: [(&str, &str); 14] = [
("Help", "HELP"),
];
/// Length of a dock transition (panes added, closed, moved, or a layout switch).
const DOCK_SECS: f32 = 0.42;
/// Eases the dock between arrangements: when panes are added, closed or moved, groups
/// glide from where they were to their new rectangles and new groups rise and fade in
/// (staggered, for a layout switch). Window and divider resizes follow immediately.
#[derive(Default)]
pub(super) struct DockMotion {
/// The arrangement last frame, to notice structural changes.
shape: Vec<ShapePart>,
/// Rectangles shown last frame, by group key.
shown: HashMap<PaneId, Rect>,
/// Where each surviving group started the current transition.
from: HashMap<PaneId, Rect>,
/// Groups new in the current transition, by key, with their stagger rank.
new: HashMap<PaneId, usize>,
start: Option<f64>,
}
/// The dock's arrangement without its proportions: split directions, child order and the
/// panes in each group. A layout switch or a swap changes it; dragging a divider does not.
#[derive(Debug, PartialEq)]
enum ShapePart {
Split(Dir),
End,
Group(Vec<PaneId>),
}
fn shape_of(node: &Node, out: &mut Vec<ShapePart>) {
match node {
Node::Tabs { panes, .. } => {
let mut p = panes.clone();
p.sort_unstable();
out.push(ShapePart::Group(p));
}
Node::Split { dir, children, .. } => {
out.push(ShapePart::Split(*dir));
children.iter().for_each(|c| shape_of(c, out));
out.push(ShapePart::End);
}
}
}
impl DockMotion {
/// A group keeps its key while it keeps its oldest pane.
fn key(g: &crate::dock::Group) -> PaneId {
g.panes.iter().copied().min().unwrap_or(0)
}
/// Move `groups` (laid out from `dock`) to where they are shown this frame; returns
/// how far each has appeared (1 = fully).
fn apply(&mut self, ctx: &egui::Context, dock: &Node, groups: &mut [crate::dock::Group]) -> Vec<f32> {
let mut shape = Vec::new();
shape_of(dock, &mut shape);
let now = ctx.input(|i| i.time);
if shape != self.shape {
// The first arrangement shows as is.
if !self.shape.is_empty() {
self.from.clear();
self.new.clear();
for g in groups.iter() {
let k = Self::key(g);
match self.shown.get(&k) {
Some(r) => {
self.from.insert(k, *r);
}
None => {
let rank = self.new.len();
self.new.insert(k, rank);
}
}
}
self.start = Some(now);
}
self.shape = shape;
}
let t = self.start.map_or(1.0, |s| anim::since(ctx, now - s, DOCK_SECS));
if t >= 1.0 {
self.start = None;
}
self.shown.clear();
groups
.iter_mut()
.map(|g| {
let k = Self::key(g);
let mut appear = 1.0;
if t < 1.0 {
if let Some(from) = self.from.get(&k) {
let e = anim::ease_out(anim::span(t, 0.0, 0.8));
g.rect = Rect::from_min_max(from.min.lerp(g.rect.min, e), from.max.lerp(g.rect.max, e));
} else if let Some(&rank) = self.new.get(&k) {
let delay = 0.1 + (rank as f32 * 0.08).min(0.3);
appear = anim::ease_out(anim::span(t, delay, delay + 0.6));
g.rect = g.rect.translate(vec2(0.0, 14.0 * (1.0 - appear)));
}
}
self.shown.insert(k, g.rect);
appear
})
.collect()
}
}
/// Saved-layout operations from the sidebar, settings and command line.
#[derive(Debug, Clone, PartialEq)]
pub enum LayoutOp {
@@ -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 &dividers {
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
View File
@@ -8,11 +8,12 @@ use egui::{
vec2,
};
use tr_chart::drawings::{self, Anchor, Drawing, Shape, Tool};
use tr_chart::gpu::{ChartCallback, Frame, LineKind, LineRef, Style};
use tr_chart::gpu::{ChartCallback, Frame, LineKind, LineRef, Reveal, Style};
use tr_chart::scale;
use tr_chart::scene::{self, ChartType, Overlay, PricePane, SeriesGpu};
use tr_chart::viewport::{PriceScale, Viewport};
use tr_congress::{Trade, TxType};
use tr_core::indicators::registry::{self, Category};
use tr_core::{AssetClass, Bar, Security, Timeframe};
use crate::command::{IndicatorSpec, Range};
@@ -29,6 +30,36 @@ const RANGE_H: f32 = 18.0;
/// Height of the interval / chart-type segmented controls.
pub const CONTROL_H: f32 = 20.0;
const DEFAULT_VISIBLE: usize = 160;
/// Oscillator panes shown under the price pane. GPU frame ids are `id * 8 + 1 + k`, so a
/// chart has room for at most seven.
const MAX_SUB_PANES: usize = 6;
/// Length of the load-in sweep across the visible bars.
const REVEAL_SECS: f32 = 0.7;
/// Length of an eased jump (quick ranges, reset, keyboard zoom and pan).
const GLIDE_SECS: f32 = 0.32;
/// Time constant of the autoscale easing.
const SCALE_TAU: f64 = 0.07;
/// A programmatic viewport change in progress; `start` is set on the first frame shown.
#[derive(Debug, Clone, Copy)]
struct Glide {
from: Viewport,
to: Viewport,
start: Option<f64>,
}
impl Glide {
/// Pans linearly and zooms geometrically, so a zoom feels even all the way.
fn at(&self, e: f64) -> Viewport {
let (a, b) = (self.from.bar_px.ln(), self.to.bar_px.ln());
Viewport {
right: self.from.right + (self.to.right - self.from.right) * e,
bar_px: (a + (b - a) * e).exp(),
..self.to
}
}
}
#[derive(Default)]
struct DrawState {
@@ -55,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
View File
@@ -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}");
}
}
}
}
+10 -1
View File
@@ -9,6 +9,10 @@ use tr_congress::{Chamber, Party, Trade, TxType};
use crate::theme::{self, amber, mono, muted, text, ui_font};
use crate::worker::Profile;
/// Shown instead of Congress data in the browser build, which can't fetch disclosures yet.
pub const WEB_NOTE: &str =
"Congress data needs the desktop app for now: browsers can't reach Senate eFD or the House Clerk.";
/// What a click inside a panel asks the app to do.
#[derive(Debug, Clone, PartialEq)]
pub enum Action {
@@ -192,7 +196,12 @@ pub fn feed(ui: &mut Ui, trades: &[Trade], filter: &mut FeedFilter, status: &str
header(ui, "FILED TRADED LAG POLITICIAN PTY C TYPE TICKER AMOUNT OWNER ASSET");
let rows: Vec<&Trade> = trades.iter().filter(|t| filter.matches(t)).collect();
if rows.is_empty() {
plain(ui, "No disclosures yet. Ingest runs in the background (REFRESH to force).", muted());
let empty = if cfg!(target_arch = "wasm32") {
WEB_NOTE
} else {
"No disclosures yet. Ingest runs in the background (REFRESH to force)."
};
plain(ui, empty, muted());
}
let row_h = 16.0;
ScrollArea::both().id_salt("feed").auto_shrink([false, false]).show_rows(ui, row_h, rows.len(), |ui, range| {
+1028
View File
File diff suppressed because it is too large. Load diff
+18 -29
View File
@@ -1,6 +1,6 @@
//! Named, saved workspace layouts: the dock tree, each pane's kind and settings, link
//! groups and popped-out windows. Stored one JSON file per layout in
//! `<data dir>/layouts/` (`~/Library/Application Support/terminal-rs/layouts` on macOS),
//! `<data dir>/layouts/` (`~/Library/Application Support/terminal/layouts` on macOS),
//! so they can be shared, hand-edited, imported and exported.
use std::collections::HashSet;
@@ -16,6 +16,7 @@ use crate::command::IndicatorSpec;
use crate::congress_view::FeedFilter;
use crate::dock::{Node, PaneId};
use crate::layout::Layout;
use crate::storage;
pub const FORMAT_VERSION: u32 = 1;
@@ -267,7 +268,7 @@ pub fn chart_settings(symbol: &str, d: &crate::prefs::PanelDefaults) -> PaneSett
tf: d.timeframe,
chart_type: d.chart_type,
log: false,
indicators: vec![IndicatorSpec::Sma(20), IndicatorSpec::Sma(50)],
indicators: crate::command::default_indicators(),
drawings: Vec::new(),
compare: None,
show_volume: d.show_volume,
@@ -276,16 +277,6 @@ pub fn chart_settings(symbol: &str, d: &crate::prefs::PanelDefaults) -> PaneSett
}
}
/// Write via a temp file and rename, so a crash never leaves a half-written file.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, bytes)?;
std::fs::rename(tmp, path)
}
/// The layouts folder and its files.
pub struct Store {
pub dir: PathBuf,
@@ -314,19 +305,17 @@ impl Store {
/// Saved layout names, sorted case-insensitively. Names come from the documents, so a
/// file copied in under any file name keeps its display name.
pub fn list(&self) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(&self.dir)
let mut names: Vec<String> = storage::list(&self.dir)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
.filter_map(|e| {
let bytes = std::fs::read(e.path()).ok()?;
.filter(|p| p.extension().is_some_and(|x| x == "json"))
.filter_map(|p| {
let bytes = storage::read(&p).ok()?;
let doc: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
Some(
doc["name"]
.as_str()
.map(str::to_string)
.unwrap_or_else(|| e.path().file_stem().unwrap_or_default().to_string_lossy().into_owned()),
.unwrap_or_else(|| p.file_stem().unwrap_or_default().to_string_lossy().into_owned()),
)
})
.collect();
@@ -343,12 +332,12 @@ impl Store {
/// that name (e.g. dropped into the folder by hand).
fn find_file(&self, name: &str) -> Option<PathBuf> {
let p = self.path(name);
if p.exists() {
if storage::exists(&p) {
return Some(p);
}
std::fs::read_dir(&self.dir).ok()?.flatten().map(|e| e.path()).find(|p| {
storage::list(&self.dir).into_iter().find(|p| {
p.extension().is_some_and(|x| x == "json")
&& std::fs::read(p)
&& storage::read(p)
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.is_some_and(|v| v["name"].as_str().is_some_and(|n| n.eq_ignore_ascii_case(name)))
@@ -357,7 +346,7 @@ impl Store {
pub fn load(&self, name: &str) -> Result<LayoutDoc> {
let path = self.find_file(name).with_context(|| format!("no saved layout named {name:?}"))?;
let mut doc = LayoutDoc::from_json(&std::fs::read(&path)?).with_context(|| format!("{}", path.display()))?;
let mut doc = LayoutDoc::from_json(&storage::read(&path)?).with_context(|| format!("{}", path.display()))?;
if doc.name.trim().is_empty() {
doc.name = name.to_string();
}
@@ -366,13 +355,13 @@ impl Store {
pub fn save(&self, doc: &LayoutDoc) -> Result<PathBuf> {
let path = self.path(&doc.name);
write_atomic(&path, &doc.to_json()).with_context(|| format!("writing {}", path.display()))?;
storage::write_atomic(&path, &doc.to_json()).with_context(|| format!("writing {}", path.display()))?;
Ok(path)
}
pub fn delete(&self, name: &str) -> Result<()> {
let path = self.find_file(name).with_context(|| format!("no saved layout named {name:?}"))?;
std::fs::remove_file(path)?;
storage::remove(&path)?;
Ok(())
}
@@ -411,16 +400,16 @@ impl Store {
(2..).map(|i| format!("{base} {i}")).find(|n| !self.exists(n)).expect("unbounded")
}
/// Write a layout to an arbitrary path.
pub fn export(&self, name: &str, to: &Path) -> Result<()> {
/// Write a layout to an arbitrary path (a download in the browser). Returns where it went.
pub fn export(&self, name: &str, to: &Path) -> Result<String> {
let doc = self.load(name)?;
write_atomic(to, &doc.to_json()).with_context(|| format!("writing {}", to.display()))
storage::export(to, &doc.to_json()).with_context(|| format!("writing {}", to.display()))
}
/// Copy a layout file into the store (renaming on conflict). Returns its name.
pub fn import(&self, from: &Path) -> Result<String> {
let mut doc =
LayoutDoc::from_json(&std::fs::read(from).with_context(|| format!("reading {}", from.display()))?)?;
LayoutDoc::from_json(&storage::import(from).with_context(|| format!("reading {}", from.display()))?)?;
if doc.name.trim().is_empty() {
doc.name = from.file_stem().unwrap_or_default().to_string_lossy().into_owned();
}
+54 -18
View File
@@ -1,4 +1,4 @@
//! terminal-rs: a keyboard-driven, Bloomberg-style market terminal with GPU charts and
//! Terminal: a keyboard-driven, Bloomberg-style market terminal with GPU charts and
//! congressional (STOCK Act) trade tracking.
mod anim;
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+469
View File
@@ -0,0 +1,469 @@
//! Where preferences, settings, layouts, themes, palette history and the demo account are
//! kept: files under the data directory on the desktop, `localStorage` entries keyed by path
//! in the browser. Every saved document is UTF-8 text (JSON or TOML).
//!
//! [`export`], [`import`] and [`reveal`] are for files the user names: on the desktop they are
//! ordinary files and folders; in the browser an export is a download, and there is no import
//! or folder yet.
use std::io;
use std::path::{Path, PathBuf};
pub use imp::*;
/// Saved documents keyed by path, as the browser keeps them.
#[cfg(any(target_arch = "wasm32", test))]
trait KeyStore {
fn keys(&self) -> Vec<String>;
fn get(&self, key: &str) -> Option<String>;
/// False when the store refuses the write (full or blocked).
fn set(&mut self, key: &str, value: &str) -> bool;
fn remove(&mut self, key: &str);
}
/// Copies every key under `old/` to the same path under `new/`, once. Returns whether to use
/// `new`. Keys already under `new/` are never overwritten, and the old keys stay as they are. A
/// marker key records that the copy finished, so a file deleted afterwards doesn't come back on
/// the next load. If the store refuses a write, the copies made so far are removed and this
/// returns false, so the page keeps its complete old set and the next load tries again.
#[cfg(any(target_arch = "wasm32", test))]
fn copy_keys(store: &mut impl KeyStore, old: &str, new: &str) -> bool {
let marker = marker(new);
if store.get(&marker).is_some() {
return true;
}
let keys: Vec<(String, String)> = store
.keys()
.into_iter()
.filter_map(|k| Some((format!("{new}/{}", k.strip_prefix(old)?.strip_prefix('/')?), k)))
.collect();
if keys.is_empty() {
return true;
}
let mut copied = Vec::new();
let mut ok = true;
for (to, from) in keys {
if store.get(&to).is_some() {
continue;
}
let Some(value) = store.get(&from) else { continue };
if !store.set(&to, &value) {
ok = false;
break;
}
copied.push(to);
}
if ok && store.set(&marker, old) {
tracing::info!("copied {} saved files from {old}/ to {new}/", copied.len());
return true;
}
for k in &copied {
store.remove(k);
}
tracing::warn!("browser storage refused copying saved files from {old}/ to {new}/; using {old}/ for now");
false
}
/// The key [`copy_keys`] sets once everything under `new/` is in place.
#[cfg(any(target_arch = "wasm32", test))]
fn marker(new: &str) -> String {
format!("{new}/.migrated")
}
/// Where a page that kept the old prefix (because [`copy_keys`] failed) should read and write
/// `key`. Once another tab has finished the copy, keys under `old` map to `new`, so this page's
/// saves land where later loads look instead of under keys they no longer read.
#[cfg(any(target_arch = "wasm32", test))]
fn redirect(store: &impl KeyStore, old: &str, new: &str, key: String) -> String {
match key.strip_prefix(old) {
Some(rest) if (rest.is_empty() || rest.starts_with('/')) && store.get(&marker(new)).is_some() => {
format!("{new}{rest}")
}
_ => key,
}
}
#[cfg(not(target_arch = "wasm32"))]
mod imp {
use super::*;
/// Moves the data folder from `old` (its name before the app was renamed) to `new` the first
/// time, and leaves a link at the old path so builds that still use the old name find the
/// same files. Returns the folder to use: `new`, or `old` if it couldn't be moved. Never
/// replaces an existing `new`.
pub fn migrate(old: &Path, new: &Path) -> PathBuf {
if new.exists() || !old.is_dir() {
return new.to_path_buf();
}
if let Err(e) = std::fs::rename(old, new) {
// Another copy of the app may have moved it first.
if new.is_dir() {
return new.to_path_buf();
}
tracing::warn!("could not move {} to {} ({e}); using it where it is", old.display(), new.display());
return old.to_path_buf();
}
tracing::info!("moved {} to {}", old.display(), new.display());
#[cfg(unix)]
{
// Name the sibling rather than repeating `new`, which may be relative (an unset HOME
// or a relative XDG_DATA_HOME) and would then resolve from the link's folder.
let target = match new.file_name() {
Some(name) if old.parent() == new.parent() => PathBuf::from(name),
_ => std::path::absolute(new).unwrap_or_else(|_| new.to_path_buf()),
};
if let Err(e) = std::os::unix::fs::symlink(&target, old) {
tracing::warn!("linking {} to {}: {e}", old.display(), target.display());
}
}
new.to_path_buf()
}
pub fn read(path: &Path) -> io::Result<Vec<u8>> {
std::fs::read(path)
}
/// Write via a temp file and rename, so a crash never leaves a half-written file.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, bytes)?;
std::fs::rename(tmp, path)
}
/// Entries directly inside `dir` (none if it doesn't exist).
pub fn list(dir: &Path) -> Vec<PathBuf> {
std::fs::read_dir(dir).into_iter().flatten().flatten().map(|e| e.path()).collect()
}
pub fn exists(path: &Path) -> bool {
path.exists()
}
pub fn remove(path: &Path) -> io::Result<()> {
std::fs::remove_file(path)
}
pub fn rename(from: &Path, to: &Path) -> io::Result<()> {
std::fs::rename(from, to)
}
/// Write a file the user exports. Returns where it went, for the status line.
pub fn export(path: &Path, bytes: &[u8]) -> io::Result<String> {
write_atomic(path, bytes)?;
Ok(path.display().to_string())
}
/// Read a file the user imports.
pub fn import(path: &Path) -> io::Result<Vec<u8>> {
std::fs::read(path)
}
/// Open a folder in the system file manager, creating it first.
pub fn reveal(dir: &Path) -> io::Result<()> {
std::fs::create_dir_all(dir)?;
let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
std::process::Command::new(opener).arg(dir).spawn().map(|_| ())
}
}
#[cfg(target_arch = "wasm32")]
mod imp {
use super::*;
fn store() -> io::Result<web_sys::Storage> {
web_sys::window()
.and_then(|w| w.local_storage().ok().flatten())
.ok_or_else(|| io::Error::new(io::ErrorKind::Unsupported, "browser storage is unavailable"))
}
thread_local! {
/// The old and new prefixes while this page keeps the old one (see [`migrate`]).
static FALLBACK: std::cell::RefCell<Option<(String, String)>> = const { std::cell::RefCell::new(None) };
}
fn key(path: &Path) -> String {
let key = path.to_string_lossy().into_owned();
let Some((old, new)) = FALLBACK.with_borrow(Clone::clone) else { return key };
match store() {
Ok(s) => redirect(&s, &old, &new, key),
Err(_) => key,
}
}
impl KeyStore for web_sys::Storage {
fn keys(&self) -> Vec<String> {
(0..self.length().unwrap_or(0)).filter_map(|i| self.key(i).ok().flatten()).collect()
}
fn get(&self, key: &str) -> Option<String> {
self.get_item(key).ok().flatten()
}
fn set(&mut self, key: &str, value: &str) -> bool {
self.set_item(key, value).is_ok()
}
fn remove(&mut self, key: &str) {
let _ = self.remove_item(key);
}
}
/// Copies what the page saved under `old/` (its prefix before the app was renamed) to
/// `new/`, once (see [`copy_keys`]). Returns the prefix to use. If the copy fails, the page
/// keeps `old`, and its keys follow [`redirect`] from then on, in case another tab finishes
/// the copy while this one is open.
pub fn migrate(old: &Path, new: &Path) -> PathBuf {
let Ok(mut s) = store() else { return new.to_path_buf() };
let (old, new) = (key(old), key(new));
if copy_keys(&mut s, &old, &new) {
return PathBuf::from(new);
}
FALLBACK.with_borrow_mut(|f| *f = Some((old.clone(), new)));
PathBuf::from(old)
}
pub fn read(path: &Path) -> io::Result<Vec<u8>> {
match store()?.get_item(&key(path)) {
Ok(Some(text)) => Ok(text.into_bytes()),
Ok(None) => Err(io::ErrorKind::NotFound.into()),
Err(_) => Err(io::Error::other("browser storage refused the read")),
}
}
/// A single `setItem`, which is already all-or-nothing.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
let text = std::str::from_utf8(bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
store()?.set_item(&key(path), text).map_err(|_| io::Error::other("browser storage is full or blocked"))
}
/// Keys directly under `dir/`.
pub fn list(dir: &Path) -> Vec<PathBuf> {
let Ok(s) = store() else { return Vec::new() };
let prefix = format!("{}/", key(dir).trim_end_matches('/'));
(0..s.length().unwrap_or(0))
.filter_map(|i| s.key(i).ok().flatten())
.filter(|k| k.strip_prefix(&prefix).is_some_and(|rest| !rest.is_empty() && !rest.contains('/')))
.map(PathBuf::from)
.collect()
}
pub fn exists(path: &Path) -> bool {
store().ok().and_then(|s| s.get_item(&key(path)).ok().flatten()).is_some()
}
pub fn remove(path: &Path) -> io::Result<()> {
if !exists(path) {
return Err(io::ErrorKind::NotFound.into());
}
store()?.remove_item(&key(path)).map_err(|_| io::Error::other("browser storage refused the delete"))
}
pub fn rename(from: &Path, to: &Path) -> io::Result<()> {
write_atomic(to, &read(from)?)?;
remove(from)
}
/// Download the exported file under its file name. Returns where it went, for the status line.
pub fn export(path: &Path, bytes: &[u8]) -> io::Result<String> {
use wasm_bindgen::JsCast;
let fail = |what: &str| io::Error::other(format!("the browser refused the download ({what})"));
let name = path.file_name().map_or_else(|| "export".into(), |n| n.to_string_lossy().into_owned());
let doc = web_sys::window().and_then(|w| w.document()).ok_or_else(|| fail("no document"))?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let blob = web_sys::Blob::new_with_u8_array_sequence(&parts).map_err(|_| fail("blob"))?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(|_| fail("object URL"))?;
let link: web_sys::HtmlAnchorElement =
doc.create_element("a").map_err(|_| fail("link"))?.dyn_into().map_err(|_| fail("link"))?;
link.set_href(&url);
link.set_download(&name);
link.click();
let _ = web_sys::Url::revoke_object_url(&url);
Ok(format!("your downloads as {name}"))
}
// TODO(web): read imports through a file picker instead of a typed path.
pub fn import(_path: &Path) -> io::Result<Vec<u8>> {
Err(io::Error::new(io::ErrorKind::Unsupported, "importing files needs the desktop app for now"))
}
pub fn reveal(_dir: &Path) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::Unsupported, "the browser keeps these in its local storage, not a folder"))
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
#[test]
fn round_trip() {
let dir = std::env::temp_dir().join(format!("tr-storage-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let a = dir.join("sub/a.json");
assert!(list(&dir.join("sub")).is_empty());
assert_eq!(read(&a).unwrap_err().kind(), io::ErrorKind::NotFound);
write_atomic(&a, b"{}").unwrap();
assert!(exists(&a));
assert_eq!(list(&dir.join("sub")), vec![a.clone()]);
let b = dir.join("sub/b.json");
rename(&a, &b).unwrap();
assert!(!exists(&a));
assert_eq!(read(&b).unwrap(), b"{}");
remove(&b).unwrap();
assert!(list(&dir.join("sub")).is_empty());
std::fs::remove_dir_all(&dir).unwrap();
}
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("tr-migrate-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn migrate_moves_the_old_folder_and_links_it() {
let root = scratch("move");
let (old, new) = (root.join("terminal-rs"), root.join("terminal"));
write_atomic(&old.join("layouts/Desk.json"), b"{}").unwrap();
assert_eq!(migrate(&old, &new), new);
assert_eq!(read(&new.join("layouts/Desk.json")).unwrap(), b"{}");
#[cfg(unix)]
{
assert_eq!(std::fs::read_link(&old).unwrap(), Path::new("terminal"), "relative to the link's folder");
assert_eq!(read(&old.join("layouts/Desk.json")).unwrap(), b"{}", "old builds still find the files");
}
// Later launches find the new folder and leave everything as it is.
write_atomic(&new.join("settings.json"), b"[]").unwrap();
assert_eq!(migrate(&old, &new), new);
assert_eq!(read(&new.join("settings.json")).unwrap(), b"[]");
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn migrate_never_replaces_an_existing_folder() {
let root = scratch("keep");
let (old, new) = (root.join("terminal-rs"), root.join("terminal"));
write_atomic(&old.join("settings.json"), b"old").unwrap();
write_atomic(&new.join("settings.json"), b"new").unwrap();
assert_eq!(migrate(&old, &new), new);
assert_eq!(read(&new.join("settings.json")).unwrap(), b"new");
assert_eq!(read(&old.join("settings.json")).unwrap(), b"old", "the old folder is left alone");
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn migrate_without_an_old_folder_creates_nothing() {
let root = scratch("fresh");
let (old, new) = (root.join("terminal-rs"), root.join("terminal"));
assert_eq!(migrate(&old, &new), new);
assert!(!old.exists() && !new.exists());
std::fs::remove_dir_all(&root).unwrap();
}
#[cfg(unix)]
#[test]
fn migrate_keeps_using_the_old_folder_when_it_cannot_move() {
use std::os::unix::fs::PermissionsExt;
let root = scratch("stuck");
let (old, new) = (root.join("terminal-rs"), root.join("terminal"));
write_atomic(&old.join("settings.json"), b"{}").unwrap();
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o555)).unwrap();
if std::fs::create_dir(root.join("probe")).is_ok() {
// Running as root, where a read-only folder doesn't stop the move.
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::remove_dir_all(&root).unwrap();
return;
}
let used = migrate(&old, &new);
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(used, old);
assert_eq!(read(&old.join("settings.json")).unwrap(), b"{}");
std::fs::remove_dir_all(&root).unwrap();
}
/// `localStorage` stand-in: a map that refuses writes once it holds `room` keys.
struct Keys {
map: std::collections::BTreeMap<String, String>,
room: usize,
}
impl Keys {
fn new(entries: &[(&str, &str)]) -> Self {
Self { map: entries.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), room: usize::MAX }
}
}
impl KeyStore for Keys {
fn keys(&self) -> Vec<String> {
self.map.keys().cloned().collect()
}
fn get(&self, key: &str) -> Option<String> {
self.map.get(key).cloned()
}
fn set(&mut self, key: &str, value: &str) -> bool {
if !self.map.contains_key(key) && self.map.len() >= self.room {
return false;
}
self.map.insert(key.into(), value.into());
true
}
fn remove(&mut self, key: &str) {
self.map.remove(key);
}
}
#[test]
fn copy_keys_copies_once_without_overwriting() {
let mut s = Keys::new(&[
("terminal-rs/settings.json", "old settings"),
("terminal-rs/layouts/Desk.json", "desk"),
("terminal/settings.json", "newer settings"),
("other-site-key", "untouched"),
]);
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
assert_eq!(s.get("terminal/settings.json").as_deref(), Some("newer settings"), "newer data wins");
assert_eq!(s.get("terminal/layouts/Desk.json").as_deref(), Some("desk"));
assert_eq!(s.get("terminal-rs/layouts/Desk.json").as_deref(), Some("desk"), "the old keys stay");
assert_eq!(s.get("other-site-key").as_deref(), Some("untouched"));
// A layout deleted after the copy doesn't come back on the next load.
s.remove("terminal/layouts/Desk.json");
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
assert_eq!(s.get("terminal/layouts/Desk.json"), None);
}
#[test]
fn copy_keys_undoes_a_partial_copy() {
let mut s = Keys::new(&[("terminal-rs/a.json", "a"), ("terminal-rs/b.json", "b")]);
s.room = 3;
assert!(!copy_keys(&mut s, "terminal-rs", "terminal"), "storage is full: keep using the old keys");
assert_eq!(s.keys(), ["terminal-rs/a.json", "terminal-rs/b.json"]);
s.room = usize::MAX;
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
assert_eq!(s.get("terminal/b.json").as_deref(), Some("b"));
}
#[test]
fn a_page_left_on_the_old_prefix_follows_another_tabs_copy() {
let mut s = Keys::new(&[("terminal-rs/settings.json", "a")]);
s.room = 1;
// Tab A can't copy, so it keeps the old prefix.
assert!(!copy_keys(&mut s, "terminal-rs", "terminal"));
let key = |s: &Keys, k: &str| redirect(s, "terminal-rs", "terminal", k.into());
assert_eq!(key(&s, "terminal-rs/settings.json"), "terminal-rs/settings.json");
// Space frees up and tab B finishes the copy; tab A's saves now go where loads read.
s.room = usize::MAX;
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
assert_eq!(key(&s, "terminal-rs/settings.json"), "terminal/settings.json");
assert_eq!(key(&s, "terminal-rs/layouts"), "terminal/layouts");
assert_eq!(key(&s, "terminal-rs"), "terminal");
assert_eq!(key(&s, "terminal-rsx/a.json"), "terminal-rsx/a.json", "only that prefix");
}
#[test]
fn copy_keys_with_nothing_saved_writes_nothing() {
let mut s = Keys::new(&[]);
assert!(copy_keys(&mut s, "terminal-rs", "terminal"));
assert!(s.keys().is_empty());
}
}
+46 -1
View File
@@ -53,6 +53,9 @@ thread_local! {
static DENSITY: Cell<f32> = const { Cell::new(1.0) };
static UI_MONO: Cell<bool> = const { Cell::new(false) };
static FONTS: Cell<Option<MonoFont>> = const { Cell::new(None) };
/// Fonts fetched after start-up (the browser build's emoji fallbacks).
#[cfg(target_arch = "wasm32")]
static LATE_FONTS: std::cell::RefCell<Vec<(String, std::sync::Arc<FontData>)>> = const { std::cell::RefCell::new(Vec::new()) };
}
/// Spacing multiplier from the density preference (compact < 1 < comfortable).
@@ -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
View File
@@ -10,6 +10,7 @@ use egui::Color32;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::prefs::{Density, MonoFont, UiFont};
use crate::storage;
use crate::theme::Palette;
/// Selection that follows the OS appearance using the configured dark/light pair.
@@ -561,11 +562,8 @@ impl ThemeStore {
/// built-in or earlier name is listed with a suffix rather than shadowing it.
pub fn load_all(&self) -> Vec<Theme> {
let mut out = builtins();
let mut files: Vec<PathBuf> = std::fs::read_dir(&self.dir)
let mut files: Vec<PathBuf> = storage::list(&self.dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "toml" || x == "json"))
.collect();
files.sort();
@@ -606,7 +604,7 @@ impl ThemeStore {
.clone()
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
.unwrap_or_else(|| self.path(&theme.name));
crate::layouts::write_atomic(&path, theme.to_toml().as_bytes())
storage::write_atomic(&path, theme.to_toml().as_bytes())
.with_context(|| format!("writing {}", path.display()))?;
Ok(path)
}
@@ -617,23 +615,33 @@ impl ThemeStore {
bail!("built-in themes cannot be deleted");
}
let path = theme.source.clone().unwrap_or_else(|| self.path(&theme.name));
if !path.exists() {
if !storage::exists(&path) {
bail!("no file for theme {:?}", theme.name);
}
Ok(std::fs::remove_file(path)?)
Ok(storage::remove(&path)?)
}
/// Write a theme to `to` as JSON (`.json`) or TOML (anything else).
pub fn export(theme: &Theme, to: &Path) -> Result<()> {
/// Write a theme to `to` as JSON (`.json`) or TOML (anything else); a download in the
/// browser. Returns where it went.
pub fn export(theme: &Theme, to: &Path) -> Result<String> {
let json = to.extension().is_some_and(|x| x.eq_ignore_ascii_case("json"));
let text = if json { theme.to_json() } else { theme.to_toml() };
crate::layouts::write_atomic(to, text.as_bytes()).with_context(|| format!("writing {}", to.display()))
storage::export(to, text.as_bytes()).with_context(|| format!("writing {}", to.display()))
}
}
/// Read a TOML or JSON theme file.
/// Read a TOML or JSON theme file from the themes folder.
pub fn load_file(path: &Path) -> Result<Theme> {
let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
parse_file(path, storage::read(path).with_context(|| format!("reading {}", path.display()))?)
}
/// Read a theme file the user imports.
pub fn import_file(path: &Path) -> Result<Theme> {
parse_file(path, storage::import(path).with_context(|| format!("reading {}", path.display()))?)
}
fn parse_file(path: &Path, bytes: Vec<u8>) -> Result<Theme> {
let text = String::from_utf8(bytes).with_context(|| format!("{} is not UTF-8 text", path.display()))?;
let json = path.extension().is_some_and(|x| x.eq_ignore_ascii_case("json"));
let mut t = Theme::parse(&text, json)?;
t.source = Some(path.to_path_buf());
+404
View File
@@ -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
View File
@@ -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() }
}
}
+80
View File
@@ -265,6 +265,35 @@ pub fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 {
Color32::from_rgba_unmultiplied(l(a.r(), b.r()), l(a.g(), b.g()), l(a.b(), b.b()), l(a.a(), b.a()))
}
/// A selection highlight that glides between list rows. Create it before the rows (it
/// keeps a place under them) and call [`Glider::on`] with the selected row.
pub struct Glider {
id: egui::Id,
slot: egui::layers::ShapeIdx,
/// Top of the list; positions are kept relative to it so scrolling does not lag.
origin: f32,
}
impl Glider {
pub fn new(ui: &Ui, id: egui::Id) -> Self {
Self { id, slot: ui.painter().add(Shape::Noop), origin: ui.cursor().top() }
}
pub fn on(&self, ui: &Ui, row: Rect) {
let ctx = ui.ctx();
// A list that was just opened shows its selection in place.
let frame = ctx.cumulative_frame_nr();
let seen = self.id.with("frame");
if ctx.data(|d| d.get_temp::<u64>(seen)).is_none_or(|f| f + 1 < frame) {
ctx.data_mut(|d| d.remove::<anim::Spring>(self.id));
}
ctx.data_mut(|d| d.insert_temp(seen, frame));
let y = anim::spring_with(ctx, self.id, row.top() - self.origin, 0.22, 0.86);
let r = row.translate(vec2(0.0, self.origin + y - row.top()));
ui.painter().set(self.slot, RectShape::filled(r, theme::CONTROL_RADIUS, theme::accent_wash()));
}
}
/// Animated hover wash for list rows.
pub fn hover_wash(ui: &Ui, rect: Rect, resp: &Response, selected: bool) {
let t = anim::spring_bool(ui.ctx(), resp.id.with("wash"), resp.hovered());
@@ -275,6 +304,57 @@ pub fn hover_wash(ui: &Ui, rect: Rect, resp: &Response, selected: bool) {
}
}
/// A small action pill: label, fill and label colour.
pub type Pill<'a> = (&'a str, Color32, Color32);
/// Buy / Sell / Close pills over the right end of a table row while the pointer is on it,
/// on a backdrop so they never overlap the row's text. Returns the index clicked.
pub fn row_pills(
ui: &mut Ui,
row: Rect,
id: impl std::hash::Hash + std::fmt::Debug,
pills: &[Pill<'_>],
tips: &[&str],
) -> Option<usize> {
if !ui.rect_contains_pointer(row) || pills.is_empty() {
return None;
}
let h = (row.height() - 6.0).clamp(14.0, 18.0);
let font = ui_font(10.5);
let galleys: Vec<_> =
pills.iter().map(|(l, _, c)| ui.painter().layout_no_wrap((*l).to_string(), font.clone(), *c)).collect();
let total: f32 = galleys.iter().map(|g| g.size().x + 14.0).sum::<f32>() + 4.0 * (pills.len() - 1) as f32;
let strip = Rect::from_min_max(
pos2(row.right() - total - 12.0, row.top() + 1.0),
pos2(row.right() - 1.0, row.bottom() - 1.0),
);
ui.painter().rect_filled(strip, theme::CONTROL_RADIUS, theme::card());
let mut x = strip.left() + 6.0;
let mut clicked = None;
let base = ui.id().with(id);
for (i, ((_, fill, _), g)) in pills.iter().zip(galleys).enumerate() {
let w = g.size().x + 14.0;
let r = Rect::from_min_size(pos2(x, row.center().y - h / 2.0), vec2(w, h));
x += w + 4.0;
let mut resp = ui.interact(r, base.with(i), Sense::click());
let fill = if resp.hovered() { *fill } else { fill.gamma_multiply(0.82) };
ui.painter().rect_filled(r, h / 2.0, fill);
ui.painter().galley(r.center() - g.size() / 2.0, g, Color32::WHITE);
if let Some(t) = tips.get(i) {
resp = resp.on_hover_text(*t);
}
if resp.clicked() {
clicked = Some(i);
}
}
clicked
}
/// The usual Buy / Sell pills.
pub fn buy_sell_pills() -> [Pill<'static>; 2] {
[("B", theme::up(), Color32::WHITE), ("S", theme::down(), Color32::WHITE)]
}
#[cfg(test)]
mod tests {
use super::*;
+548 -114
View File
@@ -1,22 +1,30 @@
//! Background worker. All network, SQLite, indicator and LOD-pyramid work runs on a
//! tokio runtime; the UI thread only sends [`Request`]s and drains [`Response`]s, so it
//! never blocks.
//!
//! In the browser (`wasm32`) the same requests run as tasks on the page's event loop
//! ([`tr_data::rt`]). There is no bar cache or Congress ingest there, and CPU work such as
//! indicators runs inline.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, mpsc};
use std::time::Duration;
use chrono::Datelike;
use tokio::sync::Mutex;
use tr_chart::gpu::{LineKind, LineRef};
use tr_chart::scene::{Overlay, SeriesGpu, line_ref};
use tr_chart::scene::{Overlay, SeriesGpu, line_ref, marker_ref};
use tr_congress::db::{Alert, PoliticianSummary};
use tr_congress::model::NewTradeAlert;
use tr_congress::performance::{self, Performance, TradeReturn};
use tr_congress::refresh::{self, HttpSource, NewTradeAlert, RefreshConfig};
#[cfg(not(target_arch = "wasm32"))]
use tr_congress::refresh::{self, HttpSource, RefreshConfig};
use tr_congress::{Db, Politician, Trade};
use tr_core::{Bar, Security, Series, Timeframe, indicators};
use tr_data::{BarCache, Provider, Quote, SearchHit, YieldCurves};
use tr_core::indicators::registry::{Input, Plot, Tone};
use tr_core::{Bar, Security, Series, Timeframe};
#[cfg(not(target_arch = "wasm32"))]
use tr_data::BarCache;
use tr_data::{Provider, Quote, SearchHit, YieldCurves, rt};
use crate::command::IndicatorSpec;
use crate::theme;
@@ -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();
+74
View File
@@ -0,0 +1,74 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
<title>Terminal</title>
<link rel="icon" href="data:," />
<script>
// The WebGPU build when the browser has WebGPU, else the larger WebGL2 one (no charts).
// Start downloading now; the module below cancels this if there is no WebGPU adapter.
window.terminalLoad = { build: 'gpu' in navigator ? 'terminal' : 'terminal-webgl', abort: new AbortController() };
// Each build's JS and wasm sit in a folder named after a hash of its files, so they can be
// cached for good; scripts/build-web.sh fills in these paths.
terminalLoad.paths = { terminal: './{terminal}', 'terminal-webgl': './{terminal-webgl}' };
terminalLoad.wasm = fetch(`${terminalLoad.paths[terminalLoad.build]}_bg.wasm`, { signal: terminalLoad.abort.signal });
document.head.append(Object.assign(document.createElement('link'), { rel: 'modulepreload', href: `${terminalLoad.paths[terminalLoad.build]}.js` }));
</script>
<style>
html,
body {
margin: 0;
height: 100%;
overflow: hidden;
background: #1e1e20;
}
@media (prefers-color-scheme: light) {
html,
body {
background: #f5f5f7;
}
}
canvas {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
display: block;
outline: none;
}
#status {
position: fixed;
inset: 0;
display: grid;
place-items: center;
margin: 0;
font: 13px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: #8e8e93;
pointer-events: none;
}
</style>
</head>
<body>
<canvas id="terminal"></canvas>
<p id="status">Loading Terminal…</p>
<script type="module">
const load = window.terminalLoad;
if (load.build === 'terminal' && !(await navigator.gpu.requestAdapter().catch(() => null))) {
load.wasm.catch(() => {}); // the abort below rejects it
load.abort.abort();
load.build = 'terminal-webgl';
load.wasm = fetch(`${load.paths[load.build]}_bg.wasm`);
}
window.terminalBuild = load.build;
try {
const { default: init } = await import(`${load.paths[load.build]}.js`);
await init({ module_or_path: load.wasm }); // runs the app, which removes #status after its first frame
} catch (e) {
console.error(e);
document.getElementById('status').textContent = `Terminal couldn't start: ${e}`;
}
</script>
</body>
</html>
+12
View File
@@ -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
+589
View File
@@ -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
}
+2 -1
View File
@@ -1,12 +1,13 @@
//! Brokerage integration through [SnapTrade](https://snaptrade.com): a signed API client, a
//! normalised portfolio model (accounts, balances, positions, orders, activity), secure
//! credential storage and an offline simulated portfolio.
//! credential storage, an offline simulated portfolio and a local paper-trading [`demo`] account.
//!
//! Order entry is in [`trading`]: every order is previewed with SnapTrade's order-impact check
//! before it can be placed, and the terminal keeps trading off unless the user enables it.
//! Besides orders and registering a user, the only write is removing a brokerage connection
//! when the user explicitly asks to disconnect it.
pub mod demo;
pub mod model;
pub mod sample;
pub mod secrets;
+91 -5
View File
@@ -46,7 +46,7 @@ pub struct Connection {
pub disabled: bool,
pub created: Option<DateTime<Utc>>,
pub updated: Option<DateTime<Utc>>,
/// Whether the connection allows trading (terminal-rs only trades when the user enables it).
/// Whether the connection allows trading (Terminal only trades when the user enables it).
pub trading: bool,
}
@@ -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();
+4
View File
@@ -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
View File
@@ -2,9 +2,12 @@
//!
//! The partner keys (`clientId`, `consumerKey`) and the per-user `userId`/`userSecret` are
//! stored together as one small JSON document: in the macOS Keychain (generic password,
//! service `terminal-rs`, account `snaptrade`), or elsewhere in an owner-only (0600) file in
//! service `terminal`, account `snaptrade`), or elsewhere in an owner-only (0600) file in
//! the app's data directory. `SNAPTRADE_*` environment variables override stored values
//! field by field. Nothing is ever written inside the repository.
//!
//! Before the app was renamed from terminal-rs, the Keychain service was `terminal-rs`; the
//! first load moves credentials from there.
use std::fmt;
use std::path::{Path, PathBuf};
@@ -12,7 +15,9 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
pub const KEYCHAIN_SERVICE: &str = "terminal-rs";
pub const KEYCHAIN_SERVICE: &str = "terminal";
/// The service credentials were saved under before the rename.
pub const LEGACY_KEYCHAIN_SERVICE: &str = "terminal-rs";
pub const KEYCHAIN_ACCOUNT: &str = "snaptrade";
/// Environment variables that override stored credentials.
@@ -102,8 +107,8 @@ pub fn mask(secret: &str) -> String {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Backend {
/// macOS Keychain generic password.
Keychain,
/// macOS Keychain generic password under `service`, moved there from `legacy` on first load.
Keychain { service: String, legacy: Option<String> },
/// Owner-only JSON file.
File(PathBuf),
}
@@ -119,10 +124,19 @@ impl SecretStore {
pub fn platform(data_dir: &Path) -> Self {
let file = Backend::File(data_dir.join("secrets").join("snaptrade.json"));
let force_file = std::env::var("TR_SECRET_STORE").is_ok_and(|v| v.eq_ignore_ascii_case("file"));
let backend = if cfg!(target_os = "macos") && !force_file { Backend::Keychain } else { file };
let backend = if cfg!(target_os = "macos") && !force_file {
Backend::Keychain { service: KEYCHAIN_SERVICE.into(), legacy: Some(LEGACY_KEYCHAIN_SERVICE.into()) }
} else {
file
};
Self { backend }
}
/// The Keychain under `service`, taking over an item left under `legacy`.
pub fn keychain(service: &str, legacy: Option<&str>) -> Self {
Self { backend: Backend::Keychain { service: service.into(), legacy: legacy.map(Into::into) } }
}
pub fn file(path: impl Into<PathBuf>) -> Self {
Self { backend: Backend::File(path.into()) }
}
@@ -130,14 +144,14 @@ impl SecretStore {
/// Where credentials live, for the settings screen.
pub fn describe(&self) -> String {
match &self.backend {
Backend::Keychain => format!("macOS Keychain ({KEYCHAIN_SERVICE} / {KEYCHAIN_ACCOUNT})"),
Backend::Keychain { service, .. } => format!("macOS Keychain ({service} / {KEYCHAIN_ACCOUNT})"),
Backend::File(p) => format!("{} (owner-only)", p.display()),
}
}
pub fn load(&self) -> Result<Option<Credentials>> {
let text = match &self.backend {
Backend::Keychain => keychain::get()?,
Backend::Keychain { service, legacy } => load_keychain(service, legacy.as_deref())?,
Backend::File(p) => match std::fs::read_to_string(p) {
Ok(t) => Some(t),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
@@ -155,7 +169,7 @@ impl SecretStore {
pub fn save(&self, creds: &Credentials) -> Result<()> {
let json = serde_json::to_string(&creds.trimmed())?;
match &self.backend {
Backend::Keychain => keychain::set(&json),
Backend::Keychain { service, .. } => keychain::set(service, &json),
Backend::File(p) => write_private(p, &json),
}
}
@@ -163,7 +177,11 @@ impl SecretStore {
/// Remove stored credentials (environment variables are unaffected).
pub fn clear(&self) -> Result<()> {
match &self.backend {
Backend::Keychain => keychain::delete(),
Backend::Keychain { service, legacy } => {
keychain::delete(service)?;
// The old item too, or the credentials would come back from it on the next load.
legacy.as_deref().map_or(Ok(()), keychain::delete)
}
Backend::File(p) => match std::fs::remove_file(p) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
_ => Ok(()),
@@ -172,6 +190,24 @@ impl SecretStore {
}
}
/// Reads the Keychain item under `service`. If there is none, the item under `legacy` moves
/// there: it is deleted only after the copy is written, so a failed move leaves it in place.
fn load_keychain(service: &str, legacy: Option<&str>) -> Result<Option<String>> {
if let Some(text) = keychain::get(service)? {
return Ok(Some(text));
}
let Some(legacy) = legacy else { return Ok(None) };
let Some(text) = keychain::get(legacy)? else { return Ok(None) };
match keychain::set(service, &text) {
Ok(()) => match keychain::delete(legacy) {
Ok(()) => tracing::info!("moved SnapTrade credentials from Keychain service {legacy} to {service}"),
Err(e) => tracing::warn!("removing SnapTrade credentials from Keychain service {legacy}: {e:#}"),
},
Err(e) => tracing::warn!("moving SnapTrade credentials to Keychain service {service}: {e:#}"),
}
Ok(Some(text))
}
/// Write `text` to `path` readable only by the owner (0700 directory, 0600 file), replacing it
/// atomically.
fn write_private(path: &Path, text: &str) -> Result<()> {
@@ -204,26 +240,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);
}
}
+189 -4
View File
@@ -10,7 +10,8 @@
//! This module holds the reads, user registration, the read-only Connection Portal link and
//! deleting a connection on explicit request; order entry lives in [`crate::trading`].
use std::time::Duration;
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
use anyhow::{Context, Result, anyhow, bail};
use base64::Engine;
@@ -32,6 +33,20 @@ pub const ORDER_DAYS: u32 = 30;
pub const ACTIVITY_DAYS: i64 = 90;
pub const ACTIVITY_LIMIT: u32 = 100;
/// At most this many SnapTrade requests are in flight at once, across every client in the
/// process. SnapTrade rate-limits per key, and loading many accounts at once (four requests
/// per account) is what trips it.
pub const MAX_CONCURRENT: usize = 4;
/// A throttled read is retried this many times.
pub const MAX_RETRIES: u32 = 3;
/// Longest `Retry-After` honoured inside one call; longer waits go back to the caller.
pub const MAX_INLINE_WAIT: Duration = Duration::from_secs(20);
static IN_FLIGHT: LazyLock<tokio::sync::Semaphore> = LazyLock::new(|| tokio::sync::Semaphore::new(MAX_CONCURRENT));
/// After a throttled answer nothing is sent until this instant, so one `Retry-After` pauses
/// every request rather than each finding out on its own.
static COOLDOWN: Mutex<Option<Instant>> = Mutex::new(None);
pub struct SnapTrade {
http: reqwest::Client,
base: Url,
@@ -120,6 +135,49 @@ pub fn signature(consumer_key: &str, path: &str, query: &str, body: Option<&Valu
pub struct ApiError {
pub status: u16,
pub message: String,
/// Seconds to wait before trying again, from `Retry-After` or the throttle message.
pub retry_after: Option<u64>,
}
impl ApiError {
/// SnapTrade (or the broker behind it) refused the request for rate limiting.
pub fn throttled(&self) -> bool {
self.status == 429
}
}
/// The error is a SnapTrade rate limit, and how long to wait if SnapTrade said.
pub fn throttle(e: &anyhow::Error) -> Option<Option<Duration>> {
let api = e.downcast_ref::<ApiError>().filter(|a| a.throttled())?;
Some(api.retry_after.map(Duration::from_secs))
}
/// `Retry-After` in seconds (the HTTP-date form is not used by SnapTrade).
fn retry_after_header(h: &reqwest::header::HeaderMap) -> Option<u64> {
h.get(reqwest::header::RETRY_AFTER)?.to_str().ok()?.trim().parse::<u64>().ok()
}
/// DRF's throttle text: `Request was throttled. Expected available in 37 seconds.`
fn throttle_text(body: &str) -> Option<Option<u64>> {
let l = body.to_ascii_lowercase();
if !l.contains("throttled") && !l.contains("rate limit") && !l.contains("too many requests") {
return None;
}
let secs = l.split("available in").nth(1).and_then(|rest| {
rest.split(|c: char| !c.is_ascii_digit() && c != '.').find(|w| !w.is_empty())?.parse::<f64>().ok()
});
Some(secs.map(|s| s.ceil().max(1.0) as u64))
}
/// A short, friendly line for a rate limit; never SnapTrade's raw text.
pub fn throttle_message(retry_after: Option<u64>) -> String {
match retry_after {
Some(s) if s > 90 => {
format!("SnapTrade is rate-limiting requests; trying again in about {} minutes", s.div_ceil(60))
}
Some(s) => format!("SnapTrade is rate-limiting requests; trying again in about {s} s"),
None => "SnapTrade is rate-limiting requests; trying again shortly".into(),
}
}
impl std::fmt::Display for ApiError {
@@ -130,6 +188,18 @@ impl std::fmt::Display for ApiError {
impl std::error::Error for ApiError {}
/// The request was not sent at all (for example its deadline passed while waiting).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NotSent(pub String);
impl std::fmt::Display for NotSent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for NotSent {}
/// A readable message from a SnapTrade error body (`detail`, `message`, `code`), without
/// echoing anything from the request.
fn error_message(status: reqwest::StatusCode, body: &str) -> String {
@@ -140,7 +210,6 @@ fn error_message(status: reqwest::StatusCode, body: &str) -> String {
401 => " (check the client ID and consumer key)",
403 => " (the key may not allow this, or the user secret is wrong)",
404 => " (unknown user or account)",
429 => " (rate limited; try again shortly)",
_ => "",
};
match (detail.is_empty(), code.is_empty()) {
@@ -150,6 +219,36 @@ fn error_message(status: reqwest::StatusCode, body: &str) -> String {
}
}
fn cooldown_left() -> Option<Duration> {
let until = (*COOLDOWN.lock().unwrap_or_else(|p| p.into_inner()))?;
until.checked_duration_since(Instant::now()).filter(|d| !d.is_zero())
}
/// Sleep until the shared cooldown has passed, re-checking after each sleep because another
/// throttled answer may have extended it meanwhile. Gives up (nothing sent) as soon as the
/// time left is longer than [`MAX_INLINE_WAIT`], or would run past `deadline`.
async fn wait_for_cooldown(deadline: Option<Instant>) -> Result<()> {
loop {
let Some(left) = cooldown_left() else { return Ok(()) };
let too_late = deadline.is_some_and(|d| Instant::now() + left >= d);
if left > MAX_INLINE_WAIT || too_late {
let secs = left.as_secs().max(1);
return Err(ApiError { status: 429, message: throttle_message(Some(secs)), retry_after: Some(secs) }.into());
}
tokio::time::sleep(left).await;
}
}
/// Pause every request for the whole `wait` SnapTrade asked for (only the inline retry is
/// capped at [`MAX_INLINE_WAIT`], not the pause).
fn start_cooldown(wait: Duration) {
let until = Instant::now() + wait;
let mut c = COOLDOWN.lock().unwrap_or_else(|p| p.into_inner());
if c.is_none_or(|t| t < until) {
*c = Some(until);
}
}
impl SnapTrade {
/// Client for `SNAPTRADE_BASE_URL` (tests, proxies) or the production API.
pub fn new(creds: Credentials) -> Result<Self> {
@@ -157,18 +256,27 @@ impl SnapTrade {
Self::with_base(creds, &base)
}
#[cfg(not(target_arch = "wasm32"))]
pub fn with_base(creds: Credentials, base: &str) -> Result<Self> {
if !creds.has_keys() {
bail!("SnapTrade client ID and consumer key are not set");
}
let http = reqwest::Client::builder()
.user_agent(concat!("terminal-rs/", env!("CARGO_PKG_VERSION")))
.user_agent(concat!("terminal/", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(30))
.build()?;
let base = Url::parse(base.trim_end_matches('/')).context("invalid SnapTrade base URL")?;
Ok(Self { http, base, creds })
}
/// SnapTrade doesn't answer cross-origin requests, so the browser build can't make a client
/// (and so never sends an order).
/// TODO(web proxy): call SnapTrade through a same-origin proxy that keeps the keys server-side.
#[cfg(target_arch = "wasm32")]
pub fn with_base(_creds: Credentials, _base: &str) -> Result<Self> {
bail!("SnapTrade isn't available in the browser yet; use the desktop app for brokerage accounts")
}
pub fn credentials(&self) -> &Credentials {
&self.creds
}
@@ -194,6 +302,9 @@ impl SnapTrade {
url
}
/// One API call. At most [`MAX_CONCURRENT`] run at once; a throttled read (GET) waits as
/// SnapTrade asks (or backs off exponentially) and is retried up to [`MAX_RETRIES`]
/// times. Writes are never retried here: a throttled order is reported, not resent.
pub(crate) async fn call(
&self,
method: Method,
@@ -201,6 +312,54 @@ impl SnapTrade {
params: &[(&str, String)],
body: Option<Value>,
user: bool,
) -> Result<Value> {
self.call_by(method, path, params, body, user, None).await
}
/// [`Self::call`] that is never sent at or after `deadline` (an order preview's expiry):
/// a rate-limit pause that would reach it fails the call unsent.
pub(crate) async fn call_by(
&self,
method: Method,
path: &str,
params: &[(&str, String)],
body: Option<Value>,
user: bool,
deadline: Option<Instant>,
) -> Result<Value> {
let retry = method == Method::GET;
let mut attempt = 0;
loop {
let result = {
let _permit = IN_FLIGHT.acquire().await.expect("the limiter is never closed");
// A long pause is reported rather than waited out: nothing was sent, and the
// caller (or the user) can try again when it ends.
wait_for_cooldown(deadline).await?;
if deadline.is_some_and(|d| Instant::now() >= d) {
anyhow::bail!(NotSent("the order's preview expired before it could be sent".into()));
}
self.call_once(method.clone(), path, params, body.clone(), user).await
};
let Err(e) = result else { return result };
let Some(after) = throttle(&e) else { return Err(e) };
let wait = after.unwrap_or(Duration::from_millis(1000 << attempt.min(5)));
start_cooldown(wait);
if !retry || attempt >= MAX_RETRIES || wait > MAX_INLINE_WAIT {
return Err(e);
}
attempt += 1;
tracing::debug!("SnapTrade throttled {path}; retry {attempt} in {wait:?}");
tokio::time::sleep(wait).await;
}
}
async fn call_once(
&self,
method: Method,
path: &str,
params: &[(&str, String)],
body: Option<Value>,
user: bool,
) -> Result<Value> {
let url = self.url(path, params, user, chrono::Utc::now().timestamp());
let sig = signature(self.creds.consumer_key.trim(), url.path(), url.query().unwrap_or(""), body.as_ref());
@@ -212,9 +371,20 @@ impl SnapTrade {
// `without_url`: the URL carries the user secret and must never reach logs or the UI.
let resp = req.send().await.map_err(|e| anyhow!("SnapTrade request failed: {}", e.without_url()))?;
let status = resp.status();
let header_wait = retry_after_header(resp.headers());
let text = resp.text().await.map_err(|e| anyhow!("reading SnapTrade response: {}", e.without_url()))?;
if !status.is_success() {
return Err(ApiError { status: status.as_u16(), message: error_message(status, &text) }.into());
// Some throttles arrive with another status but DRF's "Request was throttled" text.
let throttled =
if status.as_u16() == 429 { Some(throttle_text(&text).flatten()) } else { throttle_text(&text) };
return Err(match throttled {
Some(text_wait) => {
let retry_after = header_wait.or(text_wait);
ApiError { status: 429, message: throttle_message(retry_after), retry_after }
}
None => ApiError { status: status.as_u16(), message: error_message(status, &text), retry_after: None },
}
.into());
}
if text.trim().is_empty() {
return Ok(Value::Null);
@@ -400,4 +570,19 @@ mod tests {
assert_eq!(m, "SnapTrade: Unable to verify signature sent [1076] (check the client ID and consumer key)");
assert_eq!(error_message(reqwest::StatusCode::BAD_GATEWAY, "<html>"), "SnapTrade returned HTTP 502");
}
#[test]
fn throttle_messages_are_parsed_and_friendly() {
assert_eq!(
throttle_text(r#"{"detail":"Request was throttled. Expected available in 37 seconds."}"#),
Some(Some(37))
);
assert_eq!(throttle_text(r#"{"detail":"Request was throttled."}"#), Some(None));
assert_eq!(throttle_text(r#"{"detail":"Unknown symbol"}"#), None);
assert_eq!(throttle_message(Some(30)), "SnapTrade is rate-limiting requests; trying again in about 30 s");
assert!(throttle_message(Some(600)).contains("10 minutes"));
let e: anyhow::Error = ApiError { status: 429, message: throttle_message(None), retry_after: Some(5) }.into();
assert_eq!(throttle(&e), Some(Some(Duration::from_secs(5))));
assert_eq!(throttle(&anyhow!("other")), None);
}
}
+27 -9
View File
@@ -427,10 +427,11 @@ impl Capability {
}
}
/// Per-account capabilities from `/accounts` (each account names its `brokerage_authorization`)
/// and `/authorizations` (each connection carries its brokerage and access type).
pub fn parse_capabilities(accounts: &Value, authorizations: &Value) -> std::collections::HashMap<String, Capability> {
let conns: std::collections::HashMap<String, Capability> = authorizations
/// Capabilities per brokerage connection (SnapTrade authorization id) from `/authorizations`,
/// where each connection carries its brokerage and access type. Accounts map onto these
/// through [`crate::Account::connection_id`], so one request covers every account.
pub fn parse_connection_capabilities(authorizations: &Value) -> std::collections::HashMap<String, Capability> {
authorizations
.as_array()
.into_iter()
.flatten()
@@ -452,7 +453,13 @@ pub fn parse_capabilities(accounts: &Value, authorizations: &Value) -> std::coll
},
))
})
.collect();
.collect()
}
/// Per-account capabilities from `/accounts` (each account names its `brokerage_authorization`)
/// and `/authorizations`.
pub fn parse_capabilities(accounts: &Value, authorizations: &Value) -> std::collections::HashMap<String, Capability> {
let conns = parse_connection_capabilities(authorizations);
accounts
.as_array()
.into_iter()
@@ -498,6 +505,9 @@ impl std::fmt::Display for PlaceError {
/// Only a client error (4xx) is a definite "not placed"; everything else after sending is unknown.
pub fn classify_place_error(e: anyhow::Error) -> PlaceError {
let msg = format!("{e:#}");
if e.downcast_ref::<crate::snaptrade::NotSent>().is_some() {
return PlaceError::NotSent(msg);
}
match e.downcast_ref::<crate::snaptrade::ApiError>() {
Some(api) if (400..500).contains(&api.status) => PlaceError::Rejected(msg),
_ => PlaceError::Unknown(msg),
@@ -549,10 +559,11 @@ impl SnapTrade {
return Err(PlaceError::NotSent("the preview expired; preview the order again".into()));
}
let id = segment("trade id", &preview.trade_id).map_err(|e| PlaceError::NotSent(e.to_string()))?;
match self
.call(Method::POST, &format!("/trade/{id}"), &[], Some(json!({ "wait_to_confirm": true })), true)
.await
{
// Never sent once the preview has expired, however long a rate-limit pause lasts.
let left = (preview.expires() - Utc::now()).to_std().unwrap_or_default();
let deadline = Some(std::time::Instant::now() + left);
let body = Some(json!({ "wait_to_confirm": true }));
match self.call_by(Method::POST, &format!("/trade/{id}"), &[], body, true, deadline).await {
Ok(v) => Ok(parse_placed(&preview.request.account_id, &v)),
Err(e) => Err(classify_place_error(e)),
}
@@ -566,6 +577,11 @@ impl SnapTrade {
self.call(Method::POST, &format!("/accounts/{acct}/trading/cancel"), &[], Some(body), true).await.map(|_| ())
}
/// What each brokerage connection can do, in one request however many accounts it holds.
pub async fn connection_capabilities(&self) -> Result<std::collections::HashMap<String, Capability>> {
Ok(parse_connection_capabilities(&self.call(Method::GET, "/authorizations", &[], None, true).await?))
}
/// What each account can do: brokerage support for trading and the connection's access.
pub async fn capabilities(&self) -> Result<std::collections::HashMap<String, Capability>> {
let (accounts, auths) = futures::join!(
@@ -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;
};
+4 -4
View File
@@ -93,7 +93,7 @@ async fn mock(seen: Arc<Mutex<Vec<Seen>>>, failures: Arc<Mutex<Vec<String>>>) ->
|| path.starts_with("/connection/")
|| path == "/snapTrade/login";
if user_scoped
&& (param("userId").as_deref() != Some("terminal-rs-test")
&& (param("userId").as_deref() != Some("terminal-test")
|| param("userSecret").as_deref() != Some("s3cr3t/+="))
{
fail("missing or wrong user credentials".into());
@@ -156,7 +156,7 @@ fn creds() -> Credentials {
Credentials {
client_id: "TEST-CLIENT".into(),
consumer_key: CONSUMER_KEY.into(),
user_id: "terminal-rs-test".into(),
user_id: "terminal-test".into(),
user_secret: "s3cr3t/+=".into(),
}
}
@@ -244,8 +244,8 @@ async fn registers_a_user_opens_the_portal_and_disconnects() {
let partner =
SnapTrade::with_base(Credentials { user_id: String::new(), user_secret: String::new(), ..creds() }, &base)
.unwrap();
let (id, secret) = partner.register_user("terminal-rs-test").await.unwrap();
assert_eq!((id.as_str(), secret.as_str()), ("terminal-rs-test", "fresh-secret"));
let (id, secret) = partner.register_user("terminal-test").await.unwrap();
assert_eq!((id.as_str(), secret.as_str()), ("terminal-test", "fresh-secret"));
let client = SnapTrade::with_base(creds(), &base).unwrap();
let url = client.connection_portal_url(true, None).await.unwrap();
+272
View File
@@ -0,0 +1,272 @@
//! The paper-trading demo account: fills, resting orders, buying power, persistence.
use chrono::{DateTime, Duration, TimeZone, Utc};
use tr_broker::demo::{self, ACCOUNT_ID, DemoBook, Event, STARTING_CASH};
use tr_broker::trading::{Amount, OrderRequest, OrderType, Side, TimeInForce};
fn t0() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 10, 12, 15, 0, 0).unwrap()
}
fn order(side: Side, symbol: &str, units: f64) -> OrderRequest {
OrderRequest {
account_id: ACCOUNT_ID.into(),
symbol: symbol.into(),
side,
order_type: OrderType::Market,
time_in_force: TimeInForce::Day,
amount: Amount::Units(units),
limit: None,
stop: None,
}
}
fn limit(side: Side, symbol: &str, units: f64, px: f64) -> OrderRequest {
OrderRequest { order_type: OrderType::Limit, limit: Some(px), ..order(side, symbol, units) }
}
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
#[test]
fn starts_with_virtual_cash() {
let b = DemoBook::new(t0());
assert_eq!((b.cash, b.buying_power(), b.equity()), (STARTING_CASH, STARTING_CASH, STARTING_CASH));
let pf = b.portfolio();
assert!(pf.demo && !pf.simulated);
assert_eq!(pf.accounts.len(), 1);
assert!(pf.accounts[0].is_paper);
assert_eq!(pf.accounts[0].id, ACCOUNT_ID);
}
#[test]
fn market_orders_fill_at_the_quote() {
let mut b = DemoBook::new(t0());
let o = b.submit(&order(Side::Buy, "aapl", 10.0), Some(200.0), t0()).unwrap();
assert_eq!((o.status.as_str(), o.execution_price, o.filled), ("EXECUTED", Some(200.0), Some(10.0)));
assert_eq!(o.symbol, "AAPL");
assert!(close(b.cash, STARTING_CASH - 2000.0));
assert_eq!(b.held("AAPL"), 10.0);
// Average cost on adding, realised P&L on selling.
b.submit(&order(Side::Buy, "AAPL", 10.0), Some(220.0), t0()).unwrap();
let p = b.position("AAPL").unwrap();
assert!(close(p.cost_basis.unwrap(), 210.0));
b.submit(&order(Side::Sell, "AAPL", 5.0), Some(230.0), t0()).unwrap();
assert!(close(b.realized, 100.0));
assert_eq!(b.held("AAPL"), 15.0);
assert!(close(b.cash, STARTING_CASH - 2000.0 - 2200.0 + 1150.0));
// Marking to market moves equity, not cash.
let cash = b.cash;
b.on_price("AAPL", 250.0, t0());
assert!(close(b.equity(), cash + 15.0 * 250.0));
assert_eq!(b.activities.len(), 3, "every fill is recorded");
assert!(close(b.activities[0].amount.unwrap(), 1150.0));
}
#[test]
fn notional_orders_buy_fractional_shares() {
let mut b = DemoBook::new(t0());
let req = OrderRequest { amount: Amount::Notional(500.0), ..order(Side::Buy, "NVDA", 0.0) };
let o = b.submit(&req, Some(400.0), t0()).unwrap();
assert_eq!(o.filled, Some(1.25));
assert!(close(b.cash, STARTING_CASH - 500.0));
}
#[test]
fn refuses_what_a_cash_account_cannot_do() {
let mut b = DemoBook::new(t0());
let e = b.submit(&order(Side::Buy, "BRK.A", 1.0), Some(700_000.0), t0()).unwrap_err();
assert!(e.contains("Not enough buying power"), "{e}");
let e = b.submit(&order(Side::Sell, "TSLA", 1.0), Some(250.0), t0()).unwrap_err();
assert!(e.contains("can't sell short"), "{e}");
b.submit(&order(Side::Buy, "TSLA", 2.0), Some(250.0), t0()).unwrap();
let e = b.submit(&order(Side::Sell, "TSLA", 3.0), Some(250.0), t0()).unwrap_err();
assert!(e.contains("up to 2 TSLA"), "{e}");
let e = b.submit(&order(Side::Buy, "MSFT", 1.0), None, t0()).unwrap_err();
assert!(e.contains("Waiting for a price"), "market orders need a quote: {e}");
let other = OrderRequest { account_id: "snaptrade-uuid".into(), ..order(Side::Buy, "MSFT", 1.0) };
assert!(b.submit(&other, Some(400.0), t0()).is_err(), "only the demo account");
assert!(b.orders.iter().all(|o| o.status == "EXECUTED"), "refused orders leave no trace");
}
#[test]
fn limit_orders_rest_until_the_price_crosses() {
let mut b = DemoBook::new(t0());
let o = b.submit(&limit(Side::Buy, "AAPL", 10.0, 190.0), Some(200.0), t0()).unwrap();
assert!(o.is_open());
assert!(close(b.buying_power(), STARTING_CASH - 1900.0), "a resting buy sets cash aside");
assert_eq!(b.cash, STARTING_CASH);
assert_eq!(b.watched(), vec!["AAPL".to_string()]);
assert!(b.on_price("AAPL", 195.0, t0()).is_empty(), "not there yet");
let ev = b.on_price("AAPL", 189.5, t0());
assert_eq!(ev.len(), 1);
let Event::Filled(f) = &ev[0] else { panic!("{ev:?}") };
assert_eq!(f.execution_price, Some(189.5), "fills at the crossing quote, at or better than the limit");
assert!(ev[0].message().starts_with("Bought 10 AAPL at $189.50"));
assert!(close(b.cash, STARTING_CASH - 1895.0));
assert!(close(b.buying_power(), b.cash), "the reservation is released");
// A sell limit above the market waits for a rise and holds the shares back.
let s = b.submit(&limit(Side::Sell, "AAPL", 10.0, 210.0), Some(190.0), t0()).unwrap();
assert!(s.is_open());
assert_eq!(b.available("AAPL"), 0.0);
assert!(b.submit(&order(Side::Sell, "AAPL", 1.0), Some(190.0), t0()).is_err(), "shares are spoken for");
b.on_price("AAPL", 211.0, t0());
assert_eq!(b.held("AAPL"), 0.0);
assert!(b.position("AAPL").is_none());
}
#[test]
fn marketable_limits_fill_immediately() {
let mut b = DemoBook::new(t0());
let o = b.submit(&limit(Side::Buy, "MSFT", 2.0, 420.0), Some(410.0), t0()).unwrap();
assert_eq!((o.status.as_str(), o.execution_price), ("EXECUTED", Some(410.0)));
}
#[test]
fn stops_trigger_then_fill() {
let mut b = DemoBook::new(t0());
b.submit(&order(Side::Buy, "TSLA", 10.0), Some(250.0), t0()).unwrap();
let stop = OrderRequest { order_type: OrderType::Stop, stop: Some(240.0), ..order(Side::Sell, "TSLA", 10.0) };
assert!(b.submit(&stop, Some(250.0), t0()).unwrap().is_open());
assert!(b.on_price("TSLA", 245.0, t0()).is_empty());
let ev = b.on_price("TSLA", 238.0, t0());
assert!(matches!(&ev[..], [Event::Filled(o)] if o.execution_price == Some(238.0)));
// Stop-limit: the stop arms the order, then the limit decides the fill.
b.submit(&order(Side::Buy, "TSLA", 10.0), Some(238.0), t0()).unwrap();
let sl = OrderRequest {
order_type: OrderType::StopLimit,
stop: Some(230.0),
limit: Some(228.0),
..order(Side::Sell, "TSLA", 10.0)
};
b.submit(&sl, Some(238.0), t0()).unwrap();
assert!(b.on_price("TSLA", 225.0, t0()).is_empty(), "triggered, but below the sell limit");
assert_eq!(b.orders[0].status, "TRIGGERED");
assert!(b.orders[0].is_open());
let ev = b.on_price("TSLA", 229.0, t0());
assert!(matches!(&ev[..], [Event::Filled(o)] if o.execution_price == Some(229.0)));
}
#[test]
fn resting_buys_are_rejected_if_cash_ran_out() {
let mut b = DemoBook::new(t0());
b.reset(1_000.0, t0());
b.submit(&limit(Side::Buy, "AAPL", 5.0, 150.0), Some(200.0), t0()).unwrap();
// A later order cannot spend the reserved cash.
let e = b.submit(&order(Side::Buy, "F", 30.0), Some(10.0), t0()).unwrap_err();
assert!(e.contains("buying power"), "{e}");
assert!(b.submit(&order(Side::Buy, "F", 25.0), Some(10.0), t0()).is_ok());
let ev = b.on_price("AAPL", 149.0, t0());
assert!(matches!(&ev[..], [Event::Filled(_)]), "its own reservation pays for it: {ev:?}");
}
#[test]
fn cancel_ioc_and_day_expiry() {
let mut b = DemoBook::new(t0());
let o = b.submit(&limit(Side::Buy, "AAPL", 1.0, 100.0), Some(200.0), t0()).unwrap();
b.cancel(&o.id, t0()).unwrap();
assert_eq!(b.orders[0].status, "CANCELED");
assert!(b.cancel(&o.id, t0()).is_err(), "already cancelled");
assert_eq!(b.buying_power(), STARTING_CASH);
let ioc = OrderRequest { time_in_force: TimeInForce::Ioc, ..limit(Side::Buy, "AAPL", 1.0, 100.0) };
assert_eq!(b.submit(&ioc, Some(200.0), t0()).unwrap().status, "CANCELED", "IOC never rests");
let day = b.submit(&limit(Side::Buy, "AAPL", 1.0, 100.0), Some(200.0), t0()).unwrap();
let gtc = OrderRequest { time_in_force: TimeInForce::Gtc, ..limit(Side::Buy, "AAPL", 1.0, 100.0) };
let gtc = b.submit(&gtc, Some(200.0), t0()).unwrap();
assert!(b.expire(t0() + Duration::hours(4)).is_empty(), "before the close");
let ev = b.expire(t0() + Duration::hours(5));
assert!(matches!(&ev[..], [Event::Expired(o)] if o.id == day.id));
assert!(b.orders.iter().find(|o| o.id == gtc.id).unwrap().is_open(), "GTC orders stay");
assert_eq!(
demo::day_expiry(Utc.with_ymd_and_hms(2026, 10, 12, 21, 0, 0).unwrap()).to_rfc3339(),
"2026-10-13T20:00:00+00:00"
);
}
#[test]
fn close_position_sells_everything_available() {
let mut b = DemoBook::new(t0());
b.submit(&order(Side::Buy, "AMD", 12.5), Some(100.0), t0()).unwrap();
let req = b.close_request("amd").unwrap();
assert_eq!((req.side, req.amount), (Side::Sell, Amount::Units(12.5)));
b.submit(&req, Some(101.0), t0()).unwrap();
assert!(b.position("AMD").is_none());
assert!(b.close_request("AMD").is_none());
assert!(close(b.cash, STARTING_CASH + 12.5));
}
#[test]
fn persists_and_resets() {
let dir = std::env::temp_dir().join(format!("tr-demo-test-{}", std::process::id()));
let path = dir.join("demo.json");
let _ = std::fs::remove_dir_all(&dir);
let fresh = DemoBook::load(&path, t0()).unwrap();
assert_eq!(fresh.cash, STARTING_CASH, "a missing file is a new account");
let mut b = DemoBook::new(t0());
b.submit(&order(Side::Buy, "AAPL", 3.0), Some(200.0), t0()).unwrap();
b.submit(&limit(Side::Buy, "AAPL", 1.0, 150.0), Some(200.0), t0()).unwrap();
b.save(&path).unwrap();
let mut back = DemoBook::load(&path, t0()).unwrap();
assert_eq!(back, b);
// Ids keep counting after a reload.
let next = back.submit(&order(Side::Buy, "AAPL", 1.0), Some(200.0), t0()).unwrap();
assert!(b.orders.iter().all(|o| o.id != next.id));
back.reset(50_000.0, t0());
assert_eq!((back.cash, back.starting_cash), (50_000.0, 50_000.0));
assert!(back.positions.is_empty() && back.orders.is_empty() && back.activities.is_empty());
std::fs::write(&path, b"{not json").unwrap();
assert!(DemoBook::load(&path, t0()).is_err(), "a damaged file is reported, not silently replaced");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn history_is_bounded_but_keeps_open_orders() {
let mut b = DemoBook::new(t0());
let open = b.submit(&limit(Side::Buy, "AAPL", 1.0, 1.0), Some(200.0), t0()).unwrap();
for _ in 0..(demo::HISTORY + 20) {
b.submit(&order(Side::Buy, "F", 1.0), Some(1.0), t0()).unwrap();
}
assert_eq!(b.orders.len(), demo::HISTORY);
assert!(b.orders.iter().any(|o| o.id == open.id));
assert!(b.activities.len() <= demo::HISTORY);
}
#[test]
fn a_full_history_never_loses_the_order_just_placed() {
let mut b = DemoBook::new(t0());
for i in 0..demo::HISTORY {
b.submit(&limit(Side::Buy, "F", 1.0, 0.01 + i as f64 * 1e-4), Some(1.0), t0()).unwrap();
}
assert!(b.orders.iter().all(|o| o.is_open()));
let o = b.submit(&order(Side::Buy, "F", 1.0), Some(1.0), t0()).unwrap();
assert_eq!(o.status, "EXECUTED", "the fill is reported even when trimming drops it from history");
assert_eq!(b.held("F"), 1.0);
}
#[test]
fn late_quotes_never_fill_expired_day_orders() {
let mut b = DemoBook::new(t0());
b.submit(&limit(Side::Buy, "AAPL", 10.0, 190.0), Some(200.0), t0()).unwrap();
let ev = b.on_price("AAPL", 185.0, t0() + Duration::days(1));
assert!(matches!(&ev[..], [Event::Expired(_)]), "{ev:?}");
assert_eq!((b.cash, b.held("AAPL")), (STARTING_CASH, 0.0));
}
#[test]
fn dollar_amounts_never_cost_more_than_stated() {
let mut b = DemoBook::new(t0());
let all_in = OrderRequest { amount: Amount::Notional(STARTING_CASH), ..order(Side::Buy, "F", 0.0) };
b.submit(&all_in, Some(7.0), t0()).unwrap();
assert!(b.cash >= 0.0 && b.cash < 0.01, "{}", b.cash);
}
+175
View File
@@ -0,0 +1,175 @@
//! SnapTrade rate limits: throttled reads wait as asked and retry, throttled orders are never
//! resent, and the error the user sees is a friendly line rather than SnapTrade's raw text.
//! (Its own test binary: the client's cooldown is process-wide.)
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tr_broker::trading::{Amount, OrderPreview, OrderRequest, OrderType, PlaceError, Side, SymbolMatch, TimeInForce};
use tr_broker::{Credentials, SnapTrade};
const THROTTLED: &str = r#"{"detail":"Request was throttled. Expected available in 1 seconds."}"#;
/// Answers `/authorizations` with a 429 the first `throttle_reads` times, every
/// `/trade/...` with a 429, and records each request line.
async fn mock(throttle_reads: usize, seen: Arc<Mutex<Vec<String>>>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let reads = Arc::new(AtomicUsize::new(0));
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else { break };
let (seen, reads) = (seen.clone(), reads.clone());
tokio::spawn(async move {
let mut buf = vec![0u8; 8192];
let n = sock.read(&mut buf).await.unwrap_or(0);
let head = String::from_utf8_lossy(&buf[..n]).to_string();
let line = head.lines().next().unwrap_or("").to_string();
let path = line.split(' ').nth(1).unwrap_or("").split('?').next().unwrap_or("").to_string();
let method = line.split(' ').next().unwrap_or("").to_string();
seen.lock().unwrap().push(format!("{method} {path}"));
let (status, extra, body) = if path == "/authorizations" {
if reads.fetch_add(1, Ordering::SeqCst) < throttle_reads {
(429, "Retry-After: 1\r\n", THROTTLED.to_string())
} else {
(200, "", r#"[{"id":"c1","type":"trade","brokerage":{"display_name":"Wealthsimple","allows_trading":true}}]"#.to_string())
}
} else if path.starts_with("/trade/") {
(429, "", THROTTLED.to_string())
} else {
(404, "", r#"{"detail":"Not found"}"#.to_string())
};
let reply = format!(
"HTTP/1.1 {status} X\r\n{extra}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = sock.write_all(reply.as_bytes()).await;
let _ = sock.shutdown().await;
});
}
});
format!("http://{addr}")
}
fn client(base: &str) -> SnapTrade {
let creds = Credentials {
client_id: "TEST".into(),
consumer_key: "key".into(),
user_id: "terminal-test".into(),
user_secret: "s".into(),
};
SnapTrade::with_base(creds, base).unwrap()
}
#[tokio::test]
async fn throttled_reads_retry_and_orders_are_never_resent() {
// A read throttled once waits for Retry-After and succeeds.
let seen = Arc::new(Mutex::new(Vec::new()));
let base = mock(1, seen.clone()).await;
let start = Instant::now();
let caps = client(&base).connection_capabilities().await.unwrap();
assert!(caps["c1"].can_trade());
assert!(start.elapsed().as_millis() >= 900, "waited for Retry-After");
assert_eq!(seen.lock().unwrap().iter().filter(|l| l.ends_with("/authorizations")).count(), 2);
// A read that stays throttled gives up with a friendly message.
let seen = Arc::new(Mutex::new(Vec::new()));
let base = mock(usize::MAX, seen.clone()).await;
let e = client(&base).connection_capabilities().await.unwrap_err();
let msg = format!("{e:#}");
assert!(tr_broker::snaptrade::throttle(&e).is_some());
assert!(msg.contains("rate-limiting") && !msg.contains("Request was throttled"), "{msg}");
assert_eq!(seen.lock().unwrap().len(), 1 + tr_broker::snaptrade::MAX_RETRIES as usize);
// A throttled placement is sent exactly once and reported as not placed.
let seen = Arc::new(Mutex::new(Vec::new()));
let base = mock(0, seen.clone()).await;
let req = OrderRequest {
account_id: "acct-1".into(),
symbol: "AAPL".into(),
side: Side::Buy,
order_type: OrderType::Market,
time_in_force: TimeInForce::Day,
amount: Amount::Units(1.0),
limit: None,
stop: None,
};
let preview = OrderPreview {
trade_id: "139e307a-82f7-4402-b39e-4da7baa87758".into(),
request: req,
symbol: SymbolMatch {
id: "s".into(),
symbol: "AAPL".into(),
description: String::new(),
currency: "USD".into(),
exchange: String::new(),
},
price: None,
units: None,
commission: None,
forex_fees: None,
remaining_cash: None,
currency: "USD".into(),
created: chrono::Utc::now(),
simulated: false,
};
let err = client(&base).place_previewed(&preview).await.unwrap_err();
assert!(matches!(err, PlaceError::Rejected(_)), "{err:?}");
assert!(err.message().contains("rate-limiting"));
assert_eq!(seen.lock().unwrap().iter().filter(|l| l.starts_with("POST /trade/")).count(), 1);
// A pause that would outlast the preview never sends the order late.
let seen = Arc::new(Mutex::new(Vec::new()));
let base = mock_retry_after(4, seen.clone()).await;
let _ = client(&base).connection_capabilities().await; // starts a 4 s pause
let mut soon = preview.clone();
soon.created = chrono::Utc::now()
- chrono::Duration::seconds(tr_broker::trading::PREVIEW_TTL_SECS - tr_broker::trading::PREVIEW_MARGIN_SECS - 2);
let start = Instant::now();
let e = client(&base).place_previewed(&soon).await.unwrap_err();
assert!(start.elapsed().as_secs() < 2, "refused at once, not after the pause");
assert!(!e.uncertain(), "{e:?}");
assert!(seen.lock().unwrap().iter().all(|l| !l.starts_with("POST /trade/")), "the stale order was never sent");
tokio::time::sleep(std::time::Duration::from_secs(4)).await;
// A long Retry-After pauses every request for its whole length, and nothing is sent
// meanwhile: the call fails at once, unsent, with the time left.
let seen = Arc::new(Mutex::new(Vec::new()));
let base = mock_retry_after(120, seen.clone()).await;
let e = client(&base).connection_capabilities().await.unwrap_err();
assert!(tr_broker::snaptrade::throttle(&e).flatten().is_some_and(|d| d.as_secs() >= 100));
let start = Instant::now();
let e = client(&base).place_previewed(&preview).await.unwrap_err();
assert!(start.elapsed().as_secs() < 5, "an order is never held for minutes");
assert!(matches!(e, PlaceError::Rejected(_)) && e.message().contains("minutes"), "{e:?}");
assert_eq!(seen.lock().unwrap().len(), 1, "nothing sent during the pause");
}
/// Answers everything with a 429 asking to wait `secs`.
async fn mock_retry_after(secs: u64, seen: Arc<Mutex<Vec<String>>>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else { break };
let seen = seen.clone();
tokio::spawn(async move {
let mut buf = vec![0u8; 8192];
let n = sock.read(&mut buf).await.unwrap_or(0);
let head = String::from_utf8_lossy(&buf[..n]).to_string();
seen.lock().unwrap().push(head.lines().next().unwrap_or("").to_string());
let body = r#"{"detail":"Request was throttled."}"#;
let reply = format!(
"HTTP/1.1 429 X\r\nRetry-After: {secs}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = sock.write_all(reply.as_bytes()).await;
let _ = sock.shutdown().await;
});
}
});
format!("http://{addr}")
}
+2 -2
View File
@@ -80,7 +80,7 @@ async fn mock(seen: Log, failures: Log) -> String {
let body: Option<Value> = (!body.is_empty()).then(|| serde_json::from_slice(&body).unwrap());
let sig = headers.iter().find(|(k, _)| k == "signature").map(|(_, v)| v.clone()).unwrap_or_default();
let ok = sig == signature(CONSUMER_KEY, path, query, body.as_ref())
&& param(query, "userId").as_deref() == Some("terminal-rs-test")
&& param(query, "userId").as_deref() == Some("terminal-test")
&& param(query, "userSecret").as_deref() == Some("s3cr3t");
let entry = Seen { method: method.clone(), path: path.to_string(), body: body.clone() };
if !ok {
@@ -161,7 +161,7 @@ fn client(base: &str) -> SnapTrade {
let creds = Credentials {
client_id: "TEST-CLIENT".into(),
consumer_key: CONSUMER_KEY.into(),
user_id: "terminal-rs-test".into(),
user_id: "terminal-test".into(),
user_secret: "s3cr3t".into(),
};
SnapTrade::with_base(creds, base).unwrap()
+5 -2
View File
@@ -6,8 +6,9 @@ edition.workspace = true
[dependencies]
tr-core.workspace = true
egui = "0.36"
egui-wgpu = "0.36"
egui = { version = "0.36", default-features = false }
# GPU backends come from the app (eframe), so the browser build can leave out WebGL.
egui-wgpu = { version = "0.36", default-features = false }
bytemuck = { version = "1", features = ["derive"] }
chrono.workspace = true
serde.workspace = true
@@ -15,6 +16,8 @@ pollster = "0.4"
[dev-dependencies]
criterion.workspace = true
# Tests, benches and examples render offscreen on a real backend.
egui-wgpu = "0.36"
[[bench]]
name = "frame"
+46
View File
@@ -28,6 +28,32 @@ pub struct Uniforms {
pub color: [f32; 4],
pub params: [f32; 4],
pub extra: [f32; 4],
pub anim: [f32; 4],
}
/// Load-in sweep for a pane. Bars right of `front` (a fractional bar index) are hidden
/// and the `ramp` bars behind it grow in from their open; lines are drawn up to
/// `line_front`. `opacity` fades everything in the pane.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Reveal {
pub front: f64,
pub ramp: f32,
pub line_front: f64,
pub opacity: f32,
}
impl Reveal {
pub const DONE: Self = Self { front: f64::INFINITY, ramp: 1.0, line_front: f64::INFINITY, opacity: 1.0 };
/// The sweep at progress `t` (0..1) across `visible_bars` bars starting at `left`.
pub fn sweep(t: f32, left: f64, visible_bars: f64) -> Self {
if t >= 1.0 {
return Self::DONE;
}
let ramp = (visible_bars * 0.12).max(2.0);
let front = left + t as f64 * (visible_bars + ramp);
Self { front, ramp: ramp as f32, line_front: front - ramp * 0.5, opacity: 1.0 }
}
}
/// Pane-level transform shared by every draw in a pane.
@@ -60,6 +86,7 @@ pub struct Style {
/// Physical pixels per logical point (2.0 on Retina). Pixel-sized strokes are given
/// in points and scaled by this; pane sizes in [`PaneTransform`] are physical.
pub pixels_per_point: f32,
pub reveal: Reveal,
}
impl Default for Style {
@@ -77,6 +104,7 @@ impl Default for Style {
hist_width_bars: 0.7,
baseline: 0.0,
pixels_per_point: 1.0,
reveal: Reveal::DONE,
}
}
}
@@ -84,6 +112,9 @@ impl Default for Style {
impl Uniforms {
pub fn new(t: &PaneTransform, s: &Style) -> Self {
let left_hi = t.left.floor();
// Relative to `left_hi` for f32 precision on long series.
let rel = |x: f64| if x.is_finite() { (x - left_hi) as f32 } else { 1e9 };
let r = s.reveal;
Self {
rect: [t.width_px, t.height_px, left_hi as f32, (t.left - left_hi) as f32],
view: [t.visible_bars as f32, t.y_lo as f32, t.y_hi as f32, if t.log { 1.0 } else { 0.0 }],
@@ -97,6 +128,7 @@ impl Uniforms {
if s.ohlc_bars { 1.0 } else { 0.0 },
],
extra: [s.vol_frac, s.vol_max, s.hist_width_bars, s.baseline],
anim: [rel(r.front), r.ramp.max(1e-3), rel(r.line_front), r.opacity],
}
}
}
@@ -121,6 +153,10 @@ pub enum LineKind {
Line,
Area,
Hist,
/// A square marker at each finite point.
Dots,
/// A flat dash across each finite point's bar, with no joins between bars.
Steps,
}
#[derive(Clone)]
@@ -152,6 +188,8 @@ enum Pipe {
Line,
Area,
Hist,
Dots,
Steps,
}
struct Resolved {
@@ -167,6 +205,8 @@ pub struct ChartRenderer {
line: wgpu::RenderPipeline,
area: wgpu::RenderPipeline,
hist: wgpu::RenderPipeline,
dots: wgpu::RenderPipeline,
steps: wgpu::RenderPipeline,
uniform_buf: wgpu::Buffer,
uniform_bg: wgpu::BindGroup,
storage_bgl: wgpu::BindGroupLayout,
@@ -277,6 +317,8 @@ impl ChartRenderer {
line: make(&line_mod, "vs_line"),
area: make(&line_mod, "vs_area"),
hist: make(&line_mod, "vs_hist"),
dots: make(&line_mod, "vs_dots"),
steps: make(&line_mod, "vs_steps"),
uniform_buf,
uniform_bg,
storage_bgl,
@@ -352,6 +394,8 @@ impl ChartRenderer {
LineKind::Line => Pipe::Line,
LineKind::Area => Pipe::Area,
LineKind::Hist => Pipe::Hist,
LineKind::Dots => Pipe::Dots,
LineKind::Steps => Pipe::Steps,
};
(pipe, (true, data.key), *range, uniforms)
}
@@ -376,6 +420,8 @@ impl ChartRenderer {
Pipe::Line => (&self.line, 6),
Pipe::Area => (&self.area, 6),
Pipe::Hist => (&self.hist, 6),
Pipe::Dots => (&self.dots, 6),
Pipe::Steps => (&self.steps, 6),
};
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &self.uniform_bg, &[d.slot * SLOT as u32]);
+2 -2
View File
@@ -1,8 +1,8 @@
//! GPU chart rendering for terminal-rs.
//! GPU chart rendering for Terminal.
//!
//! - [`viewport`]: bar-index/price <-> pixel math, pan and zoom.
//! - [`lod`]: level-of-detail pyramids that bound per-frame work to the plot width.
//! - [`gpu`]: wgpu pipelines (candles, OHLC bars, volume, lines, areas, histograms)
//! - [`gpu`]: wgpu pipelines (candles, OHLC bars, volume, lines, areas, histograms, dots)
//! and the egui paint-callback adapter.
//! - [`scale`]: axis ticks and labels. [`drawings`]: user drawing model and hit testing.
+29 -2
View File
@@ -133,15 +133,25 @@ pub struct LinePyramid {
}
impl LinePyramid {
/// Keeps the later point of each pair (its value is the group's close).
pub fn build(values: &[f64]) -> Self {
Self::reduce(values, |_, later| later)
}
/// For sparse markers: a pair whose later point is blank keeps the earlier one, so
/// markers survive zooming out.
pub fn build_markers(values: &[f64]) -> Self {
Self::reduce(values, |earlier, later| if later[1].is_nan() { earlier } else { later })
}
fn reduce(values: &[f64], merge: impl Fn([f32; 2], [f32; 2]) -> [f32; 2]) -> Self {
let mut data: Vec<[f32; 2]> = values.iter().enumerate().map(|(i, v)| [i as f32 + 0.5, *v as f32]).collect();
let mut offsets = vec![0];
let (mut start, mut n) = (0, values.len());
while n > 1 {
let next_start = data.len();
for j in (0..n).step_by(2) {
// Keep the later point of each pair (its value is the group's close).
let p = if j + 1 < n { data[start + j + 1] } else { data[start + j] };
let p = if j + 1 < n { merge(data[start + j], data[start + j + 1]) } else { data[start + j] };
data.push(p);
}
offsets.push(next_start);
@@ -251,4 +261,21 @@ mod tests {
assert_eq!((a, b), (2, 7));
assert_eq!(p.segment_range(0, 0, 0), (0, 1));
}
#[test]
fn marker_pyramid_keeps_sparse_points() {
let mut v = vec![f64::NAN; 16];
v[2] = 5.0;
v[9] = 7.0;
let lines = LinePyramid::build(&v);
assert_eq!(lines.extent(1, 0, 16), Some((7.0, 7.0)), "lines keep the later point");
let marks = LinePyramid::build_markers(&v);
// Both survive every level where they fall in different groups, at their own bars.
for k in 0..marks.levels() - 1 {
assert_eq!(marks.extent(k, 0, 16), Some((5.0, 7.0)), "level {k}");
let level = &marks.data[marks.offsets[k]..marks.offsets[k + 1]];
assert!(level.contains(&[2.5, 5.0]) && level.contains(&[9.5, 7.0]), "level {k}");
}
assert_eq!(marks.data[marks.offsets[marks.levels() - 1]], [9.5, 7.0]);
}
}
+21
View File
@@ -60,6 +60,19 @@ pub fn format_volume(v: f64) -> String {
}
}
/// Indicator values of any magnitude in a few characters: 1.25B, 37.9K, 245.3, 61.42,
/// 0.734, 0.0012.
pub fn format_value(v: f64) -> String {
match v.abs() {
x if x >= 1e4 => format_volume(v),
x if x >= 100.0 => format!("{v:.1}"),
x if x >= 1.0 || x == 0.0 => format!("{v:.2}"),
x if x >= 0.01 => format!("{v:.3}"),
x if x >= 1e-4 => format!("{v:.4}"),
_ => format!("{v:.1e}"),
}
}
/// How significant a time boundary is; larger boundaries get labels first.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Boundary {
@@ -152,6 +165,14 @@ mod tests {
fn formatting() {
assert_eq!(format_volume(37_881_199.0), "37.88M");
assert_eq!(format_volume(101_820.0), "101.8K");
assert_eq!(format_value(-1_250_000_000.0), "-1.25B");
assert_eq!(format_value(37_900.0), "37.9K");
assert_eq!(format_value(245.31), "245.3");
assert_eq!(format_value(61.416), "61.42");
assert_eq!(format_value(0.0), "0.00");
assert_eq!(format_value(0.7341), "0.734");
assert_eq!(format_value(-0.00123), "-0.0012");
assert_eq!(format_value(0.000012), "1.2e-5");
assert_eq!(decimals_for(0.25), 2);
assert_eq!(decimals_for(0.001), 3);
assert_eq!(decimals_for(10.0), 0);
+173 -15
View File
@@ -81,6 +81,11 @@ pub fn line_ref(values: &[f64]) -> LineRef {
LineRef { key: next_key(), version: 1, pyramid: Arc::new(LinePyramid::build(values)) }
}
/// A [`LineKind::Dots`] series, reduced so sparse markers stay visible when zoomed out.
pub fn marker_ref(values: &[f64]) -> LineRef {
LineRef { key: next_key(), version: 1, pyramid: Arc::new(LinePyramid::build_markers(values)) }
}
/// A line overlay with its colour.
#[derive(Clone)]
pub struct Overlay {
@@ -143,7 +148,7 @@ pub fn visible_extent(
}
_ => p.extent(k, lo, hi)?,
};
for o in overlays.iter().filter(|o| o.kind == LineKind::Line) {
for o in overlays.iter().filter(|o| matches!(o.kind, LineKind::Line | LineKind::Dots | LineKind::Steps)) {
let kk = level_for(v.bar_px, o.line.pyramid.levels());
if let Some((a, b)) = o.line.pyramid.extent(kk, lo, hi) {
l = l.min(a * o.scale);
@@ -153,6 +158,24 @@ pub fn visible_extent(
Some((l, h, vmax))
}
/// The scale `price_pane` uses when `manual_scale` is `None`.
pub fn autoscale(p: &PricePane<'_>) -> PriceScale {
autoscale_from(p, visible_extent(p.series, p.overlays, p.viewport, p.chart_type))
}
fn autoscale_from(p: &PricePane<'_>, ext: Option<(f64, f64, f64)>) -> PriceScale {
let (l, h) = ext.map(|e| (e.0, e.1)).unwrap_or((0.0, 1.0));
// Leave room at the bottom for the volume overlay.
let mut s = PriceScale::padded(l, h, 0.08, p.log, p.height_px);
if p.show_volume {
let (a, b) = s.bounds_t();
let extra = (b - a) * 0.12;
let lo_t = a - extra;
s.lo = if s.log { lo_t.exp() } else { lo_t };
}
s
}
pub fn price_pane(p: &PricePane<'_>) -> PaneOutput {
let v = p.viewport;
let pyr = &p.series.bars.pyramid;
@@ -160,18 +183,7 @@ pub fn price_pane(p: &PricePane<'_>) -> PaneOutput {
let k = level_for(v.bar_px, pyr.levels());
let ext = visible_extent(p.series, p.overlays, v, p.chart_type);
let vol_max = ext.map(|e| e.2).unwrap_or(1.0);
let scale = p.manual_scale.unwrap_or_else(|| {
let (l, h) = ext.map(|e| (e.0, e.1)).unwrap_or((0.0, 1.0));
// Leave room at the bottom for the volume overlay.
let mut s = PriceScale::padded(l, h, 0.08, p.log, p.height_px);
if p.show_volume {
let (a, b) = s.bounds_t();
let extra = (b - a) * 0.12;
let lo_t = a - extra;
s.lo = if s.log { lo_t.exp() } else { lo_t };
}
s
});
let scale = p.manual_scale.unwrap_or_else(|| autoscale_from(p, ext));
let t = transform(v, p.height_px, &scale, p.style.pixels_per_point);
let mut style = p.style;
style.ohlc_bars = p.chart_type == ChartType::Bars;
@@ -243,8 +255,9 @@ fn overlay_cmds(overlays: &[Overlay], v: &Viewport, t: &PaneTransform, base: &St
}
}
let range = match o.kind {
LineKind::Hist => {
s.hist_width_bars = (base.body_frac as f64 * (1u64 << k) as f64) as f32;
LineKind::Hist | LineKind::Dots | LineKind::Steps => {
let frac = if o.kind == LineKind::Steps { 1.0 } else { base.body_frac as f64 };
s.hist_width_bars = (frac * (1u64 << k) as f64) as f32;
let n = lp.level_len(k);
let off = lp.offsets[k];
let a = (lo >> k).min(n);
@@ -361,6 +374,68 @@ mod tests {
assert_eq!(one.scale, two.scale);
}
#[test]
fn autoscale_is_what_price_pane_uses() {
let s = SeriesGpu::build(&sample::generate("X", Timeframe::D1, 500, 1_790_000_000));
let v = Viewport::fit_last(500, 120, 600.0);
for (show_volume, log) in [(true, false), (false, true)] {
let p = PricePane {
series: &s,
chart_type: ChartType::Candles,
show_volume,
overlays: &[],
viewport: &v,
height_px: 300.0,
log,
manual_scale: None,
style: Style::default(),
};
assert_eq!(autoscale(&p), price_pane(&p).scale);
}
}
#[test]
fn reveal_sweeps_relative_to_the_left_edge() {
use crate::gpu::Reveal;
let s = SeriesGpu::build(&sample::generate("X", Timeframe::D1, 500, 1_790_000_000));
let v = Viewport::fit_last(500, 100, 400.0);
let anim = |reveal: Reveal| {
let out = price_pane(&PricePane {
series: &s,
chart_type: ChartType::Candles,
show_volume: true,
overlays: &[],
viewport: &v,
height_px: 300.0,
log: false,
manual_scale: None,
style: Style { reveal, ..Style::default() },
});
out.cmds
.iter()
.map(|c| match c {
DrawCmd::Candles { uniforms, .. } | DrawCmd::Volume { uniforms, .. } => uniforms.anim,
DrawCmd::Line { uniforms, .. } => uniforms.anim,
})
.collect::<Vec<_>>()
};
// Done: everything drawn, nothing clipped.
for a in anim(Reveal::DONE) {
assert_eq!(a, [1e9, 1.0, 1e9, 1.0]);
}
assert_eq!(Reveal::sweep(1.0, v.left(), v.visible_bars()), Reveal::DONE);
// Start: the front sits on the left edge, so no bar has grown yet.
let start = Reveal::sweep(0.0, v.left(), v.visible_bars());
let left_lo = (v.left() - v.left().floor()) as f32;
for a in anim(start) {
assert!((a[0] - left_lo).abs() < 1e-3, "{a:?}");
assert!(a[2] < a[0] && a[1] >= 2.0);
}
// Midway the front is inside the visible range, lines trailing it.
let mid = Reveal::sweep(0.5, v.left(), v.visible_bars());
assert!(mid.front > v.left() && mid.front < v.right && mid.line_front < mid.front);
}
#[test]
fn sub_pane_fixed_and_auto() {
let vals: Vec<f64> = (0..100).map(|i| (i as f64 * 0.2).sin()).collect();
@@ -371,4 +446,87 @@ mod tests {
let fixed = sub_pane(&[o], &v, 100.0, Some((0.0, 100.0)), Style::default());
assert_eq!((fixed.scale.lo, fixed.scale.hi), (0.0, 100.0));
}
#[test]
fn dots_draw_one_instance_per_point_and_scale_the_price_pane() {
let bars = sample::generate("X", Timeframe::D1, 200, 1_790_000_000);
let s = SeriesGpu::build(&bars);
let v = Viewport::fit_last(200, 200, 800.0);
let top = bars.iter().map(|b| b.high).fold(f64::MIN, f64::max) * 2.0;
let mut marks = vec![f64::NAN; 200];
marks[150] = top;
let o = Overlay { line: line_ref(&marks), color: [1.0; 4], kind: LineKind::Dots, scale: 1.0 };
let out = price_pane(&PricePane {
series: &s,
chart_type: ChartType::Candles,
show_volume: false,
overlays: std::slice::from_ref(&o),
viewport: &v,
height_px: 400.0,
log: false,
manual_scale: None,
style: Style::default(),
});
let Some(DrawCmd::Line { kind: LineKind::Dots, range, .. }) = out.cmds.last() else { panic!("no dots drawn") };
assert_eq!(range.1 - range.0, 200);
assert!(out.scale.hi >= top);
}
#[test]
fn zoomed_out_markers_keep_sparse_points() {
let bars = sample::generate("X", Timeframe::M1, 4096, 1_790_000_000);
let s = SeriesGpu::build(&bars);
let v = Viewport { right: 4096.0, bar_px: 0.2, width_px: 800.0 };
let top = bars.iter().map(|b| b.high).fold(f64::MIN, f64::max) * 2.0;
// A fractal-like marker whose neighbours are blank.
let mut marks = vec![f64::NAN; 4096];
marks[2002] = top;
let pane = |line: LineRef| {
let o = Overlay { line, color: [1.0; 4], kind: LineKind::Dots, scale: 1.0 };
let out = price_pane(&PricePane {
series: &s,
chart_type: ChartType::Candles,
show_volume: false,
overlays: std::slice::from_ref(&o),
viewport: &v,
height_px: 400.0,
log: false,
manual_scale: None,
style: Style::default(),
});
let Some(DrawCmd::Line { data, range, .. }) = out.cmds.last() else { panic!("no dots drawn") };
let drawn = data.pyramid.data[range.0 as usize..range.1 as usize].contains(&[2002.5, top as f32]);
(out.level, drawn, out.scale.hi >= top)
};
let (level, drawn, scaled) = pane(marker_ref(&marks));
assert!(level > 0 && drawn && scaled, "level {level}: drawn {drawn}, scaled {scaled}");
assert_eq!(pane(line_ref(&marks)), (level, false, false), "line reduction drops the marker");
}
#[test]
fn steps_draw_one_full_bar_dash_per_point() {
let s = SeriesGpu::build(&sample::generate("X", Timeframe::H4, 200, 1_790_000_000));
let levels: Vec<f64> = (0..200).map(|i| if i / 2 % 2 == 0 { 1.0 } else { 1e6 }).collect();
let o = Overlay { line: line_ref(&levels), color: [1.0; 4], kind: LineKind::Steps, scale: 1.0 };
for (bar_px, group) in [(4.0, 1.0), (0.5, 4.0)] {
let v = Viewport { right: 200.0, bar_px, width_px: 800.0 };
let out = price_pane(&PricePane {
series: &s,
chart_type: ChartType::Candles,
show_volume: false,
overlays: std::slice::from_ref(&o),
viewport: &v,
height_px: 400.0,
log: false,
manual_scale: None,
style: Style::default(),
});
let Some(DrawCmd::Line { kind: LineKind::Steps, range, uniforms, .. }) = out.cmds.last() else {
panic!("no steps drawn")
};
assert_eq!(uniforms.extra[2], group, "a dash spans its whole group of bars");
assert_eq!(range.1 - range.0, 200 / group as u32);
assert!(out.scale.hi >= 1e6);
}
}
}
+36 -8
View File
@@ -14,6 +14,8 @@ struct Uniforms {
params: vec4<f32>,
// vol_frac, vol_max, hist_width_bars, baseline
extra: vec4<f32>,
// reveal front, ramp (bars), line front (both relative to left_hi), pane opacity
anim: vec4<f32>,
};
@group(0) @binding(0) var<uniform> u: Uniforms;
@@ -55,11 +57,24 @@ fn corner(vi: u32) -> vec2<f32> {
return c[vi % 6u];
}
// Premultiplied colour with the pane opacity applied.
fn shade(color: vec4<f32>) -> vec4<f32> {
let a = color.a * u.anim.w;
return vec4<f32>(color.rgb * a, a);
}
// How far the bar centred at `idx` has grown in (0..1, eased) behind the reveal front.
fn grow(idx: f32) -> f32 {
let f = clamp((u.anim.x - (idx - u.rect.z)) / u.anim.y, 0.0, 1.0);
let r = 1.0 - f;
return 1.0 - r * r * r;
}
fn rect(vi: u32, x0: f32, x1: f32, y0: f32, y1: f32, color: vec4<f32>) -> VsOut {
let c = corner(vi);
var out: VsOut;
out.pos = vec4<f32>(mix(x0, x1, c.x), mix(y0, y1, c.y), 0.0, 1.0);
out.color = vec4<f32>(color.rgb * color.a, color.a);
out.color = shade(color);
return out;
}
@@ -95,7 +110,16 @@ struct Bar {
@vertex
fn vs_candle(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VsOut {
let b = bars[ii];
let color = select(u.down, u.up, b.close >= b.open);
let g = grow(b.x + b.w * 0.5);
if (g <= 0.0) {
return degenerate();
}
// Growing in: the bar opens out from its open price.
let high = mix(b.open, b.high, g);
let low = mix(b.open, b.low, g);
let close = mix(b.open, b.close, g);
var color = select(u.down, u.up, b.close >= b.open);
color.a = color.a * clamp(g * 3.0, 0.0, 1.0);
let cx = x_ndc(b.x + b.w * 0.5);
let half_body = max((b.w * u.params.x * 0.5) / u.view.x * 2.0, px_to_ndc_x(0.5));
let half_wick = px_to_ndc_x(u.params.y * 0.5);
@@ -103,34 +127,38 @@ fn vs_candle(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -
let one_px = px_to_ndc_y(1.0);
if (u.params.w < 0.5) {
if (r == 0u) {
return rect(vi, cx - half_wick, cx + half_wick, y_ndc(b.low), max(y_ndc(b.high), y_ndc(b.low) + one_px), color);
return rect(vi, cx - half_wick, cx + half_wick, y_ndc(low), max(y_ndc(high), y_ndc(low) + one_px), color);
}
if (r == 1u) {
let y0 = y_ndc(min(b.open, b.close));
let y1 = max(y_ndc(max(b.open, b.close)), y0 + one_px);
let y0 = y_ndc(min(b.open, close));
let y1 = max(y_ndc(max(b.open, close)), y0 + one_px);
return rect(vi, cx - half_body, cx + half_body, y0, y1, color);
}
return degenerate();
}
let tick = px_to_ndc_y(max(u.params.y, 1.0) * 0.5);
if (r == 0u) {
return rect(vi, cx - half_wick, cx + half_wick, y_ndc(b.low), max(y_ndc(b.high), y_ndc(b.low) + one_px), color);
return rect(vi, cx - half_wick, cx + half_wick, y_ndc(low), max(y_ndc(high), y_ndc(low) + one_px), color);
}
if (r == 1u) {
let y = y_ndc(b.open);
return rect(vi, cx - half_body, cx, y - tick, y + tick, color);
}
let y = y_ndc(b.close);
let y = y_ndc(close);
return rect(vi, cx, cx + half_body, y - tick, y + tick, color);
}
@vertex
fn vs_volume(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VsOut {
let b = bars[ii];
let g = grow(b.x + b.w * 0.5);
if (g <= 0.0) {
return degenerate();
}
var color = select(u.down, u.up, b.close >= b.open);
color.a = color.a * u.color.a;
let cx = x_ndc(b.x + b.w * 0.5);
let half = max((b.w * u.params.x * 0.5) / u.view.x * 2.0, px_to_ndc_x(0.5));
let h = clamp(b.volume / max(u.extra.y, 1e-30), 0.0, 1.0) * u.extra.x * 2.0;
let h = clamp(b.volume / max(u.extra.y, 1e-30), 0.0, 1.0) * u.extra.x * 2.0 * g;
return rect(vi, cx - half, cx + half, -1.0, -1.0 + h, color);
}
+52 -9
View File
@@ -15,14 +15,24 @@ fn px_to_ndc(p: vec2<f32>) -> vec2<f32> {
return vec2<f32>(p.x / u.rect.x * 2.0 - 1.0, p.y / u.rect.y * 2.0 - 1.0);
}
// Segment end `b` cut back to the line front while the pane is revealing.
fn traced(a: vec2<f32>, b: vec2<f32>) -> vec2<f32> {
let fa = a.x - u.rect.z;
let fb = b.x - u.rect.z;
if (fb <= u.anim.z) {
return b;
}
return mix(a, b, clamp((u.anim.z - fa) / max(fb - fa, 1e-6), 0.0, 1.0));
}
// Thick segment between points ii and ii+1, expanded in pixel space.
@vertex
fn vs_line(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VsOut {
let a = pts[ii];
let b = pts[ii + 1u];
if (!(finite(a.y) && finite(b.y))) {
if (!(finite(a.y) && finite(pts[ii + 1u].y)) || a.x - u.rect.z >= u.anim.z) {
return degenerate();
}
let b = traced(a, pts[ii + 1u]);
let pa = to_px(a);
let pb = to_px(b);
var d = pb - pa;
@@ -36,7 +46,7 @@ fn vs_line(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) ->
let p = along + n * (c.y * 2.0 - 1.0);
var out: VsOut;
out.pos = vec4<f32>(px_to_ndc(p), 0.0, 1.0);
out.color = vec4<f32>(u.color.rgb * u.color.a, u.color.a);
out.color = shade(u.color);
return out;
}
@@ -44,19 +54,18 @@ fn vs_line(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) ->
@vertex
fn vs_area(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VsOut {
let a = pts[ii];
let b = pts[ii + 1u];
if (!(finite(a.y) && finite(b.y))) {
if (!(finite(a.y) && finite(pts[ii + 1u].y)) || a.x - u.rect.z >= u.anim.z) {
return degenerate();
}
let b = traced(a, pts[ii + 1u]);
let c = corner(vi);
let x = mix(x_ndc(a.x), x_ndc(b.x), c.x);
let top = mix(y_ndc(a.y), y_ndc(b.y), c.x);
let y = mix(-1.0, top, c.y);
// Fade towards the bottom.
let alpha = u.color.a * mix(0.15, 1.0, c.y);
var out: VsOut;
out.pos = vec4<f32>(x, y, 0.0, 1.0);
out.color = vec4<f32>(u.color.rgb * alpha, alpha);
out.color = shade(vec4<f32>(u.color.rgb, u.color.a * mix(0.15, 1.0, c.y)));
return out;
}
@@ -64,11 +73,45 @@ fn vs_area(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) ->
@vertex
fn vs_hist(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VsOut {
let a = pts[ii];
if (!finite(a.y)) {
let g = grow(a.x);
if (!finite(a.y) || g <= 0.0) {
return degenerate();
}
let color = select(u.down, u.up, a.y >= u.extra.w);
let half = max(u.extra.z * 0.5 / u.view.x * 2.0, px_to_ndc_x(0.5));
let cx = x_ndc(a.x);
return rect(vi, cx - half, cx + half, y_ndc(u.extra.w), y_ndc(a.y), color);
return rect(vi, cx - half, cx + half, y_ndc(u.extra.w), y_ndc(mix(u.extra.w, a.y, g)), color);
}
// Flat dash at point ii's level across the bars of its group (extra.z bars wide, one when
// not zoomed out). The point is its group's last bar, so the dash runs from the group's
// start to that bar; a short final group stays short. Consecutive levels never join, so a
// session's pivot starts afresh instead of stepping up from the last one.
@vertex
fn vs_steps(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VsOut {
let a = pts[ii];
let x1 = a.x + 0.5;
let x0 = floor(a.x / u.extra.z) * u.extra.z;
if (!finite(a.y) || x0 - u.rect.z >= u.anim.z) {
return degenerate();
}
let hy = px_to_ndc_y(u.params.z * 0.5);
let cy = y_ndc(a.y);
return rect(vi, x_ndc(x0), x_ndc(min(x1, u.rect.z + u.anim.z)), cy - hy, cy + hy, u.color);
}
// Square marker centred on point ii (e.g. parabolic SAR), sized from the line thickness.
// Markers grow in behind the reveal front.
@vertex
fn vs_dots(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VsOut {
let a = pts[ii];
let g = grow(a.x);
if (!finite(a.y) || g <= 0.0) {
return degenerate();
}
let hx = px_to_ndc_x(u.params.z * 1.25 * g);
let hy = px_to_ndc_y(u.params.z * 1.25 * g);
let cx = x_ndc(a.x);
let cy = y_ndc(a.y);
return rect(vi, cx - hx, cx + hx, cy - hy, cy + hy, u.color);
}
+128 -1
View File
@@ -1,6 +1,6 @@
//! Renders real frames offscreen (any adapter, including software Vulkan) and checks pixels.
use tr_chart::gpu::{Frame, LineKind, Style};
use tr_chart::gpu::{Frame, LineKind, Reveal, Style};
use tr_chart::headless::Headless;
use tr_chart::scene::{ChartType, Overlay, PricePane, SeriesGpu, line_ref, price_pane, sub_pane};
use tr_chart::viewport::Viewport;
@@ -73,3 +73,130 @@ fn renders_candles_volume_lines_and_hist() {
assert!(count(&px, |p| p[1] > 150 && p[0] < 100) > 200);
assert!(count(&px, |p| p[0] > 200 && p[1] < 100) > 200);
}
#[test]
fn reveal_sweeps_in_from_the_left_and_fades() {
let Some(mut h) = Headless::new(800, 400) else {
eprintln!("no wgpu adapter; skipping");
return;
};
let bars = sample::generate("AAPL", Timeframe::D1, 2_000, 1_790_000_000);
let series = SeriesGpu::build(&bars);
let closes: Vec<f64> = bars.iter().map(|b| b.close).collect();
let overlays = [
Overlay {
line: line_ref(&indicators::sma(&closes, 20)),
color: [0.0, 0.6, 1.0, 1.0],
kind: LineKind::Line,
scale: 1.0,
},
Overlay {
line: line_ref(&indicators::ema(&closes, 50)),
color: [1.0, 0.3, 0.3, 1.0],
kind: LineKind::Dots,
scale: 1.0,
},
];
let v = Viewport::fit_last(bars.len(), 150, 800.0);
let mut draw = |reveal: Reveal, pane: u64| {
let out = price_pane(&PricePane {
series: &series,
chart_type: ChartType::Candles,
show_volume: true,
overlays: &overlays,
viewport: &v,
height_px: 400.0,
log: false,
manual_scale: None,
style: Style { reveal, ..Style::default() },
});
h.render(&mut [Frame { pane, frame_no: pane, cmds: out.cmds }]);
h.read_pixels()
};
// Lit pixels (candles, volume or the SMA) in columns [x0, x1).
let lit = |px: &[u8], x0: usize, x1: usize| {
px.as_chunks::<4>()
.0
.iter()
.enumerate()
.filter(|(i, p)| (x0..x1).contains(&(i % 800)) && p[..3] != [0, 0, 0])
.count()
};
let half = draw(Reveal::sweep(0.45, v.left(), v.visible_bars()), 1);
if let Ok(dir) = std::env::var("TR_DUMP_DIR") {
dump_ppm(&format!("{dir}/reveal.ppm"), 800, 400, &half);
}
let (left, right) = (lit(&half, 0, 250), lit(&half, 450, 800));
eprintln!("half revealed: left {left} right {right}");
assert!(left > 2_000, "bars behind the front are drawn ({left})");
assert_eq!(right, 0, "nothing past the front, lines and dots included");
let full = draw(Reveal::DONE, 2);
assert!(lit(&full, 450, 800) > 2_000);
let faded = draw(Reveal { opacity: 0.0, ..Reveal::DONE }, 3);
assert_eq!(lit(&faded, 0, 800), 0, "opacity 0 draws nothing");
}
#[test]
fn steps_draw_short_sessions_without_joining_them() {
let Some(mut h) = Headless::new(800, 400) else {
eprintln!("no wgpu adapter; skipping");
return;
};
// Two-bar sessions alternating between 25 and 75 (pivots on a 4H equity chart), then a
// session of one bar at 50.
let mut levels: Vec<f64> = (0..39).map(|i| if i / 2 % 2 == 0 { 25.0 } else { 75.0 }).collect();
levels.push(50.0);
let v = Viewport::fit_last(levels.len(), levels.len(), 800.0);
let mut draw = |kind: LineKind, pane: u64| {
let o = [Overlay { line: line_ref(&levels), color: [1.0; 4], kind, scale: 1.0 }];
let out = sub_pane(&o, &v, 400.0, Some((0.0, 100.0)), Style::default());
h.render(&mut [Frame { pane, frame_no: pane, cmds: out.cmds }]);
h.read_pixels()
};
// Lit pixels in rows [y0, y1); 25 and 75 sit on rows 300 and 100, 50 on row 200.
let rows = |px: &[u8], y0: usize, y1: usize| {
px.as_chunks::<4>().0.iter().enumerate().filter(|(i, p)| (y0..y1).contains(&(i / 800)) && p[0] > 0).count()
};
let steps = draw(LineKind::Steps, 1);
if let Ok(dir) = std::env::var("TR_DUMP_DIR") {
dump_ppm(&format!("{dir}/steps.ppm"), 800, 400, &steps);
}
let (high, low, one_bar) = (rows(&steps, 95, 105), rows(&steps, 295, 305), rows(&steps, 195, 205));
eprintln!("steps: 75 {high}, 25 {low}, 50 {one_bar}");
assert!(high > 300 && low > 300, "every session draws its level");
assert!(one_bar > 10, "a one-bar session draws");
assert_eq!(rows(&steps, 110, 190) + rows(&steps, 210, 290), 0, "sessions are not joined");
let lines = draw(LineKind::Line, 2);
assert!(rows(&lines, 110, 190) > 0, "a line would join them");
}
#[test]
fn zoomed_out_steps_stay_inside_their_group() {
let Some(mut h) = Headless::new(800, 400) else {
eprintln!("no wgpu adapter; skipping");
return;
};
// 401 bars at one pixel each draw in groups of two; the last group holds only bar 400,
// which starts a new session at 75.
let mut levels = vec![25.0; 400];
levels.push(75.0);
let v = Viewport { right: 410.0, bar_px: 1.0, width_px: 800.0 };
let o = [Overlay { line: line_ref(&levels), color: [1.0; 4], kind: LineKind::Steps, scale: 1.0 }];
let out = sub_pane(&o, &v, 400.0, Some((0.0, 100.0)), Style::default());
h.render(&mut [Frame { pane: 1, frame_no: 0, cmds: out.cmds }]);
let px = h.read_pixels();
// Bar i spans columns [i + 390, i + 391); 75 sits on row 100.
let lit = |x0: usize, x1: usize| {
px.as_chunks::<4>()
.0
.iter()
.enumerate()
.filter(|(i, p)| (95..105).contains(&(i / 800)) && (x0..x1).contains(&(i % 800)) && p[0] > 0)
.count()
};
assert!(lit(790, 791) > 0, "the new session draws on its own bar");
assert_eq!(lit(0, 790), 0, "and not over the previous session");
}
+6 -3
View File
@@ -1,16 +1,19 @@
[package]
name = "tr-congress"
description = "STOCK Act disclosure ingest (Senate eFD + House Clerk PTRs) for terminal-rs"
description = "STOCK Act disclosure ingest (Senate eFD + House Clerk PTRs) for Terminal"
version.workspace = true
edition.workspace = true
[dependencies]
anyhow.workspace = true
thiserror.workspace = true
chrono.workspace = true
serde.workspace = true
serde_json.workspace = true
regex.workspace = true
# Ingest and storage: desktop only (the browser build has no SQLite and can't reach the sources).
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
thiserror.workspace = true
serde_json.workspace = true
tokio.workspace = true
reqwest.workspace = true
rusqlite.workspace = true
+1 -20
View File
@@ -7,6 +7,7 @@ use anyhow::Result;
use chrono::{NaiveDate, Utc};
use rusqlite::{Connection, OptionalExtension, Row, params};
pub use crate::model::{Alert, PoliticianSummary};
use crate::model::{AmountRange, Chamber, ParsedTrade, Party, Politician, Trade, TxType};
const SCHEMA: &str = r#"
@@ -111,17 +112,6 @@ pub struct FilingRecord {
pub is_amendment: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Alert {
pub id: i64,
pub politician_id: i64,
pub politician: String,
pub filing_id: String,
pub created_at: i64,
pub message: String,
pub seen: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Stats {
pub politicians: i64,
@@ -130,15 +120,6 @@ pub struct Stats {
pub scanned: i64,
}
/// A politician with aggregate trade counts, for list views.
#[derive(Debug, Clone, PartialEq)]
pub struct PoliticianSummary {
pub politician: Politician,
pub trades: i64,
pub last_filed: Option<NaiveDate>,
pub tracked: bool,
}
pub struct Db {
conn: Connection,
}
+60
View File
@@ -0,0 +1,60 @@
//! The browser build's stand-in for the SQLite store: always empty, because the browser
//! can't fetch disclosures yet. It keeps the desktop store's method signatures so callers
//! compile unchanged.
//!
//! TODO(web proxy): fill this from a same-origin proxy for Senate eFD and the House Clerk.
use std::path::Path;
use anyhow::{Result, bail};
pub use crate::model::{Alert, PoliticianSummary};
use crate::model::{Politician, Trade};
pub struct Db;
impl Db {
pub fn open(_path: impl AsRef<Path>) -> Result<Self> {
Ok(Self)
}
pub fn open_in_memory() -> Result<Self> {
Ok(Self)
}
pub fn latest_trades(&self, _limit: usize) -> Result<Vec<Trade>> {
Ok(Vec::new())
}
pub fn trades_for_ticker(&self, _ticker: &str, _limit: usize) -> Result<Vec<Trade>> {
Ok(Vec::new())
}
pub fn trades_for_politician(&self, _politician_id: i64) -> Result<Vec<Trade>> {
Ok(Vec::new())
}
pub fn politician(&self, _id: i64) -> Result<Option<Politician>> {
Ok(None)
}
pub fn search_politicians(&self, _query: &str, _limit: usize) -> Result<Vec<PoliticianSummary>> {
Ok(Vec::new())
}
pub fn set_tracked(&self, _politician_id: i64, _tracked: bool) -> Result<()> {
bail!("tracking needs congressional data, which the browser build can't load yet")
}
pub fn is_tracked(&self, _politician_id: i64) -> Result<bool> {
Ok(false)
}
pub fn alerts(&self, _unseen_only: bool, _limit: usize) -> Result<Vec<Alert>> {
Ok(Vec::new())
}
pub fn mark_alerts_seen(&self) -> Result<()> {
Ok(())
}
}
+11
View File
@@ -1,12 +1,23 @@
//! STOCK Act congressional trade disclosures, ingested directly from the
//! official sources: the Senate eFD system and the House Clerk's PTR filings.
//!
//! The browser build (`wasm32`) has no SQLite and can't reach either site (they don't allow
//! cross-origin requests), so there [`db`] is an empty stand-in and there is no ingest.
#[cfg(not(target_arch = "wasm32"))]
pub mod db;
#[cfg(target_arch = "wasm32")]
#[path = "db_web.rs"]
pub mod db;
#[cfg(not(target_arch = "wasm32"))]
pub mod house;
pub mod model;
pub mod performance;
#[cfg(not(target_arch = "wasm32"))]
pub mod refresh;
#[cfg(not(target_arch = "wasm32"))]
pub mod roster;
#[cfg(not(target_arch = "wasm32"))]
pub mod senate;
pub use db::Db;
+30
View File
@@ -243,6 +243,36 @@ pub fn normalize_ticker(raw: &str) -> Option<String> {
RE.is_match(&t).then_some(t)
}
/// An alert raised when a tracked politician files new trades.
#[derive(Debug, Clone, PartialEq)]
pub struct Alert {
pub id: i64,
pub politician_id: i64,
pub politician: String,
pub filing_id: String,
pub created_at: i64,
pub message: String,
pub seen: bool,
}
/// A politician with aggregate trade counts, for list views.
#[derive(Debug, Clone, PartialEq)]
pub struct PoliticianSummary {
pub politician: Politician,
pub trades: i64,
pub last_filed: Option<NaiveDate>,
pub tracked: bool,
}
/// A new filing by a tracked politician, found during a refresh.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct NewTradeAlert {
pub politician_id: i64,
pub politician: String,
pub filing_id: String,
pub message: String,
}
#[cfg(test)]
mod tests {
use super::*;
+2 -9
View File
@@ -12,6 +12,7 @@ use tracing::{debug, info, warn};
use crate::db::{Db, FilingRecord, FilingStatus, NewPolitician};
use crate::house::{self, IndexEntry, PdfError};
pub use crate::model::NewTradeAlert;
use crate::model::{Chamber, Party};
use crate::roster::{self, Roster};
use crate::senate::{self, ReportKind, ReportRef};
@@ -58,14 +59,6 @@ impl RefreshConfig {
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct NewTradeAlert {
pub politician_id: i64,
pub politician: String,
pub filing_id: String,
pub message: String,
}
#[derive(Debug, Clone, Default)]
pub struct RefreshReport {
pub new_filings: usize,
@@ -394,7 +387,7 @@ impl HttpSource {
pub fn new() -> Result<Self> {
let client = reqwest::Client::builder()
.user_agent(concat!(
"terminal-rs/",
"terminal/",
env!("CARGO_PKG_VERSION"),
" (STOCK Act disclosure reader; +https://github.com/)"
))
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tr-core"
description = "Core market types, timeframes, indicators and sample data for terminal-rs"
description = "Core market types, timeframes, indicators and sample data for Terminal"
version.workspace = true
edition.workspace = true
@@ -10,6 +10,7 @@ serde.workspace = true
[dev-dependencies]
criterion.workspace = true
serde_json.workspace = true
[[bench]]
name = "indicators"
+14
View File
@@ -1,5 +1,6 @@
use criterion::{Criterion, criterion_group, criterion_main};
use std::hint::black_box;
use tr_core::indicators::registry::{self, Input};
use tr_core::{Timeframe, indicators, sample};
fn bench(c: &mut Criterion) {
@@ -14,6 +15,19 @@ fn bench(c: &mut Criterion) {
g.bench_function("bollinger", |b| b.iter(|| indicators::bollinger(black_box(&closes), 20, 2.0)));
g.bench_function("vwap", |b| b.iter(|| indicators::vwap(black_box(&bars))));
g.finish();
// The whole catalogue at default parameters, as the chart worker computes it.
let input = Input::new(&bars[..100_000], Timeframe::M1);
let mut g = c.benchmark_group("catalog_100k_bars");
g.sample_size(10);
g.bench_function("every_indicator", |b| {
b.iter(|| registry::all().iter().map(|d| d.run(black_box(&input), &d.defaults()).len()).sum::<usize>())
});
for code in ["ICHIMOKU", "MAMA", "SUPERTREND", "ZIGZAG", "PIVOT", "YZ"] {
let d = registry::find(code).expect(code);
g.bench_function(code, |b| b.iter(|| d.run(black_box(&input), &d.defaults())));
}
g.finish();
}
criterion_group!(benches, bench);
+24 -10
View File
@@ -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]
-289
View File
@@ -1,289 +0,0 @@
//! Technical indicators. Every function returns a vector aligned with its
//! input; positions without enough history hold `f64::NAN`.
//!
//! Conventions follow the common charting definitions: EMA uses
//! alpha = 2/(n+1) seeded with an SMA, RSI uses Wilder smoothing, Bollinger
//! bands use the population standard deviation, and VWAP resets each session.
use crate::Bar;
/// Simple moving average over `n` values, computed with a running sum.
pub fn sma(values: &[f64], n: usize) -> Vec<f64> {
let mut out = vec![f64::NAN; values.len()];
if n == 0 || values.len() < n {
return out;
}
let mut sum: f64 = values[..n].iter().sum();
out[n - 1] = sum / n as f64;
for i in n..values.len() {
sum += values[i] - values[i - n];
out[i] = sum / n as f64;
}
out
}
/// Exponential smoothing seeded with the SMA of the first `n` values.
fn smooth(values: &[f64], n: usize, alpha: f64) -> Vec<f64> {
let mut out = vec![f64::NAN; values.len()];
if n == 0 || values.len() < n {
return out;
}
let mut prev = values[..n].iter().sum::<f64>() / n as f64;
out[n - 1] = prev;
for i in n..values.len() {
prev = alpha * values[i] + (1.0 - alpha) * prev;
out[i] = prev;
}
out
}
/// Exponential moving average, alpha = 2/(n+1).
pub fn ema(values: &[f64], n: usize) -> Vec<f64> {
smooth(values, n, 2.0 / (n as f64 + 1.0))
}
/// Wilder's moving average (RMA), alpha = 1/n.
pub fn rma(values: &[f64], n: usize) -> Vec<f64> {
smooth(values, n, 1.0 / n as f64)
}
/// Relative Strength Index with Wilder smoothing (first value at index `n`).
/// Single pass with no intermediate buffers: on large series the cost is dominated by
/// first-touch of output memory, so only the output is allocated.
pub fn rsi(closes: &[f64], n: usize) -> Vec<f64> {
let mut out = vec![f64::NAN; closes.len()];
if n == 0 || closes.len() <= n {
return out;
}
let value = |g: f64, l: f64| {
if l == 0.0 { if g == 0.0 { 50.0 } else { 100.0 } } else { 100.0 - 100.0 / (1.0 + g / l) }
};
let (mut ag, mut al) = (0.0, 0.0);
for i in 1..=n {
let d = closes[i] - closes[i - 1];
ag += d.max(0.0);
al += (-d).max(0.0);
}
ag /= n as f64;
al /= n as f64;
out[n] = value(ag, al);
let alpha = 1.0 / n as f64;
for i in (n + 1)..closes.len() {
let d = closes[i] - closes[i - 1];
ag = alpha * d.max(0.0) + (1.0 - alpha) * ag;
al = alpha * (-d).max(0.0) + (1.0 - alpha) * al;
out[i] = value(ag, al);
}
out
}
#[derive(Debug, Clone)]
pub struct Macd {
pub macd: Vec<f64>,
pub signal: Vec<f64>,
pub histogram: Vec<f64>,
}
/// Streaming EMA seeded with the SMA of its first `n` inputs.
struct Ema {
n: usize,
alpha: f64,
seen: usize,
acc: f64,
}
impl Ema {
fn new(n: usize) -> Self {
Self { n, alpha: 2.0 / (n as f64 + 1.0), seen: 0, acc: 0.0 }
}
/// Feed one value; returns the EMA once `n` values have been seen.
fn push(&mut self, v: f64) -> Option<f64> {
self.seen += 1;
if self.seen < self.n {
self.acc += v;
None
} else if self.seen == self.n {
self.acc = (self.acc + v) / self.n as f64;
Some(self.acc)
} else {
self.acc = self.alpha * v + (1.0 - self.alpha) * self.acc;
Some(self.acc)
}
}
}
/// MACD line (fast EMA - slow EMA), its signal EMA and histogram, in one pass.
pub fn macd(closes: &[f64], fast: usize, slow: usize, signal: usize) -> Macd {
let len = closes.len();
let mut line = vec![f64::NAN; len];
let mut sig = vec![f64::NAN; len];
let mut histogram = vec![f64::NAN; len];
if fast == 0 || slow == 0 || signal == 0 {
return Macd { macd: line, signal: sig, histogram };
}
let (mut ef, mut es, mut eg) = (Ema::new(fast), Ema::new(slow), Ema::new(signal));
for (i, &c) in closes.iter().enumerate() {
let (f, s) = (ef.push(c), es.push(c));
let (Some(f), Some(s)) = (f, s) else { continue };
let m = f - s;
line[i] = m;
// The signal EMA starts once the MACD line itself is defined.
if let Some(g) = eg.push(m) {
sig[i] = g;
histogram[i] = m - g;
}
}
Macd { macd: line, signal: sig, histogram }
}
#[derive(Debug, Clone)]
pub struct Bands {
pub middle: Vec<f64>,
pub upper: Vec<f64>,
pub lower: Vec<f64>,
}
/// Bollinger bands: SMA(n) +/- k * population standard deviation.
pub fn bollinger(closes: &[f64], n: usize, k: f64) -> Bands {
let middle = sma(closes, n);
let mut upper = vec![f64::NAN; closes.len()];
let mut lower = vec![f64::NAN; closes.len()];
if n > 0 && closes.len() >= n {
let mut sum_sq: f64 = closes[..n].iter().map(|v| v * v).sum();
for i in (n - 1)..closes.len() {
if i >= n {
sum_sq += closes[i] * closes[i] - closes[i - n] * closes[i - n];
}
let mean = middle[i];
let var = (sum_sq / n as f64 - mean * mean).max(0.0);
let sd = var.sqrt();
upper[i] = mean + k * sd;
lower[i] = mean - k * sd;
}
}
Bands { middle, upper, lower }
}
/// Volume-weighted average price over hlc3, reset whenever `session_of(ts)` changes.
pub fn vwap_by(bars: &[Bar], session_of: impl Fn(i64) -> i64) -> Vec<f64> {
let mut out = Vec::with_capacity(bars.len());
let (mut pv, mut vol) = (0.0, 0.0);
let mut session = None;
for b in bars {
let s = session_of(b.ts);
if session != Some(s) {
session = Some(s);
pv = 0.0;
vol = 0.0;
}
pv += b.hlc3() * b.volume;
vol += b.volume;
out.push(if vol > 0.0 { pv / vol } else { b.hlc3() });
}
out
}
/// Session VWAP resetting each UTC day. For daily and longer bars this
/// degenerates to hlc3, so callers typically anchor it by month instead.
pub fn vwap(bars: &[Bar]) -> Vec<f64> {
vwap_by(bars, |ts| ts.div_euclid(86_400))
}
#[cfg(test)]
mod tests {
use super::*;
fn close_to(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
fn assert_series(got: &[f64], want: &[f64]) {
assert_eq!(got.len(), want.len());
for (i, (g, w)) in got.iter().zip(want).enumerate() {
assert!((g.is_nan() && w.is_nan()) || close_to(*g, *w), "index {i}: got {g}, want {w}");
}
}
const N: f64 = f64::NAN;
#[test]
fn sma_basic() {
assert_series(&sma(&[1.0, 2.0, 3.0, 4.0, 5.0], 3), &[N, N, 2.0, 3.0, 4.0]);
assert!(sma(&[1.0], 3)[0].is_nan());
assert!(sma(&[1.0, 2.0], 0).iter().all(|v| v.is_nan()));
}
#[test]
fn ema_seeded_with_sma() {
// alpha = 0.5; seed = mean(1,2,3) = 2; then 0.5*4+0.5*2 = 3; 0.5*5+0.5*3 = 4.
assert_series(&ema(&[1.0, 2.0, 3.0, 4.0, 5.0], 3), &[N, N, 2.0, 3.0, 4.0]);
// Constant input stays constant.
assert!(close_to(ema(&[7.0; 50], 10)[49], 7.0));
}
#[test]
fn rsi_reference_values() {
// Wilder's original 14-period example (New Concepts in Technical Trading Systems).
let closes = [
44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08, 45.89, 46.03, 45.61, 46.28, 46.28,
46.00, 46.03, 46.41, 46.22, 45.64,
];
let r = rsi(&closes, 14);
assert!(r[13].is_nan());
assert!((r[14] - 70.46).abs() < 0.01, "rsi[14] = {}", r[14]);
assert!((r[15] - 66.25).abs() < 0.01, "rsi[15] = {}", r[15]);
assert!((r[19] - 57.92).abs() < 0.05, "rsi[19] = {}", r[19]);
}
#[test]
fn rsi_extremes() {
let up: Vec<f64> = (0..30).map(|i| i as f64).collect();
assert_eq!(rsi(&up, 14)[29], 100.0);
assert_eq!(rsi(&[5.0; 30], 14)[29], 50.0);
}
#[test]
fn macd_matches_manual_emas() {
let closes: Vec<f64> = (0..60).map(|i| 100.0 + (i as f64 * 0.3).sin() * 5.0 + i as f64 * 0.1).collect();
let m = macd(&closes, 12, 26, 9);
let f = ema(&closes, 12);
let s = ema(&closes, 26);
assert!(m.macd[24].is_nan());
assert!(close_to(m.macd[25], f[25] - s[25]));
// Signal needs 9 MACD values: first at 25 + 8.
assert!(m.signal[32].is_nan());
let seed: f64 = m.macd[25..34].iter().sum::<f64>() / 9.0;
assert!(close_to(m.signal[33], seed));
assert!(close_to(m.histogram[40], m.macd[40] - m.signal[40]));
}
#[test]
fn bollinger_population_sd() {
let b = bollinger(&[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0], 8, 2.0);
// mean 5, population sd 2.
assert!(close_to(b.middle[7], 5.0));
assert!(close_to(b.upper[7], 9.0));
assert!(close_to(b.lower[7], 1.0));
assert!(b.upper[6].is_nan());
// Rolling window stays numerically consistent with a fresh computation.
let xs: Vec<f64> = (0..200).map(|i| ((i * 37) % 101) as f64).collect();
let roll = bollinger(&xs, 20, 2.0);
let fresh = bollinger(&xs[180..], 20, 2.0);
assert!(close_to(roll.upper[199], fresh.upper[19]));
}
#[test]
fn vwap_resets_per_session() {
let day = 86_400;
let bars = [
Bar::new(0, 0.0, 12.0, 8.0, 10.0, 100.0),
Bar::new(60, 0.0, 22.0, 18.0, 20.0, 300.0),
Bar::new(day, 0.0, 6.0, 4.0, 5.0, 10.0),
];
let v = vwap(&bars);
assert!(close_to(v[0], 10.0));
assert!(close_to(v[1], (10.0 * 100.0 + 20.0 * 300.0) / 400.0));
assert!(close_to(v[2], 5.0));
}
}
+176
View File
@@ -0,0 +1,176 @@
//! Price levels: session keys and pivot points.
use chrono::Datelike;
use crate::Timeframe;
use super::{NAN, Ohlcv};
/// The session a bar at `ts` belongs to on `tf`, for VWAP and pivots: the UTC day
/// intraday, the month on daily bars, the year above that.
pub fn session_key(tf: Timeframe) -> impl Fn(i64) -> i64 {
move |ts| {
let d = chrono::DateTime::from_timestamp(ts, 0).unwrap_or_default();
match tf {
t if t.is_intraday() => ts.div_euclid(86_400),
Timeframe::D1 => (d.year() * 12 + d.month() as i32) as i64,
_ => d.year() as i64,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PivotKind {
Classic,
Fibonacci,
Camarilla,
Woodie,
Demark,
}
/// Open, high, low and close of one session.
#[derive(Debug, Clone, Copy)]
struct Session {
open: f64,
high: f64,
low: f64,
close: f64,
}
impl PivotKind {
/// Level names, in the order [`pivots`] returns them.
pub fn names(self) -> &'static [&'static str] {
match self {
PivotKind::Classic | PivotKind::Fibonacci | PivotKind::Woodie => &["P", "R1", "S1", "R2", "S2", "R3", "S3"],
PivotKind::Camarilla => &["P", "R1", "S1", "R2", "S2", "R3", "S3", "R4", "S4"],
PivotKind::Demark => &["P", "R1", "S1"],
}
}
/// Levels from the previous session `s`, given the current session's `open`.
fn levels(self, s: Session, open: f64) -> Vec<f64> {
let Session { open: o, high: h, low: l, close: c } = s;
let range = h - l;
let classic =
|p: f64| vec![p, 2.0 * p - l, 2.0 * p - h, p + range, p - range, h + 2.0 * (p - l), l - 2.0 * (h - p)];
match self {
PivotKind::Classic => classic((h + l + c) / 3.0),
PivotKind::Woodie => classic((h + l + 2.0 * open) / 4.0),
PivotKind::Fibonacci => {
let p = (h + l + c) / 3.0;
vec![
p,
p + 0.382 * range,
p - 0.382 * range,
p + 0.618 * range,
p - 0.618 * range,
p + range,
p - range,
]
}
PivotKind::Camarilla => {
let p = (h + l + c) / 3.0;
let k = 1.1 * range;
vec![
p,
c + k / 12.0,
c - k / 12.0,
c + k / 6.0,
c - k / 6.0,
c + k / 4.0,
c - k / 4.0,
c + k / 2.0,
c - k / 2.0,
]
}
PivotKind::Demark => {
let x = if c < o {
h + 2.0 * l + c
} else if c > o {
2.0 * h + l + c
} else {
h + l + 2.0 * c
};
vec![x / 4.0, x / 2.0 - l, x / 2.0 - h]
}
}
}
}
/// Pivot levels for each bar from the previous session's prices (`session_of` maps a
/// timestamp to its session); the first session has none.
pub fn pivots(o: &Ohlcv, session_of: impl Fn(i64) -> i64, kind: PivotKind) -> Vec<(&'static str, Vec<f64>)> {
let names = kind.names();
let len = o.len();
let mut out = vec![vec![NAN; len]; names.len()];
let keys: Vec<i64> = o.ts.iter().map(|&t| session_of(t)).collect();
let mut cur: Option<Session> = None;
let mut levels: Option<Vec<f64>> = None;
for i in 0..len {
let bar = Session { open: o.open[i], high: o.high[i], low: o.low[i], close: o.close[i] };
match cur.as_mut() {
Some(s) if keys[i] == keys[i - 1] => {
s.high = s.high.max(bar.high);
s.low = s.low.min(bar.low);
s.close = bar.close;
}
prev => {
levels = prev.map(|p| kind.levels(*p, bar.open));
cur = Some(bar);
}
}
if let Some(l) = &levels {
for (series, v) in out.iter_mut().zip(l) {
series[i] = *v;
}
}
}
names.iter().copied().zip(out).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Bar;
#[test]
fn session_keys() {
let day = 86_400;
let k = session_key(Timeframe::M5);
assert_eq!(k(10), k(day - 1));
assert_ne!(k(day - 1), k(day));
// 2026-01-31 and 2026-02-01 fall in different months on daily bars.
let m = session_key(Timeframe::D1);
assert_ne!(m(1_769_817_600), m(1_769_904_000));
assert_eq!(session_key(Timeframe::W1)(1_769_817_600), 2026);
}
#[test]
fn classic_pivots_from_the_previous_session() {
let day = 86_400;
let bars = [
Bar::new(0, 10.0, 12.0, 8.0, 11.0, 1.0),
Bar::new(60, 11.0, 15.0, 9.0, 13.0, 1.0),
Bar::new(day, 13.0, 14.0, 12.0, 13.5, 1.0),
Bar::new(day + 60, 13.5, 14.5, 12.5, 14.0, 1.0),
Bar::new(2 * day, 14.0, 15.0, 13.0, 14.0, 1.0),
];
let o = Ohlcv::new(&bars);
let p = pivots(&o, |ts| ts.div_euclid(day), PivotKind::Classic);
let names: Vec<&str> = p.iter().map(|(n, _)| *n).collect();
assert_eq!(names, ["P", "R1", "S1", "R2", "S2", "R3", "S3"]);
// Day 1: H 15, L 8, C 13 → P = 12, R1 = 16, S1 = 9.
let (pp, r1, s1) = (&p[0].1, &p[1].1, &p[2].1);
assert!(pp[0].is_nan() && pp[1].is_nan());
assert_eq!((pp[2], r1[2], s1[2]), (12.0, 16.0, 9.0));
// Every bar of day 2 carries its levels; day 3 uses H 14.5, L 12, C 14.
assert_eq!((pp[3], r1[3], s1[3]), (12.0, 16.0, 9.0));
assert!((pp[4] - 40.5 / 3.0).abs() < 1e-12);
let dm = pivots(&o, |ts| ts.div_euclid(day), PivotKind::Demark);
// Day 1 closed above its open: X = 2H + L + C = 51.
assert_eq!((dm[0].1[2], dm[1].1[2], dm[2].1[2]), (12.75, 17.5, 10.5));
let w = pivots(&o, |ts| ts.div_euclid(day), PivotKind::Woodie);
assert_eq!(w[0].1[2], (15.0 + 8.0 + 2.0 * 13.0) / 4.0);
assert_eq!(pivots(&o, |ts| ts.div_euclid(day), PivotKind::Camarilla).len(), 9);
}
}
+491
View File
@@ -0,0 +1,491 @@
//! Moving averages and adaptive filters, including John Ehlers' DSP smoothers.
use std::f64::consts::PI;
use super::stats::{change, highest, lowest, sum, windows};
use super::{NAN, Ohlcv, from_first, ratio, zip};
/// Simple moving average over `n` values.
pub fn sma(values: &[f64], n: usize) -> Vec<f64> {
let k = n as f64;
sum(values, n).into_iter().map(|s| s / k).collect()
}
/// Exponential smoothing with factor `alpha`, seeded with the SMA of the first `n` finite
/// values. A non-finite input yields NaN at that bar and leaves the state untouched.
pub(super) fn smooth(values: &[f64], n: usize, alpha: f64) -> Vec<f64> {
let mut out = vec![NAN; values.len()];
if n == 0 {
return out;
}
let (mut seen, mut acc) = (0usize, 0.0);
for (i, &v) in values.iter().enumerate() {
if !v.is_finite() {
continue;
}
if seen < n {
acc += v;
seen += 1;
if seen < n {
continue;
}
acc /= n as f64;
} else {
acc = alpha * v + (1.0 - alpha) * acc;
}
out[i] = acc;
}
out
}
/// Exponential moving average, alpha = 2/(n+1).
pub fn ema(values: &[f64], n: usize) -> Vec<f64> {
smooth(values, n, 2.0 / (n as f64 + 1.0))
}
/// Wilder's moving average (RMA, SMMA), alpha = 1/n.
pub fn rma(values: &[f64], n: usize) -> Vec<f64> {
smooth(values, n, 1.0 / n as f64)
}
/// Linearly weighted moving average (newest weight n, oldest 1), O(1) per bar.
pub fn wma(values: &[f64], n: usize) -> Vec<f64> {
let mut out = vec![NAN; values.len()];
let denom = (n * (n + 1)) as f64 / 2.0;
windows(values, n, |i, r, _, w, _| out[i] = w / denom + r);
out
}
/// Hull moving average: WMA(2·WMA(n/2) − WMA(n), √n).
pub fn hma(values: &[f64], n: usize) -> Vec<f64> {
if n < 2 {
return wma(values, n);
}
let diff = zip(&wma(values, n / 2), &wma(values, n), |a, b| 2.0 * a - b);
wma(&diff, ((n as f64).sqrt() as usize).max(1))
}
/// Double EMA: 2·EMA − EMA(EMA).
pub fn dema(values: &[f64], n: usize) -> Vec<f64> {
let e1 = ema(values, n);
let e2 = ema(&e1, n);
zip(&e1, &e2, |a, b| 2.0 * a - b)
}
/// Triple EMA: 3·E1 − 3·E2 + E3.
pub fn tema(values: &[f64], n: usize) -> Vec<f64> {
let e1 = ema(values, n);
let e2 = ema(&e1, n);
let e3 = ema(&e2, n);
(0..values.len()).map(|i| 3.0 * e1[i] - 3.0 * e2[i] + e3[i]).collect()
}
/// Triangular moving average: an SMA of an SMA spanning `n` bars in total.
pub fn trima(values: &[f64], n: usize) -> Vec<f64> {
if n == 0 {
return vec![NAN; values.len()];
}
let (a, b) = if n % 2 == 1 { (n.div_ceil(2), n.div_ceil(2)) } else { (n / 2, n / 2 + 1) };
sma(&sma(values, a), b)
}
/// Kaufman adaptive moving average: the smoothing constant moves between the `fast` and
/// `slow` EMA constants with the efficiency ratio over `n` bars.
pub fn kama(values: &[f64], n: usize, fast: usize, slow: usize) -> Vec<f64> {
from_first(values, |v| {
let mut out = vec![NAN; v.len()];
if n == 0 || v.len() < n {
return out;
}
let (fsc, ssc) = (2.0 / (fast as f64 + 1.0), 2.0 / (slow as f64 + 1.0));
let mut k = v[n - 1];
out[n - 1] = k;
let mut vol: f64 = (1..n.min(v.len())).map(|j| (v[j] - v[j - 1]).abs()).sum();
for i in n..v.len() {
vol += (v[i] - v[i - 1]).abs();
if i > n {
vol -= (v[i - n] - v[i - n - 1]).abs();
}
let er = ratio((v[i] - v[i - n]).abs(), vol, 0.0).clamp(0.0, 1.0);
let sc = (er * (fsc - ssc) + ssc).powi(2);
k += sc * (v[i] - k);
out[i] = k;
}
out
})
}
/// Zero-lag EMA: an EMA of `2·x − x[lag]` with lag (n−1)/2.
pub fn zlema(values: &[f64], n: usize) -> Vec<f64> {
let lag = n.saturating_sub(1) / 2;
let adj: Vec<f64> =
(0..values.len()).map(|i| if i >= lag { 2.0 * values[i] - values[i - lag] } else { NAN }).collect();
ema(&adj, n)
}
/// Arnaud Legoux moving average: Gaussian weights centred at `offset` (0..1) across the
/// window, width n/`sigma`.
pub fn alma(values: &[f64], n: usize, offset: f64, sigma: f64) -> Vec<f64> {
let mut out = vec![NAN; values.len()];
if n == 0 || sigma <= 0.0 {
return out;
}
let m = offset * (n - 1) as f64;
let s = n as f64 / sigma;
let w: Vec<f64> = (0..n).map(|j| (-(j as f64 - m).powi(2) / (2.0 * s * s)).exp()).collect();
let norm: f64 = w.iter().sum();
for i in n - 1..values.len() {
let win = &values[i + 1 - n..=i];
if win.iter().all(|x| x.is_finite()) {
out[i] = win.iter().zip(&w).map(|(x, w)| x * w).sum::<f64>() / norm;
}
}
out
}
/// Volume-weighted moving average (falls back to the SMA where the window has no volume).
pub fn vwma(values: &[f64], volume: &[f64], n: usize) -> Vec<f64> {
let pv = zip(values, volume, |c, v| c * v);
let (spv, sv, sc) = (sum(&pv, n), sum(volume, n), sma(values, n));
(0..values.len()).map(|i| if sv[i] > 0.0 { spv[i] / sv[i] } else { sc[i] }).collect()
}
/// Elastic volume-weighted moving average: each bar moves the average by its share of the
/// last `n` bars' volume.
pub fn evwma(values: &[f64], volume: &[f64], n: usize) -> Vec<f64> {
let sv = sum(volume, n);
let mut out = vec![NAN; values.len()];
let mut prev = NAN;
for i in 0..values.len() {
if !sv[i].is_finite() || !values[i].is_finite() {
continue;
}
prev = if prev.is_nan() {
values[i]
} else if sv[i] > 0.0 {
((sv[i] - volume[i]) * prev + volume[i] * values[i]) / sv[i]
} else {
prev
};
out[i] = prev;
}
out
}
/// Tillson T3: six chained EMAs combined with volume factor `v`.
pub fn t3(values: &[f64], n: usize, v: f64) -> Vec<f64> {
let e1 = ema(values, n);
let e2 = ema(&e1, n);
let e3 = ema(&e2, n);
let e4 = ema(&e3, n);
let e5 = ema(&e4, n);
let e6 = ema(&e5, n);
let (v2, v3) = (v * v, v * v * v);
let (c1, c2, c3, c4) = (-v3, 3.0 * v2 + 3.0 * v3, -6.0 * v2 - 3.0 * v - 3.0 * v3, 1.0 + 3.0 * v + v3 + 3.0 * v2);
(0..values.len()).map(|i| c1 * e6[i] + c2 * e5[i] + c3 * e4[i] + c4 * e3[i]).collect()
}
/// McGinley Dynamic: an EMA-seeded average whose speed adapts to (price/average)^4. The
/// step is capped at the full distance so it never overshoots.
pub fn mcginley(values: &[f64], n: usize) -> Vec<f64> {
let seed = ema(values, n);
let mut out = vec![NAN; values.len()];
let mut md = NAN;
for i in 0..values.len() {
let x = values[i];
if md.is_nan() {
md = seed[i];
} else if x.is_finite() {
let d = n as f64 * (x / md).powi(4);
md = if md != 0.0 && d.is_finite() { md + (x - md) / d.max(1.0) } else { x };
}
out[i] = md;
}
out
}
/// Chande's variable index dynamic average: an EMA whose factor scales with |CMO(`cmo_n`)|.
pub fn vidya(values: &[f64], n: usize, cmo_n: usize) -> Vec<f64> {
let ch = change(values, 1);
let up: Vec<f64> = ch.iter().map(|c| if c.is_nan() { NAN } else { c.max(0.0) }).collect();
let dn: Vec<f64> = ch.iter().map(|c| if c.is_nan() { NAN } else { (-c).max(0.0) }).collect();
let (su, sd) = (sum(&up, cmo_n), sum(&dn, cmo_n));
let alpha = 2.0 / (n as f64 + 1.0);
let mut out = vec![NAN; values.len()];
let mut v = NAN;
for i in 0..values.len() {
if !su[i].is_finite() || !sd[i].is_finite() || !values[i].is_finite() {
continue;
}
let k = alpha * ratio(su[i] - sd[i], su[i] + sd[i], 0.0).abs();
v = if v.is_nan() { values[i] } else { k * values[i] + (1.0 - k) * v };
out[i] = v;
}
out
}
/// Ehlers' fractal adaptive moving average of the median price over `n` (even) bars.
pub fn frama(d: &Ohlcv, n: usize) -> Vec<f64> {
let n = n.max(2).next_multiple_of(2);
let h = n / 2;
let price = d.hl2();
let (hh1, ll1) = (highest(&d.high, h), lowest(&d.low, h));
let (hh, ll) = (highest(&d.high, n), lowest(&d.low, n));
let mut out = vec![NAN; d.len()];
let (mut dim, mut filt) = (1.0, NAN);
for i in n - 1..d.len() {
let n1 = (hh1[i] - ll1[i]) / h as f64;
let n2 = (hh1[i - h] - ll1[i - h]) / h as f64;
let n3 = (hh[i] - ll[i]) / n as f64;
if !(n1.is_finite() && n2.is_finite() && n3.is_finite() && price[i].is_finite()) {
continue;
}
if n1 > 0.0 && n2 > 0.0 && n3 > 0.0 {
dim = ((n1 + n2).ln() - n3.ln()) / 2f64.ln();
}
let alpha = (-4.6 * (dim - 1.0)).exp().clamp(0.01, 1.0);
filt = if filt.is_nan() { price[i] } else { alpha * price[i] + (1.0 - alpha) * filt };
out[i] = filt;
}
out
}
/// Ehlers' two-pole super smoother with critical period `n`.
pub fn super_smoother(values: &[f64], n: usize) -> Vec<f64> {
if n == 0 {
return vec![NAN; values.len()];
}
from_first(values, |v| {
let a1 = (-1.414 * PI / n as f64).exp();
let c2 = 2.0 * a1 * (1.414 * PI / n as f64).cos();
let c3 = -a1 * a1;
let c1 = 1.0 - c2 - c3;
let mut out = v.to_vec();
for i in 2..v.len() {
out[i] = c1 * (v[i] + v[i - 1]) / 2.0 + c2 * out[i - 1] + c3 * out[i - 2];
}
out
})
}
/// Ehlers' simple decycler: the series minus its two-pole high-pass component, which
/// removes cycles shorter than `n` bars with no lag. Cutoffs under 3 bars put the
/// high-pass pole outside the unit circle, so they are blank.
pub fn decycler(values: &[f64], n: usize) -> Vec<f64> {
if n < 3 {
return vec![NAN; values.len()];
}
from_first(values, |v| {
let w = 0.707 * 2.0 * PI / n as f64;
let a = (w.cos() + w.sin() - 1.0) / w.cos();
let (k1, k2, k3) = ((1.0 - a / 2.0).powi(2), 2.0 * (1.0 - a), (1.0 - a).powi(2));
let mut hp = vec![0.0; v.len()];
for i in 2..v.len() {
hp[i] = k1 * (v[i] - 2.0 * v[i - 1] + v[i - 2]) + k2 * hp[i - 1] - k3 * hp[i - 2];
}
zip(v, &hp, |x, h| x - h)
})
}
fn at(v: &[f64], i: usize, k: usize) -> f64 {
if i >= k { v[i - k] } else { 0.0 }
}
/// MESA adaptive moving average and its following average.
#[derive(Debug, Clone)]
pub struct Mama {
pub mama: Vec<f64>,
pub fama: Vec<f64>,
}
/// Ehlers' MESA adaptive moving average: a Hilbert-transform phase rate sets the EMA
/// factor between `slow` and `fast` limits.
pub fn mama(values: &[f64], fast: f64, slow: f64) -> Mama {
let len = values.len();
let mut out = Mama { mama: vec![NAN; len], fama: vec![NAN; len] };
let Some(start) = values.iter().position(|v| v.is_finite()) else { return out };
let p = &values[start..];
let m = p.len();
let [mut smooth, mut detr, mut i1, mut q1, mut i2, mut q2, mut re, mut im, mut period, mut phase] =
std::array::from_fn(|_| vec![0.0; m]);
let (mut ma, mut fa) = (p[0], p[0]);
for i in 0..m {
let pr = |k: usize| if i >= k { p[i - k] } else { p[0] };
smooth[i] = (4.0 * pr(0) + 3.0 * pr(1) + 2.0 * pr(2) + pr(3)) / 10.0;
let adj = 0.075 * at(&period, i, 1) + 0.54;
let hilbert =
|v: &[f64]| (0.0962 * v[i] + 0.5769 * at(v, i, 2) - 0.5769 * at(v, i, 4) - 0.0962 * at(v, i, 6)) * adj;
detr[i] = hilbert(&smooth);
q1[i] = hilbert(&detr);
i1[i] = at(&detr, i, 3);
let (ji, jq) = (hilbert(&i1), hilbert(&q1));
i2[i] = 0.2 * (i1[i] - jq) + 0.8 * at(&i2, i, 1);
q2[i] = 0.2 * (q1[i] + ji) + 0.8 * at(&q2, i, 1);
let r = i2[i] * at(&i2, i, 1) + q2[i] * at(&q2, i, 1);
let j = i2[i] * at(&q2, i, 1) - q2[i] * at(&i2, i, 1);
re[i] = 0.2 * r + 0.8 * at(&re, i, 1);
im[i] = 0.2 * j + 0.8 * at(&im, i, 1);
let prev = at(&period, i, 1);
let mut per = prev;
if im[i] != 0.0 && re[i] != 0.0 {
per = 2.0 * PI / (im[i] / re[i]).atan();
}
per = per.min(1.5 * prev).max(0.67 * prev).clamp(6.0, 50.0);
period[i] = 0.2 * per + 0.8 * prev;
phase[i] = if i1[i] != 0.0 { (q1[i] / i1[i]).atan().to_degrees() } else { at(&phase, i, 1) };
let delta = (at(&phase, i, 1) - phase[i]).max(1.0);
let alpha = (fast / delta).max(slow);
ma = alpha * p[i] + (1.0 - alpha) * ma;
fa = 0.5 * alpha * ma + (1.0 - 0.5 * alpha) * fa;
if i >= 6 {
out.mama[start + i] = ma;
out.fama[start + i] = fa;
}
}
out
}
/// Ehlers' instantaneous trendline and its trigger (2·IT − IT[2]).
pub fn itrend(values: &[f64], alpha: f64) -> (Vec<f64>, Vec<f64>) {
let it = from_first(values, |p| {
let mut it = vec![0.0; p.len()];
let a = alpha;
for i in 0..p.len() {
let pr = |k: usize| if i >= k { p[i - k] } else { p[0] };
it[i] = if i < 7 {
(pr(0) + 2.0 * pr(1) + pr(2)) / 4.0
} else {
(a - a * a / 4.0) * p[i] + 0.5 * a * a * p[i - 1] - (a - 0.75 * a * a) * p[i - 2]
+ 2.0 * (1.0 - a) * it[i - 1]
- (1.0 - a).powi(2) * it[i - 2]
};
}
it
});
let trigger = (0..it.len()).map(|i| if i >= 2 { 2.0 * it[i] - it[i - 2] } else { NAN }).collect();
(it, trigger)
}
/// Symmetrically weighted moving average over 4 bars (weights 1, 2, 2, 1).
pub(super) fn swma(values: &[f64]) -> Vec<f64> {
(0..values.len())
.map(
|i| {
if i < 3 { NAN } else { (values[i] + 2.0 * values[i - 1] + 2.0 * values[i - 2] + values[i - 3]) / 6.0 }
},
)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9 * (1.0 + b.abs())
}
fn wave(n: usize) -> Vec<f64> {
(0..n)
.map(|i| 100.0 + (i as f64 * 0.21).sin() * 8.0 + (i as f64 * 0.05).cos() * 3.0 + i as f64 * 0.04)
.collect()
}
#[test]
fn wma_hand_computed_and_rolling() {
// (1·1 + 2·2 + 3·3) / 6 = 14/6; (2 + 6 + 12) / 6 = 20/6.
let w = wma(&[1.0, 2.0, 3.0, 4.0], 3);
assert!(w[1].is_nan() && near(w[2], 14.0 / 6.0) && near(w[3], 20.0 / 6.0));
let xs = wave(300);
let got = wma(&xs, 9);
for i in 8..300 {
let want = (0..9).map(|k| (k + 1) as f64 * xs[i - 8 + k]).sum::<f64>() / 45.0;
assert!(near(got[i], want), "{i}");
}
}
#[test]
fn compositions_follow_their_definitions() {
let xs = wave(400);
let (e1, e2) = (ema(&xs, 10), ema(&ema(&xs, 10), 10));
let d = dema(&xs, 10);
assert!(near(d[300], 2.0 * e1[300] - e2[300]));
let h = hma(&xs, 16);
let diff = zip(&wma(&xs, 8), &wma(&xs, 16), |a, b| 2.0 * a - b);
assert!(near(h[300], wma(&diff, 4)[300]));
// TRIMA spans n bars: its first value is at n - 1.
let t = trima(&xs, 10);
assert!(t[8].is_nan() && t[9].is_finite());
let t = trima(&xs, 9);
assert!(t[7].is_nan() && t[8].is_finite());
// T3 coefficients sum to 1, so a constant stays constant.
assert!(near(t3(&[5.0; 200], 5, 0.7)[199], 5.0));
}
#[test]
fn adaptive_averages_track_a_constant_and_a_trend() {
let flat = vec![42.0; 300];
for v in [
kama(&flat, 10, 2, 30),
zlema(&flat, 20),
alma(&flat, 9, 0.85, 6.0),
mcginley(&flat, 14),
vidya(&flat, 20, 9),
super_smoother(&flat, 10),
decycler(&flat, 60),
mama(&flat, 0.5, 0.05).mama,
itrend(&flat, 0.07).0,
] {
assert!(near(v[299], 42.0), "{}", v[299]);
}
// With a perfectly efficient trend KAMA uses the fast constant: k += (2/3)^2 (x - k).
let line: Vec<f64> = (0..50).map(|i| i as f64).collect();
let k = kama(&line, 10, 2, 30);
let fsc: f64 = (2.0f64 / 3.0).powi(2);
assert!(near(k[10], 9.0 + fsc * (10.0 - 9.0)));
// ALMA weights sum to 1 and lean to the recent end with offset 0.85.
let a = alma(&line, 9, 0.85, 6.0);
assert!(a[20] > sma(&line, 9)[20] && a[20] < 20.0);
}
#[test]
fn volume_weighting() {
let c = [10.0, 20.0, 30.0];
let v = [1.0, 1.0, 2.0];
assert!(near(vwma(&c, &v, 3)[2], (10.0 + 20.0 + 60.0) / 4.0));
assert!(near(vwma(&c, &[0.0; 3], 3)[2], 20.0), "no volume falls back to the SMA");
let e = evwma(&c, &v, 2);
assert!(e[0].is_nan() && near(e[1], 20.0));
assert!(near(e[2], ((3.0 - 2.0) * 20.0 + 2.0 * 30.0) / 3.0));
}
#[test]
fn frama_moves_between_bounds() {
let bars: Vec<crate::Bar> = wave(200)
.into_iter()
.enumerate()
.map(|(i, c)| crate::Bar::new(i as i64, c, c + 1.0, c - 1.0, c, 1.0))
.collect();
let f = frama(&Ohlcv::new(&bars), 16);
assert!(f[14].is_nan() && f[15].is_finite());
// A running blend of prices never leaves the range seen so far.
let price: Vec<f64> = bars.iter().map(|b| (b.high + b.low) / 2.0).collect();
for i in 15..f.len() {
let seen = &price[15..=i];
let (lo, hi) = seen.iter().fold((f64::MAX, f64::MIN), |(l, h), v| (l.min(*v), h.max(*v)));
assert!(f[i] >= lo - 1e-9 && f[i] <= hi + 1e-9, "frama[{i}] = {} outside {lo}..{hi}", f[i]);
}
}
#[test]
fn decycler_is_stable_from_its_shortest_cutoff() {
let xs: Vec<f64> = (0..2000).map(|i| 100.0 + (i as f64 * 1.7).sin()).collect();
for n in 3..=10 {
let d = decycler(&xs, n);
assert!(d.iter().all(|v| (90.0..110.0).contains(v)), "cutoff {n} runs away");
}
for n in 0..3 {
assert!(decycler(&xs, n).iter().all(|v| v.is_nan()), "cutoff {n} is unstable");
}
}
}
+209
View File
@@ -0,0 +1,209 @@
//! Technical indicators. Every function returns a vector aligned with its input; positions
//! without enough history hold `f64::NAN`. Inputs may themselves start with NaN (the output
//! of another indicator): smoothing starts at the first defined value, and a gap later on
//! blanks only the windows that contain it.
//!
//! Conventions follow the common charting definitions: EMA uses alpha = 2/(n+1) seeded with
//! an SMA, RSI, ATR and ADX use Wilder smoothing, standard deviations are population ones,
//! and VWAP and pivot levels reset each session (a day intraday, a month on daily bars, a
//! year above that).
//!
//! The functions are grouped by family; [`registry`] lists every indicator with its
//! parameters, placement and outputs, and is what the app's command line, picker and chart
//! are driven from.
use crate::Bar;
mod levels;
mod ma;
mod momentum;
pub mod registry;
mod stats;
mod trend;
mod volatility;
mod volume;
pub use levels::*;
pub use ma::*;
pub use momentum::*;
pub use stats::*;
pub use trend::*;
pub use volatility::*;
pub use volume::*;
const NAN: f64 = f64::NAN;
/// Bars split into columns: the input of the bar-based indicators.
#[derive(Debug, Clone, Default)]
pub struct Ohlcv {
pub ts: Vec<i64>,
pub open: Vec<f64>,
pub high: Vec<f64>,
pub low: Vec<f64>,
pub close: Vec<f64>,
pub volume: Vec<f64>,
}
impl Ohlcv {
pub fn new(bars: &[Bar]) -> Self {
Self {
ts: bars.iter().map(|b| b.ts).collect(),
open: bars.iter().map(|b| b.open).collect(),
high: bars.iter().map(|b| b.high).collect(),
low: bars.iter().map(|b| b.low).collect(),
close: bars.iter().map(|b| b.close).collect(),
volume: bars.iter().map(|b| b.volume).collect(),
}
}
pub fn len(&self) -> usize {
self.close.len()
}
pub fn is_empty(&self) -> bool {
self.close.is_empty()
}
/// Median price, (high + low) / 2.
pub fn hl2(&self) -> Vec<f64> {
zip(&self.high, &self.low, |h, l| (h + l) / 2.0)
}
/// Typical price, (high + low + close) / 3.
pub fn hlc3(&self) -> Vec<f64> {
(0..self.len()).map(|i| (self.high[i] + self.low[i] + self.close[i]) / 3.0).collect()
}
/// Average price, (open + high + low + close) / 4.
pub fn ohlc4(&self) -> Vec<f64> {
(0..self.len()).map(|i| (self.open[i] + self.high[i] + self.low[i] + self.close[i]) / 4.0).collect()
}
/// Weighted close, (high + low + 2 × close) / 4.
pub fn hlcc4(&self) -> Vec<f64> {
(0..self.len()).map(|i| (self.high[i] + self.low[i] + 2.0 * self.close[i]) / 4.0).collect()
}
}
/// Run `f` on `values` from the first finite one on; leading NaNs stay NaN.
fn from_first(values: &[f64], f: impl FnOnce(&[f64]) -> Vec<f64>) -> Vec<f64> {
let start = values.iter().position(|v| v.is_finite()).unwrap_or(values.len());
let mut out = vec![NAN; start];
out.extend(f(&values[start..]));
debug_assert_eq!(out.len(), values.len());
out
}
fn zip(a: &[f64], b: &[f64], f: impl Fn(f64, f64) -> f64) -> Vec<f64> {
a.iter().zip(b).map(|(x, y)| f(*x, *y)).collect()
}
/// `a / b`, or `zero` when `b` is 0.
fn ratio(a: f64, b: f64, zero: f64) -> f64 {
if b == 0.0 { zero } else { a / b }
}
/// `values[i - k]`, NaN before the start.
fn ago(values: &[f64], i: usize, k: usize) -> f64 {
if i >= k { values[i - k] } else { NAN }
}
#[cfg(test)]
mod tests {
use super::*;
fn close_to(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
fn assert_series(got: &[f64], want: &[f64]) {
assert_eq!(got.len(), want.len());
for (i, (g, w)) in got.iter().zip(want).enumerate() {
assert!((g.is_nan() && w.is_nan()) || close_to(*g, *w), "index {i}: got {g}, want {w}");
}
}
const N: f64 = f64::NAN;
#[test]
fn sma_basic() {
assert_series(&sma(&[1.0, 2.0, 3.0, 4.0, 5.0], 3), &[N, N, 2.0, 3.0, 4.0]);
assert!(sma(&[1.0], 3)[0].is_nan());
assert!(sma(&[1.0, 2.0], 0).iter().all(|v| v.is_nan()));
}
#[test]
fn ema_seeded_with_sma() {
// alpha = 0.5; seed = mean(1,2,3) = 2; then 0.5*4+0.5*2 = 3; 0.5*5+0.5*3 = 4.
assert_series(&ema(&[1.0, 2.0, 3.0, 4.0, 5.0], 3), &[N, N, 2.0, 3.0, 4.0]);
// Constant input stays constant.
assert!(close_to(ema(&[7.0; 50], 10)[49], 7.0));
// Leading NaNs (another indicator's warm-up) delay the seed instead of poisoning it.
assert_series(&ema(&[N, N, 1.0, 2.0, 3.0, 4.0], 3), &[N, N, N, N, 2.0, 3.0]);
}
#[test]
fn rsi_reference_values() {
// Wilder's original 14-period example (New Concepts in Technical Trading Systems).
let closes = [
44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08, 45.89, 46.03, 45.61, 46.28, 46.28,
46.00, 46.03, 46.41, 46.22, 45.64,
];
let r = rsi(&closes, 14);
assert!(r[13].is_nan());
assert!((r[14] - 70.46).abs() < 0.01, "rsi[14] = {}", r[14]);
assert!((r[15] - 66.25).abs() < 0.01, "rsi[15] = {}", r[15]);
assert!((r[19] - 57.92).abs() < 0.05, "rsi[19] = {}", r[19]);
}
#[test]
fn rsi_extremes() {
let up: Vec<f64> = (0..30).map(|i| i as f64).collect();
assert_eq!(rsi(&up, 14)[29], 100.0);
assert_eq!(rsi(&[5.0; 30], 14)[29], 50.0);
}
#[test]
fn macd_matches_manual_emas() {
let closes: Vec<f64> = (0..60).map(|i| 100.0 + (i as f64 * 0.3).sin() * 5.0 + i as f64 * 0.1).collect();
let m = macd(&closes, 12, 26, 9);
let f = ema(&closes, 12);
let s = ema(&closes, 26);
assert!(m.macd[24].is_nan());
assert!(close_to(m.macd[25], f[25] - s[25]));
// Signal needs 9 MACD values: first at 25 + 8.
assert!(m.signal[32].is_nan());
let seed: f64 = m.macd[25..34].iter().sum::<f64>() / 9.0;
assert!(close_to(m.signal[33], seed));
assert!(close_to(m.histogram[40], m.macd[40] - m.signal[40]));
}
#[test]
fn bollinger_population_sd() {
let b = bollinger(&[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0], 8, 2.0);
// mean 5, population sd 2.
assert!(close_to(b.middle[7], 5.0));
assert!(close_to(b.upper[7], 9.0));
assert!(close_to(b.lower[7], 1.0));
assert!(b.upper[6].is_nan());
// Rolling window stays numerically consistent with a fresh computation.
let xs: Vec<f64> = (0..200).map(|i| ((i * 37) % 101) as f64).collect();
let roll = bollinger(&xs, 20, 2.0);
let fresh = bollinger(&xs[180..], 20, 2.0);
assert!(close_to(roll.upper[199], fresh.upper[19]));
}
#[test]
fn vwap_resets_per_session() {
let day = 86_400;
let bars = [
Bar::new(0, 0.0, 12.0, 8.0, 10.0, 100.0),
Bar::new(60, 0.0, 22.0, 18.0, 20.0, 300.0),
Bar::new(day, 0.0, 6.0, 4.0, 5.0, 10.0),
];
let v = vwap(&bars);
assert!(close_to(v[0], 10.0));
assert!(close_to(v[1], (10.0 * 100.0 + 20.0 * 300.0) / 400.0));
assert!(close_to(v[2], 5.0));
}
}
+655
View File
@@ -0,0 +1,655 @@
//! Oscillators and momentum: RSI and its variants, stochastics, MACD-style differences,
//! rate of change, and the cycle oscillators.
use super::ma::{ema, rma, sma, smooth, swma, wma};
use super::stats::{change, highest, linreg, lowest, percent_rank, sum};
use super::volatility::true_range;
use super::{NAN, Ohlcv, ago, from_first, ratio, zip};
/// Relative Strength Index with Wilder smoothing (first value at index `n`).
/// Single pass with no intermediate buffers: on large series the cost is dominated by
/// first-touch of output memory, so only the output is allocated.
pub fn rsi(closes: &[f64], n: usize) -> Vec<f64> {
from_first(closes, |closes| {
let mut out = vec![NAN; closes.len()];
if n == 0 || closes.len() <= n {
return out;
}
let value = |g: f64, l: f64| {
if l == 0.0 { if g == 0.0 { 50.0 } else { 100.0 } } else { 100.0 - 100.0 / (1.0 + g / l) }
};
let (mut ag, mut al) = (0.0, 0.0);
for i in 1..=n {
let d = closes[i] - closes[i - 1];
ag += d.max(0.0);
al += (-d).max(0.0);
}
ag /= n as f64;
al /= n as f64;
out[n] = value(ag, al);
let alpha = 1.0 / n as f64;
for i in (n + 1)..closes.len() {
let d = closes[i] - closes[i - 1];
ag = alpha * d.max(0.0) + (1.0 - alpha) * ag;
al = alpha * (-d).max(0.0) + (1.0 - alpha) * al;
out[i] = value(ag, al);
}
out
})
}
#[derive(Debug, Clone)]
pub struct Macd {
pub macd: Vec<f64>,
pub signal: Vec<f64>,
pub histogram: Vec<f64>,
}
/// Streaming EMA seeded with the SMA of its first `n` inputs.
struct Ema {
n: usize,
alpha: f64,
seen: usize,
acc: f64,
}
impl Ema {
fn new(n: usize) -> Self {
Self { n, alpha: 2.0 / (n as f64 + 1.0), seen: 0, acc: 0.0 }
}
/// Feed one value; returns the EMA once `n` values have been seen.
fn push(&mut self, v: f64) -> Option<f64> {
self.seen += 1;
if self.seen < self.n {
self.acc += v;
None
} else if self.seen == self.n {
self.acc = (self.acc + v) / self.n as f64;
Some(self.acc)
} else {
self.acc = self.alpha * v + (1.0 - self.alpha) * self.acc;
Some(self.acc)
}
}
}
/// MACD line (fast EMA - slow EMA), its signal EMA and histogram, in one pass.
pub fn macd(closes: &[f64], fast: usize, slow: usize, signal: usize) -> Macd {
let len = closes.len();
let mut line = vec![f64::NAN; len];
let mut sig = vec![f64::NAN; len];
let mut histogram = vec![f64::NAN; len];
if fast == 0 || slow == 0 || signal == 0 {
return Macd { macd: line, signal: sig, histogram };
}
let (mut ef, mut es, mut eg) = (Ema::new(fast), Ema::new(slow), Ema::new(signal));
for (i, &c) in closes.iter().enumerate() {
let (f, s) = (ef.push(c), es.push(c));
let (Some(f), Some(s)) = (f, s) else { continue };
let m = f - s;
line[i] = m;
// The signal EMA starts once the MACD line itself is defined.
if let Some(g) = eg.push(m) {
sig[i] = g;
histogram[i] = m - g;
}
}
Macd { macd: line, signal: sig, histogram }
}
/// A line with an EMA signal and their difference.
fn with_signal(line: Vec<f64>, n: usize) -> Macd {
let signal = ema(&line, n);
let histogram = zip(&line, &signal, |a, b| a - b);
Macd { macd: line, signal, histogram }
}
/// Percentage price oscillator: (fast EMA − slow EMA) / slow EMA × 100, with signal.
pub fn ppo(values: &[f64], fast: usize, slow: usize, signal: usize) -> Macd {
let line = zip(&ema(values, fast), &ema(values, slow), |f, s| ratio(f - s, s, 0.0) * 100.0);
with_signal(line, signal)
}
/// Absolute price oscillator: fast EMA − slow EMA.
pub fn apo(values: &[f64], fast: usize, slow: usize) -> Vec<f64> {
zip(&ema(values, fast), &ema(values, slow), |f, s| f - s)
}
/// Rate of change in percent over `n` bars.
pub fn roc(values: &[f64], n: usize) -> Vec<f64> {
(0..values.len())
.map(|i| if i >= n { ratio(values[i] - values[i - n], values[i - n], NAN) * 100.0 } else { NAN })
.collect()
}
/// Momentum: the change over `n` bars.
pub fn momentum(values: &[f64], n: usize) -> Vec<f64> {
change(values, n)
}
/// %K and %D lines of a stochastic.
#[derive(Debug, Clone)]
pub struct Stoch {
pub k: Vec<f64>,
pub d: Vec<f64>,
}
/// Raw stochastic: where `close` sits in the `n`-bar high/low range, 0..100 (50 when flat).
fn stoch_raw(high: &[f64], low: &[f64], close: &[f64], n: usize) -> Vec<f64> {
let (hh, ll) = (highest(high, n), lowest(low, n));
(0..close.len()).map(|i| 100.0 * ratio(close[i] - ll[i], hh[i] - ll[i], 0.5)).collect()
}
/// Stochastic oscillator: %K = SMA(raw, `smooth_k`), %D = SMA(%K, `d`).
pub fn stochastic(o: &Ohlcv, n: usize, smooth_k: usize, d: usize) -> Stoch {
let k = sma(&stoch_raw(&o.high, &o.low, &o.close, n), smooth_k);
let d = sma(&k, d);
Stoch { k, d }
}
/// Stochastic RSI: a stochastic of RSI(`rsi_n`) over `stoch_n` bars.
pub fn stoch_rsi(values: &[f64], rsi_n: usize, stoch_n: usize, smooth_k: usize, d: usize) -> Stoch {
let r = rsi(values, rsi_n);
let k = sma(&stoch_raw(&r, &r, &r, stoch_n), smooth_k);
let d = sma(&k, d);
Stoch { k, d }
}
/// Commodity channel index of `values` (usually the typical price) over `n` bars.
pub fn cci(values: &[f64], n: usize) -> Vec<f64> {
let m = sma(values, n);
(0..values.len())
.map(|i| {
if m[i].is_nan() {
return NAN;
}
let md = values[i + 1 - n..=i].iter().map(|v| (v - m[i]).abs()).sum::<f64>() / n as f64;
ratio(values[i] - m[i], 0.015 * md, 0.0)
})
.collect()
}
/// Williams %R: −100 (at the `n`-bar low) to 0 (at the high).
pub fn williams_r(o: &Ohlcv, n: usize) -> Vec<f64> {
stoch_raw(&o.high, &o.low, &o.close, n).into_iter().map(|k| k - 100.0).collect()
}
/// Ultimate oscillator: buying pressure over true range on three horizons, weighted 4:2:1.
pub fn ultimate(o: &Ohlcv, a: usize, b: usize, c: usize) -> Vec<f64> {
let prev = |i: usize| ago(&o.close, i, 1);
let bp: Vec<f64> = (0..o.len()).map(|i| o.close[i] - o.low[i].min(prev(i))).collect();
let tr: Vec<f64> = (0..o.len()).map(|i| o.high[i].max(prev(i)) - o.low[i].min(prev(i))).collect();
let avg = |n| zip(&sum(&bp, n), &sum(&tr, n), |p, t| ratio(p, t, 0.0));
let (x, y, z) = (avg(a), avg(b), avg(c));
(0..o.len()).map(|i| 100.0 * (4.0 * x[i] + 2.0 * y[i] + z[i]) / 7.0).collect()
}
/// Awesome oscillator: SMA(median price, `fast`) − SMA(median price, `slow`).
pub fn awesome(o: &Ohlcv, fast: usize, slow: usize) -> Vec<f64> {
let hl2 = o.hl2();
zip(&sma(&hl2, fast), &sma(&hl2, slow), |a, b| a - b)
}
/// Accelerator oscillator: AO − SMA(AO, `n`).
pub fn accelerator(o: &Ohlcv, fast: usize, slow: usize, n: usize) -> Vec<f64> {
let ao = awesome(o, fast, slow);
zip(&ao, &sma(&ao, n), |a, b| a - b)
}
/// True strength index: double-smoothed momentum over double-smoothed |momentum|, × 100.
pub fn tsi(values: &[f64], long: usize, short: usize) -> Vec<f64> {
let pc = change(values, 1);
let apc: Vec<f64> = pc.iter().map(|v| v.abs()).collect();
let num = ema(&ema(&pc, long), short);
let den = ema(&ema(&apc, long), short);
zip(&num, &den, |a, b| ratio(a, b, 0.0) * 100.0)
}
/// TRIX: one-bar percent change of a triple-smoothed EMA.
pub fn trix(values: &[f64], n: usize) -> Vec<f64> {
let e3 = ema(&ema(&ema(values, n), n), n);
roc(&e3, 1)
}
/// Pring's Know Sure Thing: weighted sum of four smoothed rates of change, with a signal.
pub fn kst(values: &[f64], roc_n: [usize; 4], sma_n: [usize; 4], signal: usize) -> Macd {
let mut line = vec![0.0; values.len()];
for (w, (r, s)) in roc_n.iter().zip(sma_n).enumerate() {
let part = sma(&roc(values, *r), s);
for (l, p) in line.iter_mut().zip(&part) {
*l += (w + 1) as f64 * p;
}
}
let signal = sma(&line, signal);
let histogram = zip(&line, &signal, |a, b| a - b);
Macd { macd: line, signal, histogram }
}
/// Up and down moves (`max(Δ, 0)`, `max(−Δ, 0)`) of `values` over `k` bars.
fn moves(values: &[f64], k: usize) -> (Vec<f64>, Vec<f64>) {
let ch = change(values, k);
let up = ch.iter().map(|c| if c.is_nan() { NAN } else { c.max(0.0) }).collect();
let dn = ch.iter().map(|c| if c.is_nan() { NAN } else { (-c).max(0.0) }).collect();
(up, dn)
}
/// Chande momentum oscillator, −100..100.
pub fn cmo(values: &[f64], n: usize) -> Vec<f64> {
let (up, dn) = moves(values, 1);
zip(&sum(&up, n), &sum(&dn, n), |u, d| 100.0 * ratio(u - d, u + d, 0.0))
}
/// Detrended price oscillator: close − SMA(n) from n/2 + 1 bars ago.
pub fn dpo(values: &[f64], n: usize) -> Vec<f64> {
let m = sma(values, n);
let back = n / 2 + 1;
(0..values.len()).map(|i| values[i] - ago(&m, i, back)).collect()
}
/// Coppock curve: WMA(ROC(long) + ROC(short), `n`).
pub fn coppock(values: &[f64], n: usize, long: usize, short: usize) -> Vec<f64> {
wma(&zip(&roc(values, long), &roc(values, short), |a, b| a + b), n)
}
/// Relative vigor index: close − open over high − low, summed over `n`, with an SWMA signal.
pub fn rvgi(o: &Ohlcv, n: usize) -> Macd {
let num = swma(&zip(&o.close, &o.open, |c, op| c - op));
let den = swma(&zip(&o.high, &o.low, |h, l| h - l));
let line = zip(&sum(&num, n), &sum(&den, n), |a, b| ratio(a, b, 0.0));
let signal = swma(&line);
let histogram = zip(&line, &signal, |a, b| a - b);
Macd { macd: line, signal, histogram }
}
/// Ehlers' Fisher transform of the median price's position in its `n`-bar range, with the
/// previous value as trigger.
pub fn fisher(o: &Ohlcv, n: usize) -> (Vec<f64>, Vec<f64>) {
let hl2 = o.hl2();
let (hh, ll) = (highest(&hl2, n), lowest(&hl2, n));
let mut fish = vec![NAN; o.len()];
let (mut v, mut f) = (0.0, 0.0);
for i in 0..o.len() {
if hh[i].is_nan() {
continue;
}
v = (0.66 * (ratio(hl2[i] - ll[i], hh[i] - ll[i], 0.5) - 0.5) + 0.67 * v).clamp(-0.999, 0.999);
f = 0.5 * ((1.0 + v) / (1.0 - v)).ln() + 0.5 * f;
fish[i] = f;
}
let trigger = (0..fish.len()).map(|i| ago(&fish, i, 1)).collect();
(fish, trigger)
}
/// Blau's stochastic momentum index: distance from the midpoint of the `n`-bar range,
/// double-smoothed over `d`, as a percentage of half the range; with an EMA signal.
pub fn smi(o: &Ohlcv, n: usize, d: usize, signal: usize) -> Macd {
let (hh, ll) = (highest(&o.high, n), lowest(&o.low, n));
let rel: Vec<f64> = (0..o.len()).map(|i| o.close[i] - (hh[i] + ll[i]) / 2.0).collect();
let range = zip(&hh, &ll, |h, l| h - l);
let num = ema(&ema(&rel, d), d);
let den = ema(&ema(&range, d), d);
with_signal(zip(&num, &den, |a, b| ratio(a, b / 2.0, 0.0) * 100.0), signal)
}
/// SMI ergodic indicator: TSI(`long`, `short`) with an EMA signal and their difference.
pub fn smi_ergodic(values: &[f64], short: usize, long: usize, signal: usize) -> Macd {
with_signal(tsi(values, long, short), signal)
}
/// Connors RSI: the mean of RSI(`rsi_n`), RSI(`streak_n`) of the up/down streak length, and
/// the percent rank of the one-bar change over `rank_n` bars.
pub fn connors_rsi(values: &[f64], rsi_n: usize, streak_n: usize, rank_n: usize) -> Vec<f64> {
let mut streak = vec![0.0; values.len()];
for i in 1..values.len() {
let s = streak[i - 1];
streak[i] = if values[i] > values[i - 1] {
if s > 0.0 { s + 1.0 } else { 1.0 }
} else if values[i] < values[i - 1] {
if s < 0.0 { s - 1.0 } else { -1.0 }
} else {
0.0
};
}
let (a, b, c) = (rsi(values, rsi_n), rsi(&streak, streak_n), percent_rank(&roc(values, 1), rank_n));
(0..values.len()).map(|i| (a[i] + b[i] + c[i]) / 3.0).collect()
}
/// Ehlers' Laguerre RSI with damping `gamma`, 0..1.
pub fn laguerre_rsi(values: &[f64], gamma: f64) -> Vec<f64> {
from_first(values, |p| {
let g = gamma;
let mut l = [p.first().copied().unwrap_or_default(); 4];
p.iter()
.map(|&x| {
let prev = l;
l[0] = (1.0 - g) * x + g * prev[0];
l[1] = -g * l[0] + prev[0] + g * prev[1];
l[2] = -g * l[1] + prev[1] + g * prev[2];
l[3] = -g * l[2] + prev[2] + g * prev[3];
let (mut cu, mut cd) = (0.0, 0.0);
for w in l.windows(2) {
let d = w[0] - w[1];
if d >= 0.0 { cu += d } else { cd -= d }
}
ratio(cu, cu + cd, 0.0)
})
.collect()
})
}
/// Relative momentum index: an RSI of the change over `m` bars, Wilder-smoothed over `n`.
pub fn rmi(values: &[f64], n: usize, m: usize) -> Vec<f64> {
let (up, dn) = moves(values, m);
zip(&rma(&up, n), &rma(&dn, n), |u, d| 100.0 * ratio(u, u + d, 0.5))
}
/// DecisionPoint price momentum oscillator: the one-bar ROC smoothed twice with the custom
/// 2/n factor (×10 after the first), with an EMA signal.
pub fn pmo(values: &[f64], a: usize, b: usize, signal: usize) -> Macd {
let r = roc(values, 1);
let s1: Vec<f64> = smooth(&r, a, 2.0 / a as f64).into_iter().map(|v| v * 10.0).collect();
with_signal(smooth(&s1, b, 2.0 / b as f64), signal)
}
/// Balance of power: SMA of (close − open) / (high − low).
pub fn bop(o: &Ohlcv, n: usize) -> Vec<f64> {
let raw: Vec<f64> = (0..o.len()).map(|i| ratio(o.close[i] - o.open[i], o.high[i] - o.low[i], 0.0)).collect();
sma(&raw, n)
}
/// Elder ray: bull power (high − EMA) and bear power (low − EMA).
pub fn elder_ray(o: &Ohlcv, n: usize) -> (Vec<f64>, Vec<f64>) {
let e = ema(&o.close, n);
(zip(&o.high, &e, |h, e| h - e), zip(&o.low, &e, |l, e| l - e))
}
/// Bull bear power: (high − EMA) + (low − EMA).
pub fn bull_bear_power(o: &Ohlcv, n: usize) -> Vec<f64> {
let (bull, bear) = elder_ray(o, n);
zip(&bull, &bear, |a, b| a + b)
}
/// Chande's Qstick: SMA of close − open.
pub fn qstick(o: &Ohlcv, n: usize) -> Vec<f64> {
sma(&zip(&o.close, &o.open, |c, op| c - op), n)
}
/// Psychological line: the percentage of up closes over `n` bars.
pub fn psychological(values: &[f64], n: usize) -> Vec<f64> {
let up: Vec<f64> =
(0..values.len()).map(|i| if i == 0 { NAN } else { f64::from(values[i] > values[i - 1]) }).collect();
sum(&up, n).into_iter().map(|s| 100.0 * s / n as f64).collect()
}
/// Chande forecast oscillator: percent distance of the close from its `n`-bar regression.
pub fn cfo(values: &[f64], n: usize) -> Vec<f64> {
zip(values, &linreg(values, n), |c, l| 100.0 * ratio(c - l, c, 0.0))
}
/// Intraday momentum index: an RSI of open-to-close moves, 0..100.
pub fn imi(o: &Ohlcv, n: usize) -> Vec<f64> {
let gains: Vec<f64> = (0..o.len()).map(|i| (o.close[i] - o.open[i]).max(0.0)).collect();
let losses: Vec<f64> = (0..o.len()).map(|i| (o.open[i] - o.close[i]).max(0.0)).collect();
zip(&sum(&gains, n), &sum(&losses, n), |g, l| 100.0 * ratio(g, g + l, 0.5))
}
/// Schaff trend cycle: a twice-stochasticised, half-smoothed MACD(`fast`, `slow`) over a
/// `cycle`-bar window, 0..100.
pub fn stc(values: &[f64], cycle: usize, fast: usize, slow: usize) -> Vec<f64> {
let m = apo(values, fast, slow);
let stage = |x: &[f64]| {
let (lo, hi) = (lowest(x, cycle), highest(x, cycle));
let mut out = vec![NAN; x.len()];
let (mut raw, mut sm) = (NAN, NAN);
for i in 0..x.len() {
if lo[i].is_nan() {
continue;
}
let range = hi[i] - lo[i];
if range > 0.0 {
raw = (x[i] - lo[i]) / range * 100.0;
} else if raw.is_nan() {
raw = 0.0;
}
sm = if sm.is_nan() { raw } else { sm + 0.5 * (raw - sm) };
out[i] = sm;
}
out
};
stage(&stage(&m))
}
/// Ehlers' center of gravity over `n` bars, with the previous value as signal.
pub fn center_of_gravity(values: &[f64], n: usize) -> (Vec<f64>, Vec<f64>) {
let mut cg = vec![NAN; values.len()];
for i in (n.max(1) - 1..values.len()).filter(|_| n > 0) {
let (mut num, mut den) = (0.0, 0.0);
for k in 0..n {
num += (k + 1) as f64 * values[i - k];
den += values[i - k];
}
cg[i] = if den != 0.0 { -num / den + (n as f64 + 1.0) / 2.0 } else { 0.0 };
}
let signal = (0..cg.len()).map(|i| ago(&cg, i, 1)).collect();
(cg, signal)
}
/// Inverse Fisher transform of RSI(`rsi_n`), WMA-smoothed over `n`, −1..1.
pub fn ift_rsi(values: &[f64], rsi_n: usize, n: usize) -> Vec<f64> {
let v: Vec<f64> = rsi(values, rsi_n).into_iter().map(|r| 0.1 * (r - 50.0)).collect();
wma(&v, n).into_iter().map(f64::tanh).collect()
}
/// Pretty good oscillator: distance from the SMA in units of the average true range.
pub fn pgo(o: &Ohlcv, n: usize) -> Vec<f64> {
let m = sma(&o.close, n);
let atr = ema(&true_range(o), n);
(0..o.len()).map(|i| ratio(o.close[i] - m[i], atr[i], 0.0)).collect()
}
/// QQE: RSI smoothed over `smooth` bars and a trailing level `factor` times its smoothed
/// volatility away, flipping sides when crossed.
pub fn qqe(values: &[f64], rsi_n: usize, smooth: usize, factor: f64) -> (Vec<f64>, Vec<f64>) {
let line = ema(&rsi(values, rsi_n), smooth);
let wilders = (2 * rsi_n).saturating_sub(1).max(1);
let atr: Vec<f64> = (0..line.len()).map(|i| (line[i] - ago(&line, i, 1)).abs()).collect();
let dar: Vec<f64> = ema(&ema(&atr, wilders), wilders).into_iter().map(|v| v * factor).collect();
let mut trail = vec![NAN; line.len()];
let (mut long, mut short, mut trend) = (NAN, NAN, 1);
let mut prev = NAN;
for i in 0..line.len() {
let (r, d) = (line[i], dar[i]);
if r.is_nan() || d.is_nan() {
continue;
}
let (nl, ns) = (r - d, r + d);
let (pl, ps) = (long, short);
long = if !pl.is_nan() && prev > pl && r > pl { pl.max(nl) } else { nl };
short = if !ps.is_nan() && prev < ps && r < ps { ps.min(ns) } else { ns };
if !ps.is_nan() && prev <= ps && r > ps {
trend = 1;
} else if !pl.is_nan() && prev >= pl && r < pl {
trend = -1;
}
trail[i] = if trend == 1 { long } else { short };
prev = r;
}
(line, trail)
}
/// WaveTrend (LazyBear): a CCI-like channel index of the typical price, EMA-smoothed, with
/// a 4-bar SMA signal.
pub fn wavetrend(o: &Ohlcv, n1: usize, n2: usize) -> (Vec<f64>, Vec<f64>) {
let ap = o.hlc3();
let esa = ema(&ap, n1);
let dev = ema(&zip(&ap, &esa, |a, e| (a - e).abs()), n1);
let ci: Vec<f64> = (0..ap.len()).map(|i| ratio(ap[i] - esa[i], 0.015 * dev[i], 0.0)).collect();
let wt1 = ema(&ci, n2);
let wt2 = sma(&wt1, 4);
(wt1, wt2)
}
/// Ehlers' cyber cycle with smoothing `alpha`, and its previous value as trigger.
pub fn cyber_cycle(values: &[f64], alpha: f64) -> (Vec<f64>, Vec<f64>) {
let cycle = from_first(values, |p| {
let a = alpha;
let pr = |i: usize, k: usize| if i >= k { p[i - k] } else { p[0] };
let sm: Vec<f64> =
(0..p.len()).map(|i| (pr(i, 0) + 2.0 * pr(i, 1) + 2.0 * pr(i, 2) + pr(i, 3)) / 6.0).collect();
let mut c = vec![0.0; p.len()];
for i in 0..p.len() {
c[i] = if i < 7 {
(pr(i, 0) - 2.0 * pr(i, 1) + pr(i, 2)) / 4.0
} else {
(1.0 - 0.5 * a).powi(2) * (sm[i] - 2.0 * sm[i - 1] + sm[i - 2]) + 2.0 * (1.0 - a) * c[i - 1]
- (1.0 - a).powi(2) * c[i - 2]
};
}
c
});
let trigger = (0..cycle.len()).map(|i| ago(&cycle, i, 1)).collect();
(cycle, trigger)
}
/// Disparity index: percent distance of the close from its `n`-bar SMA.
pub fn disparity(values: &[f64], n: usize) -> Vec<f64> {
zip(values, &sma(values, n), |c, m| 100.0 * ratio(c - m, m, 0.0))
}
/// Polarized fractal efficiency: straight-line distance over path length across `n` bars,
/// signed by direction and EMA-smoothed, −100..100.
pub fn pfe(values: &[f64], n: usize, smoothing: usize) -> Vec<f64> {
let k = n as f64;
let step: Vec<f64> = (0..values.len())
.map(|i| if i == 0 { NAN } else { ((values[i] - values[i - 1]).powi(2) + 1.0).sqrt() })
.collect();
let path = sum(&step, n);
let raw: Vec<f64> = (0..values.len())
.map(|i| {
if i < n || path[i].is_nan() {
return NAN;
}
let d = values[i] - values[i - n];
let p = 100.0 * (d * d + k * k).sqrt() / path[i];
if d > 0.0 { p } else { -p }
})
.collect();
ema(&raw, smoothing)
}
/// Kaufman's efficiency ratio: |net change| over the sum of |bar changes|, 0..1.
pub fn efficiency_ratio(values: &[f64], n: usize) -> Vec<f64> {
let step: Vec<f64> = change(values, 1).into_iter().map(f64::abs).collect();
let path = sum(&step, n);
(0..values.len())
.map(|i| if i >= n { ratio((values[i] - values[i - n]).abs(), path[i], 0.0) } else { NAN })
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Bar;
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9 * (1.0 + b.abs())
}
fn ohlcv(rows: &[(f64, f64, f64, f64)]) -> Ohlcv {
let bars: Vec<Bar> =
rows.iter().enumerate().map(|(i, r)| Bar::new(i as i64, r.0, r.1, r.2, r.3, 100.0)).collect();
Ohlcv::new(&bars)
}
fn wave(n: usize) -> Ohlcv {
let rows: Vec<(f64, f64, f64, f64)> = (0..n)
.map(|i| {
let c = 100.0 + (i as f64 * 0.17).sin() * 6.0 + i as f64 * 0.02;
(c - 0.3, c + 1.0 + (i % 3) as f64 * 0.2, c - 1.1, c)
})
.collect();
ohlcv(&rows)
}
#[test]
fn stochastic_and_williams_hand_computed() {
// 3-bar window: HH 12, LL 8, close 11 → raw 75, %R −25.
let o = ohlcv(&[(9.0, 10.0, 8.0, 9.0), (10.0, 12.0, 9.0, 11.0), (11.0, 11.5, 10.0, 11.0)]);
let s = stochastic(&o, 3, 1, 1);
assert!(near(s.k[2], 75.0) && near(s.d[2], 75.0));
assert!(near(williams_r(&o, 3)[2], -25.0));
// A flat window sits in the middle instead of dividing by zero.
let flat = ohlcv(&[(5.0, 5.0, 5.0, 5.0); 4]);
assert_eq!(stochastic(&flat, 3, 1, 1).k[3], 50.0);
}
#[test]
fn cci_hand_computed() {
// Typical prices 1, 2, 3: mean 2, mean deviation 2/3 → (3 − 2) / (0.015 · 2/3) = 100.
assert!(near(cci(&[1.0, 2.0, 3.0], 3)[2], 100.0));
}
#[test]
fn differences_match_their_parts() {
let o = wave(300);
let c = &o.close;
let p = ppo(c, 12, 26, 9);
let (f, s) = (ema(c, 12), ema(c, 26));
assert!(near(p.macd[200], (f[200] - s[200]) / s[200] * 100.0));
assert!(near(apo(c, 12, 26)[200], f[200] - s[200]));
assert!(near(roc(c, 9)[50], (c[50] / c[41] - 1.0) * 100.0));
assert!(near(momentum(c, 10)[50], c[50] - c[40]));
let hl2 = o.hl2();
assert!(near(awesome(&o, 5, 34)[100], sma(&hl2, 5)[100] - sma(&hl2, 34)[100]));
let ao = awesome(&o, 5, 34);
assert!(near(accelerator(&o, 5, 34, 5)[100], ao[100] - sma(&ao, 5)[100]));
let b = bull_bear_power(&o, 13);
let (bull, bear) = elder_ray(&o, 13);
assert!(near(b[90], bull[90] + bear[90]));
}
#[test]
fn bounded_oscillators_stay_in_range() {
let o = wave(500);
let c = &o.close;
let checks: Vec<(Vec<f64>, f64, f64)> = vec![
(cmo(c, 9), -100.0, 100.0),
(stoch_rsi(c, 14, 14, 3, 3).k, 0.0, 100.0),
(ultimate(&o, 7, 14, 28), 0.0, 100.0),
(connors_rsi(c, 3, 2, 100), 0.0, 100.0),
(laguerre_rsi(c, 0.5), 0.0, 1.0),
(rmi(c, 20, 5), 0.0, 100.0),
(imi(&o, 14), 0.0, 100.0),
(psychological(c, 12), 0.0, 100.0),
(stc(c, 12, 26, 50), 0.0, 100.0),
(ift_rsi(c, 5, 9), -1.0, 1.0),
(qqe(c, 14, 5, 4.236).0, 0.0, 100.0),
(pfe(c, 10, 5), -100.0, 100.0),
(efficiency_ratio(c, 10), 0.0, 1.0),
(smi(&o, 10, 3, 10).macd, -100.0, 100.0),
];
for (k, (v, lo, hi)) in checks.iter().enumerate() {
let finite: Vec<f64> = v.iter().copied().filter(|x| x.is_finite()).collect();
assert!(finite.len() > 300, "check {k} has {} values", finite.len());
assert!(finite.iter().all(|x| *x >= lo - 1e-9 && *x <= hi + 1e-9), "check {k} out of range");
}
}
#[test]
fn known_shapes() {
let up: Vec<f64> = (0..60).map(|i| 10.0 + i as f64).collect();
assert_eq!(cmo(&up, 9)[30], 100.0);
assert_eq!(efficiency_ratio(&up, 10)[30], 1.0);
assert_eq!(psychological(&up, 12)[30], 100.0);
assert!(near(tsi(&up, 25, 13)[59], 100.0));
// Streaks: three rises then a fall.
let c = connors_rsi(&[1.0, 2.0, 3.0, 4.0, 3.0], 2, 2, 2);
assert!(c[4].is_finite());
let (cg, sig) = center_of_gravity(&[1.0; 20], 10);
assert!(near(cg[15], -(55.0 / 10.0) + 5.5) && near(sig[16], cg[15]));
// DPO compares with the SMA from n/2 + 1 bars back.
let d = dpo(&up, 10);
assert!(near(d[30], up[30] - sma(&up, 10)[24]));
}
}
File diff suppressed because it is too large. Load diff
+372
View File
@@ -0,0 +1,372 @@
//! Rolling statistics: sums, extremes, dispersion, least-squares fits and ranks. Running
//! sums are recomputed exactly once per window length, so drift stays bounded over
//! millions of bars at O(1) amortised cost per bar.
use std::collections::VecDeque;
use super::NAN;
/// Calls `f(i, r, s, w, s2)` for every full window of `n` values ending at `i` that holds
/// only finite values, where, over `y - r` (`r` = the first finite value, for precision),
/// `s` = Σy, `w` = Σk·y with k = 1 for the oldest … n for the newest, and `s2` = Σy².
pub(super) fn windows(values: &[f64], n: usize, mut f: impl FnMut(usize, f64, f64, f64, f64)) {
if n == 0 {
return;
}
let r = values.iter().copied().find(|v| v.is_finite()).unwrap_or(0.0);
let y = |i: usize| if values[i].is_finite() { values[i] - r } else { 0.0 };
let (mut s, mut w, mut s2, mut bad) = (0.0, 0.0, 0.0, 0usize);
for i in 0..values.len() {
if !values[i].is_finite() {
bad += 1;
}
let v = y(i);
if i >= n {
if !values[i - n].is_finite() {
bad -= 1;
}
let o = y(i - n);
w -= s;
s -= o;
s2 -= o * o;
w += n as f64 * v;
} else {
w += (i + 1) as f64 * v;
}
s += v;
s2 += v * v;
if i + 1 >= n {
if (i + 1) % n == 0 {
(s, w, s2) = (0.0, 0.0, 0.0);
for (k, j) in (i + 1 - n..=i).enumerate() {
let v = y(j);
s += v;
w += (k + 1) as f64 * v;
s2 += v * v;
}
}
if bad == 0 {
f(i, r, s, w, s2);
}
}
}
}
/// Rolling sum of `n` values. Windows holding a non-finite value are NaN.
pub fn sum(values: &[f64], n: usize) -> Vec<f64> {
let mut out = vec![NAN; values.len()];
windows(values, n, |i, r, s, _, _| out[i] = s + r * n as f64);
out
}
/// Population variance over `n` values.
pub fn variance(values: &[f64], n: usize) -> Vec<f64> {
let mut out = vec![NAN; values.len()];
let k = n as f64;
windows(values, n, |i, _, s, _, s2| {
let m = s / k;
out[i] = (s2 / k - m * m).max(0.0);
});
out
}
/// Population standard deviation over `n` values.
pub fn stdev(values: &[f64], n: usize) -> Vec<f64> {
variance(values, n).into_iter().map(f64::sqrt).collect()
}
/// Distance from the `n`-value mean in standard deviations (0 when flat).
pub fn zscore(values: &[f64], n: usize) -> Vec<f64> {
let mut out = vec![NAN; values.len()];
let k = n as f64;
windows(values, n, |i, r, s, _, s2| {
let m = s / k;
let sd = (s2 / k - m * m).max(0.0).sqrt();
out[i] = if sd > 0.0 { (values[i] - r - m) / sd } else { 0.0 };
});
out
}
/// Index of the highest (`max`) or lowest value in each window of `n` (the most recent one
/// on ties), or `usize::MAX` while the window is short or holds a non-finite value.
pub(super) fn extreme_at(values: &[f64], n: usize, max: bool) -> Vec<usize> {
let mut out = vec![usize::MAX; values.len()];
if n == 0 {
return out;
}
let mut dq: VecDeque<usize> = VecDeque::new();
let mut bad = 0usize;
for (i, &v) in values.iter().enumerate() {
if v.is_finite() {
while let Some(&b) = dq.back() {
if (max && values[b] <= v) || (!max && values[b] >= v) {
dq.pop_back();
} else {
break;
}
}
dq.push_back(i);
} else {
bad += 1;
}
if i >= n && !values[i - n].is_finite() {
bad -= 1;
}
while dq.front().is_some_and(|&f| f + n <= i) {
dq.pop_front();
}
if i + 1 >= n
&& bad == 0
&& let Some(&f) = dq.front()
{
out[i] = f;
}
}
out
}
fn pick(values: &[f64], idx: Vec<usize>) -> Vec<f64> {
idx.into_iter().map(|j| if j == usize::MAX { NAN } else { values[j] }).collect()
}
/// Highest value over the last `n` (monotonic deque, O(1) amortised).
pub fn highest(values: &[f64], n: usize) -> Vec<f64> {
pick(values, extreme_at(values, n, true))
}
/// Lowest value over the last `n`.
pub fn lowest(values: &[f64], n: usize) -> Vec<f64> {
pick(values, extreme_at(values, n, false))
}
/// `values[i] - values[i - k]`.
pub fn change(values: &[f64], k: usize) -> Vec<f64> {
(0..values.len()).map(|i| if i >= k { values[i] - values[i - k] } else { NAN }).collect()
}
/// Displace a series by `k` bars: forward (into the future, extending the output past the
/// last bar) when positive, back when negative.
pub fn displace(values: &[f64], k: isize) -> Vec<f64> {
let len = values.len();
if k >= 0 {
let k = k as usize;
let mut out = vec![NAN; len + k];
out[k..].copy_from_slice(values);
out
} else {
let k = k.unsigned_abs().min(len);
let mut out = vec![NAN; len];
out[..len - k].copy_from_slice(&values[k..]);
out
}
}
/// Least-squares line through one window, with x = 0 for the oldest value.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Fit {
pub slope: f64,
pub intercept: f64,
/// Coefficient of determination (0 for a flat window).
pub r2: f64,
/// Standard error of the estimate, sqrt(SSE / (n - 2)).
pub se: f64,
}
impl Fit {
pub fn at(&self, x: f64) -> f64 {
self.intercept + self.slope * x
}
}
/// Map every rolling `n`-value least-squares fit through `f` (O(1) per bar).
pub fn linreg_map(values: &[f64], n: usize, f: impl Fn(Fit) -> f64) -> Vec<f64> {
let mut out = vec![NAN; values.len()];
let k = n as f64;
let sx = k * (k - 1.0) / 2.0;
let sxx = (k - 1.0) * k * (2.0 * k - 1.0) / 6.0;
let den = k * sxx - sx * sx;
windows(values, n, |i, r, s, w, s2| {
let sxy = w - s;
let slope = if den != 0.0 { (k * sxy - sx * s) / den } else { 0.0 };
let b = (s - slope * sx) / k;
let sse = (s2 - b * s - slope * sxy).max(0.0);
let sst = s2 - s * s / k;
let r2 = if sst > 1e-12 * s2.max(1e-300) { (1.0 - sse / sst).clamp(0.0, 1.0) } else { 0.0 };
let se = if n > 2 { (sse / (k - 2.0)).sqrt() } else { 0.0 };
out[i] = f(Fit { slope, intercept: b + r, r2, se });
});
out
}
/// Linear regression value at the newest bar (least squares moving average, LSMA).
pub fn linreg(values: &[f64], n: usize) -> Vec<f64> {
linreg_map(values, n, |f| f.at(n as f64 - 1.0))
}
/// Slope of the rolling regression line, per bar.
pub fn linreg_slope(values: &[f64], n: usize) -> Vec<f64> {
linreg_map(values, n, |f| f.slope)
}
/// R² of the rolling regression line.
pub fn r_squared(values: &[f64], n: usize) -> Vec<f64> {
linreg_map(values, n, |f| f.r2)
}
/// Time series forecast: the regression line extended one bar ahead.
pub fn tsf(values: &[f64], n: usize) -> Vec<f64> {
linreg_map(values, n, |f| f.at(n as f64))
}
/// Percentage of the previous `n` values less than or equal to the current one.
pub fn percent_rank(values: &[f64], n: usize) -> Vec<f64> {
let mut out = vec![NAN; values.len()];
if n == 0 {
return out;
}
for i in n..values.len() {
let v = values[i];
let w = &values[i - n..i];
if v.is_finite() && w.iter().all(|x| x.is_finite()) {
out[i] = 100.0 * w.iter().filter(|&&x| x <= v).count() as f64 / n as f64;
}
}
out
}
/// Rolling median of `n` values (sorted window, O(n) per bar).
pub fn median(values: &[f64], n: usize) -> Vec<f64> {
let mut out = vec![NAN; values.len()];
if n == 0 {
return out;
}
let mut sorted: Vec<f64> = Vec::with_capacity(n);
let mut bad = 0usize;
let pos = |s: &[f64], v: f64| s.partition_point(|x| *x < v);
for i in 0..values.len() {
let v = values[i];
if v.is_finite() {
let p = pos(&sorted, v);
sorted.insert(p, v);
} else {
bad += 1;
}
if i >= n {
let o = values[i - n];
if o.is_finite() {
let p = pos(&sorted, o);
sorted.remove(p);
} else {
bad -= 1;
}
}
if i + 1 >= n && bad == 0 {
out[i] = if n % 2 == 1 { sorted[n / 2] } else { (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0 };
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn noisy(n: usize) -> Vec<f64> {
(0..n).map(|i| 100.0 + ((i * 7919) % 101) as f64 * 0.37 - (i as f64 * 0.11).sin() * 9.0).collect()
}
fn near(a: f64, b: f64) -> bool {
(a.is_nan() && b.is_nan()) || (a - b).abs() <= 1e-7 * (1.0 + b.abs())
}
#[test]
fn rolling_sums_match_brute_force() {
let xs = noisy(500);
for n in [1, 2, 7, 20] {
let s = sum(&xs, n);
let var = variance(&xs, n);
for i in 0..xs.len() {
if i + 1 < n {
assert!(s[i].is_nan());
continue;
}
let w = &xs[i + 1 - n..=i];
let m = w.iter().sum::<f64>() / n as f64;
assert!(near(s[i], w.iter().sum()), "sum n={n} i={i}");
let v = w.iter().map(|x| (x - m).powi(2)).sum::<f64>() / n as f64;
assert!(near(var[i], v), "var n={n} i={i}: {} vs {v}", var[i]);
}
}
}
#[test]
fn gaps_only_blank_the_windows_that_hold_them() {
let mut xs = noisy(40);
xs[10] = NAN;
let s = sum(&xs, 5);
assert!(s[9].is_finite());
assert!((10..15).all(|i| s[i].is_nan()));
assert!(near(s[15], xs[11..16].iter().sum()));
assert!(highest(&xs, 5)[14].is_nan() && highest(&xs, 5)[15].is_finite());
}
#[test]
fn extremes_match_brute_force() {
let xs = noisy(400);
for n in [1, 3, 14, 52] {
let (h, l) = (highest(&xs, n), lowest(&xs, n));
for i in n - 1..xs.len() {
let w = &xs[i + 1 - n..=i];
assert_eq!(h[i], w.iter().cloned().fold(f64::MIN, f64::max));
assert_eq!(l[i], w.iter().cloned().fold(f64::MAX, f64::min));
}
}
// Ties resolve to the most recent bar.
assert_eq!(extreme_at(&[1.0, 3.0, 2.0, 3.0], 4, true)[3], 3);
}
#[test]
fn regression_on_a_line_is_exact() {
let xs: Vec<f64> = (0..50).map(|i| 3.0 + 0.5 * i as f64).collect();
let lr = linreg(&xs, 10);
let sl = linreg_slope(&xs, 10);
let r2 = r_squared(&xs, 10);
let f = tsf(&xs, 10);
for i in 9..50 {
assert!(near(lr[i], xs[i]), "{i}");
assert!(near(sl[i], 0.5));
assert!(near(r2[i], 1.0));
assert!(near(f[i], xs[i] + 0.5));
}
// Noisy data: compare with a direct fit.
let ys = noisy(120);
let n = 25;
let got = linreg_map(&ys, n, |f| f.slope + f.se);
let i = 101;
let w = &ys[i + 1 - n..=i];
let k = n as f64;
let mx = (k - 1.0) / 2.0;
let my = w.iter().sum::<f64>() / k;
let sxy: f64 = w.iter().enumerate().map(|(j, y)| (j as f64 - mx) * (y - my)).sum();
let sxx: f64 = (0..n).map(|j| (j as f64 - mx).powi(2)).sum();
let b = sxy / sxx;
let a = my - b * mx;
let sse: f64 = w.iter().enumerate().map(|(j, y)| (y - a - b * j as f64).powi(2)).sum();
assert!(near(got[i], b + (sse / (k - 2.0)).sqrt()));
}
#[test]
fn rank_median_and_shift() {
let xs = [5.0, 1.0, 4.0, 2.0, 3.0, 6.0];
assert_eq!(percent_rank(&xs, 4)[4], 50.0);
assert_eq!(percent_rank(&xs, 4)[5], 100.0);
assert_eq!(median(&xs, 3)[2], 4.0);
assert_eq!(median(&xs, 4)[5], 3.5);
assert_eq!(displace(&[1.0, 2.0], 2)[3], 2.0);
let back = displace(&[1.0, 2.0, 3.0], -1);
assert_eq!(&back[..2], &[2.0, 3.0]);
assert!(back[2].is_nan());
assert_eq!(change(&[1.0, 4.0, 9.0], 2)[2], 8.0);
assert!(zscore(&[2.0; 10], 5)[9] == 0.0);
}
}
+521
View File
@@ -0,0 +1,521 @@
//! Trend: directional movement, trailing stops, Ichimoku, Bill Williams' tools, zigzag and
//! trend-strength filters.
use super::ma::{rma, sma};
use super::stats::{change, displace, extreme_at, highest, linreg, lowest, sum};
use super::volatility::{atr, bollinger, true_range};
use super::{NAN, Ohlcv, ago, ratio, zip};
/// Wilder's directional movement index.
#[derive(Debug, Clone)]
pub struct Dmi {
pub plus: Vec<f64>,
pub minus: Vec<f64>,
pub adx: Vec<f64>,
}
/// +DI and −DI over `n` bars and ADX smoothed over `adx_n`, all Wilder-smoothed.
pub fn dmi(o: &Ohlcv, n: usize, adx_n: usize) -> Dmi {
let len = o.len();
let (up, down) = (change(&o.high, 1), change(&o.low, 1));
let plus_dm: Vec<f64> = (0..len)
.map(|i| {
if up[i].is_nan() {
NAN
} else if up[i] > -down[i] && up[i] > 0.0 {
up[i]
} else {
0.0
}
})
.collect();
let minus_dm: Vec<f64> = (0..len)
.map(|i| {
if down[i].is_nan() {
NAN
} else if -down[i] > up[i] && -down[i] > 0.0 {
-down[i]
} else {
0.0
}
})
.collect();
let mut tr = true_range(o);
if let Some(t) = tr.first_mut() {
*t = NAN;
}
let tr = rma(&tr, n);
let plus = zip(&rma(&plus_dm, n), &tr, |d, t| 100.0 * ratio(d, t, 0.0));
let minus = zip(&rma(&minus_dm, n), &tr, |d, t| 100.0 * ratio(d, t, 0.0));
let dx: Vec<f64> = (0..len).map(|i| ratio((plus[i] - minus[i]).abs(), plus[i] + minus[i], 0.0)).collect();
let adx = rma(&dx, adx_n).into_iter().map(|v| 100.0 * v).collect();
Dmi { plus, minus, adx }
}
/// Aroon up and down: how recently the `n`-bar high and low were set, 0..100.
pub fn aroon(o: &Ohlcv, n: usize) -> (Vec<f64>, Vec<f64>) {
let k = n as f64;
let since = |idx: Vec<usize>| -> Vec<f64> {
idx.into_iter()
.enumerate()
.map(|(i, j)| if j == usize::MAX { NAN } else { 100.0 * (k - (i - j) as f64) / k })
.collect()
};
(since(extreme_at(&o.high, n + 1, true)), since(extreme_at(&o.low, n + 1, false)))
}
/// Vortex indicator: +VI and −VI over `n` bars.
pub fn vortex(o: &Ohlcv, n: usize) -> (Vec<f64>, Vec<f64>) {
let vp: Vec<f64> = (0..o.len()).map(|i| (o.high[i] - ago(&o.low, i, 1)).abs()).collect();
let vm: Vec<f64> = (0..o.len()).map(|i| (o.low[i] - ago(&o.high, i, 1)).abs()).collect();
let tr = sum(&true_range(o), n);
(zip(&sum(&vp, n), &tr, |v, t| ratio(v, t, 0.0)), zip(&sum(&vm, n), &tr, |v, t| ratio(v, t, 0.0)))
}
/// A stop-and-reverse level and, per bar, whether it trails an uptrend (below price).
#[derive(Debug, Clone)]
pub struct Trail {
pub value: Vec<f64>,
pub up: Vec<bool>,
}
impl Trail {
fn new(len: usize) -> Self {
Trail { value: vec![NAN; len], up: vec![false; len] }
}
/// The level split into its uptrend and downtrend stretches (NaN elsewhere), so each
/// side draws as its own broken line.
pub fn split(&self) -> (Vec<f64>, Vec<f64>) {
let side = |want: bool| -> Vec<f64> {
self.value.iter().zip(&self.up).map(|(v, u)| if *u == want { *v } else { NAN }).collect()
};
(side(true), side(false))
}
}
/// Wilder's parabolic SAR with acceleration `start`, step `inc` and cap `max`.
pub fn psar(o: &Ohlcv, start: f64, inc: f64, max: f64) -> Trail {
let len = o.len();
let mut t = Trail::new(len);
if len < 2 {
return t;
}
let (h, l, c) = (&o.high, &o.low, &o.close);
let mut below = c[1] > c[0];
let (mut ext, mut sar) = if below { (h[1], l[0]) } else { (l[1], h[0]) };
let mut af = start;
for i in 1..len {
let mut fresh = i == 1;
sar += af * (ext - sar);
if below && sar > l[i] {
(fresh, below, sar, ext, af) = (true, false, h[i].max(ext), l[i], start);
} else if !below && sar < h[i] {
(fresh, below, sar, ext, af) = (true, true, l[i].min(ext), h[i], start);
}
if !fresh && ((below && h[i] > ext) || (!below && l[i] < ext)) {
ext = if below { h[i] } else { l[i] };
af = (af + inc).min(max);
}
if below {
sar = sar.min(l[i - 1]);
if i > 1 {
sar = sar.min(l[i - 2]);
}
} else {
sar = sar.max(h[i - 1]);
if i > 1 {
sar = sar.max(h[i - 2]);
}
}
t.value[i] = sar;
t.up[i] = below;
}
t
}
/// SuperTrend: median price ± `factor` × ATR(`n`), ratcheting with the trend.
pub fn supertrend(o: &Ohlcv, n: usize, factor: f64) -> Trail {
let (a, mid) = (atr(o, n), o.hl2());
let mut t = Trail::new(o.len());
let (mut lb_prev, mut ub_prev, mut was_up) = (NAN, NAN, None);
for i in 0..o.len() {
if a[i].is_nan() {
continue;
}
let (mut lb, mut ub) = (mid[i] - factor * a[i], mid[i] + factor * a[i]);
let pc = ago(&o.close, i, 1);
if !lb_prev.is_nan() && !(lb > lb_prev || pc < lb_prev) {
lb = lb_prev;
}
if !ub_prev.is_nan() && !(ub < ub_prev || pc > ub_prev) {
ub = ub_prev;
}
let up = match was_up {
None => false,
Some(false) => o.close[i] > ub,
Some(true) => o.close[i] >= lb,
};
t.value[i] = if up { lb } else { ub };
t.up[i] = up;
(lb_prev, ub_prev, was_up) = (lb, ub, Some(up));
}
t
}
/// Volatility stop: a close-based trailing stop `factor` × ATR(`n`) from the extreme close
/// of the current swing.
pub fn volatility_stop(o: &Ohlcv, n: usize, factor: f64) -> Trail {
let (a, tr) = (atr(o, n), true_range(o));
let c = &o.close;
let mut t = Trail::new(c.len());
let Some(&first) = c.first() else { return t };
let (mut hi, mut lo, mut up, mut stop) = (first, first, true, 0.0f64);
for i in 0..c.len() {
let src = c[i];
let m = if a[i].is_nan() { tr[i] } else { a[i] * factor };
hi = hi.max(src);
lo = lo.min(src);
stop = if up { stop.max(hi - m) } else { stop.min(lo + m) };
if stop.is_nan() {
stop = src;
}
let now_up = src - stop >= 0.0;
if now_up != up {
(hi, lo) = (src, src);
stop = if now_up { hi - m } else { lo + m };
}
up = now_up;
t.value[i] = stop;
t.up[i] = up;
}
t
}
/// Gann HiLo activator: the SMA of lows under an uptrend, of highs over a downtrend; the
/// trend flips when the close crosses the other side's previous value.
pub fn gann_hilo(o: &Ohlcv, high_n: usize, low_n: usize) -> Trail {
let (sh, sl) = (sma(&o.high, high_n), sma(&o.low, low_n));
let mut t = Trail::new(o.len());
let mut up = None;
for i in 0..o.len() {
if o.close[i] > ago(&sh, i, 1) {
up = Some(true);
} else if o.close[i] < ago(&sl, i, 1) {
up = Some(false);
}
if let Some(u) = up {
t.value[i] = if u { sl[i] } else { sh[i] };
t.up[i] = u;
}
}
t
}
#[derive(Debug, Clone)]
pub struct Ichimoku {
pub conversion: Vec<f64>,
pub base: Vec<f64>,
/// Leading spans, displaced into the future (longer than the input).
pub span_a: Vec<f64>,
pub span_b: Vec<f64>,
/// The close displaced into the past.
pub lagging: Vec<f64>,
}
/// Ichimoku Kinko Hyo: Tenkan-sen, Kijun-sen, Senkou spans A and B and Chikou span.
pub fn ichimoku(o: &Ohlcv, conversion: usize, base: usize, span_b: usize, displacement: usize) -> Ichimoku {
let mid = |n| zip(&highest(&o.high, n), &lowest(&o.low, n), |h, l| (h + l) / 2.0);
let (conv, base) = (mid(conversion), mid(base));
let shift = displacement.saturating_sub(1) as isize;
let span_a = displace(&zip(&conv, &base, |a, b| (a + b) / 2.0), shift);
let span_b = displace(&mid(span_b), shift);
let lagging = displace(&o.close, -shift);
Ichimoku { conversion: conv, base, span_a, span_b, lagging }
}
#[derive(Debug, Clone)]
pub struct Alligator {
pub jaw: Vec<f64>,
pub teeth: Vec<f64>,
pub lips: Vec<f64>,
}
/// Bill Williams' alligator: smoothed (Wilder) median price over `lengths`, each displaced
/// forward by its `offsets`.
pub fn alligator(o: &Ohlcv, lengths: [usize; 3], offsets: [usize; 3]) -> Alligator {
let hl2 = o.hl2();
let line = |k: usize| displace(&rma(&hl2, lengths[k]), offsets[k] as isize);
Alligator { jaw: line(0), teeth: line(1), lips: line(2) }
}
/// Gator oscillator: |jaw − teeth| above zero and −|teeth − lips| below, at each bar.
pub fn gator(o: &Ohlcv, lengths: [usize; 3], offsets: [usize; 3]) -> (Vec<f64>, Vec<f64>) {
let a = alligator(o, lengths, offsets);
let len = o.len();
let upper = zip(&a.jaw[..len], &a.teeth[..len], |j, t| (j - t).abs());
let lower = zip(&a.teeth[..len], &a.lips[..len], |t, l| -(t - l).abs());
(upper, lower)
}
/// Bill Williams' fractals: bars whose high (low) is strictly beyond the `n` bars on each
/// side, marked at that high (low). A fractal is only known `n` bars later.
pub fn fractals(o: &Ohlcv, n: usize) -> (Vec<f64>, Vec<f64>) {
let len = o.len();
let (mut up, mut down) = (vec![NAN; len], vec![NAN; len]);
for i in n..len.saturating_sub(n) {
let mut around = (i - n..i).chain(i + 1..=i + n);
if around.clone().all(|j| o.high[j] < o.high[i]) {
up[i] = o.high[i];
}
if around.all(|j| o.low[j] > o.low[i]) {
down[i] = o.low[i];
}
}
(up, down)
}
/// Zigzag through swing highs and lows that reverse by at least `deviation` percent,
/// interpolated linearly between pivots. The last leg runs to the latest extreme and
/// repaints until the reversal is confirmed.
pub fn zigzag(o: &Ohlcv, deviation: f64) -> Vec<f64> {
let len = o.len();
let mut out = vec![NAN; len];
if len == 0 {
return out;
}
let (h, l) = (&o.high, &o.low);
let (up_by, down_by) = (1.0 + deviation / 100.0, 1.0 - deviation / 100.0);
let mut pivots: Vec<(usize, f64)> = Vec::new();
let (mut dir, mut hi, mut lo) = (0i8, 0usize, 0usize);
for i in 1..len {
match dir {
1 if h[i] >= h[hi] => hi = i,
1 if l[i] <= h[hi] * down_by => {
pivots.push((hi, h[hi]));
(dir, lo) = (-1, i);
}
-1 if l[i] <= l[lo] => lo = i,
-1 if h[i] >= l[lo] * up_by => {
pivots.push((lo, l[lo]));
(dir, hi) = (1, i);
}
0 => {
if h[i] > h[hi] {
hi = i;
}
if l[i] < l[lo] {
lo = i;
}
if hi > lo && h[hi] >= l[lo] * up_by {
pivots.push((lo, l[lo]));
dir = 1;
} else if lo > hi && l[lo] <= h[hi] * down_by {
pivots.push((hi, h[hi]));
dir = -1;
}
}
_ => {}
}
}
match dir {
1 => pivots.push((hi, h[hi])),
-1 => pivots.push((lo, l[lo])),
_ => {}
}
for w in pivots.windows(2) {
let ((i0, v0), (i1, v1)) = (w[0], w[1]);
for (k, slot) in out.iter_mut().enumerate().take(i1 + 1).skip(i0) {
*slot = v0 + (v1 - v0) * (k - i0) as f64 / (i1 - i0).max(1) as f64;
}
}
out
}
/// Choppiness index: 100 × log10(ΣTR / (highest high − lowest low)) / log10(n). High values
/// mean a sideways market, low values a trend.
pub fn choppiness(o: &Ohlcv, n: usize) -> Vec<f64> {
let tr = sum(&true_range(o), n);
let (hh, ll) = (highest(&o.high, n), lowest(&o.low, n));
let k = (n as f64).log10();
(0..o.len()).map(|i| 100.0 * ratio(tr[i], hh[i] - ll[i], n as f64).log10() / k).collect()
}
/// Vertical horizontal filter: the `n`-bar close range over the sum of bar-to-bar moves.
pub fn vhf(values: &[f64], n: usize) -> Vec<f64> {
let (hh, ll) = (highest(values, n), lowest(values, n));
let path = sum(&change(values, 1).into_iter().map(f64::abs).collect::<Vec<_>>(), n);
(0..values.len()).map(|i| ratio(hh[i] - ll[i], path[i], 0.0)).collect()
}
/// Random walk index: the `n`-bar move against the range a random walk would cover.
pub fn rwi(o: &Ohlcv, n: usize) -> (Vec<f64>, Vec<f64>) {
let a = atr(o, n);
let scale = (n as f64).sqrt();
let high = (0..o.len()).map(|i| ratio(o.high[i] - ago(&o.low, i, n), a[i] * scale, 0.0)).collect();
let low = (0..o.len()).map(|i| ratio(ago(&o.high, i, n) - o.low[i], a[i] * scale, 0.0)).collect();
(high, low)
}
/// Trend intensity index: the share of positive deviations from SMA(`ma_n`) over the last
/// `n` bars, 0..100.
pub fn tii(values: &[f64], ma_n: usize, n: usize) -> Vec<f64> {
let dev = zip(values, &sma(values, ma_n), |c, m| c - m);
let pos: Vec<f64> = dev.iter().map(|d| if d.is_nan() { NAN } else { d.max(0.0) }).collect();
let neg: Vec<f64> = dev.iter().map(|d| if d.is_nan() { NAN } else { (-d).max(0.0) }).collect();
zip(&sum(&pos, n), &sum(&neg, n), |p, q| 100.0 * ratio(p, p + q, 0.5))
}
/// Chande Kroll stop: ATR-offset extremes over `p` bars, then their extremes over `q`.
/// Returns (stop for shorts, stop for longs).
pub fn chande_kroll(o: &Ohlcv, p: usize, x: f64, q: usize) -> (Vec<f64>, Vec<f64>) {
let a = atr(o, p);
let first_high = zip(&highest(&o.high, p), &a, |h, a| h - x * a);
let first_low = zip(&lowest(&o.low, p), &a, |l, a| l + x * a);
(highest(&first_high, q), lowest(&first_low, q))
}
/// Chandelier exits: `mult` × ATR(`n`) below the `n`-bar high (longs) and above the low
/// (shorts).
pub fn chandelier(o: &Ohlcv, n: usize, mult: f64) -> (Vec<f64>, Vec<f64>) {
let a = atr(o, n);
(zip(&highest(&o.high, n), &a, |h, a| h - mult * a), zip(&lowest(&o.low, n), &a, |l, a| l + mult * a))
}
#[derive(Debug, Clone)]
pub struct Squeeze {
/// Linear-regression momentum of the close against its channel midpoint.
pub momentum: Vec<f64>,
/// Bollinger bands inside the Keltner channels.
pub on: Vec<bool>,
/// Bollinger bands outside the Keltner channels.
pub off: Vec<bool>,
}
/// TTM-style squeeze momentum: Bollinger(`bb_n`, `bb_mult`) against Keltner(`kc_n`,
/// `kc_mult`, SMA of true range), with linear-regression momentum.
pub fn squeeze(o: &Ohlcv, bb_n: usize, bb_mult: f64, kc_n: usize, kc_mult: f64) -> Squeeze {
let bb = bollinger(&o.close, bb_n, bb_mult);
let (ma, range) = (sma(&o.close, kc_n), sma(&true_range(o), kc_n));
let len = o.len();
let (mut on, mut off) = (vec![false; len], vec![false; len]);
for i in 0..len {
let (ku, kl) = (ma[i] + kc_mult * range[i], ma[i] - kc_mult * range[i]);
on[i] = bb.lower[i] > kl && bb.upper[i] < ku;
off[i] = bb.lower[i] < kl && bb.upper[i] > ku;
}
let (hh, ll) = (highest(&o.high, kc_n), lowest(&o.low, kc_n));
let rel: Vec<f64> = (0..len).map(|i| o.close[i] - ((hh[i] + ll[i]) / 2.0 + ma[i]) / 2.0).collect();
Squeeze { momentum: linreg(&rel, kc_n), on, off }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Bar;
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9 * (1.0 + b.abs())
}
fn ohlcv(rows: &[(f64, f64, f64, f64)]) -> Ohlcv {
let bars: Vec<Bar> =
rows.iter().enumerate().map(|(i, r)| Bar::new(i as i64, r.0, r.1, r.2, r.3, 100.0)).collect();
Ohlcv::new(&bars)
}
fn line(n: usize, slope: f64) -> Ohlcv {
let rows: Vec<(f64, f64, f64, f64)> =
(0..n).map(|i| 100.0 + slope * i as f64).map(|c| (c - slope / 2.0, c + 0.5, c - 0.5, c)).collect();
ohlcv(&rows)
}
#[test]
fn dmi_in_a_steady_uptrend() {
let d = dmi(&line(100, 1.0), 14, 14);
assert!(d.plus[13].is_nan() && d.plus[14].is_finite());
assert!(d.minus[99] == 0.0 && d.plus[99] > 0.0);
assert!(near(d.adx[99], 100.0));
}
#[test]
fn aroon_counts_bars_since_extremes() {
let o = line(30, 1.0);
let (up, down) = aroon(&o, 10);
assert!(up[9].is_nan());
assert_eq!((up[20], down[20]), (100.0, 0.0));
}
#[test]
fn trails_follow_the_trend() {
let o = line(80, 1.0);
for t in
[psar(&o, 0.02, 0.02, 0.2), supertrend(&o, 10, 3.0), volatility_stop(&o, 20, 2.0), gann_hilo(&o, 13, 21)]
{
assert!(t.up[79], "should trail an uptrend");
assert!(t.value[79] < o.low[79]);
let (u, d) = t.split();
assert!(u[79].is_finite() && d[79].is_nan());
}
let falling = line(80, -1.0);
let st = supertrend(&falling, 10, 3.0);
assert!(!st.up[79] && st.value[79] > falling.high[79]);
}
#[test]
fn psar_flips_on_reversal() {
let mut rows: Vec<(f64, f64, f64, f64)> =
(0..20).map(|i| 100.0 + i as f64).map(|c| (c, c + 0.5, c - 0.5, c)).collect();
rows.extend((0..20).map(|i| 119.0 - 2.0 * i as f64).map(|c| (c, c + 0.5, c - 0.5, c)));
let p = psar(&ohlcv(&rows), 0.02, 0.02, 0.2);
assert!(p.up[15] && !p.up[39]);
assert!(p.value[39] > rows[39].1);
}
#[test]
fn ichimoku_displacement() {
let o = line(100, 1.0);
let k = ichimoku(&o, 9, 26, 52, 26);
assert_eq!(k.span_a.len(), 125);
assert!(near(k.span_a[124], (k.conversion[99] + k.base[99]) / 2.0));
assert!(near(k.lagging[0], o.close[25]));
assert!(k.lagging[99].is_nan());
let a = alligator(&o, [13, 8, 5], [8, 5, 3]);
assert_eq!(a.jaw.len(), 108);
let (up, low) = gator(&o, [13, 8, 5], [8, 5, 3]);
assert!(up[99] > 0.0 && low[99] < 0.0);
}
#[test]
fn fractals_and_zigzag_find_swings() {
// Up 10%, down 10%, up again.
let path = [100.0, 104.0, 110.0, 105.0, 99.0, 103.0, 108.0];
let rows: Vec<(f64, f64, f64, f64)> = path.iter().map(|&c| (c, c, c, c)).collect();
let o = ohlcv(&rows);
let (up, down) = fractals(&o, 2);
assert_eq!(up[2], 110.0);
assert_eq!(down[4], 99.0);
assert!(up[3].is_nan());
let z = zigzag(&o, 5.0);
assert_eq!((z[0], z[2], z[4], z[6]), (100.0, 110.0, 99.0, 108.0));
assert!(near(z[1], 105.0) && near(z[3], 104.5));
}
#[test]
fn trend_filters() {
let o = line(100, 1.0);
assert!(near(vhf(&o.close, 28)[99], 27.0 / 28.0));
assert!(near(tii(&o.close, 60, 30)[99], 100.0));
let c = choppiness(&o, 14)[99];
assert!(c > 0.0 && c < 50.0, "trending market is not choppy: {c}");
let (hi, lo) = rwi(&o, 14);
assert!(hi[99] > lo[99]);
let (sp_short, sp_long) = chande_kroll(&o, 10, 1.0, 9);
assert!(sp_short[99].is_finite() && sp_long[99].is_finite());
let (long_exit, short_exit) = chandelier(&o, 22, 3.0);
assert!(near(long_exit[99], o.high[99] - 3.0 * atr(&o, 22)[99]));
assert!(near(short_exit[99], lowest(&o.low, 22)[99] + 3.0 * atr(&o, 22)[99]));
let s = squeeze(&o, 20, 2.0, 20, 1.5);
assert!(s.momentum[99] > 0.0);
}
}
+307
View File
@@ -0,0 +1,307 @@
//! Ranges, bands and channels, and volatility estimators.
use crate::Timeframe;
use super::ma::{ema, rma, sma};
use super::momentum::roc;
use super::stats::{change, highest, linreg_map, lowest, stdev, sum, variance};
use super::{NAN, Ohlcv, ago, ratio, zip};
/// True range: the bar's range extended to the previous close (high − low on the first bar).
pub fn true_range(o: &Ohlcv) -> Vec<f64> {
(0..o.len())
.map(|i| {
let p = ago(&o.close, i, 1);
if p.is_nan() { o.high[i] - o.low[i] } else { o.high[i].max(p) - o.low[i].min(p) }
})
.collect()
}
/// Average true range (Wilder).
pub fn atr(o: &Ohlcv, n: usize) -> Vec<f64> {
rma(&true_range(o), n)
}
/// ATR as a percentage of the close.
pub fn natr(o: &Ohlcv, n: usize) -> Vec<f64> {
zip(&atr(o, n), &o.close, |a, c| 100.0 * ratio(a, c, NAN))
}
#[derive(Debug, Clone)]
pub struct Bands {
pub upper: Vec<f64>,
pub middle: Vec<f64>,
pub lower: Vec<f64>,
}
impl Bands {
/// `middle ± width`.
fn around(middle: Vec<f64>, width: &[f64]) -> Self {
let upper = zip(&middle, width, |m, w| m + w);
let lower = zip(&middle, width, |m, w| m - w);
Bands { upper, middle, lower }
}
}
/// Bollinger bands: SMA ± `k` population standard deviations.
pub fn bollinger(closes: &[f64], n: usize, k: f64) -> Bands {
let sd: Vec<f64> = stdev(closes, n).into_iter().map(|s| k * s).collect();
Bands::around(sma(closes, n), &sd)
}
/// Bollinger %B: where the close sits between the bands (0 at the lower, 1 at the upper).
pub fn percent_b(closes: &[f64], n: usize, k: f64) -> Vec<f64> {
let b = bollinger(closes, n, k);
(0..closes.len()).map(|i| ratio(closes[i] - b.lower[i], b.upper[i] - b.lower[i], 0.5)).collect()
}
/// Bollinger bandwidth: band width as a percentage of the middle band.
pub fn bandwidth(closes: &[f64], n: usize, k: f64) -> Vec<f64> {
let b = bollinger(closes, n, k);
(0..closes.len()).map(|i| 100.0 * ratio(b.upper[i] - b.lower[i], b.middle[i], 0.0)).collect()
}
/// Keltner channels: EMA(close, `n`) ± `mult` × ATR(`atr_n`).
pub fn keltner(o: &Ohlcv, n: usize, mult: f64, atr_n: usize) -> Bands {
let w: Vec<f64> = atr(o, atr_n).into_iter().map(|a| mult * a).collect();
Bands::around(ema(&o.close, n), &w)
}
/// Donchian channels: the `n`-bar highest high and lowest low, and their midpoint.
pub fn donchian(o: &Ohlcv, n: usize) -> Bands {
let (upper, lower) = (highest(&o.high, n), lowest(&o.low, n));
let middle = zip(&upper, &lower, |h, l| (h + l) / 2.0);
Bands { upper, middle, lower }
}
/// Moving average envelopes: SMA ± `pct` percent.
pub fn envelopes(values: &[f64], n: usize, pct: f64) -> Bands {
let middle = sma(values, n);
let w: Vec<f64> = middle.iter().map(|m| m * pct / 100.0).collect();
Bands::around(middle, &w)
}
/// Headley's acceleration bands: SMAs of the high and low pushed out by `factor` times the
/// bar's relative range.
pub fn acceleration_bands(o: &Ohlcv, n: usize, factor: f64) -> Bands {
let rel: Vec<f64> = (0..o.len()).map(|i| ratio(o.high[i] - o.low[i], o.high[i] + o.low[i], 0.0)).collect();
let up: Vec<f64> = (0..o.len()).map(|i| o.high[i] * (1.0 + factor * rel[i])).collect();
let dn: Vec<f64> = (0..o.len()).map(|i| o.low[i] * (1.0 - factor * rel[i])).collect();
Bands { upper: sma(&up, n), middle: sma(&o.close, n), lower: sma(&dn, n) }
}
/// STARC bands: SMA(close, `n`) ± `mult` × ATR(`atr_n`).
pub fn starc(o: &Ohlcv, n: usize, atr_n: usize, mult: f64) -> Bands {
let w: Vec<f64> = atr(o, atr_n).into_iter().map(|a| mult * a).collect();
Bands::around(sma(&o.close, n), &w)
}
/// Standard error bands: the `n`-bar regression value ± `k` standard errors, both smoothed
/// with an SMA over `smooth` bars.
pub fn standard_error_bands(values: &[f64], n: usize, smooth: usize, k: f64) -> Bands {
let mid = sma(&linreg_map(values, n, |f| f.at(n as f64 - 1.0)), smooth);
let se: Vec<f64> = sma(&linreg_map(values, n, |f| f.se), smooth).into_iter().map(|s| k * s).collect();
Bands::around(mid, &se)
}
/// Bars per year on `tf`, for annualising volatility (US equity hours intraday).
pub fn periods_per_year(tf: Timeframe) -> f64 {
match tf {
Timeframe::D1 => 252.0,
Timeframe::W1 => 52.0,
Timeframe::MN1 => 12.0,
intraday => 252.0 * 23_400.0 / intraday.seconds() as f64,
}
}
/// `ln(a / b)` for positive prices, NaN otherwise.
fn ln_ratio(a: f64, b: f64) -> f64 {
if a > 0.0 && b > 0.0 { (a / b).ln() } else { NAN }
}
/// Annualised percentage volatility from a per-bar variance; rounding can leave tiny
/// negative variances, which read as zero. Missing variances stay NaN.
fn annualised(variance: f64, ppy: f64) -> f64 {
if variance.is_finite() { 100.0 * (variance.max(0.0) * ppy).sqrt() } else { NAN }
}
/// Annualised percentage volatility from a rolling mean of per-bar variance terms.
fn annualise(terms: &[f64], n: usize, ppy: f64) -> Vec<f64> {
sma(terms, n).into_iter().map(|v| annualised(v, ppy)).collect()
}
/// Historical (close-to-close) volatility: annualised standard deviation of log returns, %.
pub fn historical_volatility(values: &[f64], n: usize, ppy: f64) -> Vec<f64> {
let r: Vec<f64> = (0..values.len()).map(|i| ln_ratio(values[i], ago(values, i, 1))).collect();
stdev(&r, n).into_iter().map(|s| 100.0 * s * ppy.sqrt()).collect()
}
/// Parkinson's high-low volatility estimator, annualised %.
pub fn parkinson(o: &Ohlcv, n: usize, ppy: f64) -> Vec<f64> {
let k = 1.0 / (4.0 * std::f64::consts::LN_2);
let terms: Vec<f64> = (0..o.len()).map(|i| k * ln_ratio(o.high[i], o.low[i]).powi(2)).collect();
annualise(&terms, n, ppy)
}
/// Garman-Klass OHLC volatility estimator, annualised %.
pub fn garman_klass(o: &Ohlcv, n: usize, ppy: f64) -> Vec<f64> {
let c = 2.0 * std::f64::consts::LN_2 - 1.0;
let terms: Vec<f64> = (0..o.len())
.map(|i| 0.5 * ln_ratio(o.high[i], o.low[i]).powi(2) - c * ln_ratio(o.close[i], o.open[i]).powi(2))
.collect();
annualise(&terms, n, ppy)
}
/// Per-bar Rogers-Satchell variance term (drift independent).
fn rs_terms(o: &Ohlcv) -> Vec<f64> {
(0..o.len())
.map(|i| {
let (h, l, op, c) = (o.high[i], o.low[i], o.open[i], o.close[i]);
ln_ratio(h, c) * ln_ratio(h, op) + ln_ratio(l, c) * ln_ratio(l, op)
})
.collect()
}
/// Rogers-Satchell volatility estimator, annualised %.
pub fn rogers_satchell(o: &Ohlcv, n: usize, ppy: f64) -> Vec<f64> {
annualise(&rs_terms(o), n, ppy)
}
/// Yang-Zhang volatility: overnight, open-to-close and Rogers-Satchell variances combined
/// to minimise estimator variance, annualised %.
pub fn yang_zhang(o: &Ohlcv, n: usize, ppy: f64) -> Vec<f64> {
let overnight: Vec<f64> = (0..o.len()).map(|i| ln_ratio(o.open[i], ago(&o.close, i, 1))).collect();
let intraday: Vec<f64> = (0..o.len()).map(|i| ln_ratio(o.close[i], o.open[i])).collect();
let k = n as f64;
let sample = if n > 1 { k / (k - 1.0) } else { 1.0 };
let w = 0.34 / (1.34 + (k + 1.0) / (k - 1.0).max(1.0));
let (vo, vc, rs) = (variance(&overnight, n), variance(&intraday, n), sma(&rs_terms(o), n));
(0..o.len()).map(|i| annualised(sample * vo[i] + w * sample * vc[i] + (1.0 - w) * rs[i], ppy)).collect()
}
/// Ulcer index: root mean square of the percentage drawdown from the `n`-bar high.
pub fn ulcer(values: &[f64], n: usize) -> Vec<f64> {
let hh = highest(values, n);
let dd: Vec<f64> = (0..values.len()).map(|i| (100.0 * ratio(values[i] - hh[i], hh[i], 0.0)).powi(2)).collect();
sma(&dd, n).into_iter().map(f64::sqrt).collect()
}
/// Chaikin volatility: the `roc_n`-bar rate of change of the EMA of the high-low range.
pub fn chaikin_volatility(o: &Ohlcv, n: usize, roc_n: usize) -> Vec<f64> {
roc(&ema(&zip(&o.high, &o.low, |h, l| h - l), n), roc_n)
}
/// Dorsey's mass index: the `sum_n`-bar sum of EMA(range) / EMA(EMA(range)).
pub fn mass_index(o: &Ohlcv, n: usize, sum_n: usize) -> Vec<f64> {
let e1 = ema(&zip(&o.high, &o.low, |h, l| h - l), n);
let e2 = ema(&e1, n);
sum(&zip(&e1, &e2, |a, b| ratio(a, b, 1.0)), sum_n)
}
/// Dorsey's relative volatility index: an RSI of the `n`-bar standard deviation, split by
/// the direction of the close, EMA-smoothed over `smooth`.
pub fn relative_volatility_index(values: &[f64], n: usize, smooth: usize) -> Vec<f64> {
let sd = stdev(values, n);
let ch = change(values, 1);
let side = |up: bool| -> Vec<f64> {
(0..values.len()).map(|i| if sd[i].is_nan() || (ch[i] > 0.0) == up { sd[i] } else { 0.0 }).collect()
};
zip(&ema(&side(true), smooth), &ema(&side(false), smooth), |u, d| 100.0 * ratio(u, u + d, 0.5))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Bar;
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9 * (1.0 + b.abs())
}
fn ohlcv(rows: &[(f64, f64, f64, f64)]) -> Ohlcv {
let bars: Vec<Bar> =
rows.iter().enumerate().map(|(i, r)| Bar::new(i as i64, r.0, r.1, r.2, r.3, 100.0)).collect();
Ohlcv::new(&bars)
}
#[test]
fn true_range_uses_previous_close() {
let o = ohlcv(&[(10.0, 11.0, 9.0, 10.0), (12.0, 13.0, 12.0, 12.5), (9.0, 10.0, 8.0, 9.0)]);
assert_eq!(true_range(&o), vec![2.0, 3.0, 4.5]);
let a = atr(&o, 2);
assert!(a[0].is_nan() && near(a[1], 2.5) && near(a[2], 0.5 * 4.5 + 0.5 * 2.5));
}
#[test]
fn bands_hand_computed() {
let o = ohlcv(&[(1.0, 2.0, 0.0, 1.0), (2.0, 4.0, 1.0, 3.0), (3.0, 3.5, 2.5, 3.0)]);
let d = donchian(&o, 2);
assert!(near(d.upper[2], 4.0) && near(d.lower[2], 1.0) && near(d.middle[2], 2.5));
let e = envelopes(&o.close, 2, 10.0);
assert!(near(e.upper[2], 3.3) && near(e.lower[2], 2.7));
let b = bollinger(&[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0], 8, 2.0);
assert!(near(percent_b(&[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0], 8, 2.0)[7], (9.0 - 1.0) / 8.0));
assert!(near(bandwidth(&[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0], 8, 2.0)[7], 800.0 / 5.0));
assert!(near(b.middle[7], 5.0));
// Flat prices: zero-width bands put %B in the middle.
assert_eq!(percent_b(&[3.0; 10], 5, 2.0)[9], 0.5);
}
#[test]
fn estimators_agree_on_constant_ranges() {
// Every bar spans a fixed log range and opens/closes at the extremes alternately:
// the range-based estimators then all see the same per-bar variance.
let rows: Vec<(f64, f64, f64, f64)> = (0..200)
.map(|i| {
let lo = 100.0 * (1.0 + 0.001 * (i % 7) as f64);
let hi = lo * 1.02;
if i % 2 == 0 { (lo, hi, lo, hi) } else { (hi, hi, lo, lo) }
})
.collect();
let o = ohlcv(&rows);
let r = (1.02f64).ln();
let ppy = 252.0;
// Rogers-Satchell is zero when bars open and close at their extremes.
assert!(near(rogers_satchell(&o, 20, ppy)[100], 0.0));
let p = parkinson(&o, 20, ppy)[100];
assert!(near(p, 100.0 * (r * r / (4.0 * std::f64::consts::LN_2) * ppy).sqrt()));
let gk = garman_klass(&o, 20, ppy)[100];
let c = 2.0 * std::f64::consts::LN_2 - 1.0;
assert!(near(gk, 100.0 * ((0.5 * r * r - c * r * r) * ppy).sqrt()));
assert!(yang_zhang(&o, 20, ppy)[100].is_finite());
}
#[test]
fn estimators_are_blank_until_their_window_fills() {
let rows: Vec<(f64, f64, f64, f64)> = (0..40).map(|i| (100.0, 101.0 + (i % 3) as f64, 99.0, 100.5)).collect();
let o = ohlcv(&rows);
for (name, v, first) in [
("parkinson", parkinson(&o, 20, 252.0), 19),
("garman-klass", garman_klass(&o, 20, 252.0), 19),
("rogers-satchell", rogers_satchell(&o, 20, 252.0), 19),
("yang-zhang", yang_zhang(&o, 20, 252.0), 20),
] {
assert!(v[..first].iter().all(|x| x.is_nan()), "{name} reads before its window fills");
assert!(v[first..].iter().all(|x| x.is_finite() && *x > 0.0), "{name} is blank after warm-up");
}
// A bar with a non-positive price has no log range; its windows stay blank.
let mut rows = rows;
rows[30].2 = 0.0;
assert!(parkinson(&ohlcv(&rows), 20, 252.0)[30..].iter().all(|x| x.is_nan()));
}
#[test]
fn historical_volatility_of_constant_growth_is_zero() {
let g: Vec<f64> = (0..50).map(|i| 100.0 * 1.01f64.powi(i)).collect();
assert!(historical_volatility(&g, 20, 252.0)[49].abs() < 1e-6);
assert!(ulcer(&g, 14)[49].abs() < 1e-9);
assert_eq!(periods_per_year(Timeframe::H1), 252.0 * 6.5);
}
#[test]
fn mass_index_of_constant_range() {
let rows: Vec<(f64, f64, f64, f64)> =
(0..100).map(|i| (i as f64, i as f64 + 2.0, i as f64, i as f64 + 1.0)).collect();
assert!(near(mass_index(&ohlcv(&rows), 9, 25)[99], 25.0));
}
}
+395
View File
@@ -0,0 +1,395 @@
//! Volume: cumulative flows, money flow, volume oscillators and session VWAP.
use crate::Bar;
use super::ma::{ema, rma, sma};
use super::momentum::{Macd, ppo, roc};
use super::stats::{change, sum};
use super::volatility::Bands;
use super::{NAN, Ohlcv, ago, ratio, zip};
/// VWAP resetting whenever `session_of(ts)` changes; falls back to the typical price
/// while the session has no volume.
pub fn vwap_by(bars: &[Bar], session_of: impl Fn(i64) -> i64) -> Vec<f64> {
let mut out = Vec::with_capacity(bars.len());
let (mut pv, mut vol) = (0.0, 0.0);
let mut session = None;
for b in bars {
let s = session_of(b.ts);
if session != Some(s) {
session = Some(s);
pv = 0.0;
vol = 0.0;
}
pv += b.hlc3() * b.volume;
vol += b.volume;
out.push(if vol > 0.0 { pv / vol } else { b.hlc3() });
}
out
}
/// Session VWAP resetting each UTC day. For daily and longer bars this
/// degenerates to hlc3, so callers typically anchor it by month instead.
pub fn vwap(bars: &[Bar]) -> Vec<f64> {
vwap_by(bars, |ts| ts.div_euclid(86_400))
}
/// Session VWAP with bands `k` volume-weighted standard deviations of the typical price
/// away.
pub fn vwap_bands(o: &Ohlcv, session_of: impl Fn(i64) -> i64, k: f64) -> Bands {
let tp = o.hlc3();
let len = o.len();
let (mut middle, mut width) = (vec![NAN; len], vec![NAN; len]);
let (mut pv, mut pv2, mut vol, mut session) = (0.0, 0.0, 0.0, None);
for i in 0..len {
let s = session_of(o.ts[i]);
if session != Some(s) {
(pv, pv2, vol, session) = (0.0, 0.0, 0.0, Some(s));
}
let v = o.volume[i];
pv += tp[i] * v;
pv2 += tp[i] * tp[i] * v;
vol += v;
if vol > 0.0 {
let m = pv / vol;
middle[i] = m;
width[i] = k * (pv2 / vol - m * m).max(0.0).sqrt();
} else {
(middle[i], width[i]) = (tp[i], 0.0);
}
}
let upper = zip(&middle, &width, |m, w| m + w);
let lower = zip(&middle, &width, |m, w| m - w);
Bands { upper, middle, lower }
}
/// Running total of `f(i)` from the first bar.
fn cumulative(len: usize, mut f: impl FnMut(usize) -> f64) -> Vec<f64> {
let mut acc = 0.0;
(0..len)
.map(|i| {
acc += f(i);
acc
})
.collect()
}
/// `values[i] - values[i - 1]` with 0 on the first bar.
fn step(values: &[f64], i: usize) -> f64 {
if i == 0 { 0.0 } else { values[i] - values[i - 1] }
}
/// On-balance volume: volume added on up closes, subtracted on down closes.
pub fn obv(o: &Ohlcv) -> Vec<f64> {
cumulative(o.len(), |i| {
let d = step(&o.close, i);
if d > 0.0 {
o.volume[i]
} else if d < 0.0 {
-o.volume[i]
} else {
0.0
}
})
}
/// Close location value × volume: the money flow volume of each bar.
fn money_flow_volume(o: &Ohlcv) -> Vec<f64> {
(0..o.len())
.map(|i| {
let (h, l, c) = (o.high[i], o.low[i], o.close[i]);
ratio((c - l) - (h - c), h - l, 0.0) * o.volume[i]
})
.collect()
}
/// Chaikin's accumulation/distribution line.
pub fn adl(o: &Ohlcv) -> Vec<f64> {
let mfv = money_flow_volume(o);
cumulative(o.len(), |i| mfv[i])
}
/// Chaikin money flow: money flow volume over volume across `n` bars, −1..1.
pub fn cmf(o: &Ohlcv, n: usize) -> Vec<f64> {
zip(&sum(&money_flow_volume(o), n), &sum(&o.volume, n), |m, v| ratio(m, v, 0.0))
}
/// Chaikin oscillator: EMA(ADL, `fast`) − EMA(ADL, `slow`).
pub fn chaikin_osc(o: &Ohlcv, fast: usize, slow: usize) -> Vec<f64> {
let a = adl(o);
zip(&ema(&a, fast), &ema(&a, slow), |f, s| f - s)
}
/// Money flow index: a volume-weighted RSI of the typical price, 0..100.
pub fn mfi(o: &Ohlcv, n: usize) -> Vec<f64> {
let tp = o.hlc3();
let flow = |up: bool| -> Vec<f64> {
(0..o.len())
.map(|i| {
let d = tp[i] - ago(&tp, i, 1);
if d.is_nan() {
NAN
} else if (up && d > 0.0) || (!up && d < 0.0) {
tp[i] * o.volume[i]
} else {
0.0
}
})
.collect()
};
zip(&sum(&flow(true), n), &sum(&flow(false), n), |p, q| 100.0 * ratio(p, p + q, 0.5))
}
/// Elder's force index: EMA of the close change × volume.
pub fn force_index(o: &Ohlcv, n: usize) -> Vec<f64> {
ema(&zip(&change(&o.close, 1), &o.volume, |d, v| d * v), n)
}
/// Arms' ease of movement: SMA of the median-price move per unit of volume, scaled by
/// `divisor`. Bars without volume contribute nothing.
pub fn ease_of_movement(o: &Ohlcv, n: usize, divisor: f64) -> Vec<f64> {
let hl2 = o.hl2();
let raw: Vec<f64> = (0..o.len())
.map(|i| ratio(divisor * (hl2[i] - ago(&hl2, i, 1)) * (o.high[i] - o.low[i]), o.volume[i], 0.0))
.collect();
sma(&raw, n)
}
/// Price volume trend: cumulative volume × percentage close change.
pub fn pvt(o: &Ohlcv) -> Vec<f64> {
cumulative(o.len(), |i| if i == 0 { 0.0 } else { ratio(step(&o.close, i), o.close[i - 1], 0.0) * o.volume[i] })
}
/// A volume index starting at 1000 that compounds the close's percentage change only on
/// bars where `pick(volume, previous volume)` holds.
fn volume_index(o: &Ohlcv, pick: impl Fn(f64, f64) -> bool) -> Vec<f64> {
let mut v = 1000.0;
(0..o.len())
.map(|i| {
if i > 0 && pick(o.volume[i], o.volume[i - 1]) {
v *= 1.0 + ratio(step(&o.close, i), o.close[i - 1], 0.0);
}
v
})
.collect()
}
/// Negative volume index (moves on volume declines) with an EMA signal.
pub fn nvi(o: &Ohlcv, signal: usize) -> (Vec<f64>, Vec<f64>) {
let line = volume_index(o, |v, p| v < p);
let sig = ema(&line, signal);
(line, sig)
}
/// Positive volume index (moves on volume increases) with an EMA signal.
pub fn pvi(o: &Ohlcv, signal: usize) -> (Vec<f64>, Vec<f64>) {
let line = volume_index(o, |v, p| v > p);
let sig = ema(&line, signal);
(line, sig)
}
/// Klinger volume oscillator (simplified): EMA difference of volume signed by the
/// typical-price trend, with an EMA signal.
pub fn klinger(o: &Ohlcv, fast: usize, slow: usize, signal: usize) -> Macd {
let tp = o.hlc3();
let sv: Vec<f64> = (0..o.len())
.map(|i| {
let d = tp[i] - ago(&tp, i, 1);
if d.is_nan() {
NAN
} else if d >= 0.0 {
o.volume[i]
} else {
-o.volume[i]
}
})
.collect();
let line = zip(&ema(&sv, fast), &ema(&sv, slow), |f, s| f - s);
let signal = ema(&line, signal);
let histogram = zip(&line, &signal, |a, b| a - b);
Macd { macd: line, signal, histogram }
}
/// Volume oscillator: percentage difference of fast and slow volume EMAs.
pub fn volume_osc(o: &Ohlcv, fast: usize, slow: usize) -> Vec<f64> {
zip(&ema(&o.volume, fast), &ema(&o.volume, slow), |f, s| 100.0 * ratio(f - s, s, 0.0))
}
/// Percentage volume oscillator: PPO of volume, with signal.
pub fn pvo(o: &Ohlcv, fast: usize, slow: usize, signal: usize) -> Macd {
ppo(&o.volume, fast, slow, signal)
}
/// Volume rate of change in percent over `n` bars.
pub fn vroc(o: &Ohlcv, n: usize) -> Vec<f64> {
roc(&o.volume, n)
}
/// Relative volume: this bar's volume over the average of the previous `n` bars.
pub fn relative_volume(o: &Ohlcv, n: usize) -> Vec<f64> {
let avg = sma(&o.volume, n);
(0..o.len()).map(|i| ratio(o.volume[i], ago(&avg, i, 1), NAN)).collect()
}
/// Twiggs money flow: Wilder-smoothed true-range money flow volume over smoothed volume.
pub fn twiggs_mf(o: &Ohlcv, n: usize) -> Vec<f64> {
let adv: Vec<f64> = (0..o.len())
.map(|i| {
let p = ago(&o.close, i, 1);
let (th, tl) = (o.high[i].max(p), o.low[i].min(p));
ratio((o.close[i] - tl) - (th - o.close[i]), th - tl, 0.0) * o.volume[i]
})
.collect();
zip(&rma(&adv, n), &rma(&o.volume, n), |a, v| ratio(a, v, 0.0))
}
/// Williams accumulation/distribution: cumulative close moves measured from the true range
/// extreme on the opposite side.
pub fn williams_ad(o: &Ohlcv) -> Vec<f64> {
cumulative(o.len(), |i| {
if i == 0 {
return 0.0;
}
let (p, c) = (o.close[i - 1], o.close[i]);
if c > p {
c - o.low[i].min(p)
} else if c < p {
c - o.high[i].max(p)
} else {
0.0
}
})
}
/// Wilder's accumulative swing index with limit move `limit`.
pub fn asi(o: &Ohlcv, limit: f64) -> Vec<f64> {
cumulative(o.len(), |i| {
if i == 0 {
return 0.0;
}
let (h, l, c, op) = (o.high[i], o.low[i], o.close[i], o.open[i]);
let (pc, po) = (o.close[i - 1], o.open[i - 1]);
let (a, b, cc, d) = ((h - pc).abs(), (l - pc).abs(), h - l, (pc - po).abs());
let r = if a >= b && a >= cc {
a - 0.5 * b + 0.25 * d
} else if b >= a && b >= cc {
b - 0.5 * a + 0.25 * d
} else {
cc + 0.25 * d
};
let k = a.max(b);
ratio(50.0 * (c - pc + 0.5 * (c - op) + 0.25 * (pc - po)) * k, r * limit, 0.0)
})
}
/// Bill Williams' market facilitation index: range per unit of volume.
pub fn bw_mfi(o: &Ohlcv) -> Vec<f64> {
(0..o.len()).map(|i| ratio(o.high[i] - o.low[i], o.volume[i], 0.0)).collect()
}
/// Net volume: volume signed by the close's direction.
pub fn net_volume(o: &Ohlcv) -> Vec<f64> {
(0..o.len())
.map(|i| {
let d = step(&o.close, i);
if d > 0.0 {
o.volume[i]
} else if d < 0.0 {
-o.volume[i]
} else {
0.0
}
})
.collect()
}
/// Volume zone oscillator: EMA of direction-signed volume over EMA of volume, −100..100.
pub fn vzo(o: &Ohlcv, n: usize) -> Vec<f64> {
zip(&ema(&net_volume(o), n), &ema(&o.volume, n), |s, v| 100.0 * ratio(s, v, 0.0))
}
/// Time segmented volume: the `n`-bar sum of close change × volume.
pub fn tsv(o: &Ohlcv, n: usize) -> Vec<f64> {
sum(&zip(&change(&o.close, 1), &o.volume, |d, v| d * v), n)
}
#[cfg(test)]
mod tests {
use super::*;
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9 * (1.0 + b.abs())
}
fn bars(rows: &[(f64, f64, f64, f64, f64)]) -> Ohlcv {
let b: Vec<Bar> =
rows.iter().enumerate().map(|(i, r)| Bar::new(i as i64 * 60, r.0, r.1, r.2, r.3, r.4)).collect();
Ohlcv::new(&b)
}
#[test]
fn cumulative_flows_hand_computed() {
let o = bars(&[
(10.0, 12.0, 8.0, 11.0, 100.0),
(11.0, 13.0, 10.0, 10.0, 200.0),
(10.0, 11.0, 9.0, 10.0, 50.0),
(10.0, 12.0, 10.0, 12.0, 300.0),
]);
assert_eq!(obv(&o), vec![0.0, -200.0, -200.0, 100.0]);
// Close location: (11−8 − (12−11))/4 = 0.5, (0 − 3)/3 = −1, 0, 1.
assert_eq!(adl(&o), vec![50.0, -150.0, -150.0, 150.0]);
assert!(near(cmf(&o, 4)[3], 150.0 / 650.0));
assert!(near(pvt(&o)[1], -200.0 / 11.0));
assert_eq!(net_volume(&o), vec![0.0, -200.0, 0.0, 300.0]);
assert_eq!(williams_ad(&o), vec![0.0, 0.0 + (10.0 - 13.0), -3.0, -3.0 + (12.0 - 10.0)]);
let (n, _) = nvi(&o, 3);
let (p, _) = pvi(&o, 3);
assert!(near(n[2], 1000.0) && near(p[1], 1000.0 * (1.0 - 1.0 / 11.0)));
assert!(near(n[3], 1000.0) && near(p[3], p[1] * 1.2));
}
#[test]
fn money_flow_index_bounds() {
let rising: Vec<(f64, f64, f64, f64, f64)> =
(0..30).map(|i| i as f64 + 10.0).map(|c| (c, c + 1.0, c - 1.0, c, 1000.0)).collect();
assert_eq!(mfi(&bars(&rising), 14)[29], 100.0);
let flat: Vec<(f64, f64, f64, f64, f64)> = (0..30).map(|_| (5.0, 5.0, 5.0, 5.0, 0.0)).collect();
let o = bars(&flat);
assert_eq!(mfi(&o, 14)[29], 50.0);
assert_eq!(ease_of_movement(&o, 14, 10_000.0)[29], 0.0);
assert_eq!(cmf(&o, 20)[29], 0.0);
assert!(relative_volume(&o, 20)[29].is_nan());
}
#[test]
fn vwap_bands_weight_by_volume() {
let raw = [Bar::new(0, 0.0, 10.0, 10.0, 10.0, 1.0), Bar::new(60, 0.0, 20.0, 20.0, 20.0, 3.0)];
let b = vwap_bands(&Ohlcv::new(&raw), |_| 0, 1.0);
// Mean 17.5; variance (1·56.25 + 3·6.25) / 4 = 18.75.
assert!(near(b.middle[1], 17.5));
assert!(near(b.upper[1] - b.middle[1], 18.75f64.sqrt()));
assert_eq!(b.upper[0], b.lower[0]);
assert!(near(vwap_by(&raw, |_| 0)[1], b.middle[1]));
}
#[test]
fn oscillators_relate_to_their_parts() {
let rows: Vec<(f64, f64, f64, f64, f64)> = (0..120)
.map(|i| {
let c = 50.0 + (i as f64 * 0.2).sin() * 4.0;
(c - 0.2, c + 0.6, c - 0.7, c, 1000.0 + (i % 5) as f64 * 150.0)
})
.collect();
let o = bars(&rows);
let v = &o.volume;
assert!(near(volume_osc(&o, 5, 10)[60], 100.0 * (ema(v, 5)[60] - ema(v, 10)[60]) / ema(v, 10)[60]));
assert!(near(relative_volume(&o, 20)[60], v[60] / sma(v, 20)[59]));
let z = vzo(&o, 14);
assert!(z.iter().filter(|x| x.is_finite()).all(|x| x.abs() <= 100.0));
let k = klinger(&o, 34, 55, 13);
assert!(near(k.histogram[100], k.macd[100] - k.signal[100]));
assert!(twiggs_mf(&o, 21)[100].abs() <= 1.0);
assert!(asi(&o, 10_000.0)[119].is_finite());
assert!(near(tsv(&o, 13)[50], (38..=50).map(|i| (o.close[i] - o.close[i - 1]) * v[i]).sum::<f64>()));
}
}
+2 -1
View File
@@ -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;
+334
View File
@@ -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());
}
}
}
+55 -8
View File
@@ -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
View File
@@ -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
View File
@@ -6,13 +6,12 @@ use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail};
use chrono::DateTime;
use futures::{SinkExt, StreamExt};
use serde_json::Value;
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
use tr_core::{Bar, Security, Timeframe, catalog};
use crate::{LiveFeed, Quote};
use crate::ws::Socket;
use crate::{LiveFeed, Quote, rt};
const BINANCE_REST: [&str; 2] = ["https://data-api.binance.vision", "https://api.binance.com"];
const BINANCE_WS: [&str; 2] = ["wss://data-stream.binance.vision/ws", "wss://stream.binance.com:9443/ws"];
@@ -186,25 +185,37 @@ pub fn parse_coinbase_book(json: &str, symbol: &str, depth: usize) -> Result<cra
Ok(b)
}
const TIMEOUT: Duration = Duration::from_secs(20);
pub struct CryptoProvider {
client: reqwest::Client,
}
impl CryptoProvider {
pub fn new() -> Result<Self> {
let client = reqwest::Client::builder()
.user_agent(concat!("terminal-rs/", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(20))
.build()?;
Ok(Self { client })
let client = reqwest::Client::builder();
// Browsers set their own User-Agent, and fetch takes its timeout per request.
#[cfg(not(target_arch = "wasm32"))]
let client = client.user_agent(concat!("terminal/", env!("CARGO_PKG_VERSION"))).timeout(TIMEOUT);
Ok(Self { client: client.build()? })
}
async fn get(&self, url: &str, query: &[(&str, String)]) -> Result<String> {
let resp = self.client.get(url).query(query).send().await?;
if !resp.status().is_success() {
bail!("GET {url}: HTTP {}", resp.status());
}
Ok(resp.text().await?)
let req = self.client.get(url).query(query);
#[cfg(target_arch = "wasm32")]
let req = req.timeout(TIMEOUT);
let fetch = async move {
let resp = req.send().await?;
if !resp.status().is_success() {
bail!("GET {url}: HTTP {}", resp.status());
}
Ok(resp.text().await?)
};
// A browser fetch holds JavaScript values, which are not `Send`. The web build has
// one thread, so asserting it is sound and keeps the provider traits shared.
#[cfg(target_arch = "wasm32")]
let fetch = send_wrapper::SendWrapper::new(fetch);
fetch.await
}
async fn binance_history(
@@ -363,7 +374,7 @@ impl CryptoProvider {
let (tx, rx) = mpsc::channel(256);
let stream = format!("{}@kline_{}", binance_symbol(sec).to_ascii_lowercase(), binance_interval(tf));
let product = coinbase_product(sec);
tokio::spawn(async move {
rt::spawn(async move {
let mut backoff = 1;
let mut attempt = 0usize;
while !tx.is_closed() {
@@ -378,7 +389,7 @@ impl CryptoProvider {
tracing::debug!("live feed {stream}: {e:#}");
}
attempt += 1;
tokio::time::sleep(Duration::from_secs(backoff)).await;
rt::sleep(Duration::from_secs(backoff)).await;
backoff = (backoff * 2).min(30);
}
});
@@ -387,32 +398,25 @@ impl CryptoProvider {
}
async fn binance_ws(url: &str, tx: &mpsc::Sender<Bar>) -> Result<()> {
let (mut ws, _) = tokio_tungstenite::connect_async(url).await.context("binance websocket")?;
while let Some(msg) = ws.next().await {
match msg? {
Message::Text(t) => {
if let Some(bar) = parse_binance_ws(t.as_str())
&& tx.send(bar).await.is_err()
{
return Ok(());
}
}
Message::Close(_) => break,
_ => {}
let mut ws = Socket::connect(url).await.context("binance websocket")?;
while let Some(text) = ws.next().await {
if let Some(bar) = parse_binance_ws(&text?)
&& tx.send(bar).await.is_err()
{
return Ok(());
}
}
bail!("binance websocket closed")
}
async fn coinbase_ws(product: &str, tf: Timeframe, tx: &mpsc::Sender<Bar>) -> Result<()> {
let (mut ws, _) = tokio_tungstenite::connect_async(COINBASE_WS).await.context("coinbase websocket")?;
let mut ws = Socket::connect(COINBASE_WS).await.context("coinbase websocket")?;
let sub = serde_json::json!({"type": "subscribe", "product_ids": [product], "channels": ["ticker"]});
ws.send(Message::Text(sub.to_string().into())).await?;
ws.send(sub.to_string()).await?;
// Aggregate ticks into the current bar; the chart merges partial bars into history.
let mut bar: Option<Bar> = None;
while let Some(msg) = ws.next().await {
let Message::Text(t) = msg? else { continue };
let Some((ts, px, size)) = parse_coinbase_ws(t.as_str()) else { continue };
while let Some(text) = ws.next().await {
let Some((ts, px, size)) = parse_coinbase_ws(&text?) else { continue };
let bucket = tf.bucket_start(ts);
let b = match bar.as_mut() {
Some(b) if b.ts == bucket => {
+123 -9
View File
@@ -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();
+13 -1
View File
@@ -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
+132
View File
@@ -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));
}
}
+27 -4
View File
@@ -5,7 +5,7 @@ use async_trait::async_trait;
use tokio::sync::mpsc;
use tr_core::{Bar, Timeframe, sample};
use crate::{LiveFeed, MarketData, OptionChain, OrderBook, security};
use crate::{LiveFeed, MarketData, OptionChain, OrderBook, 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();
+1 -1
View File
@@ -113,7 +113,7 @@ pub struct TreasuryProvider {
impl TreasuryProvider {
pub fn new() -> Result<Self> {
let client = reqwest::Client::builder()
.user_agent("Mozilla/5.0 (compatible; terminal-rs)")
.user_agent("Mozilla/5.0 (compatible; terminal)")
.timeout(Duration::from_secs(30))
.build()?;
Ok(Self { client, years: Mutex::new(HashMap::new()) })
+177
View File
@@ -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");
}
}
+181
View File
@@ -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();
}
}
}
+2 -2
View File
@@ -25,7 +25,7 @@ impl YahooProvider {
pub fn new() -> Result<Self> {
let client = reqwest::Client::builder()
// Yahoo rejects requests without a browser-like user agent.
.user_agent("Mozilla/5.0 (compatible; terminal-rs)")
.user_agent("Mozilla/5.0 (compatible; terminal)")
.timeout(Duration::from_secs(20))
.cookie_store(true)
.build()?;
@@ -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(),
}
}
+31
View File
@@ -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
+247
View File
@@ -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(&reg, &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(&reg, &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