| Item | Requirement |
|---|---|
minSdk |
≥ 24 |
| JVM target | 11 |
| Kotlin | Enable the Kotlin plugin (the SDK is built on Kotlin 2.0.x) |
Apply the whalecore.gradle script that ships with the delivery in the target module’s build.gradle[.kts]:
// Kotlin DSL
apply(from = "<path-to>/whalecore/whalecore.gradle")// Groovy DSL
apply from: "<path-to>/whalecore/whalecore.gradle"whalecore.gradle automatically injects into the module:
- Every local AAR under
whalecore/libs/(the SDK itself and its dependencies) - The remote transitive dependencies the SDK needs at runtime (
lifecycle-runtime,datastore-preferences,jackson-kotlin,protobuf-kotlin-lite,moshi-kotlin,jna) - The
coreLibraryDesugaringconfiguration required whenminSdk < 26
The host still configures minSdk, JVM target, and the Kotlin plugin itself. See example/app/build.gradle.kts for a complete integration example.
Delivery directory layout:
WhaleCore-Android-<version>/
├── README.md Documentation
├── CHANGELOG.md Public interface change log
├── whalecore/
│ ├── libs/ Local AARs for the SDK and its dependencies
│ └── whalecore.gradle One-line integration script
└── example/ A self-contained sample project that already applies whalecore.gradleexample/ demonstrates the full integration flow. Run it before integrating into your own project:
cd example
./gradlew :app:installDebugYou can also open example/ directly in Android Studio.
Call WhaleCore.initialize before using any service. It is blocking (it performs network requests), so run it on an IO thread. Call it in Application.onCreate or before the first protected screen.
val config = WhaleCoreConfig(
appId = "<assigned>",
appKey = "<assigned>",
appSecret = "<assigned>",
token = accessToken,
refreshToken = refreshToken,
defaultAccountChannel = "lb_hk",
deviceId = deviceId,
language = WhaleCoreLanguage.EN,
logLevel = WhaleCoreLogLevel.INFO,
tokenRefreshCallback = appTokenRefreshCallback,
)
withContext(Dispatchers.IO) {
WhaleCore.initialize(application, config, isDebug = BuildConfig.DEBUG)
}Common WhaleCoreConfig fields:
| Field | Default | Description |
|---|---|---|
token / refreshToken |
Required | User access token and its paired refresh token, obtained by the host backend |
defaultAccountChannel |
Required | Default account channel; sets the current account when it matches, otherwise falls back to the first account in the list |
deviceId |
Required | Device identifier, at least 32 characters, generated by the host and stable across app launches on the same device; identifies the request source device and message-push delivery target |
logLevel |
INFO |
Log level: TRACE > DEBUG > INFO > WARN > ERROR; use ERROR in production |
language |
ZH_HANS |
Language preference: EN / ZH_HK / ZH_HANS, which affects server-returned text |
tokenRefreshCallback |
null |
Token refresh and expiry fallback callback; see Session token refresh |
tokenResolverTimeout |
30 |
Timeout in seconds for the fallback token fetch; non-positive values fall back to the default |
Key points:
- Repeated calls are safe; only the first takes effect. Concurrent calls block until the first initialization completes.
- Initialization validates required fields first and throws
WhaleCoreException.InvalidParameterwhen a field is missing (ordeviceIdis shorter than 32 characters);parameterNameidentifies the offending field. - Query the status with
WhaleCore.isInitialized(). - Never log the whole
config; it contains sensitive credentials such asappSecretandtoken.
WhaleCore.resume() and WhaleCore.pause() describe the whole application moving between foreground and background — call each once per transition. Forwarding them from ProcessLifecycleOwner in your Application class is the recommended approach:
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) = WhaleCore.resume()
override fun onStop(owner: LifecycleOwner) = WhaleCore.pause()
})
}
}Do not hook them to a single Activity’s onResume / onPause — navigating between screens fires those too, which would falsely report “went to background” to the SDK. Both are safe no-ops before initialization.
Sign out, tear down, or switch accounts with:
withContext(Dispatchers.IO) { WhaleCore.logoutAndDestroy() }logoutAndDestroy() first notifies the server that the current session is invalid, then releases every resource the session held (network connections, caches, and native handles). It is blocking: by the time it returns, both sign-out and resource release have finished. Sign-out waits only up to a limit and its failure does not block resource release, so the method does not return a sign-out result. After teardown the SDK returns to the uninitialized state; calling initialize again with a new account’s config completes an account switch.
The SDK renews the access token automatically with refreshToken, so the host does not need to handle routine refresh. To keep the session usable, register a TokenRefreshCallback through config.tokenRefreshCallback (every method is called on the main thread and has a default implementation):
val appTokenRefreshCallback = object : TokenRefreshCallback {
override fun onTokenRefreshed(token: String, refreshToken: String) {
// New token / refreshToken. Whether and how to persist them is up to the host.
credentialStore.update(token, refreshToken)
}
override fun resolveExpiredToken(callback: NewTokenCallback) {
appScope.launch {
val fresh = runCatching { authApi.silentLogin() }.getOrNull()
// Supplying non-empty credentials lets the SDK retry the failed requests automatically.
// Supplying empty values, or timing out, fails those requests; guide the user to sign in again.
callback.onResult(fresh?.token.orEmpty(), fresh?.refreshToken.orEmpty())
}
}
override fun onTokenRefreshFailed(exception: WhaleCoreException) {
// Final notification that even the fallback retrieval failed: guide the user to sign in again.
sessionRouter.gotoLogin()
}
}- Refresh succeeds (
onTokenRefreshed): the SDK notifies the host that the token and refresh token changed. - Session expires (
resolveExpiredToken): the SDK gives the host one chance to fetch a fresh token; requests in flight suspend until it resolves. Multiple requests failing at once trigger this callback only once, and they share the result. - Final failure (
onTokenRefreshFailed): fired when the retry also failed to supply a valid token — normally the point at which you guide the user to sign in again. - Call
callback.onResult(...)within the timeout (tokenResolverTimeout, 30 seconds by default); not calling it in time counts as failure. - The SDK holds this callback for the whole session. Pass an app-level singleton to avoid leaking an Activity or Fragment.
The following rules apply across services. Read them once; later sections do not repeat them.
A three-part format, ST/MARKET/CODE, such as "ST/US/AAPL" or "ST/HK/00700". Options additionally encode the strike price, expiry date, and direction. Quotes, orders, the watchlist, and profit-and-loss analysis all locate instruments with this format.
Every capability offers a coroutine suspend fun and a callback-based *Async(..., AsyncCallback<T>?) variant; choose whichever fits the call site (Java callers, or contexts where coroutines are inconvenient, use the callback variant). Method tables in this document list only the coroutine signature; the callback variant adds the Async suffix and keeps the same parameters.
// Coroutine variant
val stock = WhaleCore.getQuoteService().getStock("ST/US/AAPL")
// Callback variant
WhaleCore.getQuoteService().getStockAsync("ST/US/AAPL", object : AsyncCallback<Stock> {
override fun onSuccess(result: Stock?) { /* ... */ }
override fun onError(error: Throwable) { /* ... */ }
})*Async calls and observation callbacks all fire on a non-main thread; switch back to the main thread before updating UI. A cancelled coroutine task (for example, after the session is destroyed) triggers no callback and never calls onError either. OptionCalculator is the exception: it is purely synchronous, involves no coroutines, and has no *Async variant.
Order, portfolio, watchlist, and quote push events all expose the same three consumption forms; choose the one that fits the call site:
| Form | Fits | Release |
|---|---|---|
observeXxxEvents(): Flow<T> |
Kotlin coroutine contexts; the recommended default | Released automatically when the coroutine scope ends |
observeXxxEvents(lifecycleOwner, callback) |
Binding to an Activity or Fragment lifecycle | Cancelled automatically at DESTROYED |
observeXxxEvents(callback) |
Contexts where binding a lifecycle is inconvenient | Must call the returned Subscription.cancel() manually, or it leaks |
SharedFlow defaults to replay = 0: events that happen before you register a listener are not replayed. Always register the listener before triggering a refresh or subscription.
Do not cache the instance returned by getXxxService() for long — it becomes invalid after logoutAndDestroy(), so fetch it again each time you need it. getXxxService() throws WhaleCoreException.NotReady before initialization or after teardown.
Trading requires a trade token, which the SDK obtains and renews automatically by default; the host does not participate and needs no configuration. If a trading-scoped call (submit, replace, cancel, and similar) fails with WhaleCoreException.TradeAuthFailed, that is a rare server-side auth failure — handle it through the common error handling path.
| Method | Service | Main capability |
|---|---|---|
getQuoteService() |
QuoteService |
Quote subscriptions, option chain/detail subscriptions, K-lines, snapshots, timeshares, historical trades, and quote level with device eviction (multi-device eviction and reclaim) |
getOrderService() |
OrderService |
Submit, replace, cancel, attached orders, position take-profit/stop-loss, order preview, capacity estimates, pre-submit validation, and order events |
getPortfoliosService() |
PortfolioService |
Portfolio subscriptions, cash detail, member settings, and profit-and-loss analysis |
getWatchlistService() |
WatchlistService |
Watchlist groups, stocks, ordering, pinning, and events |
getRequestService() |
RequestService |
Pass-through authenticated HTTP requests |
Getting a service before initialization throws WhaleCoreException.NotReady. OptionCalculator is a stateless utility class you call directly, not through WhaleCore; see Option calculator.
The earlier standalone entry point WhaleCore.getOrderValidationService() is deprecated. For pre-submit validation, call the same-named methods on WhaleCore.getOrderService() directly.
Fetch a snapshot, or use subscribe to open a live subscription that keeps delivering updates:
val quotes = WhaleCore.getQuoteService()
// Snapshot: fetch once
val stock = quotes.getStock("ST/US/AAPL")
// Live subscription: subscribe() opens it, chain the channels you need, then start(); onChange keeps delivering the latest quote
val subscription = quotes.subscribe(
counterIds = listOf("ST/US/AAPL", "ST/HK/00700"),
callback = object : QuoteEventCallback {
override fun onChange(stock: Stock) { updateRow(stock) }
},
).detail().depth().trade().start()
subscription.cancel() // Cancel when the screen is destroyedIf the quote is already subscribed through another path, such as WatchlistService, use observeQuoteEvents(...) to observe the quote stream without opening a duplicate subscription.
val klines = quotes.getKlines("ST/US/AAPL", KlineType.PER_DAY, count = 200)
// A K-line update event requires an existing quote subscription for that instrument (even just .list()); the event
// only identifies which instrument changed, so fetch the latest series again with getKlines
lifecycleScope.launch {
quotes.observeKlineUpdates(listOf("ST/US/AAPL")).collect { update ->
val latest = quotes.getKlines(update.counterId, KlineType.PER_DAY, count = 200)
// Redraw the K-line chart
}
}Use this endpoint for an intraday price chart; do not approximate it with 1-minute K-lines. Timeshares are grouped by trading day and carry the previous close and a running average price, while K-lines are a continuous series counted back from an anchor. On markets with few bars per day, such as Hong Kong or A-shares, approximating timeshares with K-lines mixes in data from the previous trading day.
val today = quotes.getTimeshares("ST/US/AAPL").timeshares.lastOrNull()
today?.minutes?.forEach { minute -> /* price via minute.price, average via minute.avgPrice */ }Choose either a single-contract detail subscription or a batch chain subscription:
// Option detail subscription: single-contract snapshot + subscription, chain the data types you need
val optionSub = quotes.subscribeOptionDetail("OP/US/AAPL240119C190000", callback)
.detail().depth().trade().start()
// Option chain subscription: a whole set of contracts, replaced wholesale on interaction (switching expiry, scrolling
// the visible range); no chained type selection needed
val chainSub = quotes.subscribeOptionChain(visibleCounterIds, callback)
chainSub.updateCounterIds(newVisibleCounterIds) // Wholesale replacement, not incremental; hold a strong reference to the handle
chainSub.cancel()Options have no separate depth-subscription channel: the snapshot from start() carries one level of depth, but subsequent pushes still need an explicit .depth() in the chain — without it, only the first frame carries depth. Greeks are not part of the quote push; subscribe to the underlying separately, take its price, and pass it to OptionCalculator for local computation.
When the same account uses premium quotes on multiple devices, those devices evict one another, and the evicted market is downgraded to a lower quote level.
// Read the current state synchronously; returns null until the first quote-entitlement check completes
val level = quotes.getQuoteLevelInfo()
if (level?.isMarketEvicted("US") == true) {
// When evicted but the server returned no message, evictedDescribe is null or an empty string
level.evictedDescribe?.takeIf { it.isNotEmpty() }?.let { showBanner(it) }
}
// Observe refreshes: the current snapshot arrives on subscription, and once per new quote-entitlement
// result thereafter (eviction, reclaim, or a language change)
lifecycleScope.launch {
quotes.observeQuoteLevel().collect { info -> renderBanner(info) }
}
// "Restart quotes": take the entitlement back to this device. The state is not yet refreshed when this
// returns — dismiss the banner based on the subsequent callback
quotes.reclaimQuoteAccess()Per-sub-market level detail lives in QuoteLevelInfo.subMarketQuote: the raw level identifier, the price level (priceLevel, with the convenience property isDelayed), depth levels by type (depthLevel, one entry per depthType; use maxDepthLevel for a coarse check), and flags for trades, broker queue, overnight, and pre/post-market. After a reclaim, the latest quotes for already-subscribed counters refresh automatically and pushes resume, but the tick-by-tick records from the evicted window are not backfilled — screens that need the complete sequence should call getTrades again after receiving a new, non-evicted snapshot. Banner presentation and policies such as “don’t show again today” are up to the host.
Method overview:
| Method | Description |
|---|---|
getInterestRate() |
Reads the SDK’s built-in risk-free rate (annualized decimal) for display in the option calculator; returns null when unavailable |
getStock(counterId) |
Gets the current quote snapshot for an instrument; returns null when not subscribed or not yet received |
getTrades(counterId,count = 100,lastSequenceId = 0,lastTradeSession = 0,tradeType = 0) |
Fetches historical trade-by-trade data (paged, query-only, no subscription); combine with subscribe(...).trade() for the live increment |
getKlines(counterId,klineType,count,timestamp = 0,adjustType = FORWARD_ADJUST,klineSession = ALL) |
Gets K-line data in ascending time order |
getTimeshares(counterId,fiveDays = false,klineSession = ALL) |
Gets timeshare data grouped by trading day |
subscribe(counterIds, callback) |
Creates a general quote subscription builder; chain the types you need, then call start() |
subscribeOptionDetail(counterId, callback) |
Creates an option detail subscription builder (single contract) |
subscribeOptionChain(counterIds, callback) |
Creates a batch option chain subscription (wholesale replacement, push types fixed and not selectable) |
observeQuoteEvents(counterIds) |
Observes the quote event stream without opening an underlying subscription; pair it with a subscription entry point |
observeKlineUpdates(counterIds) |
Observes the K-line update event stream |
getQuoteLevelInfo() |
Reads the quote-level and device-eviction snapshot; returns null before the first quote-entitlement check |
observeQuoteLevel() |
Observes quote level and device eviction (the current snapshot arrives on subscription) |
reclaimQuoteAccess() |
Takes the quote entitlement back to this device (“restart quotes”); the refreshed level arrives through the observer callback |
Chained type-selection methods on the subscription builders returned by subscribe / subscribeOptionDetail: list(), detail(), depth(), trade(), preTrade(), postTrade(); subscribe also offers broker(), nightTrade(), totalView(), and totalViewBrief() (the option data path does not support these four). Both builders support calling start() with no type selected at all, which fetches one snapshot and opens no push subscription — useful as a one-off snapshot call. The QuoteSubscription returned by start() has only cancel(); OptionChainSubscription additionally has updateCounterIds(counterIds) and an isCancelled property.
The request parameter tradeType on getTrades and the response field Trade.tradeSession use different, non-bitmask encodings. Request 3 explicitly when you need pre-market, after-hours, or overnight trades outside regular hours — the default 0 returns only regular-hours data:
Request tradeType |
Meaning | Response Trade.tradeSession |
|---|---|---|
0 (default) |
Regular hours | 0 |
1 |
Pre-market | 1 |
2 |
After-hours | 2 |
3 |
All sessions merged | No single equivalent value (a merged result) |
4 |
Overnight | 4 |
KlineSession has four values: TRADING (regular hours only), ALL (includes pre-market, after-hours, and overnight; the default), NORMAL_AND_PRE_POST, and NORMAL_AND_OVER_NIGHT. AdjustType has only NO_ADJUST and FORWARD_ADJUST (the default; there is no backward-adjustment option).
Option chain subscriptions use wholesale replacement: updateCounterIds does not unsubscribe contracts removed from the set immediately — the underlying subscription stays open, and the callback dispatch layer filters by the currently effective set, releasing everything together on cancel(). It debounces internally for about 300 milliseconds, but the first non-empty set takes effect immediately (no wait on first render); an unchanged set produces no request. Push types are fixed to detail, trade, pre-market, after-hours, and overnight, with no chained selection — this is by design, not an omission.
Three ways to judge quote level. SubMarketQuote.quoteLevel (for example App|USOP|Global|LV1) is a free-form display string whose format is not guaranteed to be stable — use isDelayed to determine whether quotes are delayed (it derives from priceLevel.priceLevel, whose full value table is in its KDoc — value 3 means real-time but not pushed, so you must query actively) instead of parsing that string. Depth entitlements come in two kinds, regular depth and US deep depth, each with its own level count: to render “N-level order book”, filter the depthLevel list by DepthLevel.depthType (1 regular depth, 2 US deep depth) and take the matching entry (the same type may arrive as several entries from different quote sources; take the largest level among them). maxDepthLevel takes the maximum across types and is only suitable for coarse checks such as “is there any depth at all”. The evicted-market detail list may repeat the same market (the server lists one entry per evicted entitlement), so de-duplicate it yourself when displaying a list. EvictedLevelDetail.market may be an empty string (some server entries carry no market dimension): use isMarketEvicted(market) for market-level checks (it already skips those entries) and isSubMarketEvicted(subMarket) for sub-markets. The banner text evictedDescribe is null or an empty string when the market is evicted but the server returned no message, so check for both before displaying it.
Data availability while evicted and downgraded. Tick-by-tick trades for that market are unavailable — live pushes stop, and getTrades history queries are equally entitlement-limited and return an empty list. Whether overnight and pre/post-market data remain available is not a fixed consequence of the downgrade; check the hasOvernight and hasPrePost flags of the current level in the snapshot individually.
OptionCalculator is a stateless, pure computation utility (it is not registered on any service, performs no network calls, and has no *Async variant); the caller supplies all input, and it prices European options with Black-Scholes. In a quote context, take the underlying price from your own underlying subscription, and take implied volatility, strike price, days to expiry, and dividend from the option push.
val optionData = stock.extraData as ExtraData.OptionData
val underlyingPrice = underlyingStock.trading.lastDone ?: return
// Do not use optionData.dayToExpire directly (it counts trading days).
// Use fractionalDaysToExpire() for calculation input (fractional days, matching the official
// client's option calculator); use daysToExpire() (whole calendar days) for "N days left" displays
val days = OptionCalculator.fractionalDaysToExpire(optionData.expireDate, TimeZone.getTimeZone("America/New_York"))
?: return // Invalid expiry date format
val input = OptionPricingInput(
underlyingPrice = underlyingPrice,
strikePrice = optionData.strikePrice ?: return,
impliedVolatility = optionData.impliedVolatility ?: return,
daysToExpire = days,
isCall = OptionDirection.fromQuoteDirection(optionData.direction) == OptionDirection.CALL,
dividendToExpire = optionData.dividendToExpire,
// Leave interestRate unset to use the SDK's built-in rate (same source as QuoteService.getInterestRate())
)
val greeks = OptionCalculator.greeks(input) // Returns null on invalid input; show a placeholder in the UIInvalid input always returns null (show “–” in the UI): implied volatility ≤ 0, days < 0, underlying price or strike price ≤ 0, or an unavailable interest rate. Both day-conversion APIs return a negative number once expired (daysToExpire returns 0 on the expiry day, which is valid) — a negative value cannot be passed to greeks or profitProbability; it is treated as invalid input and returns null. interestRate is an annualized decimal (for example, 0.045 for 4.5%), not a percentage; passing the wrong unit skews Greeks by a factor of 100.
Method overview:
| Method | Description |
|---|---|
greeks(input) |
Computes Black-Scholes Greeks (delta, gamma, vega, theta, rho) and theoretical price |
intrinsicValue(underlyingPrice, strikePrice, isCall) |
Computes intrinsic value (the payoff from exercising immediately), always ≥ 0 |
timeValue(optionPrice, intrinsicValue) |
Computes time value = premium − intrinsic value; can be negative and is not clamped for deep-in-the-money, near-expiry contracts |
daysToExpire(expireDate, timeZone) |
Converts an expiry date (yyyyMMdd) to whole calendar days remaining, for “N days left” displays |
fractionalDaysToExpire(expireDate,timeZone,holidays = emptyList(),dayOffset = 0) |
Converts an expiry date to fractional days remaining (expiry at 20:00, prorated by the hour); use this as the input to Greeks and probability of profit to match the official client’s option calculator |
profitProbability(underlyingPrice,breakevenPoint,impliedVolatility,daysToExpire,isCall,dividendToExpire = null,interestRate = null) |
Computes the probability of profit at expiry, using the breakeven point rather than the strike price, under Black-Scholes risk-neutral assumptions |
val orders = WhaleCore.getOrderService()
orders.observeOrderEvents(this, object : OrderEventCallback {
override fun onChange(order: Order) { updateOrder(order) } // Delivers the latest snapshot on every change
})The recommended flow validates before submitting: express the intent with OrderIntent, run validateOrder for full pre-submit validation, and submit the SDK-completed request directly once it passes — no need to assemble SubmitOrderRequest by hand.
// 1. Build the order intent: a limit buy of 100 shares of AAPL at 180.00
val intent = OrderIntent(
counterId = "ST/US/AAPL",
action = OrderAction.BUY,
orderType = OrderType.LO,
).apply {
price = "180.00"
quantity = "100"
}
// 2. Run full pre-submit validation
val result = orders.validateOrder(intent, ValidationScope.Submission)
val request = result.request
if (request != null) {
// 3. Validation passed; submit the SDK-completed request
val submit = orders.submitOrder(request) // submit.orderId is the order ID
} else {
showIssues(result.issues) // Validation failed; issues list the reasons
}You can also build a request directly with SubmitOrderRequest’s typed factory methods (market, if-touched, trailing stop, and so on), skipping the validation funnel. See Order types and attached orders for the full factory matrix.
An order can apply a commission-free card, a stock cash card, or a platform-fee card to discount the corresponding fee, passed through OrderIntent.cards (TradeCards). Validation, preview, and submission share the same value. The host fetches the card list itself; the SDK does not wrap a query endpoint. With no card selected, the submitted request’s card_ids is an empty array and the server redeems nothing, and the preview likewise computes without a card — to actually use a card, the host must place it explicitly in the matching slot.
intent.cards = TradeCards(
// The amount is required: the preview computes discounted fees from it, and without it the
// preview will not match what is actually charged
commissionCard = TradeCard(cardId = "1001", availableAmount = BigDecimal("50"), rebateRate = BigDecimal("0.8")),
)The three slots can also be read and written by category, so three card pickers can share one piece of UI logic:
val cards = intent.cards ?: TradeCards().also { intent.cards = it }
cards.setCard(TradeCardCategory.CASH, TradeCard(cardId = "2002", availableAmount = BigDecimal("100")))
cards.card(TradeCardCategory.CASH) // Read that category back
cards.isEmpty // Whether all three slots are unselectedThe server-configured default card is returned with the constraints snapshot, which is handy for marking the default selection in the card picker. The SDK does not fill it in automatically — whether to use it is the integrator’s decision:
val constraints = orderService.getOrderConstraints(counterId)
// A null slot means the server configured no card of that category for this counter,
// so disable that category's card picker
constraints.suggestedCard(TradeCardCategory.COMMISSION)?.let { suggested ->
suggested.cardType // The card-type argument for querying that category's card list
cards.setCard(TradeCardCategory.COMMISSION, suggested.toTradeCard()) // null when the amount is missing; nothing is pre-filled
}A stock cash card applies only to buy orders; selecting it on a sell order is blocked by the validation rule CASH_CARD_NOT_APPLICABLE_ON_SELL (1115). A TradeCard.cardId of empty string counts as unselected: it does not enter the submitted request, does not take part in the preview discount, and does not trigger that rule — the cards deducted in the preview are always the cards submitted. rebateRate is effective on the commission-card slot only.
val today = orders.getTodayOrders()
val history = orders.getHistoryOrders(HistoryOrdersRequest(page = 1, limit = 20))
val detail = orders.getOrderDetail(orderId)
orders.cancelOrder(orderId)
// Replace: change only the price (quantity is required — resend the original quantity even when you are not changing it)
orders.replaceOrder(ReplaceOrderRequest.regular(orderId = orderId, quantity = "100", price = "182.00"))Replacing conditional orders (LIT/MIT/TSL) and option orders uses their own factory methods; see Order types and attached orders.
Batch cancellation:
orders.batchCancelOrders(counterId = "ST/US/AAPL") // Cancel by instrument (omit action for both directions)
orders.batchCancelOrdersByIds(listOf("1", "2", "3"))// Recommended: one call returns the trade capacity snapshot for an entire order screen
val capacity = orders.getTradeCapacity(
TradeCapacityRequest(counterId = "ST/US/AAPL", action = OrderAction.SELL,
submitPrice = "180.5", orderType = OrderType.LO, settlementCurrency = "USD")
)
val sellable = capacity.sellableQuantity // Sellable from holdings
val shortable = capacity.shortSellableQuantity // Sellable shortSee the method overview below for the maximum buy limit (stocks only, getEstimateBuyLimit), position detail (getTradeDetail), and order info (getOrderInfo).
A black-box validation capability: the host passes only the order intent, and the SDK manages data fetching and caching internally. Every issue means the order would fail if submitted as-is, with no warning level; a business failure never throws — it is always returned through issues.
// 1. Before entering the order screen
val entry = orders.checkTradability("ST/HK/00700")
if (!entry.passed) { showBlocked(entry.issues.first().reason); return }
// 2. Render the constraint snapshot when the order screen opens
val constraints = orders.getOrderConstraints("ST/HK/00700")
renderOrderTypes(constraints.supportedOrderTypes)
// 3. While editing: lightweight per-field validation drives button and field states
val draft = orders.validateOrder(intent, ValidationScope.Draft)
submitButton.isEnabled = draft.passed
// 4. Full pre-submit validation; on success, submit the resulting request (see "Build and submit an order")Qualification states (for example, the US overnight-trading disclosure or a W-8BEN) are queried and resolved through getQualifications(counterId), which returns the full set — each item has type, state, expired, and acceptableViaApi. Branch on acceptableViaApi first:
val qualifications = orders.getQualifications("ST/US/AAPL")
val unresolved = qualifications.firstOrNull { !it.isSatisfied }
if (unresolved != null && unresolved.acceptableViaApi) {
orders.acceptAgreement(unresolved.type) // The SDK invalidates its cache after acceptance; re-run validation to see the new state
}Items with acceptableViaApi == true — disclosure and entitlement items such as US overnight trading, options overnight trading, odd lots, US short selling, penny stocks, OTC trading, warrants and CBBCs, CAR-CKA, and virtual-asset ETF assessment and additional risk disclosure, plus the options risk agreement and the listed-derivatives ETF assessment — can be accepted in one call with acceptAgreement(type), or, for the assessment only, answered with submitListedDerivAssessment(experience) (choosing DerivExperience.NONE records a failed assessment and keeps the block in place). The virtual-asset ETF assessment and its additional risk disclosure share a single signature: accepting either one sets both server-side, so present both to the user and obtain confirmation before calling. Items with acceptableViaApi == false — assessment or certification items such as PI, VA, W-8BEN, and Hong Kong margin short selling — must be handled with the broker directly; the SDK exposes no handling link, and calling acceptAgreement on one of these throws IllegalArgumentException (a programming error, not one of the WhaleCoreException types). Both methods invalidate the internal qualification cache on success, so re-running validation picks up the new state.
The earlier standalone entry point WhaleCore.getOrderValidationService() is deprecated. New code should call the same-named methods on WhaleCore.getOrderService() directly.
OrderValidationRule.code is segmented by stage (stage number × 100) and only ever grows across versions, never gets renumbered. Treat an unrecognized code as “block and show a generic message”:
| Stage | Code range | Scope | Covers |
|---|---|---|---|
| Before entering the order screen | 1001–1005 |
ENTRY | Empty instrument, account not opened or suspended, index sectors not tradable, server-side tradability determination |
| Screen button state | 1101–1115 |
DRAFT+SUBMISSION | Channel/direction restrictions, required price/quantity/trigger price/trailing value/monitor price, order-type support, amount-based order applicability and minimum amount, stock cash card incompatible with sell |
| Agreement | 1201 |
SUBMISSION | Options risk agreement not accepted |
| Session and time-in-force | 1401–1403 |
DRAFT+SUBMISSION | Order-type session support, time-in-force support, GTD expiry required |
| Qualifications and disclosures | 1501–1512 |
SUBMISSION only | ETF assessment, short-selling/margin qualification, W-8BEN, US/options overnight disclosures, odd-lot disclosure and session, OTC trading entitlement, warrant trading entitlement, CAR/CKA certification, virtual-asset ETF trading assessment and additional risk disclosure |
| Attached orders | 1601–1608 |
DRAFT+SUBMISSION | Required take-profit/stop-loss trigger and submit prices, GTD expiry, whether the order type supports attached orders |
| Position take-profit/stop-loss | 1701–1709 |
DRAFT+SUBMISSION | See Position take-profit/stop-loss |
Three pass-through conventions: when the server does not return a given authorization item, treat it as not applicable; an empty handling link on a granted_info item means the server did not return that requirement; and when account, quote, or position data is missing, treat it as undeterminable and pass through, leaving the server to enforce it. The second convention applies only to granted_info items — 1508–1510 are account-level gates, and while they are not enabled, submission is blocked for every counter.
One confirmed difference from iOS: the bracket-order price-direction rules (1606/1607, which block an attached order’s trigger price based on its position relative to the current price) are not implemented on Android yet. The server currently rejects such orders on its own, and the codes are reserved but unused — do not assume this validation exists.
// Shares the same OrderIntent as validation and submission; preview at any point while editing
val context = OrderPreviewContext().apply {
marketReferencePrice = BigDecimal("180.00") // Required for market orders or when there is no submit price; otherwise amount fields are null
// Option instruments must pass optionDirection, or the call throws WhaleCoreException.InvalidParameter
}
val preview = orders.previewOrder(intent, context)
preview.orderAmount // Estimated order amount
preview.fees.total // Estimated total fees (after discount)
preview.orderTotal // Estimated order total
preview.riskHint // Risk hint, such as FINANCING_NEEDED (financing will be used)A synchronous, pure-computation entry point, OrderPreviewCalculator.compute(fields), is also available for hosts that fetch data themselves; it shares the same computation as previewOrder.
In OrderPreviewResult, null always means “structurally not applicable”, not a data-fetch failure:
financing(financing rate and daily interest) andoccupy(buying-power/financing usage) are produced only for a buy that opens a long position in a non-cash account.optionFreeze(option sell freeze) is produced only for a sell in a non-margin contract mode with a known direction; a call freezes the underlying, a put freezes cash.initialMargin/maintenanceMargin: a closing-trial value takes priority; otherwise stocks follow the margin-rate factor path. Closing an option produces noinitialMargin.riskHintpriority: a closing trade raising margin > a buy exceeding cash buying power that needs financing > no hint.- Reference price rule: limit-type orders use the submit price, MIT uses the trigger price, and market orders use
context.marketReferencePrice(amount fields arenullif you don’t pass it). - Option instruments must pass
context.optionDirection, or the call throwsWhaleCoreException.InvalidParameter— the SDK does not fetch quotes and cannot infer the direction. - Exchange rates are used only for cross-currency conversion inside the occupy calculation; the SDK maintains them internally and exposes no standalone lookup API, matching the portfolio module (see Portfolio).
- Fee discount balances (
commRemain,platformDeductionsRemain,deductionsRemain) reflect the card selected via Trade cards; with no card selected they stay empty and the corresponding fee is not discounted. When the card currency differs from the settlement currency, they are converted usingcommCurrency,platformDeductionsCurrency, anddeductionsCurrencyrespectively before entering the computation — when callingOrderPreviewCalculator.compute(fields)manually, fill in those three currency fields yourself; thepreviewOrder(intent, context)path handles this automatically.
One confirmed difference from iOS: fees.third (third-party fees) keeps 0 when the value is 0 rather than becoming null (null means no fee data at all) — iOS converts 0 to nil, so account for this when aligning copy.
Places a take-profit and a stop-loss conditional order in one call against a position you already hold. This differs from an attached order: an attached order rides on a newly submitted parent order and activates only after that order fills, while TPSL acts directly on an existing position and needs no parent order. The caller does not specify a direction; the SDK derives it from whether the position is long or short.
val tpslIntent = TPSLOrderIntent("ST/US/AAPL").apply {
quantity = "100"
wantsTakeProfit = true
takeProfitTriggerPrice = "200.00"
wantsStopLoss = true
stopLossTriggerPrice = "160.00"
}
submitBtn.isEnabled = orders.validateTPSLOrder(tpslIntent, ValidationScope.Draft).passed
val tpslResult = orders.validateTPSLOrder(tpslIntent, ValidationScope.Submission)
if (tpslResult.passed) {
val placed = orders.submitTPSLOrder(tpslResult.request!!)
// placed.ployId is shared by both orders; placed.orders lists each leg's ployType (TAKE_PROFIT/STOP_LOSS) and orderId
}Replace and cancel go through the regular endpoints, not this one: a placed TPSL order is an ordinary conditional order, so replace it with replaceOrder and cancel it with cancelOrder.
validateTPSLOrder runs its own determination, with two things worth noting: it does not guarantee the full set of issues — hitting 1708 (no position) or 1709 (unsupported instrument) returns early without further field-level checks; and the required-quantity (1106), unsupported order type not LIT/MIT (1109), and missing GTD expiry (1403) checks reuse the parent-order codes rather than defining new ones.
| code | Rule | Trigger condition |
|---|---|---|
1701 |
Neither take-profit nor stop-loss enabled | Neither side enabled, nothing to place |
1702/1703 |
Trigger price required | Enabled but the trigger price is missing or non-positive |
1704/1705 |
Submit price required | The side uses LIT and is enabled, but the submit price is missing or non-positive |
1706 |
Trigger prices reversed | For a long position, the stop-loss trigger ≥ the take-profit trigger (reversed for a short position) |
1708 |
No position | No position exists in the instrument |
1709 |
Unsupported instrument | Hong Kong limited to stocks/ETFs/bull-bear warrants, Singapore/A-shares limited to stocks, US unrestricted |
Two checks the SDK deliberately skips: it does not block a trigger price that is already crossed by the current price (that is a legitimate “close now” intent), and it does not block a quantity exceeding what is closable (the closable quantity is a snapshot; the server has the final say).
The two orders placed share the same ployId in the order list, with ployType set to TAKE_PROFIT or STOP_LOSS respectively so you can pair them for display.
SubmitOrderRequest can only be built through its companion-object factories:
| Factory method | Applicable order types | Type-specific parameters |
|---|---|---|
limit(common, price, orderType = LO) |
LO/SLO/ELO/ALO/SpecialLO/ODD | price |
market(common, orderType = MO) |
MO/AO/MOO/MOC | — |
limitIfTouched(common, triggerPrice, submitPrice, trend, triggerCount = 1) |
LIT | Trigger price + submit price + trigger direction + touch-count guard |
marketIfTouched(common, triggerPrice, trend, triggerCount = 1) |
MIT | Trigger price + trigger direction + touch-count guard |
trailingStopLimit(common, trigger, leg, monitorPrice, triggerCount = 1) |
TSL | Trailing amount (amount or percentage) + limit leg (spread or offset) + monitor price |
OrderType.TS (trailing stop market) is entirely unsupported — the enum constant exists, but there is no submit or replace factory for it, and pre-submit validation blocks it with ORDER_TYPE_NOT_SUPPORTED (1109).
ReplaceOrderRequest follows the same pattern with companion-object factories: regular (a plain stock order), option (an option parent order, which always carries the required price and quantity), and limitIfTouched / marketIfTouched / trailingStopLimit (conditional orders, which take triggerStatus and isOption — only an already-triggered order routes to the plain/option parent-order endpoint, and everything else uses the conditional-order endpoint).
Attached-order (AttachedParams) factories: takeProfit, stopLoss, bracket, and cancelAll (used only when replacing an order, to cancel all its attached orders). Activation methods: AttachedActivation.marketIfTouched(rth) and limitIfTouched(profitTakerSubmitPrice?, stopLossSubmitPrice?, rth). Price validity constraints: for a buy parent order, takeProfitPrice > current price > stopLossPrice (reversed for a sell); attached orders cannot be included when forceOnlyRth = OVERNIGHT.
// A bracket order
val withAttached = SubmitOrderRequest.limit(common, price = "350.00").attaching(
AttachedParams.bracket(
takeProfitPrice = "360", stopLossPrice = "340",
activation = AttachedActivation.marketIfTouched(),
)
)
// Replace the parent order and cancel all its attached orders
service.replaceOrder(
ReplaceOrderRequest.regular(orderId = "123", quantity = "50")
.attaching(AttachedParams.cancelAll())
)
// Change a single attached order's trigger price without touching the parent order; a LIT-activated attached
// order's submit price must be resent even when it is not changing
service.replaceAttachedOrder(
ReplaceAttachedOrderRequest.modifyProfitTaker(
mainId = "123", mainQuantity = "100", marketPrice = "355",
profitTakerId = "999", newPrice = "365",
),
)Single-leg option orders reuse the same factories for submit and replace, with no option-specific parameters — the SDK routes to the option-specific endpoint automatically when counterId is an option contract. The option channel does not return an attached-order allowlist, so selecting take-profit or stop-loss is blocked with 1608.
When you build SubmitOrderRequest directly through the factories, pass cards through OrderCommon.cardIds (submission only needs the card IDs; the balance and discount rate are needed only on the OrderIntent.cards path used for validation and preview). The three card slots always appear in a fixed order: commission-free card, cash card, platform-fee card.
Method overview:
| Method | Description |
|---|---|
getOrderInfo(counterId, orderId = null) |
Basic instrument info and account channel permissions before submitting; pass orderId when replacing to get the fields that can be changed |
getEstimateBuyLimit(request) |
Queries the maximum buy limit (stocks only; see getTradeCapacity for options) |
getTradeDetail(counterId, settlementCurrency) |
Queries position detail (total quantity, sellable quantity, cost, cash) |
getTradeCapacity(request) |
Queries the trade capacity snapshot (the recommended entry point for capacity data) |
submitOrder(request) |
Submits an order |
replaceOrder(request) |
Replaces an order (a shared entry for plain, conditional, and option parent orders, routed automatically by type) |
cancelOrder(orderId) |
Cancels a single order |
batchCancelOrders(counterId = null, action = null) |
Batch-cancels orders by instrument and direction |
batchCancelOrdersByIds(orderIds) |
Batch-cancels orders by ID |
cancelAttachedOrder(attachedOrderId) |
Cancels a single attached order |
replaceAttachedOrder(request) |
Modifies an attached order, or cancels all attached orders under a parent order |
previewOrder(intent, context) |
Previews an order |
validateTPSLOrder(intent, scope) |
Validates a position take-profit/stop-loss order |
submitTPSLOrder(request) |
Submits a position take-profit/stop-loss order |
observeOrderEvents() |
Observes the order change event stream |
checkTradability(counterId) |
Checks tradability before entering the order screen |
getOrderConstraints(counterId, orderId = null) |
Returns the constraint snapshot for rendering the screen (new order or replace) |
validateOrder(intent, scope) |
The unified entry point for pre-submit validation |
getQualifications(counterId) |
Queries the full set of qualification/disclosure states for an instrument |
acceptAgreement(type) |
Accepts a qualification or disclosure that can be accepted via the API |
submitListedDerivAssessment(experience) |
Submits the listed-derivatives ETF assessment answer |
getTodayOrders(filter) |
Queries today’s order list |
getHistoryOrders(filter) |
Queries the historical order list (paged) |
getOrderDetail(orderId, isAttached) |
Queries order detail, including its status change history; querying an attached order also returns its parent order |
A standalone utility, OrderPreviewCalculator.compute(fields) (synchronous pure computation), is also available; see Order preview.
val portfolios = WhaleCore.getPortfoliosService()
// Register the listener before subscribing to accounts, so you don't miss the first frame
portfolios.observePortfolioEvents(this, object : PortfolioEventCallback {
override fun onMessage(accountInfo: AccountInfo, portfolio: Portfolio) { render(portfolio) }
override fun onFailure(accountInfo: AccountInfo?, error: Throwable) { showError(accountInfo, error) }
})
portfolios.setSubscribedAccounts(listOf("lb"), "HKD") // Subscribes every opened account (including sub-accounts) under the channel
portfolios.refresh() // Refresh once when entering the screenCash detail and position-quote linkage toggles: enableCashDetail(enabled) / isEnableCashDetail(), enableQuotes(enabled) / isEnableQuotes().
Android currently has no standalone exchange-rate conversion capability, unlike iOS. The already-converted fields on the Portfolio model, such as marketValueExchanged, are display fields the server returns — not a conversion tool the SDK provides.
Read, modify, and submit; a successful update triggers a refresh automatically:
val setting = portfolios.getPortfolioMemberSetting()
portfolios.updatePortfolioMemberSetting(
setting.copy(costType = PortfolioCostType.AVG, showDelistedHoldings = true)
)Business-level aggregation APIs for account-level, per-stock, and per-market profit-and-loss analysis, one method per UI panel:
| Method | Serves this UI panel |
|---|---|
getProfitLossAnalysisMeta() |
Page init: time-filter bounds + the full list of comparison indexes |
getProfitOverview(currency, period) |
The overview headline figures + per-asset-type summary + cumulative traded amount/stock count |
getProfitTrend(currency,period,indexCounterId) |
The trend chart: cumulative return/total-asset curve + optional index comparison + outperformance |
getPLCalendar(currency, period, markets) |
The calendar view: daily/monthly/yearly grids + trading-day/holiday markers |
getProfitRanking(currency, period) |
The ranking view: top gainers and top losers in one call (up to 10,000 rows per list) |
getAssetFlow(period) |
Per-currency asset flow on the “My assets” screen |
getStockPLMeta(counterId) |
Per-stock P&L page init: time-filter bounds |
getStockCumulativePL(counterId, period) |
Per-stock cumulative P&L + underlying/derivative composition (fetched once; no refetch on tab switch) |
getStockPLFlows(counterId,derivative,page,size,period) |
Per-stock P&L flow detail (paged; underlying and derivatives queried separately) |
getPLTradedMarkets() |
The market-tab data source on the “Stock P&L” page |
getMarketStocksPLMeta() |
“Stock P&L” page init: time-filter bounds (including all markets) |
getMarketStocksPL(market,currency,order,page,size,period) |
Per-market stock P&L list (totals + per-instrument rows, sorted and paged server-side) |
getLiquidatedStocksPL(market,currency,page,size,period,underlyingCounterId = null) |
Closed-position P&L: six summary metrics + per-instrument grouped detail, paged |
val meta = portfolios.getProfitLossAnalysisMeta() // Time-filter bounds + optional comparison indexes
val overview = portfolios.getProfitOverview("HKD", PLPeriod.allTime)
val trend = portfolios.getProfitTrend(
currency = "HKD",
period = PLPeriod.allTime,
indexCounterId = meta.indexes.firstOrNull()?.counterId, // Pass null to skip index comparison
)
renderTrendChart(trend.selfSeries, trend.indexSeries) // Both series are equal length and index-aligned; plot them directlyModels mirror the server response: amounts, ratios, and timestamps are always raw strings; non-business nullable fields default to an empty value (empty string / 0 / false).
Field semantics: amounts, ratios, counts, and timestamps are always raw strings — the SDK performs no numeric conversion. Ratios are decimals (multiply by 100 for display); timestamps are second-precision strings, and date strings are fixed as "yyyy-MM-dd". Non-business nullable fields default to a value when there is no data (an empty string, 0, or false). Only two kinds of fields remain genuinely nullable: nested objects that can be entirely absent (such as ProfitOverview.profits or StockCumulativePL.underlyingDetails), and SDK-computed fields with no result when input is insufficient (ipoHitRate(), ProfitTrend.outperformedSimple). The models always return the full data set; it is up to the host to decide what to display.
Two ways to build a PLPeriod: PLPeriod.allTime (all time, starting automatically from the data boundary) and PLPeriod.range(start, end) (a specific range given as second-precision timestamps); the selectable range comes from the date bounds returned by getProfitLossAnalysisMeta(), getStockPLMeta(), or getMarketStocksPLMeta().
Convenience methods built into the models: ProfitOverview.stockSummary() / fundSummary() / cryptoSummary() (the top-gaining/top-losing instrument per asset type), ProfitOverview.ipoHitRate() (IPO allocation hit rate; null when the subscription count is 0 or data is missing), and StockCumulativePL.isDerivativeFocused() (decides whether the derivatives tab or the underlying tab shows by default).
Open-ended enum/value references (string-constant objects with an open value set — pass through and keep a default branch for values not listed here): PortfolioCostType (AVG/DILUTED), PLSortOrder (DESCENDING/ASCENDING), MarketDayStatus (WORKINGDAY/HOLIDAY/WEEKEND), PLSecurityType (STOCK/FUND/VA/MMF), PLInvestType (CASH/STOCK/OTHER), PLFlowDirection (NONE = "-1" a directionless flow placeholder / SELL = "0" / BUY = "1").
Ranking row cap: getProfitRanking returns at most 10,000 rows for the gainers list and 10,000 for the losers list, 20,000 combined. The server does not return anything beyond that, so narrow the statistical range when you need a longer list.
Degradation and failure semantics (the primary data throws on failure; decorative supplementary data degrades to empty): if index comparison fails, getProfitTrend returns an empty indexSeries without affecting its own curve; if holiday markers fail to load, getPLCalendar returns an empty marks list; if one ranking fails to parse, getProfitRanking treats that ranking as an empty list without affecting the other.
Trend chart granularity: ProfitTrend.granularity switches to monthly automatically once the range exceeds about one year; the daily dateKey is "yyyy-MM-dd", and the monthly one is "yyyy-M".
Cumulative and current-period returns on trend points: ProfitTrendPoint provides both cumulative (accumulateSimpleEarningYield / accumulateTimeEarningYield) and current-period (simpleEarningYield / timeEarningYield, meaning that day at daily granularity and that month at monthly granularity) returns. At monthly granularity a missing cumulative value falls back to the month’s last daily point, and to "0" if that is also missing; current-period values have no fallback (a monthly return is not the daily return of the month’s last day) and are an empty string when the server does not return them. IndexComparePoint symmetrically provides cumulativeReturn and currentReturn (relative to the previous curve point; for the first point, relative to the closing price of the last trading day before the range starts).
Per-market convention: an empty string for market means “all markets”; the three per-stock P&L endpoints need no market/currency parameters.
Core method overview (subscription/settings; see the table above for profit-and-loss analysis methods):
| Method | Description |
|---|---|
setSubscribedAccounts(accountChannels, currency) |
Sets the subscribed account channels and display currency, replacing the current subscription scope entirely; an empty list cancels all subscriptions |
observePortfolioEvents() |
Observes the portfolio event stream |
refresh() |
Manually refreshes the currently subscribed portfolio data |
enableCashDetail(enabled) / isEnableCashDetail() |
Toggles cash detail display |
enableQuotes(enabled) / isEnableQuotes() |
Toggles recomputing the portfolio when position quotes change |
getPortfolioMemberSetting() |
Gets the current member asset settings |
updatePortfolioMemberSetting(setting) |
Updates the member asset settings (only submits changed fields) |
val watchlist = WhaleCore.getWatchlistService()
// Register the listener before triggering a data change — SharedFlow defaults to replay=0 and does not replay
// events from before you subscribed
lifecycleScope.launch {
watchlist.observeWatchlistEvents().collect { event ->
when (event) {
is WatchlistEvent.Groups -> renderGroups(event.groups, event.stockInfo, event.ties)
is WatchlistEvent.SortGroups -> applySort(event.sortGroups)
else -> Unit
}
}
}
watchlist.refresh(sub = true)
// Add, move across groups, remove
watchlist.addStocks(counters = listOf("ST/US/AAPL"), groups = listOf(groupId), removeGroups = emptyList())
watchlist.removeStocks(counters = listOf("ST/US/NVDA"), groups = listOf(groupId), removeAll = false)Fund and note data do not live on WatchlistStock; fetch them through their own events instead — funds through WatchlistEvent.Funds, and notes through WatchlistEvent.Notes (Map<counterId, note>).
val groupId = watchlist.addGroup("Tech stocks")
watchlist.renameGroup(groupId, "US tech")
watchlist.sortGroups(listOf(groupId, otherGroupId)) // Pass every group ID in the target orderwatchlist.tie(listOf("ST/US/AAPL"))
watchlist.sortTied(listOf("ST/US/AAPL", "ST/HK/00700"))
// Sort the current group intelligently by market open time
watchlist.setGroup(groupId = groupId, sortMode = "US|HK,SG,CN", asc = true)Identifying a system group (use these fields, not the display name — display names are localized and can repeat):
| Field | Description |
|---|---|
kind |
1 for a system default group, 2 for a custom group (the only kind you can create, delete, edit, or reorder by drag); the isCustom() extension is kind == 2 |
name |
A stable server identifier (all/holdings/funds/options/va) for semantic checks; do not use it for display |
showName / displayName() |
showName is a localized display name that can be empty; always use the displayName() extension in the UI, which falls back to name when empty |
id |
Preset system groups use negative IDs (holdings is -6, funds is -12), but semantic checks should still rely on name, not id |
The standard for identifying a system group is “kind == 1 and name matches one of the stable values (case-insensitive)”. The full set of extension functions: isCustom(), isSystem(), isHolding(), isFund(), isOption(), isAll(), isVirtualAsset().
WatchlistStock extra fields: holdingInfo is non-null only for a held position (cost, quantity); quoteInfo is the non-real-time quote cached when the instrument was added to the watchlist, meant only as a placeholder — subscribe through QuoteService for the live quote; industry, liveScope, and icons can all be null.
WatchlistSortField has 56 enum values in three groups by purpose: general quote fields (SYMBOL, LAST_PRICE, CHANGE_PERCENT, VOLUME, and others), option-specific fields (DELTA, GAMMA, THETA, VEGA, RHO, and others), and fund-specific or fund-compatible general fields (ASSET, NAV, YEAR_TO_DATE, and others); see the enum source’s KDoc for the complete list.
sortMode value reference:
sortMode |
Meaning | SortGroup.id |
|---|---|---|
"natural" |
The user’s custom order, unchanged | Empty string |
"port_ai" |
PortAI intelligent sorting | The sector instrument’s counterId |
A form like "US|HK,SG,CN" |
Sorts intelligently by market open time; | separates the US and Asia groups, , separates order within a group |
The market code |
Sorting ignores special trading states such as a trading halt only when sortMode="natural" and sortField=NONE.
Method overview:
| Method | Description |
|---|---|
observeWatchlistEvents() |
Observes the watchlist event stream |
addGroup(groupName) |
Creates a new watchlist group, returning the new group ID |
removeGroup(groupId, deleteStocks) |
Deletes a group; deleteStocks controls whether to also delete stocks that belong only to that group |
renameGroup(groupId, name) |
Renames a group |
sortGroups(groupIds) |
Reorders groups |
addStocks(counters,groups,removeGroups,sub = true) |
Adds stocks to groups (also usable to move stocks between groups) |
removeStocks(counters, groups, removeAll) |
Removes stocks from groups; removeAll=true ignores groups |
sortStocks(groupId, counterIds) |
Reorders instruments within a group |
tie(counters) |
Pins stocks |
untie(counters) |
Unpins stocks |
sortTied(counters) |
Reorders pinned instruments |
setGroup(groupId,sortMode = "natural",sortField = NONE,asc = true) |
Switches the current group and sort mode, returning the sorted list |
resort() |
Triggers a re-sort with the current sort rule |
refresh(sub = false) |
Refreshes watchlist data; sub controls whether to also subscribe to quotes |
resubscribe() |
Re-subscribes to quote pushes for instruments in the current group |
unsubscribe() |
Cancels the quote subscription without affecting the watchlist data |
invalidTickers() |
Gets the counterIds of invalid instruments |
removeInvalidTickers() |
Removes all invalid instruments |
Use WhaleCore.getRequestService() to call TradingAPI endpoints that do not yet have a typed WhaleCore service. Take the endpoint path, parameters, and response schema from the TradingAPI documentation. The host supplies those request values; WhaleCore adds the common parameters and headers for signing, authentication, and tracing. If login or trade authentication expires, the SDK recovers it and retries once.
HttpRequest is immutable and accepts:
| Constructor argument | Type | Behavior |
|---|---|---|
method |
HttpMethod |
GET, POST, PUT, or DELETE |
path |
String |
Endpoint path beginning with / |
query |
Map<String, Any>? |
Used by GET and DELETE; ignored by POST and PUT |
body |
Map<String, Any>? |
Used by POST and PUT; ignored by GET and DELETE |
requiresTradeToken |
Boolean |
When true, obtains a valid trade token before sending; defaults to false |
send returns an HttpResponse containing the raw JSON bodyString and response headers. Parse the body with the JSON library used by the host app.
import longbridge.whalecore.business.request.model.HttpMethod
import longbridge.whalecore.business.request.model.HttpRequest
val request = HttpRequest(
method = HttpMethod.GET,
path = "/v2/member/info",
query = mapOf("include_accounts" to true)
)
val response = WhaleCore.getRequestService().send(request)
val member = json.decodeFromString<MemberInfo>(response.bodyString)
println(member.name)For an endpoint that requires trade authentication:
val request = HttpRequest(
method = HttpMethod.GET,
path = "/v5/orders/today",
requiresTradeToken = true
)
val response = WhaleCore.getRequestService().send(request)
println(response.bodyString)Java callers can use sendAsync(request, callback) instead of the suspending send method.
The first version does not support custom request headers. Do not add signatures or authentication tokens to query or body; WhaleCore supplies them through its managed session.
| Model category | Main types |
|---|---|
| Quotes | Stock / StockTemplate / TradeStatus / KlineUpdate / Kline / TimeShares / QuoteLevelInfo / SubMarketQuote / PriceLevel / DepthLevel / EvictedLevelDetail |
| Option calculator | OptionPricingInput / OptionGreeks |
| Orders | Order / OrderIntent / SubmitOrderRequest / SubmitOrderResult / ReplaceOrderRequest / OrderConstraints / OrderInfo / OrderValidationResult / TradeCapacity / TradeCapacityRequest / QualificationStatus / TPSLOrderIntent / SubmitTPSLOrderRequest / TPSLValidationResult / HistoryOrdersRequest / HistoryOrdersResult / TradeCard / TradeCards / TradeCardCategory / SuggestedTradeCard |
| Portfolio | Portfolio / AccountInfo / PortfolioMemberSettingInfo / PortfolioCostType |
| Profit and loss | PLAnalysisMeta / ProfitOverview / ProfitTrend / PLCalendar / ProfitRanking / CurrencyAssetFlow / StockCumulativePL / MarketStocksPL / PLPeriod |
| Watchlist | WatchlistGroup / WatchlistStock / WatchlistFund / WatchlistSortField / WatchlistSortGroup / WatchlistEvent |
| Pass-through requests | HttpRequest / HttpResponse / HttpMethod |
See each type’s KDoc for the complete field reference in your IDE.
Every public API fails through a subtype of WhaleCoreException, so the host can branch on it:
| Type | Meaning |
|---|---|
NotReady |
The SDK is not initialized or has been destroyed |
InvalidParameter |
The caller’s argument is missing or invalid (a development-time issue to fix during integration, not a runtime-recoverable error) |
NetworkUnavailable |
The network is unavailable |
RequestTimeout |
The request timed out |
Unauthorized |
Authentication failed (includes a business error code, businessCode) |
TradeAuthFailed |
The trade token is invalid or missing (rare; the SDK handles trade auth automatically by default) |
ServerError |
The server returned an error |
SerializationFailed |
The request parameters (query / body) could not be serialized to JSON |
ValidationDataUnavailable |
Data required by pre-submit validation is not ready or failed to load (this does not mean the order failed validation — a business failure always goes through OrderValidationResult.issues) |
Unknown |
Any other uncategorized error |
try {
val stock = WhaleCore.getQuoteService().getStock("ST/US/AAPL")
} catch (e: WhaleCoreException.NetworkUnavailable) {
// Show a network hint and retry
} catch (e: WhaleCoreException) {
showToast(e.message) // message is already localized to language; show it directly
}- Get the SDK version at runtime with
WhaleCore.version(format1.0.0(123)); the version name and build number are also available separately asWhaleCore.versionName/WhaleCore.versionCode. - Check the initialization state with
WhaleCore.isInitialized(). - Switch language at runtime with
WhaleCore.setLanguage(...)(no re-initialization needed).