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