Realtime Trading
Prices updating on a timer while sorting and filtering stay live. Ports examples/react/realtime-trading from TanStack Table.
RealtimeTrading.elm
module RealtimeTrading exposing (main)
{-| Realtime Trading.
Ports `examples/react/realtime-trading` from TanStack Table: a market
monitor whose quotes tick under a live table while sorting and filtering
stay usable. The React example runs a web worker at up to 100,000 samples
per second behind a virtualizer and a benchmark panel; this is the same
table and the same column layout driven by `Time.every` and a pure
pseudo-random walk (`RealtimeTrading.Feed`), with the feed controls the
React shell puts in its configurator: pause/start, instrument count, and
delivery interval.
Price, change and change% are coloured with the `up` / `down` classes on
every tick, exactly as the React `UpMoveCell` / `DownMoveCell` do.
-}
import Array exposing (Array)
import Browser
import Html exposing (Html, button, div, header, input, label, option, select, span, strong, table, tbody, td, text, th, thead, tr)
import Html.Attributes exposing (class, classList, colspan, placeholder, selected, style, value)
import Html.Events exposing (onClick, onInput)
import Random
import RealtimeTrading.Feed as Feed exposing (Quote)
import Table
import Table.FilterFn as FilterFn
import Table.SortFn as SortFn
import Table.Value as Value
import Time
-- COLUMNS
config : Table.Config Quote
config =
Table.config
[ Table.group "instrument"
[ Table.column "market" (.venue >> Value.String)
|> Table.withHeader "Market"
|> Table.withSize 72
, Table.column "name" (.company >> Value.String)
|> Table.withHeader "Name"
|> Table.withSize 180
, Table.column "symbol" (.symbol >> Value.String)
|> Table.withHeader "Symbol"
|> Table.withSize 92
|> Table.withFilterFn FilterFn.includesString
]
|> Table.withHeader "Instrument"
, Table.group "priceAndChange"
[ Table.column "price" (.price >> Value.Number)
|> Table.withHeader "Price"
|> Table.withSize 96
|> Table.withSortFn SortFn.basic
, Table.column "change" (Feed.dayChange >> Value.Number)
|> Table.withHeader "Chg"
|> Table.withSize 94
|> Table.withSortFn SortFn.basic
, Table.column "changePercent" (Feed.dayChangePercent >> Value.Number)
|> Table.withHeader "Chg%"
|> Table.withSize 90
|> Table.withSortFn SortFn.basic
]
|> Table.withHeader "Price & Change"
, Table.group "orderBook"
[ Table.column "bid" (.bid >> Value.Number)
|> Table.withHeader "Bid"
|> Table.withSize 90
, Table.column "bidSize" (.bidSize >> toFloat >> Value.Number)
|> Table.withHeader "Bid Vol"
|> Table.withSize 100
, Table.column "ask" (.ask >> Value.Number)
|> Table.withHeader "Ask"
|> Table.withSize 90
, Table.column "askSize" (.askSize >> toFloat >> Value.Number)
|> Table.withHeader "Ask Vol"
|> Table.withSize 100
]
|> Table.withHeader "Order Book"
, Table.group "session"
[ Table.column "open" (.open >> Value.Number)
|> Table.withHeader "Open"
|> Table.withSize 90
, Table.column "high" (.high >> Value.Number)
|> Table.withHeader "High"
|> Table.withSize 90
, Table.column "low" (.low >> Value.Number)
|> Table.withHeader "Low"
|> Table.withSize 90
]
|> Table.withHeader "Session"
, Table.group "chart"
[ Table.column "history" (.history >> List.map Value.Number >> Value.List)
|> Table.withHeader "Intraday"
|> Table.withSize 150
|> Table.withEnableSorting False
]
|> Table.withHeader "Chart"
]
|> Table.withGetRowId (\quote _ _ -> quote.id)
-- MODEL
type alias Model =
{ state : Table.State
, quotes : Array Quote
, seed : Random.Seed
, cursor : Int
, running : Bool
, instrumentCount : Int
, intervalMs : Float
, updatedRows : Int
}
init : () -> ( Model, Cmd Msg )
init _ =
( startModel 100, Cmd.none )
startModel : Int -> Model
startModel count =
let
( quotes, seed ) =
Feed.reset count 2026
in
{ state = Table.initialState
, quotes = quotes
, seed = seed
, cursor = 0
, running = True
, instrumentCount = count
, intervalMs = 250
, updatedRows = 0
}
type Msg
= Ticked Time.Posix
| ToggleFeed
| SetInstrumentCount String
| SetInterval String
| SymbolTyped String
| SortBy String
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Ticked _ ->
let
ticks : Int
ticks =
20
( quotes, cursor, seed ) =
Feed.applyTicks ticks model.cursor model.seed model.quotes
in
( { model | quotes = quotes, cursor = cursor, seed = seed, updatedRows = ticks }
, Cmd.none
)
ToggleFeed ->
( { model | running = not model.running }, Cmd.none )
SetInstrumentCount typed ->
let
count : Int
count =
Maybe.withDefault 100 (String.toInt typed)
fresh : Model
fresh =
startModel count
in
( { fresh | state = model.state, running = model.running, intervalMs = model.intervalMs }
, Cmd.none
)
SetInterval typed ->
( { model | intervalMs = Maybe.withDefault 250 (String.toFloat typed) }, Cmd.none )
SymbolTyped typed ->
( { model
| state =
Table.setColumnFilter config
(Table.coreRowModelFromList config model.state (Array.toList model.quotes))
"symbol"
(Value.String typed)
model.state
}
, Cmd.none
)
SortBy columnId ->
( { model
| state =
Table.toggleSort config
(Table.coreRowModelFromList config model.state (Array.toList model.quotes))
columnId
{ desc = Nothing, multi = False }
model.state
}
, Cmd.none
)
subscriptions : Model -> Sub Msg
subscriptions model =
if model.running then
Time.every model.intervalMs Ticked
else
Sub.none
-- VIEW
view : Model -> Html Msg
view model =
let
-- No pagination in this example: the React version renders every
-- instrument through a virtualizer, so the pipeline stops at sorted.
rowModel : Table.RowModel Quote
rowModel =
Table.coreRowModelFromList config model.state (Array.toList model.quotes)
|> Table.filteredRowModel config model.state
|> Table.sortedRowModel config model.state
in
div [ class "demo-root" ]
[ header [ class "app-bar" ]
[ strong [] [ text "MARKET MONITOR" ]
, span [ classList [ ( "feed-status", True ), ( "is-running", model.running ) ] ]
[ span [ class "status-dot" ] []
, text
(if model.running then
"FEED LIVE"
else
"FEED PAUSED"
)
]
]
, div [ class "controls" ]
[ button [ onClick ToggleFeed ]
[ text
(if model.running then
"PAUSE FEED"
else
"START FEED"
)
]
, label []
[ text "Instruments (rows) "
, select [ onInput SetInstrumentCount ]
(List.map
(\count ->
option
[ value (String.fromInt count), selected (count == model.instrumentCount) ]
[ text (String.fromInt count) ]
)
[ 25, 50, 100, 200 ]
)
]
, label []
[ text "Delivery interval "
, select [ onInput SetInterval ]
(List.map
(\ms ->
option
[ value (String.fromFloat ms), selected (ms == model.intervalMs) ]
[ text (String.fromFloat ms ++ " ms") ]
)
[ 100, 250, 500, 1000 ]
)
]
, input
[ class "filter"
, placeholder "Symbol..."
, value
(Table.getFilterValue model.state "symbol"
|> Maybe.map Value.toString
|> Maybe.withDefault ""
)
, onInput SymbolTyped
]
[]
]
, div [ class "spacer-sm" ] []
, table [ style "width" (String.fromFloat (Table.totalSize config model.state) ++ "px") ]
[ thead [] (List.map (viewHeaderRow model.state) (Table.headerGroups config model.state))
, tbody [] (List.map (viewRow model.state) rowModel.rows)
]
, div [ class "spacer-sm" ] []
, div [ class "market-statusbar muted" ]
[ span [] [ text (String.fromInt (List.length rowModel.rows) ++ " rows") ]
, span [] [ text (String.fromInt model.updatedRows ++ " quotes per delivery") ]
]
]
viewHeaderRow : Table.State -> Table.HeaderGroup Quote -> Html Msg
viewHeaderRow state group =
tr []
(List.map
(\header_ ->
let
columnId : String
columnId =
Table.headerColumnId header_
in
th
[ colspan (Table.headerColSpan header_)
, classList [ ( "sortable", Table.getCanSort config columnId ) ]
, style "width" (String.fromFloat (Table.getHeaderSize config state header_) ++ "px")
]
[ if Table.headerIsPlaceholder header_ then
text ""
else if Table.getCanSort config columnId then
button [ onClick (SortBy columnId) ]
[ text (headerLabel header_ ++ sortArrow state columnId) ]
else
text (headerLabel header_)
]
)
group.headers
)
sortArrow : Table.State -> String -> String
sortArrow state columnId =
case Table.getIsSorted state columnId of
Nothing ->
""
Just dir ->
if dir == Table.sortAsc then
" ▲"
else
" ▼"
viewRow : Table.State -> Table.Row Quote -> Html Msg
viewRow state row =
let
quote : Quote
quote =
Table.rowOriginal row
in
tr [] (List.map (viewCell quote) (Table.visibleCells config state row))
viewCell : Quote -> Table.Cell -> Html Msg
viewCell quote cell =
case cell.columnId of
"price" ->
td [ class (moveClass quote.lastMove) ] [ text (fixed2 quote.price) ]
"change" ->
td [ class (moveClass (Feed.dayChange quote)) ] [ text (signed (Feed.dayChange quote)) ]
"changePercent" ->
td [ class (moveClass (Feed.dayChangePercent quote)) ]
[ text (signed (Feed.dayChangePercent quote) ++ "%") ]
"bid" ->
td [] [ text (fixed2 quote.bid) ]
"ask" ->
td [] [ text (fixed2 quote.ask) ]
"open" ->
td [] [ text (fixed2 quote.open) ]
"high" ->
td [] [ text (fixed2 quote.high) ]
"low" ->
td [] [ text (fixed2 quote.low) ]
"bidSize" ->
td [] [ text (compact quote.bidSize) ]
"askSize" ->
td [] [ text (compact quote.askSize) ]
"history" ->
td [ class "sparkline" ] [ text (sparkline quote.history) ]
_ ->
td [] [ text (Value.toString cell.value) ]
moveClass : Float -> String
moveClass move =
if move > 0 then
"up"
else if move < 0 then
"down"
else
""
fixed2 : Float -> String
fixed2 n =
let
cents : Int
cents =
round (abs n * 100)
sign : String
sign =
if n < 0 then
"-"
else
""
in
sign
++ String.fromInt (cents // 100)
++ "."
++ String.padLeft 2 '0' (String.fromInt (modBy 100 cents))
signed : Float -> String
signed n =
if n > 0 then
"+" ++ fixed2 n
else
fixed2 n
compact : Int -> String
compact n =
if n >= 1000000 then
fixed1 (toFloat n / 1000000) ++ "M"
else if n >= 1000 then
fixed1 (toFloat n / 1000) ++ "K"
else
String.fromInt n
fixed1 : Float -> String
fixed1 n =
let
tenths : Int
tenths =
round (n * 10)
in
String.fromInt (tenths // 10) ++ "." ++ String.fromInt (modBy 10 tenths)
{-| The React `SparklineCell` draws an SVG path; this draws the same series
with block characters, which needs no extra element.
-}
sparkline : List Float -> String
sparkline values =
let
low : Float
low =
List.minimum values |> Maybe.withDefault 0
high : Float
high =
List.maximum values |> Maybe.withDefault 0
span_ : Float
span_ =
if high - low <= 0 then
1
else
high - low
blocks : Array Char
blocks =
Array.fromList [ '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█' ]
in
values
|> List.map
(\v ->
Array.get (clamp 0 7 (floor ((v - low) / span_ * 7.999))) blocks
|> Maybe.withDefault '▁'
)
|> String.fromList
headerLabel : Table.Header Quote -> String
headerLabel header_ =
Table.findColumn config (Table.headerColumnId header_)
|> Maybe.andThen Table.columnHeader
|> Maybe.withDefault (Table.headerColumnId header_)
main : Program () Model Msg
main =
Browser.element
{ init = init
, update = update
, view = view
, subscriptions = subscriptions
}
RealtimeTrading/Feed.elm
module RealtimeTrading.Feed exposing
( Quote
, reset, applyTicks
, dayChange, dayChangePercent
)
{-| The Elm counterpart of
`examples/react/realtime-trading/src/feed/worker/market-feed-engine.ts`: a
deterministic synthetic market feed.
The React example runs this in a web worker at up to 100,000 samples per
second. Elm has no worker here, so the same walk runs in `update` on a
`Time.every` tick: a cursor strides through the instruments by 97 and moves
one quote per tick, which is exactly what the worker's `applyTicks` does.
@docs Quote
@docs reset, applyTicks
@docs dayChange, dayChangePercent
-}
import Array exposing (Array)
import Random
import RealtimeTrading.Instruments as Instruments
{-| One instrument's live quote. Ports `MarketQuoteSnapshot`.
-}
type alias Quote =
{ id : String
, symbol : String
, company : String
, venue : String
, previousClose : Float
, open : Float
, high : Float
, low : Float
, price : Float
, bid : Float
, ask : Float
, bidSize : Int
, askSize : Int
, lastMove : Float
, history : List Float
}
{-| Today's move against yesterday's close.
-}
dayChange : Quote -> Float
dayChange quote =
quote.price - quote.previousClose
{-| Today's move as a percentage.
-}
dayChangePercent : Quote -> Float
dayChangePercent quote =
if quote.previousClose == 0 then
0
else
dayChange quote / quote.previousClose * 100
{-| Build `count` quotes and the seed the live feed continues from.
-}
reset : Int -> Int -> ( Array Quote, Random.Seed )
reset count seedInt =
let
universe : Array Instruments.Instrument
universe =
Array.fromList Instruments.instruments
step : Int -> ( List Quote, Random.Seed ) -> ( List Quote, Random.Seed )
step index ( acc, seed ) =
let
( quote, nextSeed ) =
newQuote universe index seed
in
( quote :: acc, nextSeed )
( quotes, finalSeed ) =
List.foldl step ( [], Random.initialSeed seedInt ) (List.range 0 (count - 1))
in
( Array.fromList (List.reverse quotes), finalSeed )
newQuote : Array Instruments.Instrument -> Int -> Random.Seed -> ( Quote, Random.Seed )
newQuote universe index seed0 =
let
size : Int
size =
max 1 (Array.length universe)
( baseSymbol, company, venue ) =
Array.get (modBy size index) universe
|> Maybe.withDefault ( "N/A", "Unknown", "US" )
series : Int
series =
index // size
symbol : String
symbol =
if series == 0 then
baseSymbol
else
baseSymbol ++ String.fromInt series
( r1, seed1 ) =
unit seed0
previousClose : Float
previousClose =
round2 (20 + r1 * 480)
( r2, seed2 ) =
unit seed1
open : Float
open =
round2 (previousClose * (1 + (r2 - 0.5) * 0.016))
( r3, seed3 ) =
unit seed2
spread : Float
spread =
max 0.01 (open * (0.0002 + r3 * 0.0004))
( history, seed4 ) =
historySamples open seed3
( bidSize, seed5 ) =
sizeIn seed4
( askSize, seed6 ) =
sizeIn seed5
in
( { id = "instrument-" ++ String.fromInt index
, symbol = symbol
, company = company
, venue = venue
, previousClose = previousClose
, open = open
, high = open
, low = open
, price = open
, bid = round2 (open - spread / 2)
, ask = round2 (open + spread / 2)
, bidSize = bidSize
, askSize = askSize
, lastMove = 0
, history = history
}
, seed6
)
historySamples : Float -> Random.Seed -> ( List Float, Random.Seed )
historySamples open seed0 =
let
step : Int -> ( List Float, Random.Seed ) -> ( List Float, Random.Seed )
step index ( acc, seed ) =
let
( r, nextSeed ) =
unit seed
in
( round2 (open * (1 + sin (toFloat index / 4) * 0.002 + (r - 0.5) * 0.001)) :: acc
, nextSeed
)
( samples, finalSeed ) =
List.foldl step ( [], seed0 ) (List.range 0 23)
in
( List.reverse samples, finalSeed )
sizeIn : Random.Seed -> ( Int, Random.Seed )
sizeIn seed =
let
( r, nextSeed ) =
unit seed
in
( floor (100 + r * 25000), nextSeed )
{-| Move `tickCount` quotes, striding through the instruments the way the
worker's row cursor does. Returns the new quotes, the new cursor, and the
new seed.
-}
applyTicks : Int -> Int -> Random.Seed -> Array Quote -> ( Array Quote, Int, Random.Seed )
applyTicks tickCount cursor0 seed0 quotes0 =
let
count : Int
count =
Array.length quotes0
step : Int -> ( Array Quote, Int, Random.Seed ) -> ( Array Quote, Int, Random.Seed )
step _ ( quotes, cursor, seed ) =
let
next : Int
next =
modBy count (cursor + 97)
in
case Array.get next quotes of
Nothing ->
( quotes, next, seed )
Just quote ->
let
( moved, nextSeed ) =
tick quote seed
in
( Array.set next moved quotes, next, nextSeed )
in
if count == 0 || tickCount <= 0 then
( quotes0, cursor0, seed0 )
else
List.foldl step ( quotes0, cursor0, seed0 ) (List.range 1 tickCount)
tick : Quote -> Random.Seed -> ( Quote, Random.Seed )
tick quote seed0 =
let
( r1, seed1 ) =
unit seed0
volatility : Float
volatility =
0.00015 + r1 * 0.0012
( r2, seed2 ) =
unit seed1
-- The worker applies thousands of these per second, so one tick's
-- move is tiny there. This feed ticks a few times per second, so
-- the step is scaled up to keep the same visible drift.
move : Float
move =
quote.price * (r2 - 0.495) * volatility * 40
nextPrice : Float
nextPrice =
round2 (max 0.1 (quote.price + move))
( r3, seed3 ) =
unit seed2
spread : Float
spread =
max 0.01 (nextPrice * (0.00015 + r3 * 0.0005))
( bidSize, seed4 ) =
sizeIn seed3
( askSize, seed5 ) =
sizeIn seed4
in
( { quote
| lastMove = round2 (nextPrice - quote.price)
, price = nextPrice
, bid = round2 (nextPrice - spread / 2)
, ask = round2 (nextPrice + spread / 2)
, bidSize = bidSize
, askSize = askSize
, high = max quote.high nextPrice
, low = min quote.low nextPrice
, history = List.drop 1 quote.history ++ [ nextPrice ]
}
, seed5
)
unit : Random.Seed -> ( Float, Random.Seed )
unit seed =
Random.step (Random.float 0 1) seed
round2 : Float -> Float
round2 n =
toFloat (round (n * 100)) / 100
RealtimeTrading/Instruments.elm
module RealtimeTrading.Instruments exposing (Instrument, instruments)
{-| The instrument universe of
`examples/react/realtime-trading/src/feed/market-instruments.ts`: current
S&P 500 constituents with international listings interleaved every third
row, so the Market column exercises several symbol formats and market
labels. The React file carries 606 of them; this is the first 100, which is
the example's default `instrumentCount`.
Source snapshot: <https://github.com/datasets/s-and-p-500-companies>.
@docs Instrument, instruments
-}
{-| Symbol, company, market code.
-}
type alias Instrument =
( String, String, String )
{-| The universe, in feed order.
-}
instruments : List Instrument
instruments =
[ ( "MMM", "3M", "US" )
, ( "ASML", "ASML Holding", "NL" )
, ( "AOS", "A. O. Smith", "US" )
, ( "ABT", "Abbott Laboratories", "US" )
, ( "INGA", "ING Group", "NL" )
, ( "ABBV", "AbbVie", "US" )
, ( "ACN", "Accenture", "US" )
, ( "ADYEN", "Adyen", "NL" )
, ( "ADBE", "Adobe Inc.", "US" )
, ( "AMD", "Advanced Micro Devices", "US" )
, ( "PHIA", "Philips", "NL" )
, ( "AES", "AES Corporation", "US" )
, ( "AFL", "Aflac", "US" )
, ( "HEIA", "Heineken", "NL" )
, ( "A", "Agilent Technologies", "US" )
, ( "APD", "Air Products", "US" )
, ( "SAP", "SAP", "DE" )
, ( "ABNB", "Airbnb", "US" )
, ( "AKAM", "Akamai Technologies", "US" )
, ( "SIE", "Siemens", "DE" )
, ( "ALB", "Albemarle Corporation", "US" )
, ( "ARE", "Alexandria Real Estate Equities", "US" )
, ( "ALV", "Allianz", "DE" )
, ( "ALGN", "Align Technology", "US" )
, ( "ALLE", "Allegion", "US" )
, ( "DTE", "Deutsche Telekom", "DE" )
, ( "LNT", "Alliant Energy", "US" )
, ( "ALL", "Allstate", "US" )
, ( "MBG", "Mercedes-Benz Group", "DE" )
, ( "GOOGL", "Alphabet Inc. (Class A)", "US" )
, ( "GOOG", "Alphabet Inc. (Class C)", "US" )
, ( "BMW", "BMW", "DE" )
, ( "MO", "Altria", "US" )
, ( "AMZN", "Amazon", "US" )
, ( "BAS", "BASF", "DE" )
, ( "AMCR", "Amcor", "US" )
, ( "AEE", "Ameren", "US" )
, ( "MUV2", "Munich Re", "DE" )
, ( "AEP", "American Electric Power", "US" )
, ( "AXP", "American Express", "US" )
, ( "VOW3", "Volkswagen Preference", "DE" )
, ( "AIG", "American International Group", "US" )
, ( "AMT", "American Tower", "US" )
, ( "IFX", "Infineon Technologies", "DE" )
, ( "AWK", "American Water Works", "US" )
, ( "AMP", "Ameriprise Financial", "US" )
, ( "MC", "LVMH", "FR" )
, ( "AME", "Ametek", "US" )
, ( "AMGN", "Amgen", "US" )
, ( "AIR", "Airbus", "FR" )
, ( "APH", "Amphenol", "US" )
, ( "ADI", "Analog Devices", "US" )
, ( "SU", "Schneider Electric", "FR" )
, ( "AON", "Aon plc", "US" )
, ( "APA", "APA Corporation", "US" )
, ( "TTE", "TotalEnergies", "FR" )
, ( "APO", "Apollo Global Management", "US" )
, ( "AAPL", "Apple Inc.", "US" )
, ( "SAN", "Sanofi", "FR" )
, ( "AMAT", "Applied Materials", "US" )
, ( "APP", "AppLovin", "US" )
, ( "BNP", "BNP Paribas", "FR" )
, ( "APTV", "Aptiv", "US" )
, ( "ACGL", "Arch Capital Group", "US" )
, ( "CS", "AXA", "FR" )
, ( "ADM", "Archer Daniels Midland", "US" )
, ( "ARES", "Ares Management", "US" )
, ( "DG", "Vinci", "FR" )
, ( "ANET", "Arista Networks", "US" )
, ( "AJG", "Arthur J. Gallagher & Co.", "US" )
, ( "ENEL", "Enel", "IT" )
, ( "AIZ", "Assurant", "US" )
, ( "T", "AT&T", "US" )
, ( "ENI", "Eni", "IT" )
, ( "ATO", "Atmos Energy", "US" )
, ( "ADSK", "Autodesk", "US" )
, ( "ISP", "Intesa Sanpaolo", "IT" )
, ( "ADP", "Automatic Data Processing", "US" )
, ( "AZO", "AutoZone", "US" )
, ( "UCG", "UniCredit", "IT" )
, ( "AVB", "AvalonBay Communities", "US" )
, ( "AVY", "Avery Dennison", "US" )
, ( "STLAM", "Stellantis", "IT" )
, ( "AXON", "Axon Enterprise", "US" )
, ( "BKR", "Baker Hughes", "US" )
, ( "SAN", "Banco Santander", "ES" )
, ( "BALL", "Ball Corporation", "US" )
, ( "BAC", "Bank of America", "US" )
, ( "IBE", "Iberdrola", "ES" )
, ( "BAX", "Baxter International", "US" )
, ( "BDX", "Becton Dickinson", "US" )
, ( "ITX", "Inditex", "ES" )
, ( "BRK.B", "Berkshire Hathaway", "US" )
, ( "BBY", "Best Buy", "US" )
, ( "BBVA", "BBVA", "ES" )
, ( "TECH", "Bio-Techne", "US" )
, ( "BIIB", "Biogen", "US" )
, ( "NESN", "Nestlé", "CH" )
, ( "BLK", "BlackRock", "US" )
, ( "ROG", "Roche Holding", "CH" )
]