Skip to content
WhaleCoreSDK

Android

WhaleCoreSDK Android installation, initialization, core concepts, and the quote, trading, portfolio, and watchlist services

Integrate

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 coreLibraryDesugaring configuration required when minSdk < 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.gradle

example/ demonstrates the full integration flow. Run it before integrating into your own project:

cd example
./gradlew :app:installDebug

You can also open example/ directly in Android Studio.

Initialize

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.InvalidParameter when a field is missing (or deviceId is shorter than 32 characters); parameterName identifies the offending field.
  • Query the status with WhaleCore.isInitialized().
  • Never log the whole config; it contains sensitive credentials such as appSecret and token.

Lifecycle and teardown

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.

Session token refresh

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.

Core concepts

The following rules apply across services. Read them once; later sections do not repeat them.

Counter IDs

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.

Coroutines and callbacks

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.

Event stream subscription

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.

Services and the auth boundary

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.

Public services

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.

Note

The earlier standalone entry point WhaleCore.getOrderValidationService() is deprecated. For pre-submit validation, call the same-named methods on WhaleCore.getOrderService() directly.

Quotes

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 destroyed

If the quote is already subscribed through another path, such as WatchlistService, use observeQuoteEvents(...) to observe the quote stream without opening a duplicate subscription.

K-lines

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
    }
}

Timeshares

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 */ }

Option quotes

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.

Quote level and device eviction

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.

Option calculator

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 UI
Warning

Invalid 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

Orders and validation

val orders = WhaleCore.getOrderService()

orders.observeOrderEvents(this, object : OrderEventCallback {
    override fun onChange(order: Order) { updateOrder(order) }   // Delivers the latest snapshot on every change
})

Build and submit an order

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.

Trade cards

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 unselected

The 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
}
Note

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.

Manage, cancel, and replace orders

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"))

Estimates

// 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 short

See the method overview below for the maximum buy limit (stocks only, getEstimateBuyLimit), position detail (getTradeDetail), and order info (getOrderInfo).

Pre-submit validation

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.

Note

The earlier standalone entry point WhaleCore.getOrderValidationService() is deprecated. New code should call the same-named methods on WhaleCore.getOrderService() directly.

Order preview

// 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.

Position take-profit/stop-loss

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.

Order types and attached orders

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.

Portfolio

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 screen

Cash detail and position-quote linkage toggles: enableCashDetail(enabled) / isEnableCashDetail(), enableQuotes(enabled) / isEnableQuotes().

Note

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.

Member asset settings

Read, modify, and submit; a successful update triggers a refresh automatically:

val setting = portfolios.getPortfolioMemberSetting()
portfolios.updatePortfolioMemberSetting(
    setting.copy(costType = PortfolioCostType.AVG, showDelistedHoldings = true)
)

Profit-and-loss analysis

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 directly

Models 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).

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)

Watchlist

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>).

Group management

val groupId = watchlist.addGroup("Tech stocks")
watchlist.renameGroup(groupId, "US tech")
watchlist.sortGroups(listOf(groupId, otherGroupId))   // Pass every group ID in the target order

Pinning and sorting

watchlist.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)

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

General HTTP requests

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.

Note

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.

Data model reference

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.

Error handling

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
}

Version and maintenance

  • Get the SDK version at runtime with WhaleCore.version (format 1.0.0(123)); the version name and build number are also available separately as WhaleCore.versionName / WhaleCore.versionCode.
  • Check the initialization state with WhaleCore.isInitialized().
  • Switch language at runtime with WhaleCore.setLanguage(...) (no re-initialization needed).
Whale Docs