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.
| 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';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.
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.
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.
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.
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.
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.
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.
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.
| 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 |
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.
| 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 |
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/700rather than00700.HK.Counter.parsereturns the padded display code and product predicates such asisStockandisOption. 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.SubTypeis a bit flag with the valuesList,Detail,Depth,Trade,Broker,PreTrade,PostTrade,NightTrade,TotalView, andTotalViewBrief, plus theTradescombination. The default isSubType.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 totruein JavaScript, so compare withcompare. Derived metrics such aschangePercent,marketCap,turnoverRate, andgreeksreturnnumberand suit sorting and approximate display rather than money. version(counter)andsubscribe(listener)match the shapeuseSyncExternalStoreexpects.
Ticks are frequent. Batch rendering with requestAnimationFrame instead of reacting to each one.
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.
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_casewhile inputs use camelCase, because the payload passes through unchanged. - Values are formatted for display. Amounts are
Decimalstrings, 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.
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.
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 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.
- Channel capability is not uniform.
orderInforeports 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
submitdoes 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 sameclientRequestIdso a retry cannot become a second order. - Orders are never written to storage. The cache is in memory only, and
disposeclears it and detaches the push listener.
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
Reconnectingrather thanClosed, 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
Openingagain. Nothing is deduplicated: compare against the previous value, or let React compare snapshots.
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.
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.
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 |