Jetpack Compose trading chart with candles, 30 indicators, multi-pane layout, live updates and marketplace components, plus a pure Kotlin order book engine.
- Candle, Line and Area series (
ChartType) with volume, a last price line and visible-range high/low markers - 9 overlay and 21 pane indicators, multi-pane layout with per-pane axes, legends and crosshair tags
- Pan with fling, pinch zoom, long-press crosshair, long-press pane resize; vertical drags go to the parent scroll
- Real-time updates that keep the viewport, zoom and crosshair and follow the right edge
- Log and percent price scales, left or right axis, grid modes, base lines, price limits, font scale (
ChartSettings) - Timeframes from tick to year with
aggregate - Korean (red up, blue down) and International (green up, red down) palettes with dark variants, or a custom
ChartColors - English and Korean labels
- Market components (depth chart, order book ladder, trade list, option selector, instant trade buttons) and
linechart-orderbook, a deterministic matching engine
dependencies {
implementation("io.github.teddko:linechart:1.1.1")
// optional, pure Kotlin matching engine for marketplace screens
implementation("io.github.teddko:linechart-orderbook:1.1.1")
}Requirements:
- minSdk 23, compileSdk 37 or newer
- Kotlin 2.2 or newer (stdlib 2.2.21)
- Compose ui and foundation 1.12.1 or newer
linechart-orderbookneeds only the Kotlin stdlib and runs on any JVM
The linechart POM lists only Compose ui, foundation and kotlin-stdlib, material3 is not a dependency. The linechart-orderbook javadoc jar is empty, which Maven Central accepts; Dokka may follow later.
val state = rememberLineChartState(items)
LineChartPanel(
modifier = Modifier.fillMaxWidth(),
state = state,
chartType = ChartType.Candle,
colors = ChartColors.Korean,
overlays = listOf(MovingAverage(), BollingerBands()),
panes = listOf(Volume(), Rsi(), Macd()),
settings = ChartSettings(scale = PriceScale.Linear),
timeframe = Timeframe.Minute5,
)ChartData(cumulativeProfit, date, open, high, low, volume, value) carries one sample:
cumulativeProfitis the line value and the candle closedateis epoch millis and the identity of the sampleopen,highandlowdefault to the closevolumeandvaluedefault to 0;valueis the traded value used byTradingValue, which falls back to close times volume when it is 0
LineChartPanel and LineChart keep their 1.0 signatures through hidden shims. Defaults are chartType = Candle, overlays = listOf(MovingAverage()), panes = listOf(Volume()), settings = ChartSettings(), paneHeight = 80.dp.
ChartType |
Drawing |
|---|---|
Candle |
Body 70 percent of the slot, wick high to low, direction color; below 3 px only the wick |
Line |
Smooth line split at a reference price: up color above, down color below; pulse on the last point in the color of its side |
Area |
Line with a vertical gradient fill, split the same way |
The reference of Line and Area is the base line value (ChartSettings.baseLine), else the previous session close, else the first visible close. An intraday series without a previous day therefore compares against the first visible close.
| Palette | Up | Down |
|---|---|---|
ChartColors.Korean (default) |
red | blue |
ChartColors.International |
green | red |
ChartColors.KoreanDark, ChartColors.InternationalDark |
dark-surface variants |
The default follows isSystemInDarkTheme(). Indicator colors are constructor parameters; direction and sign colors resolve from ChartColors.
Any up and down colors work:
LineChartPanel(state = state, colors = ChartColors(up = Color(0xFFFF6A00), down = Color(0xFF7B61FF)))The demo settings end every color row with a color wheel: angle picks the hue, distance from the center the saturation, and a slider the value.
Timeframe is a unit with a count: Tick, Second1/5/10/30, Minute1/3/5/10/15/30/60/120/240, Day, Week, Month, Year, or Timeframe(TimeframeUnit.Minute, 7). Second and minute counts align per day from local midnight, so any count works.
val candles = ticks.aggregate(Timeframe.Minute5)aggregate builds candles (first open, max high, min low, last close, summed volume and value, bucket start date) in the device time zone, weeks starting Monday. A Tick group takes the date of its first item, so a growing last group keeps its identity. aggregate(timeframe, timeZone) takes the zone for the buckets. The input must be in time order.
timeframeon the panel only labels the axis and legends; it does not aggregate- Indicator periods count candles of the current timeframe, so MA5 on a weekly chart is five weeks, and the legend shows the unit (
MA5D,MA20분) - Time axis patterns:
HH:mm:ssfor tick and second,HH:mmfor minute,MM/ddfor day and week,yyyy/MMfor month,yyyyfor year. An intraday label turns intoMM/ddat the first candle of a new day and wherever the label stride spans a day or more - The crosshair time tag uses the full date for the timeframe
- Aggregating the full list on every tick suits modest histories; with a large history keep the aggregated list and re-aggregate only the last bucket
- To show another zone pass
timeFormatter, for example{ millis -> format.format(Date(millis)) }with aSimpleDateFormatwhosetimeZoneis set - Adjusted prices (splits, dividends) are the app's responsibility: the library has no corporate-action data, so pass adjusted data
| Gesture | Result |
|---|---|
| Horizontal drag | Pan, with fling |
| Two-finger pinch | Zoom around the fingers |
| Long press, then drag | Crosshair with price and time tags across all panes, the legends follow the item under it; hides on release |
| Long press within 12dp of a separator, then drag | Resizes the panes: haptic tick, highlighted separator, the area above grows and the one below shrinks by the same amount |
| Vertical drag | Passed to the parent scroll |
Pass overlays = listOf(...) for the main pane and panes = listOf(...) for the area below it. Indicators compare by value, weight scales a pane height, warm-up values are not drawn, and n < period never throws. Indicators that need volume (Vwap, VolumeProfile, Volume, TradingValue, Obv, Mfi, VolumeRatio, VolumeOscillator) are hidden while the series has no volume. Price source is the close.
Notation: c close, h high, l low, v volume, tp = (h + l + c) / 3.
| Class | Defaults | Formula |
|---|---|---|
MovingAverage(periods, type, colors, widths, visible) |
5, 20, 60, 120, Simple |
SMA Σc/n; EMA α = 2/(n+1) seeded by SMA(n); WMA Σk·c/Σk (MaType) |
BollingerBands(period, k) |
20, 2.0 | SMA ± k·σ (population) |
Envelope(period, percent) |
20, 6 | SMA·(1 ± p/100) |
Ichimoku(conversion, base, span, shift) |
9, 26, 52, 26 | conversion (HH9+LL9)/2, base (HH26+LL26)/2, span 1 (conversion+base)/2 and span 2 (HH52+LL52)/2 both shifted forward by shift−1, lagging c shifted back by shift−1 |
ParabolicSar(step, max) |
0.02, 0.2 | Wilder SAR, initial trend from sign(c1−c0), dots below in an uptrend and above in a downtrend |
PriceChannel(period) |
20 | max h and min l of the previous n bars, current excluded |
Pivot() |
none | previous local day H/L/C (previous candle for day and longer): P = tp, R1 = 2P−L, S1 = 2P−H, R2 = P+(H−L), S2 = P−(H−L) |
Vwap() |
none | Σ(tp·v)/Σv, reset per local day for intraday, cumulative for day and longer |
VolumeProfile(bins) |
10 | the visible range split into bins, each candle volume into the bin of its close, left aligned bars up to 30 percent of the width, the largest bin marked |
| Class | Defaults | Formula | Scale and levels |
|---|---|---|---|
Volume(maPeriods) |
MA 5, 20 | v and SMA(v) | 0 to max |
TradingValue(maPeriods) |
MA 5, 20 | value when above 0, else c·v |
0 to max |
Macd(fast, slow, signal) |
12, 26, 9 | EMA12−EMA26, signal EMA9, histogram macd−signal | around 0 |
MacdOscillator(fast, slow, signal) |
12, 26, 9 | histogram only | around 0 |
Rsi(period, signal) |
14, 6 | Wilder RS, signal SMA | 0 to 100; 30, 70 |
StochasticFast(k, d) |
5, 3 | %K = 100(c−LLk)/(HHk−LLk), 50 when flat; %D = SMA | 0 to 100; 20, 80 |
StochasticSlow(k, smooth, d) |
5, 3, 3 | slow K = SMA(fast K, smooth), slow D = SMA(slow K, d) | 0 to 100; 20, 80 |
WilliamsR(period) |
14 | −100(HH−c)/(HH−LL) | −100 to 0; −20, −80 |
Cci(period) |
20 | (tp−SMA(tp))/(0.015·mean absolute deviation) | auto including ±100; ±100 |
Dmi(period, adx) |
14, 14 | Wilder +DM, −DM, TR; ±DI; DX; ADX = Wilder(DX) | auto from 0 |
Atr(period) |
14 | Wilder of max(h−l, abs(h−c₋₁), abs(l−c₋₁)) | auto |
Obv(signal) |
9 | cumulative ±v by sign(c−c₋₁), signal SMA | auto |
Mfi(period) |
14 | positive and negative money flow of tp·v by tp versus tp₋₁ | 0 to 100; 20, 80 |
Momentum(period, signal) |
10, 9 | 100·c/c₋ₙ, signal SMA | auto around 100; 100 |
Roc(period) |
12 | 100(c−c₋ₙ)/c₋ₙ | around 0; 0 |
Trix(period, signal) |
12, 9 | E3 = EMA³(c), 100(E3−E3₋₁)/E3₋₁, signal SMA | around 0 |
Disparity(periods) |
20, 60 | 100·c/SMA(n) | auto around 100; 100 |
Psychological(period) |
10 | 100·count(c > c₋₁ in the last n)/n | 0 to 100; 25, 75 |
VolumeRatio(period) |
20 | 100(ΣVup + ΣVflat/2)/(ΣVdown + ΣVflat/2) | auto; 70, 450 |
VolumeOscillator(short, long) |
5, 20 | 100(EMA5(v)−EMA20(v))/EMA20(v) | around 0 |
Sonar(period, lag, signal) |
20, 9, 9 | 100(EMAn−EMAn₋ₘ)/EMAn₋ₘ with m = lag, signal SMA | around 0 |
Notes:
- RSI uses Wilder smoothing; the 14-period reference series gives 70.46 at its first full value
- The slow stochastic defaults to 5, 3, 3
- Sonar has no single definition: brokers differ in the base average and the lag. This formula is EMA momentum, check it against the broker you compare with
- A division by zero (for example Roc across a zero close) draws a gap and shows
-in the legend, never infinity - Ichimoku extends the chart to the right by shift−1 candle slots (25 by default), and the cloud is filled up-colored where span 1 ≥ span 2, down-colored otherwise
- Oscillator values and tags use two decimals with
Locale.ROOT; volume-type panes use K, M and B suffixes
Excluded: AB ratio, ADL, CMF, Chaikin, NCO, Stochastic RSI, Keltner, SuperTrend, Heikin-Ashi.
The main pane range is the visible low and high with 5 percent padding. Bollinger, Envelope, PriceChannel, Ichimoku (spans and cloud), ParabolicSar and Vwap are included in it. MovingAverage, Pivot and VolumeProfile are not, and are clipped to the pane. A base line is always included.
ChartSettings is compared by value:
| Field | Default | Meaning |
|---|---|---|
scale |
Linear |
Linear, Log (log10 mapping, ticks at nice prices, falls back to linear while any visible value, indicator mark or base line value is zero or negative) or Percent (change against the base line, the previous close when the base line is None) |
axisSide |
Right |
Left or Right |
rightMargin |
3 | blank candle slots after the last candle; the larger of it and an indicator extension applies, clamped to half the plot |
grid |
Horizontal |
None, Horizontal, Vertical, Both |
crosshair |
true | whether a long press shows the crosshair |
legend |
true | header and pane legends |
currentPrice |
true | last price line and axis tag, the tag carries the change percent when a base exists |
baseLine |
None |
None, PreviousClose, TodayOpen, Pivot, Custom(value) (finite) |
highLowMarkers |
true | visible high and low values |
priceLimits |
false | limit lines at the previous close ± limitPercent |
limitPercent |
30.0 | distance of the limit lines |
volumeMa |
true | MA lines of Volume and TradingValue |
fontScale |
1f | multiplier of the text size, applies to sized text styles |
The previous session close is the close of the last candle of the previous local day for intraday timeframes (device time zone) and the previous candle close for day and longer.
chartHeight(default 300.dp) is the height of the main chart area with its header and time axis- Every pane adds
paneHeight × weight(default 80.dp × 1) plus a 4.dp gap, so the default Volume pane gives a total of 384.dp; dragging a separator moves height between the areas and keeps the total - With several panes the total can exceed the screen: place the panel in a vertically scrollable container (for example
Column(Modifier.verticalScroll(rememberScrollState()).navigationBarsPadding())). Vertical drags on the chart scroll that parent, so the last pane and the time axis stay reachable above the navigation bar - Each pane has its own Y axis, one-line legend, and crosshair value tag with its own format; the legend strip sits above the plot
- The header wraps its legend lines and keeps the tallest wrap seen until the series, the indicators or the width change, so the chart does not jump while the crosshair moves
- Pane resize: long press within 12dp of a separator, then drag. Minimum 120dp for the main area and 40dp for a pane. Heights live in
LineChartState, survive data updates and indicator parameter changes, follow their indicator when another pane is removed or inserted, and are not saved across recreation
rememberLineChartState(items, maxDataPoints, yAxisTicksCount, scaleFactor, strokeWidth) keeps one LineChartState for the lifetime of the composable. Pass a new list on every update.
- The last sample changed or was replaced in place: viewport unchanged
- Samples appended: the chart follows the right edge while it is at the latest sample, and stays anchored when scrolled back or while the crosshair is on
- The crosshair keeps the anchor while it is on; when it is released the chart jumps back to the latest sample if it was following when the crosshair started
- A series that no longer contains the previous last
date(for example a timeframe switch): the viewport resets to the latest sample and the zoom is kept maxDataPoints > 0keeps the latest points onlyselectedemits only when the crosshair moves to another sample- The entry reveal plays once
Components in linechart (foundation only, themed by ChartColors, with price formatters):
| Component | Purpose |
|---|---|
DepthChart(bids, asks) |
Cumulative depth steps with mid line, spread, axes and a long-press crosshair; an empty book draws an empty box |
OrderBookLadder(levels, side, onLevelClick) |
One side of the book with quantity bars; the tapped price is passed to the callback |
TradeList(trades) |
Size, price and date columns, ×n for n > 1 |
OptionSelector(options, selected, onSelect) |
Selectable option chips with the best ask or bid |
InstantTradeButtons(bestAsk, bestBid, onBuy, onSell) |
Buy and sell now buttons |
List<MarketTrade>.toDailyChartData() |
Daily ChartData for a price history chart: volume is the summed quantity and value is the summed price times quantity |
Models are MarketLevel, MarketTrade, MarketOption and MarketSide. Bid uses the up color and ask the down color. String keys are prefixed linechart_market_ and every library resource uses the linechart_ prefix. Tab rows, the bid sheet and the price chart are composed by the app, the demo shows a full screen.
Pure Kotlin matching engine in package tddy.ko.linechart.orderbook, no Android dependency.
val engine = MatchingEngine(
policy = MatchingPolicy(tickSize = 1_000, minPrice = 1_000),
feePolicy = FeePolicy { side, price, qty -> minOf(price * qty * 3 / 100, 300_000) },
)
val result = engine.place("230", Side.Buy, OrderType.Limit, quantity = 1, price = 127_000, owner = "me")
val book = engine.snapshot("230", depth = 20)Semantics:
- Books are independent per option; better price fills first, FIFO within a price, trades execute at the maker price
- Money is
Long(KRW), quantities areInt - Validation: quantity ≤ 0 is
InvalidQuantity; a limit price belowminPriceor not a multiple oftickSizeisInvalidPrice; an expiry not after now isInvalidExpiry. A rejected order consumes no id allowCrossing = falserejects a crossing limit order withCrossing; instant orders are never affected- A partial fill keeps the resting order priority; empty levels are removed
Instantfills up to an optional price bound and cancels the rest: no fill gives a nullorderIdwithNoLiquidity, a partial fill cancels the remainder withNoLiquidity- Self trade prevention applies only when
owneris set and equals the resting owner: matching stops, the remainder of the incoming order is canceled withSelfTrade, earlier fills stay modify(orderId, price, expiresAt)is cancel and replace with the same id and the remaining quantity: priority is lost and it may match. A rejected modify keeps the original. A modify that would self trade cancels the original and returns a non-nullorderIdwithSelfTrade. An expired order not yet reclaimed returnsNotFoundfrommodifywhileorder(id)still returns it- Expiry is lazy:
expiresAtis absolute epoch millis, matching skips expired orders, andexpire(now)reclaims them sorted by expiry then id. Call it on a timer - Fees are computed per fill (
buyerFee,sellerFee) by aFeePolicythat must not throw; fees of every fill are computed before the book or the id sequence changes, so a throwing policy leaves the book unchanged - Deterministic: clock and id generator are injected, order and trade ids share one sequence, no hash order is exposed
- Not thread-safe, the caller serializes access. Cancel is O(k) in the orders at a level
TradeandBookLevelare data classes and closed value types;Order,OrderResultandBookSnapshotare plain classes withtoString.BookLevel.quantityis capped atInt.MAX_VALUE
The 1.0.0 API and binary signatures are kept (LineChartPanel, LineChart, rememberLineChartState, ChartData(cumulativeProfit, date), LineChartState) through hidden shims and verified by a reflection test; a javap diff against the 1.0 jar reports no missing member. Behavior changes:
- Height:
chartHeightis the main area only and every pane addspaneHeight × weight + 4.dp. The default Volume pane makes the total 384.dp instead of 300.dp; reducechartHeightto keep the old total - The default look is Candle with MA 5/20/60/120 and a Volume pane; pass
chartType = ChartType.Line, overlays = emptyList(), panes = emptyList()for a plain line - New parameters
overlays,panes,settings,paneHeight. The 1.1 preview parametermaPeriodsandChartColors.maare removed, useMovingAverage(periods, colors);VolumeandMovingAveragetake their own periods and colors - Pan, zoom and the crosshair are on and the crosshair hides on release; a tap no longer selects a sample, a long press does
- The price axis is on the right and the time axis is drawn by the library
- The default palette is Korean (red up, blue down) and follows the system dark mode
- A header shows OHLC, change and the legends
maxDataPointskeeps the latest points (takeLast)formatValueuses#,##0.##independent of the default locale- Fewer than 2 items draw an empty box instead of nothing
- The gray split right of the crosshair is gone
- The entry reveal plays once, not on every state change
- An opaque
colors.backgroundis drawn behind the chart; useColor.Transparentto show the parent - The
labelsargument of the legacyLineChartStateconstructor is ignored, the axis shows the item number or the time DragSelection.positionsnaps to the candle center andinterpolatedValueequals the value- Moving averages are excluded from the Y range, so a far-off average no longer flattens the candles
ChartDatagainedopen,high,low,volumeandvalue; the old constructor andcopystay- material3 is no longer a transitive dependency
- 1.0.x callers compile and link unchanged;
LineChartAbiTestcovers every 1.0 signature by reflection - Built with Gradle 9.9.0, AGP 9.5.1, Kotlin 2.4.21 (API and language level 2.2), compileSdk 37, Compose 1.12.1
Apache License 2.0











