Skip to content
WhaleCoreSDK

TypeScript

WhaleCoreSDK TypeScript installation, WebAssembly hosting, initialization, sessions, and public modules

The TypeScript package runs WhaleCore in the browser. The engine is compiled to WebAssembly, and the package wraps it in typed ES modules for pass-through HTTP, quotes, watchlists, portfolios, and orders. Broker App owns every screen; the package owns the session, the connections, and the data.

Requirements and installation

Requirement Detail
Runtime A browser with WebAssembly, ES modules, and WebSocket
Bundler Any bundler that resolves exports subpaths and import.meta.url, such as Vite, esbuild, webpack 5, Rspack, or Parcel 2
TypeScript Optional. Declarations ship with the package; if Symbol.dispose errors appear, add ESNext.Disposable to lib
Node.js Not a runtime target. The engine requires window, localStorage, and WebSocket, and there is no server-side build

The package is not published to a public registry. The Whale project team delivers the archive privately, and each archive is built for one tenant, because the signing key is compiled into the WebAssembly binary. Install the file you received, and check it in or store it where the build can reach it.

One runtime dependency follows: protobufjs, because quote payloads are Protobuf. Builds that only use initialize and request drop the decoder.

Each module is a separate subpath:

import { initialize, request } from 'whalecore-web';
import { quotes, SubType } from 'whalecore-web/quotes';
import { Watchlist } from 'whalecore-web/watchlist';
import { Portfolio } from 'whalecore-web/portfolio';
import { Orders } from 'whalecore-web/orders';

Host the WebAssembly binary

engine_bg.wasm is fetched at runtime by URL, and no bundler inlines it. Copy it from engine/pkg/engine_bg.wasm inside the package to the built assets, and serve it with Content-Type: application/wasm. With Vite, publicDir performs the copy; with webpack, CopyPlugin does. Locate the package with require.resolve instead of hard-coding a node_modules path, because file: links and pnpm stores are symlinks.

The engine looks for the file next to its own JavaScript module. When the bundler moves output, or the binary is served from a CDN, load it explicitly before initialize:

import { load, initialize } from 'whalecore-web';

await load('https://cdn.example.com/engine_bg.wasm');
await initialize({ /* … */ });

load is idempotent and safe to retry. initialize calls it when the host does not.

Compress the binary

The published binary is already optimized for size, so transport is the remaining cost: Brotli quality 11 removes roughly 69% of it, where gzip -9 stops near 58%. Compress during the build rather than per request, with vite-plugin-compression, compression-webpack-plugin, or whatever the bundler provides.

Warning

Serve the compressed file with both Content-Encoding: br and Content-Type: application/wasm. The loader uses WebAssembly.instantiateStreaming, which requires that MIME type; with any other value the browser buffers the whole module before compiling. Several CDNs omit application/wasm from their compressible types and deliver the file uncompressed.

Initialize

import { initialize } from 'whalecore-web';

await initialize({
  appId: 'com.example.app',
  accountChannel: 'lb',
  deviceID: await deviceID(),
  language: 'zh-CN',
  version: '1.0.0',
  binding: {
    renewToken: () => fetchCredentials(),
    // '000000' is the paper channel's password; real channels take their own
    renewTradeToken: (accountChannel) => exchangeTradeToken(accountChannel, '000000'),
  },
  logger: (message, fields) => console.debug(fields.get('level'), message),
});
Field Required Purpose
binding Yes Credential fallback, see Tokens and trade authentication. A missing value throws no binding
appId Bundle identifier, also sent as x-app-id
accountChannel Session identifier, and an account-channel header on every request
deviceID Sent as x-device-id, see Device identifier
defaultHeaders Additional headers on every request
language en, zh-CN, or zh-HK. Any other value falls back to en
version / build Reported as x-application-version and x-application-build
logger Engine logs; fields carries level and the tracing metadata

The engine ignores unrecognized keys, so a misspelled field never fails; it takes no effect. Let the EngineConfig type check the object.

Call initialize once per page load. A second call rebuilds the HTTP client, but it does not close the previous market-data connection, and appId, version, and build keep the values from the first call. Switching accounts requires no second call, see Lifecycle.

Device identifier

deviceID becomes the x-device-id header. A browser reports no device serial, so Broker App generates the value and keeps it. The goal is one stable value for as long as the browser lives on that machine. A crypto.randomUUID written to both localStorage and a long-lived first-party cookie is enough, since the two are cleared by different actions and each restores the other; a fingerprinting library additionally recovers a similar value after both are gone.

  • Read storage first and generate only when every layer is empty, never overwriting a stored value with a freshly computed one. Re-persist on every visit so a partial clear heals.
  • Resolve the value before initialize, because the engine issues requests as soon as it starts.
  • Keep the value opaque. It reaches server logs as a header, so use a hash or a UUID and nothing that identifies a person.

Omitting deviceID is allowed: the header is absent, and nothing generates a substitute.

Client session

The access token lives in the engine session, which in the browser is localStorage without a prefix; the configuration has no token field. Implementing binding.renewToken is enough on its own: on a cold session the first authenticated request carries no token, the server rejects it, and the engine calls renewToken, stores the returned pair, and replays the request.

Writing the token and refresh_token from Broker’s login flow before initialize is an optional optimization that skips that first rejected round-trip. Never log credentials or complete tokens.

const { token, refresh_token } = await fetchCredentials();
localStorage.setItem('token', token);
localStorage.setItem('refresh_token', refresh_token);
await initialize({ /* … */ });

Trade tokens are kept separately, per account channel, and the engine writes them itself.

Tokens and trade authentication

The engine authenticates every request and renews an expired access token with the refresh token it holds. binding covers the one case it cannot resolve alone: the refresh token is also gone.

const binding = {
  // called on 401003 when the session holds no refresh_token
  async renewToken() {
    const res = await fetch('/session/login', { method: 'POST' });
    return (await res.json()).credentials; // { token, refresh_token }
  },
  // called on 402000 (with the account channel) when the session holds no trade refresh token
  renewTradeToken(accountChannel) {
    // '000000' is only lb_papertrading's password; supply the real one per channel
    return exchangeTradeToken(accountChannel, '000000');
  },
};

Return the new pair. The engine stores both, exchanges the refresh token for a fresh access token, and replays the original request, so the request caller observes no round trip. Throwing, rejecting, or returning a malformed value all mean that renewal is impossible: the engine stops, and the original ApiError reaches the caller, which is how the Client returns to the login screen. Both callbacks can run concurrently when several requests receive 401 together; the engine collapses the renewal, so keep the callback inexpensive and add no locking.

Note

Acquiring a first trade token is done by exchangeTradeToken(accountChannel, password): it exchanges the account’s trade password for the { token, refresh_token } pair, which the engine stores per channel. Pass the password as the Client typed it. Wire the call into renewTradeToken as shown.

Lifecycle

Task How
Initialize await initialize(config), once per page load
Foreground and background No call. The browser throttles timers, and the engine reconnects on its own
Switch account in the same tenant portfolio.logout(), write the new token and refresh_token, portfolio.login(), discover accounts again, resubscribe
Switch tenant Load the page with the binary built for that tenant
Log out portfolio.logout() clears token and refresh_token and unmounts every engine component
Tear down Reload the page. There is no explicit teardown call

Discover accounts

The engine holds the account list after login, so read it from the SDK instead of calling the endpoint yourself. user returns the engine payload as it is, with snake_case fields, the same convention the portfolio view uses.

import { onUserChange, user } from 'whalecore-web/portfolio';

const off = onUserChange(() => {
  const accounts = user()?.accounts ?? [];
  portfolio.watch(accounts.map((a) => ({ accountChannel: a.account_channel!, aaid: a.aaid! })));
});

user returns undefined while the member is anonymous and triggers the fetch when it is, so the first call normally still returns undefined and the data arrives later through onUserChange. Do not poll user from a render loop: once the payload lands it does not refetch, but every call made before the first fetch returns starts another round of member requests. Wait for the change, or cache what you read.

The declarations list every field on an account entry. Two are easy to conflate: account_open and deposit are separate facts, so an opened account holding no funds is distinguishable from one never opened. t_pwd reports whether the account has a trade password set.

The same payload feeds the orders module:

const orders = new Orders({ accountChannel: 'lb', account: () => accountFactsOf(user(), 'lb') });

An incorrect aaid fails silently in the portfolio module, with no view and no error, which is the reason to read the list rather than assemble it.

Public modules

Import Capability
whalecore-web initialize, load, request, setLanguage, ApiError, ErrorCode, readyState, isOpen, onConnectionChange
whalecore-web/quotes Quote store with subscriptions, snapshots, and push merging; Counter, Decimal helpers, derived metrics, and greeks
whalecore-web/watchlist Groups, membership, pinning, sorting, and quote-driven re-sorting
whalecore-web/portfolio Assets, holdings, cash, leverage, a per-account cache, and user, onUserChange
whalecore-web/orders Orders, history, submission, replacement, cancellation, pre-submit validation, preview, and arith

Quotes

The quote store materializes complete Stock snapshots from engine pushes. Start it once, mount the counters a screen needs, then subscribe.

import { quotes, SubType, Change, Counter, compare } from 'whalecore-web/quotes';

quotes.start();
await quotes.mount('watchlist', ['ST/HK/700', 'ST/US/AAPL'], SubType.List | SubType.Depth);

const off = quotes.watch('ST/HK/700', (stock, flags) => {
  if (flags & Change.Price) render(stock.trading);
  if (flags & Change.Depth) renderBook(stock.depths);
});
  • Counters are the primary key, written as ST/HK/700 rather than 00700.HK. Counter.parse returns the padded display code and product predicates such as isStock and isOption.
  • mount(id, counters, subtypes) is declarative: the identifier owns exactly the counters passed to it, and anything removed from the list is unsubscribed. Give each screen its own identifier.
  • SubType is a bit flag with the values List, Detail, Depth, Trade, Broker, PreTrade, PostTrade, NightTrade, TotalView, and TotalViewBrief, plus the Trades combination. The default is SubType.List, which already carries pre-market, post-market, and overnight prices.
  • Prices are Decimal, which is a string, so server precision survives. '9' > '10' evaluates to true in JavaScript, so compare with compare. Derived metrics such as changePercent, marketCap, turnoverRate, and greeks return number and suit sorting and approximate display rather than money.
  • version(counter) and subscribe(listener) match the shape useSyncExternalStore expects.

Ticks are frequent. Batch rendering with requestAnimationFrame instead of reacting to each one.

Watchlist

import { Watchlist, WatchlistChange, SortField } from 'whalecore-web/watchlist';

const watchlist = new Watchlist({ accountChannel: 'lb', aaid });
await watchlist.refresh({ subscribe: true });

watchlist.subscribe((flags) => {
  if (flags & WatchlistChange.Groups) renderTabs(watchlist.groups());
  if (flags & WatchlistChange.Order) renderList(watchlist.counters());
});

watchlist.setGroup(groupId, { field: SortField.ChangePercent, asc: false });
await watchlist.addStocks(['ST/HK/700'], [groupId]);
await watchlist.tie(['ST/HK/700']);

Reads hit the local cache synchronously through groups, counters, stockInfo, fundInfo, tied, sort, and version. Mutations are asynchronous and reconcile with the server. Sorting runs locally against the quote store, so the host decides when to re-sort; call resort from a batched frame.

Cross-device synchronization is enabled by default: the instance refreshes itself when another device changes a group. Pass sync: false to opt out, and a distinct id when constructing more than one instance.

Portfolio

The engine computes portfolios, including exchange rates, cost basis, profit and loss, and display formatting. The module is a typed transport with a per-account cache: declare the accounts to watch, and the engine pushes one complete view snapshot per frame.

import { Portfolio } from 'whalecore-web/portfolio';

const portfolio = Portfolio.shared();
portfolio.login();
portfolio.setUser({ currency: 'HKD', costType: 'dilute' });
portfolio.watch([{ accountChannel: 'lb', aaid }]);

portfolio.subscribe((event) => {
  if (event.kind === 'view') {
    render(event.view.overview.total_fortune, event.view.stock_hold.HK);
  } else {
    console.log(event.error);
  }
});
Method Effect
login() / logout() Report a session change. logout also clears tokens and components
setUser(settings) Currency and member preferences: currency, costType, usePrePostPrice, useOvernightPrice, useOptionExtendPrice
watch(accounts) Declarative; the list replaces the previous one. Cash detail and quote-driven recomputation stay enabled, and every call refetches. Use refresh to reload the same accounts
refresh(aaid?) Force a refetch for one account or all of them
unwatch() Stop everything and clear the cache
get(aaid) / aaids() / version() Synchronous cache reads

Three properties of the view matter:

  • Fields use snake_case while inputs use camelCase, because the payload passes through unchanged.
  • Values are formatted for display. Amounts are Decimal strings, percentages already carry % such as "12.34%" and must not be multiplied by 100, unavailable numbers are the literal '--', and option greeks are JSON numbers that may also be '--'.
  • The first frame can be incomplete. Sections fill in over the first few frames as engine fetches land, and cash detail and the asset distribution usually arrive later. A section missing from the first frame does not mean the engine omits it.

Most failures are silent by design: an incorrect aaid, a network error, a 5xx, or a business code outside the mapped set produces no event, which is indistinguishable from loading. Six codes map to four kinds: tokenRequired for 401003, 401004, 401005, and 401008; tradeTokenRequired for 402000 and 402001, carrying accountChannel; tradeAccountLimited; and unknown. For a failed-to-load state, add a timeout after watch.

Orders

The order module covers the day’s orders, history, submission, replacement, cancellation, pre-submit validation, and preview. Every method needs trade authentication, so implement renewTradeToken first, see Tokens and trade authentication. Without it every call fails with business code 402000.

import { Orders } from 'whalecore-web/orders';

const orders = new Orders({ accountChannel: 'lb' });

await orders.refresh();
orders.subscribe(() => render(orders.today()));

refresh loads the day’s orders and starts listening for order pushes, after which updates merge into the cache on their own. Reads are synchronous through today, open, get, and version, so subscribe and version fit useSyncExternalStore.

Method Effect
refresh(filter?) Reload the day’s orders and start merging pushes
history(page, limit, filter?) Paged history, with hasMore
detail(orderId) One order with fills, charge detail, and attached legs
submit(order) Place an order and return orderId
replace(request) Change price, quantity, remark, or conditional fields
cancel(orderId) / batchCancel(request) Cancel one order, or by counter and side, or by identifier list
orderInfo, tradable, constraints Per-counter trading rules and channel capability
estimateBuyLimit, estimatedCost, tradeDetail Buying power, fee estimates, and per-counter cash and holdings

Statuses normalize to 15 values that isTerminal, isCancellable, and isModifiable read, and rawStatus keeps the server string. Amounts and quantities are Decimal strings, and the same subpath exports arith for fixed-point arithmetic on them.

Validate, then submit

SubmitOrder is a discriminated union on orderType, so a limit order without a price does not compile. Build an OrderIntent from the ticket instead and let validation produce the request:

import { newRequestId, type OrderIntent } from 'whalecore-web/orders';

const intent: OrderIntent = {
  counterId: 'ST/US/AAPL',
  side: 'buy',
  orderType: 'LO',
  clientRequestId: newRequestId(),
  price: '150.25',
  quantity: '100',
  validity: { tif: 'day' },
  sessionPreference: 'rthOnly',
};

const draft = await orders.validate(intent, 'draft');
setSubmitEnabled(draft.passed);

const checked = await orders.validate(intent, 'submission');
if (checked.passed && checked.request) {
  await orders.submit(checked.request);
}

Each issue carries a stable code, a rule, the field to highlight, and a localized reason. The draft scope covers what the ticket can fix while the Client types, and submission adds account and qualification gates and returns the materialized request when it passes. Call checkTradability before opening the ticket, and constraints to render the order-type picker, because the types it lists are the ones that will not be rejected as unsupported.

clientRequestId is the idempotency key. Reuse it when retrying the same order and call newRequestId for a new one, because the server deduplicates on it.

Preview

preview collects the order, fee, cost, margin, and exchange-rate sources a ticket needs and returns computed amounts: order total, fees, financing, occupied buying power, post-trade cost, cash and margin changes, and a risk hint.

const result = await orders.preview(intent, { marketReferencePrice: quote.lastDone });
render(result.orderTotal, result.fees.total, result.riskHint);

The module reads no quotes, so pass market facts through context: a reference price for market orders, and optionDirection with optionStrikePrice for options. A field that is undefined is structurally not applicable, such as financing on a sell, rather than missing.

preview needs the account type, and cash and margin accounts differ by half a screen, so supply account facts when constructing the module. accountFactsOf maps the payload that user returns:

const orders = new Orders({
  accountChannel: 'lb',
  account: () => accountFactsOf(user(), 'lb'),
  language: () => 'zh-CN',
});

Without account facts, preview throws PreviewError with kind accountUnavailable, and the three account gates in validation pass silently instead of blocking. Hosts that fetch their own data can skip the method and call the exported pure function compute(fields), which performs no requests.

Boundaries

  • Channel capability is not uniform. orderInfo reports the order types and attached-order types a counter accepts, and a channel can still reject a request those fields appear to permit. Verify batch cancellation and attached orders on each channel you enable before exposing them to Clients.
  • A rejected submit does not prove that nothing reached the server, and an attached-order failure can still leave the main order working. Re-read the day’s orders before retrying, and reuse the same clientRequestId so a retry cannot become a second order.
  • Orders are never written to storage. The cache is in memory only, and dispose clears it and detaches the push listener.

Connection state

The engine keeps two WebSockets. The quote channel carries market data, and the text channel carries business pushes such as order updates, portfolio signals, and watchlist topics. Each one connects and reconnects independently.

import { readyState, isOpen, onConnectionChange, ReadyState } from 'whalecore-web';

readyState('quote'); // ReadyState.Open
isOpen(); // both channels connected

const off = onConnectionChange(({ channel, state }) => {
  console.log(channel, ReadyState[state]); // the enum carries a reverse map
  setOnline(isOpen());
});

Called without a channel, readyState collapses the pair and reports the worse of the two: both Open gives Open, either Closed wins, then Reconnecting, then Opening. Events themselves are per-channel, so call readyState inside the listener when the interface renders the combined state. Both calls are synchronous and cheap, and nothing is cached, so a listener attached late is never out of sync. For React, useSyncExternalStore(onConnectionChange, () => readyState('quote')) produces a stable snapshot.

Two behaviors need handling:

  • Before the first connection the state is Reconnecting rather than Closed, because that is the initial value for both channels. Never connected and dropped-and-retrying share one value, so avoid wording that implies a connection was lost.
  • The same channel and state repeat. A failing handshake retries, and every attempt reports Opening again. Nothing is deduplicated: compare against the previous value, or let React compare snapshots.

General HTTP requests

Use request for TradingAPI endpoints that have no typed module. Signing, authentication, retries, and token renewal still apply.

import { request, ApiError } from 'whalecore-web';

try {
  const { data, headers } = await request<MemberInfo>('GET', '/v2/member/info');
  console.log(data.member, headers['x-trace-id']);
} catch (err) {
  if (err instanceof ApiError && err.info.kind === 'http') {
    console.warn(err.info.status, err.info.code);
  }
}
Option Behavior
params Appended as a query string. Nested objects become a[b]=c, arrays repeat the key, and null values are dropped
data JSON request body
headers Merged over the defaults from initialize
timeout Milliseconds. 0 or an omitted value uses the engine default of 15 seconds
singleflight Deduplicates concurrent identical requests. Enabled by default for GET, HEAD, and OPTIONS; the other callers fail with ErrorCode.DEDUPLICATED

The generic parameter is a type assertion only, and nothing is validated at runtime.

Errors

ApiError.info is a discriminated union on kind:

kind Meaning Notable fields
http The server returned an error envelope status, code, message, detail, traceId, headers, data
request Transport failed, including network, CORS, and timeout code, traceId
websocket Push channel error code, status, message
other Everything else code, error

The HTTP status is not the business code. An invalid token arrives as HTTP 400 carrying business code 401004. Branch on info.code rather than info.status. The codes that end a session are 401003, 401004, 401005, and 401008.

Scope and limits

These are limits of the current package, not a delivery schedule.

Capability Status
Order submission over WebSocket Not implemented; orders use HTTP
Real-password or two-factor trade auth Default first-token acquisition ships as exchangeTradeToken; the 2FA and user-entered-password sub-flow does not
Foreground and background hooks Not required; the engine reconnects on its own
Explicit teardown Reload the page
Serving several tenants from one page Not possible; the signing key is compiled into the binary
Whale Docs