fin-primitives 2.15.0

Checked building blocks for Rust trading code: exact decimal price and quantity types, a level-2 order book, ticks to OHLCV candles, 700+ streaming indicators, Black-Scholes Greeks, a position ledger and risk limits.
Documentation

fin-primitives

A Rust library of the basic parts every trading program needs: exact prices that refuse bad values, an order book, candles built from trades, 700+ indicators, option pricing and risk limits.

For Rust developers writing trading bots, backtesters, market-data tools or quant research code who do not want to rebuild (and re-debug) the same pieces.

Install

cargo add fin-primitives rust_decimal rust_decimal_macros

A library, nothing to install system-wide. Rust 1.81 or newer (checked in CI). rust_decimal_macros gives you the dec!(64250.50) literal used in the examples.

In 15 lines

use fin_primitives::signals::{indicators::Rsi, BarInput, Signal, SignalValue};
use fin_primitives::types::Price;
use rust_decimal_macros::dec;

fn main() -> Result<(), fin_primitives::FinError> {
    assert!(Price::new(dec!(-1)).is_err()); // bad values never get in

    let mut rsi = Rsi::new("rsi14", 14)?;
    let closes = [dec!(44.34), dec!(44.09), dec!(44.15), dec!(43.61), dec!(44.33), dec!(44.83), dec!(45.10), dec!(45.42),
                  dec!(45.84), dec!(46.08), dec!(45.89), dec!(46.03), dec!(45.61), dec!(46.28), dec!(46.28), dec!(46.00)];
    for close in closes {
        if let SignalValue::Scalar(v) = rsi.update(&BarInput::from_close(close))? {
            println!("RSI(14) = {}", v.round_dp(2)); // 70.46, then 66.25
        }
    }
    Ok(())
}

Indicators answer Unavailable until they have enough bars (here the first 14), never a made-up 0 or NaN. This block is compiled and run by cargo test --doc.

Why this crate and not ta, yata or TA-Lib

Measured on the same 100,000 closes (bench/competitors, bench/talib_compare):

  • Exact decimals. Prices, sizes, bars and indicators are rust_decimal::Decimal, so 0.1 + 0.2 is 0.3 and a P&L adds up to the cent. SMA and EMA agree with ta and yata to 1e-12, and with TA-Lib to 2e-11 or better on the 22 indicators both define the same way.
  • TA-Lib definitions. Rsi is Wilder's RSI, like TA-Lib. ta's RSI smooths with a different factor and differed by up to 20 RSI points on the same data.
  • Checked inputs, typed errors. A zero price, a crossed order book or a skipped sequence number is an Err, not a silent wrong number. No unwrap in library code.
  • One crate for the whole loop: order book, ticks to bars, 700+ indicators, positions, drawdown and VaR limits, Black-Scholes with Greeks.
  • The cost is speed. ta and yata are 40 to 160 times faster per indicator update (f64 against 96-bit decimal arithmetic). fin-primitives still does 4 to 25 million updates per second per core. If you only crunch long float64 histories, use them or TA-Lib.

Already using ta, yata or wickra?

Keep them. Turn on the matching feature and their indicators read fin-primitives bars directly, and their bar types convert into fin-primitives ones:

fin-primitives = { version = "2.15", features = ["ta"] }   # or "yata", "wickra", "arrow"
use ta::indicators::MovingAverageConvergenceDivergence;
use ta::Next;

let mut macd = MovingAverageConvergenceDivergence::new(12, 26, 9)?;
for bar in aggregator.push_tick(&tick)? {          // fin_primitives::ohlcv::OhlcvBar
    let m = macd.next(&bar);                        // ta reads the bar through ta::Close
    let r = rsi.update_bar(&bar)?;                  // fin-primitives Wilder RSI, same bar
}

cargo run --example with_ta --features ta runs the full version. Going the other way, BarInput::try_from(&ta::DataItem), BarInput::try_from(&yata::core::Candle) and BarInput::try_from(&wickra_core::Candle) turn their bars into fin-primitives input.

Feature flags

feature default adds
serde yes Serialize/Deserialize on the data types; deserializing re-runs validation
async yes async_signals: a Tokio task that runs a signal pipeline over a channel
ta no interop with the ta crate
yata no interop with the yata crate
wickra no interop with wickra-core (Rust 1.86+)
arrow no bars to and from Apache Arrow RecordBatch, prices as exact Decimal128 (Rust 1.88+)
python no PyO3 bindings; cd python && maturin develop --features python (Rust 1.83+)

default-features = false leaves rust_decimal, chrono, thiserror, libm and statrs. It also builds for wasm32-unknown-unknown.

How it works

Trades come in as Ticks. OhlcvAggregator::push_tick drops each one into a time bucket and hands back the finished candle the moment a trade for the next bucket arrives. Candles feed indicators, which say Unavailable until they have enough history instead of making up a number.

Money works the same way: fills and price marks go into a PositionLedger, its account value goes into a RiskMonitor, and every rule that is broken comes back as a RiskBreach you can act on.

Examples

Six programs ship in examples/. No network, no API keys, same output every run.

Run this You get
cargo run --example order_book a BTC-USD depth ladder, spread, micro-price, the cost of a 5 BTC market buy, and two bad updates rejected
cargo run --example candles 337 trades rolled into 32 one-minute candles, drawn with EMA(9) and RSI(14)
cargo run --example position_risk a trading session marked to market, with a drawdown rule and an equity floor firing
cargo run --example option_chain a Black-Scholes option chain with Greeks and an implied-volatility round trip
cargo run --example value_at_risk one-day and ten-day VaR for a $1M book: historical, parametric and Monte Carlo at 95%, 97.5% and 99%
cargo run --example with_ta --features ta ticks rolled into bars by fin-primitives, fed to ta's MACD and Bollinger Bands and to fin-primitives' RSI

order_book: the book knows its spread and middle price, and refuses updates that would cross it or skip a sequence number.

position_risk (real output, run 2026-09-28, NO_COLOR=1):

  #   event                        equity  drawdown  risk
  5   sell 100 AAPL @ 179.10   101,237.00     0.00%  ok
  6   mark MSFT 398.10          99,865.00     1.36%  ok
  7   mark AAPL 166.80          98,635.00     2.57%  ok
  8   mark MSFT 371.50          96,507.00     4.67%  BREACH max_drawdown, min_equity
        max_drawdown: drawdown 4.67% > 4.00%
        min_equity: equity 96507.00 < floor 96600

  rejected  buy 1000 AAPL  Insufficient funds: need 171351.00, have 79866.00

(rows 1 to 4 and 9 to 10 cut for length; the diagram above plays the whole session.)

Use it in 3 steps

1. Make a project and add the crate

cargo new book-demo && cd book-demo
cargo add fin-primitives rust_decimal rust_decimal_macros

2. Put this in src/main.rs

use fin_primitives::orderbook::{BookDelta, DeltaAction, OrderBook};
use fin_primitives::types::{Price, Quantity, Side, Symbol};
use rust_decimal_macros::dec;

fn main() -> Result<(), fin_primitives::FinError> {
    // 1. Values are checked when you create them.
    println!("Price::new(-5) -> {}", Price::new(dec!(-5)).unwrap_err());

    // 2. Build a small BTC-USD order book from four quotes.
    let mut book = OrderBook::new(Symbol::new("BTC-USD")?);
    let quotes = [
        (Side::Ask, dec!(64250.50), dec!(0.842)),
        (Side::Ask, dec!(64251.00), dec!(1.310)),
        (Side::Bid, dec!(64250.00), dec!(1.204)),
        (Side::Bid, dec!(64249.50), dec!(0.655)),
    ];
    for (seq, (side, price, qty)) in (1..).zip(quotes) {
        book.apply_delta(BookDelta {
            side,
            price: Price::new(price)?,
            quantity: Quantity::new(qty)?,
            action: DeltaAction::Set,
            sequence: seq,
        })?;
    }

    // 3. Ask it questions.
    println!("spread         {}", book.spread().unwrap_or_default());
    println!("mid price      {}", book.mid_price().unwrap_or_default());
    let one_btc = Quantity::new(dec!(1))?;
    println!("buy 1 BTC at   {} average", book.vwap_for_qty(Side::Ask, one_btc)?.round_dp(2));
    Ok(())
}

3. Run it

cargo run

You will see:

Price::new(-5) -> Price must be positive, got -5
spread         0.50
mid price      64250.25
buy 1 BTC at   64250.58 average

A negative price never gets into your program, the book knows its own spread and middle price, and "what would buying 1 BTC cost" is one call (0.842 BTC fill at the best ask, the other 0.158 at the next level up). This exact program is also compiled and run by cargo test --doc in this repository.

Compared with TA-Lib

Same 100,000 seeded OHLCV bars through both libraries (method, script and full tables; i7-13700KF, TA-Lib 0.8.1). Where the definitions match, the values agree to 2e-11 or better; fin-primitives works in exact decimals, so it is the float64 side that rounds. Two definitions differ on purpose: Atr is a simple average of true range (it equals TA-Lib's SMA(TRANGE), not Wilder's ATR), and Obv starts at 0 rather than at the first bar's volume. TA-Lib is much faster: its batch functions take 0.2 to 12 ns per bar, about 100 times less than fin-primitives' exact-decimal update(). Use TA-Lib to crunch long float64 histories; use fin-primitives for exact values, bar-by-bar state and no C dependency.

indicator max difference vs TA-Lib (after bar 1,000) fin-primitives, ns per bar TA-Lib batch, ns per bar
SMA(20) / EMA(20) 9.8e-13 / 5.7e-14 74 / 149 0.8 / 1.3
RSI(14) 1.8e-13 252 2.1
MACD(12,26,9) histogram 5.0e-14 (seeded differently: 0.036 in the first bars) 362 1.7
Bollinger(20, 2) bands 1.6e-11 1023 2.7
ATR(14) equals TA-Lib SMA(TRANGE, 14) to 1.2e-14 86 0.8
Stochastic fast %K / %D (14, 3) 6.7e-13 / 1.3e-12 174 / 259 7.7
Williams %R(14) / CCI(20) / MFI(14) 6.7e-13 / 1.0e-11 / 1.3e-12 170 / 1060 / 589 1.9 / 12.2 / 5.9
ADX(14) 3.7e-13, except near exact up/down ties that float64 splits 794 6.2
OBV equals TA-Lib OBV - volume[0] exactly 13 2.8

Documentation

Read this For
docs.rs/fin-primitives every type and method, with examples that compile
docs/ARCHITECTURE.md how the modules connect, what each one guarantees, design rules, writing your own indicator or risk rule
docs/REFERENCE.md module guides (indicators, series analytics, ledger, drawdown, attribution, Greeks, regimes, backtester, Monte Carlo, factor models, yield curves and more), math definitions, API listing
docs/TESTING.md running the tests and benchmarks, current test status
CHANGELOG.md what changed in each version
Project site the same overview as a web page

Optional Python bindings are behind the python feature: cd python && maturin develop --release, then python test_smoke.py.

Research and engineering library. It does not place orders, and nothing here is financial advice.

Contributing

Issues and pull requests are welcome. Public items need /// docs, fallible code returns Result (no unwrap, expect or panic! outside tests), and new behavior needs a test. Run cargo fmt, cargo clippy and cargo test --doc before opening a PR. See CONTRIBUTING.md.

License and related projects

MIT, see LICENSE. fin-stream builds on this crate: it turns live Binance, Coinbase, Alpaca and Polygon trade messages into ticks, bars and order books.