austin-starks/chrono-ta

Timestamp-aware, duration-windowed technical indicators for Rust

Rust

54

217 commits

updated Sep 21, 2026

See the code
chatgpt
fundamental-analysis
investing
openai
rust
simfin
stock-analysis
stock-market
technical-analysis
time-series
trading

See what people are saying (1)

SourceMessageScoreDate

I created chrono-ta, a high-performance, timestamp-aware TA library (r/rust)

I was using [Greyblake's `ta-rs`](https://github.com/greyblake/ta-rs) inside my Rust trading engine. In `ta-rs`, an indicator period is a number of observations. `SimpleMovingAverage::new(30)` retains the last 30 values passed to `next`. That did not fit the data model I needed. The same engine…

1

Sep 21, 2026

README

chrono-ta

Timestamp-aware technical indicators for Rust.

CI crates.io docs.rs license Rust

chrono-ta computes moving averages, momentum, volatility, extrema, drawdown, crossovers, true range, and VWAP over elapsed-time windows. Every streaming input carries a UTC timestamp, so a 30-day indicator means 30 calendar days of observations rather than the last 30 calls.

Built for NexusTrade

chrono-ta is the technical-analysis engine used by NexusTrade, Austin Starks's algorithmic-trading and backtesting platform. NexusTrade is the reason this crate treats timestamps, irregular observations, and repeated live updates as first-class behavior: those are production data conditions, not optional edge cases.

The library remains independently useful and intentionally small, but its API is exercised against NexusTrade's real integration path. Releases are protected by fixed golden vectors, streaming-versus-batch parity checks, same-bucket replacement tests, and serialized-state continuation tests.

Animation comparing observation-count and elapsed-time windows

The animation uses the same irregular observations on both sides: upstream ta retains the last N calls, while chrono-ta replaces a repeated time bucket and expires observations according to elapsed time. Its reproducible Remotion source lives in graphic/.

The project began as a fork of Greyblake's ta, but its input model and window semantics now differ substantially.

Why this exists

Observation-count windows are useful when every series has a fixed cadence. In market systems, the same strategy may instead receive daily bars, hourly bars, irregular historical data, or repeated live updates to the current bar. chrono-ta makes time part of the indicator contract:

(timestamp, value) -> indicator -> value for that point in time

That enables:

  • windows expressed as std::time::Duration;
  • expiration based on timestamps rather than call count;
  • replacement of repeated updates within the current time bucket;
  • scalar streaming and batched processing through the same stateful API;
  • SIMD-backed batch paths for EMA and RSI, with scalar parity tests;
  • bounded storage for long-running windowed indicators.

chrono-ta versus ta

These crates share ancestry, not a drop-in-compatible API.

chrono-taUpstream ta
Window definitionElapsed time, such as 15 minutes or 30 daysNumber of observations, such as 14 values
Streaming input(DateTime<Utc>, value)A value or market-data item
Repeated live updatesReplaces the current time bucketEvery call advances state
Batch APINextBatch plus public SIMD primitivesScalar Next
Indicator scopeFocused set used by the timestamped engineBroader classic indicator catalog
Install namechrono-tata
Rust importchrono_tata

Choose upstream ta when you want its larger indicator catalog and observation-count semantics. Choose chrono-ta when timestamps, elapsed-time expiration, repeated current-bar updates, or batch processing are part of the problem.

Install

Install the published crate:

[dependencies]
chrono-ta = "2.2"

Enable serialization when indicator state must survive a restart:

[dependencies]
chrono-ta = { version = "2.2", features = ["serde"] }

To test an unreleased GitHub revision instead:

[dependencies]
chrono-ta = { git = "https://github.com/austin-starks/chrono-ta" }

Quick start

use chrono::{Duration as ChronoDuration, TimeZone, Utc};
use chrono_ta::indicators::ExponentialMovingAverage;
use chrono_ta::Next;
use std::time::Duration;

let mut ema = ExponentialMovingAverage::new(Duration::from_secs(3 * 60)).unwrap();
let start = Utc.with_ymd_and_hms(2026, 9, 20, 14, 30, 0).unwrap();

assert_eq!(ema.next((start, 2.0)), 2.0);
assert_eq!(
    ema.next((start + ChronoDuration::minutes(1), 5.0)),
    3.5
);
assert_eq!(
    ema.next((start + ChronoDuration::minutes(2), 1.0)),
    2.25
);

All indicators implement Next<T>. They also implement Reset, Debug, Display, Default, and Clone where appropriate.

From market data to an automated strategy

chrono-ta owns indicator state and signal calculation. It deliberately does not own market-data credentials, brokerage accounts, or order submission. A trading application queries timestamped observations from its data provider, feeds each completed bar into a strategy, and passes the resulting decision to a separately guarded broker adapter.

Query real market data

This example queries one-minute regular-session stock bars from Public's historical bars API. Need a Public account? You can open one through NexusTrade's Public referral link.

Generate a Public secret in your account settings, exchange it for an access token using the Public quickstart, and keep the resulting token on the server as PUBLIC_ACCESS_TOKEN. Never put a brokerage secret or access token in browser code.

Application dependencies (these are not required by chrono-ta itself):

[dependencies]
chrono = { version = "0.4", features = ["serde"] }
chrono-ta = "2.2"
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
uuid = { version = "1", features = ["v4"] }
use chrono::{DateTime, Utc};
use serde::Deserialize;
use std::{env, error::Error};

#[derive(Debug, Deserialize)]
struct Bar {
    timestamp: DateTime<Utc>,
    close: String,
}

#[derive(Default, Deserialize)]
struct MarketSession {
    #[serde(default)]
    bars: Vec<Bar>,
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct BarsResponse {
    regular_market: MarketSession,
}

async fn query_bars(
    client: &reqwest::Client,
    symbol: &str,
) -> Result<Vec<Bar>, Box<dyn Error>> {
    let access_token = env::var("PUBLIC_ACCESS_TOKEN")?;
    let url = format!(
        "https://api.public.com/userapigateway/historicdata/EQUITY/{symbol}/DAY/ONE_MINUTE"
    );

    let response: BarsResponse = client
        .get(url)
        .bearer_auth(access_token)
        .query(&[("tradingSessionToggle", "REGULAR_HOURS")])
        .send()
        .await?
        .error_for_status()?
        .json()
        .await?;

    Ok(response.regular_market.bars)
}

Public splits its response into pre-market, regular-market, and after-market sections. This example deliberately asks for regular hours and consumes only regularMarket; change that policy consciously because session selection changes the observations that reach the strategy.

Turn bars into buy, sell, or hold decisions

Here is an EMA-crossover signal strategy. A repeated update inside the same one-minute bar replaces that bar's current state, so a live feed correction does not create a phantom second crossover.

use chrono::{DateTime, Utc};
use chrono_ta::indicators::{CrossAbove, CrossBelow, ExponentialMovingAverage};
use chrono_ta::Next;
use std::time::Duration;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Decision {
    Buy,
    Sell,
    Hold,
}

struct EmaCrossStrategy {
    fast: ExponentialMovingAverage,
    slow: ExponentialMovingAverage,
    cross_above: CrossAbove,
    cross_below: CrossBelow,
}

impl EmaCrossStrategy {
    fn new() -> Result<Self, chrono_ta::errors::TaError> {
        Ok(Self {
            fast: ExponentialMovingAverage::new(Duration::from_secs(5 * 60))?,
            slow: ExponentialMovingAverage::new(Duration::from_secs(20 * 60))?,
            cross_above: CrossAbove::new(Duration::from_secs(60))?,
            cross_below: CrossBelow::new(Duration::from_secs(60))?,
        })
    }

    fn on_close(&mut self, timestamp: DateTime<Utc>, close: f64) -> Decision {
        let fast = self.fast.next((timestamp, close));
        let slow = self.slow.next((timestamp, close));
        let pair = (fast, slow);

        if self.cross_above.next((timestamp, pair)) {
            Decision::Buy
        } else if self.cross_below.next((timestamp, pair)) {
            Decision::Sell
        } else {
            Decision::Hold
        }
    }
}

Feed the queried bars through the strategy:

let bars = query_bars(&reqwest::Client::new(), "SPY").await?;
let mut strategy = EmaCrossStrategy::new()?;

for bar in bars {
    let close: f64 = bar.close.parse()?;
    match strategy.on_close(bar.timestamp, close) {
        Decision::Buy => println!("{} BUY SPY", bar.timestamp),
        Decision::Sell => println!("{} SELL SPY", bar.timestamp),
        Decision::Hold => {}
    }
}

That loop is suitable for research, backtests, or signal generation. For a bot, route a decision through a separately guarded Public adapter. Public accepts a caller-supplied UUID as the idempotent order ID:

use serde_json::json;
use std::{env, error::Error};
use uuid::Uuid;

async fn submit_public_order(
    client: &reqwest::Client,
    symbol: &str,
    decision: Decision,
) -> Result<Option<Uuid>, Box<dyn Error>> {
    let side = match decision {
        Decision::Buy => "BUY",
        Decision::Sell => "SELL",
        Decision::Hold => return Ok(None),
    };

    // Historical replay must never be able to satisfy this guard accidentally.
    if env::var("ENABLE_PUBLIC_ORDER_SUBMISSION").as_deref() != Ok("I_UNDERSTAND") {
        return Err("live Public order submission is disabled".into());
    }

    let access_token = env::var("PUBLIC_ACCESS_TOKEN")?;
    let account_id = env::var("PUBLIC_ACCOUNT_ID")?;
    let order_id = Uuid::new_v4();
    let body = json!({
        "orderId": order_id.to_string(),
        "instrument": { "symbol": symbol, "type": "EQUITY" },
        "orderSide": side,
        "orderType": "MARKET",
        "expiration": { "timeInForce": "DAY" },
        "quantity": "1"
    });

    client
        .post(format!(
            "https://api.public.com/userapigateway/trading/{account_id}/order"
        ))
        .bearer_auth(access_token)
        .json(&body)
        .send()
        .await?
        .error_for_status()?;

    Ok(Some(order_id))
}

This is the final transport step, not a complete risk system. Before enabling it, call Public's preflight endpoint, process only unseen completed bars, persist the last bar timestamp and serialized indicator state, reconcile the actual brokerage position, enforce position/notional limits, and poll the returned order ID because placement is asynchronous. Do not connect historical replay code directly to a live account.

Run the repository's provider-neutral version with:

cargo run --example ema_crossover

Current-bar replacement

Streaming feeds often send several revisions of a bar before it closes. The adaptive detector keeps those revisions from becoming several observations:

  • windows shorter than five minutes use one-second buckets;
  • intraday windows use one-minute buckets;
  • windows of one day or longer use the library's daily-session gap rule.

Calling next twice inside the same bucket replaces the current observation instead of advancing the indicator. Timestamps should therefore arrive in nondecreasing order. This behavior is a core difference from upstream ta, not an incidental optimization.

Indicators that operate on OHLCV bars accept an explicit bucket_width. This makes the identity of a revisable live bar unambiguous instead of guessing its cadence from the rolling window.

Batch processing

NextBatch returns the same state transition as calling next repeatedly. EMA and RSI use optimized batch implementations when no input would trigger same-bucket replacement; other indicators use the trait's scalar fallback.

use chrono::{Duration as ChronoDuration, TimeZone, Utc};
use chrono_ta::indicators::RelativeStrengthIndex;
use chrono_ta::NextBatch;
use std::time::Duration;

let start = Utc.with_ymd_and_hms(2026, 9, 20, 0, 0, 0).unwrap();
let inputs = vec![
    (start, 100.0),
    (start + ChronoDuration::days(1), 102.0),
    (start + ChronoDuration::days(2), 101.0),
];

let mut rsi = RelativeStrengthIndex::new(Duration::from_secs(14 * 86_400)).unwrap();
let values = rsi.next_batch(&inputs);
assert_eq!(values.len(), inputs.len());

The public simd module also exposes EMA, rate-of-change, reduction, rolling mean, and rolling-standard-deviation primitives for callers that already own contiguous slices.

Indicators

FamilyIndicators
Trend and compositionExponential Moving Average, Simple Moving Average, Rolling Sum, Lag / Value Ago, Cross Above, Cross Below
MomentumRelative Strength Index, Rate of Change
VolatilityBollinger Bands, Standard Deviation, Mean Absolute Deviation, True Range, Average True Range
VolumeRolling VWAP, Anchored VWAP
Extrema and riskMinimum, Maximum, Max Drawdown, Max Drawup

The narrower catalog is intentional. Indicators present in upstream ta, such as MACD, stochastic oscillators, and OBV, are not currently implemented here. Do not select this crate on the assumption that every upstream indicator is available.

AverageTrueRange is the arithmetic mean of true ranges inside an elapsed-time window; it is not Wilder's observation-count recurrence. RollingVwap expires contributions by elapsed time. AnchoredVwap accumulates until the caller invokes Reset::reset. Both VWAP variants use typical price (high + low + close) / 3.

OHLCV indicators

DataItem provides a validated OHLCV input, while the public Open, High, Low, Close, and Volume traits let applications use their own bar types.

use chrono::{Duration as ChronoDuration, TimeZone, Utc};
use chrono_ta::indicators::{AverageTrueRange, RollingVwap};
use chrono_ta::{DataItem, Next};
use std::time::Duration;

let start = Utc.with_ymd_and_hms(2026, 9, 20, 14, 30, 0).unwrap();
let first = DataItem::builder()
    .open(100.0)
    .high(104.0)
    .low(99.0)
    .close(102.0)
    .volume(1_000.0)
    .build()
    .unwrap();
let second = DataItem::builder()
    .open(102.0)
    .high(106.0)
    .low(101.0)
    .close(105.0)
    .volume(1_500.0)
    .build()
    .unwrap();

let bucket = Duration::from_secs(60);
let mut atr = AverageTrueRange::new(Duration::from_secs(15 * 60), bucket).unwrap();
let mut vwap = RollingVwap::new(Duration::from_secs(15 * 60), bucket).unwrap();

assert_eq!(atr.next((start, first)), 5.0);
assert_eq!(atr.next((start + ChronoDuration::minutes(1), second)), 5.0);
assert!(vwap.next((start, first)).is_some());

State and serialization

The optional serde feature serializes indicator state. Optimized derived state is rebuilt when needed after deserialization, and the test suite covers continuing an indicator after a round trip.

Serialized representations are an implementation detail, not a stable wire format. Keep the crate version with persisted state and test migrations before upgrading a long-lived store.

Migrating from the old repository name

GitHub redirects the former austin-starks/ta-rs-improved URL, so dependencies pinned to an existing commit continue to resolve. New dependencies should use the chrono-ta package and URL.

To preserve existing use ta::... imports while moving to a new revision, rename the dependency locally:

[dependencies]
ta = { package = "chrono-ta", git = "https://github.com/austin-starks/chrono-ta" }

The source imports can then remain unchanged even though the published package is named chrono-ta.

Development

cargo fmt --check
cargo test --all-targets --all-features
cargo test --doc --all-features
cargo doc --no-deps --all-features
cargo package --list

See CONTRIBUTING.md for defect reports, test expectations, and pull-request scope. Security problems should be reported privately through SECURITY.md.

Releases

Published versions are available on crates.io, with API documentation built by docs.rs. The release checklist in CONTRIBUTING.md treats the registry upload as a deliberate, irreversible step after the exact commit passes CI.

NexusTrade

chrono-ta powers time-windowed technical indicators in NexusTrade, an AI-assisted platform for researching, testing, optimizing, and deploying systematic trading strategies.

The fork's original RSI correction is described in this development article.

License and upstream credit

Released under the MIT License. chrono-ta is derived from Greyblake's ta, created by Sergey Potapov and its contributors. Austin Starks maintains this timestamp-aware fork.

Contributors

greyblake

119 commits

austin-starks

43 commits

dgunay

22 commits

bartossh

9 commits

austin-starks/chrono-ta

Timestamp-aware, duration-windowed technical indicators for Rust

Rust

54

217 commits

updated Sep 21, 2026

See the code
chatgpt
fundamental-analysis
investing
openai
rust
simfin
stock-analysis
stock-market
technical-analysis
time-series
trading

See what people are saying (1)

SourceMessageScoreDate

I created chrono-ta, a high-performance, timestamp-aware TA library (r/rust)

I was using [Greyblake's `ta-rs`](https://github.com/greyblake/ta-rs) inside my Rust trading engine. In `ta-rs`, an indicator period is a number of observations. `SimpleMovingAverage::new(30)` retains the last 30 values passed to `next`. That did not fit the data model I needed. The same engine…

1

Sep 21, 2026

README

chrono-ta

Timestamp-aware technical indicators for Rust.

CI crates.io docs.rs license Rust

chrono-ta computes moving averages, momentum, volatility, extrema, drawdown, crossovers, true range, and VWAP over elapsed-time windows. Every streaming input carries a UTC timestamp, so a 30-day indicator means 30 calendar days of observations rather than the last 30 calls.

Built for NexusTrade

chrono-ta is the technical-analysis engine used by NexusTrade, Austin Starks's algorithmic-trading and backtesting platform. NexusTrade is the reason this crate treats timestamps, irregular observations, and repeated live updates as first-class behavior: those are production data conditions, not optional edge cases.

The library remains independently useful and intentionally small, but its API is exercised against NexusTrade's real integration path. Releases are protected by fixed golden vectors, streaming-versus-batch parity checks, same-bucket replacement tests, and serialized-state continuation tests.

Animation comparing observation-count and elapsed-time windows

The animation uses the same irregular observations on both sides: upstream ta retains the last N calls, while chrono-ta replaces a repeated time bucket and expires observations according to elapsed time. Its reproducible Remotion source lives in graphic/.

The project began as a fork of Greyblake's ta, but its input model and window semantics now differ substantially.

Why this exists

Observation-count windows are useful when every series has a fixed cadence. In market systems, the same strategy may instead receive daily bars, hourly bars, irregular historical data, or repeated live updates to the current bar. chrono-ta makes time part of the indicator contract:

(timestamp, value) -> indicator -> value for that point in time

That enables:

  • windows expressed as std::time::Duration;
  • expiration based on timestamps rather than call count;
  • replacement of repeated updates within the current time bucket;
  • scalar streaming and batched processing through the same stateful API;
  • SIMD-backed batch paths for EMA and RSI, with scalar parity tests;
  • bounded storage for long-running windowed indicators.

chrono-ta versus ta

These crates share ancestry, not a drop-in-compatible API.

chrono-taUpstream ta
Window definitionElapsed time, such as 15 minutes or 30 daysNumber of observations, such as 14 values
Streaming input(DateTime<Utc>, value)A value or market-data item
Repeated live updatesReplaces the current time bucketEvery call advances state
Batch APINextBatch plus public SIMD primitivesScalar Next
Indicator scopeFocused set used by the timestamped engineBroader classic indicator catalog
Install namechrono-tata
Rust importchrono_tata

Choose upstream ta when you want its larger indicator catalog and observation-count semantics. Choose chrono-ta when timestamps, elapsed-time expiration, repeated current-bar updates, or batch processing are part of the problem.

Install

Install the published crate:

[dependencies]
chrono-ta = "2.2"

Enable serialization when indicator state must survive a restart:

[dependencies]
chrono-ta = { version = "2.2", features = ["serde"] }

To test an unreleased GitHub revision instead:

[dependencies]
chrono-ta = { git = "https://github.com/austin-starks/chrono-ta" }

Quick start

use chrono::{Duration as ChronoDuration, TimeZone, Utc};
use chrono_ta::indicators::ExponentialMovingAverage;
use chrono_ta::Next;
use std::time::Duration;

let mut ema = ExponentialMovingAverage::new(Duration::from_secs(3 * 60)).unwrap();
let start = Utc.with_ymd_and_hms(2026, 9, 20, 14, 30, 0).unwrap();

assert_eq!(ema.next((start, 2.0)), 2.0);
assert_eq!(
    ema.next((start + ChronoDuration::minutes(1), 5.0)),
    3.5
);
assert_eq!(
    ema.next((start + ChronoDuration::minutes(2), 1.0)),
    2.25
);

All indicators implement Next<T>. They also implement Reset, Debug, Display, Default, and Clone where appropriate.

From market data to an automated strategy

chrono-ta owns indicator state and signal calculation. It deliberately does not own market-data credentials, brokerage accounts, or order submission. A trading application queries timestamped observations from its data provider, feeds each completed bar into a strategy, and passes the resulting decision to a separately guarded broker adapter.

Query real market data

This example queries one-minute regular-session stock bars from Public's historical bars API. Need a Public account? You can open one through NexusTrade's Public referral link.

Generate a Public secret in your account settings, exchange it for an access token using the Public quickstart, and keep the resulting token on the server as PUBLIC_ACCESS_TOKEN. Never put a brokerage secret or access token in browser code.

Application dependencies (these are not required by chrono-ta itself):

[dependencies]
chrono = { version = "0.4", features = ["serde"] }
chrono-ta = "2.2"
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
uuid = { version = "1", features = ["v4"] }
use chrono::{DateTime, Utc};
use serde::Deserialize;
use std::{env, error::Error};

#[derive(Debug, Deserialize)]
struct Bar {
    timestamp: DateTime<Utc>,
    close: String,
}

#[derive(Default, Deserialize)]
struct MarketSession {
    #[serde(default)]
    bars: Vec<Bar>,
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct BarsResponse {
    regular_market: MarketSession,
}

async fn query_bars(
    client: &reqwest::Client,
    symbol: &str,
) -> Result<Vec<Bar>, Box<dyn Error>> {
    let access_token = env::var("PUBLIC_ACCESS_TOKEN")?;
    let url = format!(
        "https://api.public.com/userapigateway/historicdata/EQUITY/{symbol}/DAY/ONE_MINUTE"
    );

    let response: BarsResponse = client
        .get(url)
        .bearer_auth(access_token)
        .query(&[("tradingSessionToggle", "REGULAR_HOURS")])
        .send()
        .await?
        .error_for_status()?
        .json()
        .await?;

    Ok(response.regular_market.bars)
}

Public splits its response into pre-market, regular-market, and after-market sections. This example deliberately asks for regular hours and consumes only regularMarket; change that policy consciously because session selection changes the observations that reach the strategy.

Turn bars into buy, sell, or hold decisions

Here is an EMA-crossover signal strategy. A repeated update inside the same one-minute bar replaces that bar's current state, so a live feed correction does not create a phantom second crossover.

use chrono::{DateTime, Utc};
use chrono_ta::indicators::{CrossAbove, CrossBelow, ExponentialMovingAverage};
use chrono_ta::Next;
use std::time::Duration;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Decision {
    Buy,
    Sell,
    Hold,
}

struct EmaCrossStrategy {
    fast: ExponentialMovingAverage,
    slow: ExponentialMovingAverage,
    cross_above: CrossAbove,
    cross_below: CrossBelow,
}

impl EmaCrossStrategy {
    fn new() -> Result<Self, chrono_ta::errors::TaError> {
        Ok(Self {
            fast: ExponentialMovingAverage::new(Duration::from_secs(5 * 60))?,
            slow: ExponentialMovingAverage::new(Duration::from_secs(20 * 60))?,
            cross_above: CrossAbove::new(Duration::from_secs(60))?,
            cross_below: CrossBelow::new(Duration::from_secs(60))?,
        })
    }

    fn on_close(&mut self, timestamp: DateTime<Utc>, close: f64) -> Decision {
        let fast = self.fast.next((timestamp, close));
        let slow = self.slow.next((timestamp, close));
        let pair = (fast, slow);

        if self.cross_above.next((timestamp, pair)) {
            Decision::Buy
        } else if self.cross_below.next((timestamp, pair)) {
            Decision::Sell
        } else {
            Decision::Hold
        }
    }
}

Feed the queried bars through the strategy:

let bars = query_bars(&reqwest::Client::new(), "SPY").await?;
let mut strategy = EmaCrossStrategy::new()?;

for bar in bars {
    let close: f64 = bar.close.parse()?;
    match strategy.on_close(bar.timestamp, close) {
        Decision::Buy => println!("{} BUY SPY", bar.timestamp),
        Decision::Sell => println!("{} SELL SPY", bar.timestamp),
        Decision::Hold => {}
    }
}

That loop is suitable for research, backtests, or signal generation. For a bot, route a decision through a separately guarded Public adapter. Public accepts a caller-supplied UUID as the idempotent order ID:

use serde_json::json;
use std::{env, error::Error};
use uuid::Uuid;

async fn submit_public_order(
    client: &reqwest::Client,
    symbol: &str,
    decision: Decision,
) -> Result<Option<Uuid>, Box<dyn Error>> {
    let side = match decision {
        Decision::Buy => "BUY",
        Decision::Sell => "SELL",
        Decision::Hold => return Ok(None),
    };

    // Historical replay must never be able to satisfy this guard accidentally.
    if env::var("ENABLE_PUBLIC_ORDER_SUBMISSION").as_deref() != Ok("I_UNDERSTAND") {
        return Err("live Public order submission is disabled".into());
    }

    let access_token = env::var("PUBLIC_ACCESS_TOKEN")?;
    let account_id = env::var("PUBLIC_ACCOUNT_ID")?;
    let order_id = Uuid::new_v4();
    let body = json!({
        "orderId": order_id.to_string(),
        "instrument": { "symbol": symbol, "type": "EQUITY" },
        "orderSide": side,
        "orderType": "MARKET",
        "expiration": { "timeInForce": "DAY" },
        "quantity": "1"
    });

    client
        .post(format!(
            "https://api.public.com/userapigateway/trading/{account_id}/order"
        ))
        .bearer_auth(access_token)
        .json(&body)
        .send()
        .await?
        .error_for_status()?;

    Ok(Some(order_id))
}

This is the final transport step, not a complete risk system. Before enabling it, call Public's preflight endpoint, process only unseen completed bars, persist the last bar timestamp and serialized indicator state, reconcile the actual brokerage position, enforce position/notional limits, and poll the returned order ID because placement is asynchronous. Do not connect historical replay code directly to a live account.

Run the repository's provider-neutral version with:

cargo run --example ema_crossover

Current-bar replacement

Streaming feeds often send several revisions of a bar before it closes. The adaptive detector keeps those revisions from becoming several observations:

  • windows shorter than five minutes use one-second buckets;
  • intraday windows use one-minute buckets;
  • windows of one day or longer use the library's daily-session gap rule.

Calling next twice inside the same bucket replaces the current observation instead of advancing the indicator. Timestamps should therefore arrive in nondecreasing order. This behavior is a core difference from upstream ta, not an incidental optimization.

Indicators that operate on OHLCV bars accept an explicit bucket_width. This makes the identity of a revisable live bar unambiguous instead of guessing its cadence from the rolling window.

Batch processing

NextBatch returns the same state transition as calling next repeatedly. EMA and RSI use optimized batch implementations when no input would trigger same-bucket replacement; other indicators use the trait's scalar fallback.

use chrono::{Duration as ChronoDuration, TimeZone, Utc};
use chrono_ta::indicators::RelativeStrengthIndex;
use chrono_ta::NextBatch;
use std::time::Duration;

let start = Utc.with_ymd_and_hms(2026, 9, 20, 0, 0, 0).unwrap();
let inputs = vec![
    (start, 100.0),
    (start + ChronoDuration::days(1), 102.0),
    (start + ChronoDuration::days(2), 101.0),
];

let mut rsi = RelativeStrengthIndex::new(Duration::from_secs(14 * 86_400)).unwrap();
let values = rsi.next_batch(&inputs);
assert_eq!(values.len(), inputs.len());

The public simd module also exposes EMA, rate-of-change, reduction, rolling mean, and rolling-standard-deviation primitives for callers that already own contiguous slices.

Indicators

FamilyIndicators
Trend and compositionExponential Moving Average, Simple Moving Average, Rolling Sum, Lag / Value Ago, Cross Above, Cross Below
MomentumRelative Strength Index, Rate of Change
VolatilityBollinger Bands, Standard Deviation, Mean Absolute Deviation, True Range, Average True Range
VolumeRolling VWAP, Anchored VWAP
Extrema and riskMinimum, Maximum, Max Drawdown, Max Drawup

The narrower catalog is intentional. Indicators present in upstream ta, such as MACD, stochastic oscillators, and OBV, are not currently implemented here. Do not select this crate on the assumption that every upstream indicator is available.

AverageTrueRange is the arithmetic mean of true ranges inside an elapsed-time window; it is not Wilder's observation-count recurrence. RollingVwap expires contributions by elapsed time. AnchoredVwap accumulates until the caller invokes Reset::reset. Both VWAP variants use typical price (high + low + close) / 3.

OHLCV indicators

DataItem provides a validated OHLCV input, while the public Open, High, Low, Close, and Volume traits let applications use their own bar types.

use chrono::{Duration as ChronoDuration, TimeZone, Utc};
use chrono_ta::indicators::{AverageTrueRange, RollingVwap};
use chrono_ta::{DataItem, Next};
use std::time::Duration;

let start = Utc.with_ymd_and_hms(2026, 9, 20, 14, 30, 0).unwrap();
let first = DataItem::builder()
    .open(100.0)
    .high(104.0)
    .low(99.0)
    .close(102.0)
    .volume(1_000.0)
    .build()
    .unwrap();
let second = DataItem::builder()
    .open(102.0)
    .high(106.0)
    .low(101.0)
    .close(105.0)
    .volume(1_500.0)
    .build()
    .unwrap();

let bucket = Duration::from_secs(60);
let mut atr = AverageTrueRange::new(Duration::from_secs(15 * 60), bucket).unwrap();
let mut vwap = RollingVwap::new(Duration::from_secs(15 * 60), bucket).unwrap();

assert_eq!(atr.next((start, first)), 5.0);
assert_eq!(atr.next((start + ChronoDuration::minutes(1), second)), 5.0);
assert!(vwap.next((start, first)).is_some());

State and serialization

The optional serde feature serializes indicator state. Optimized derived state is rebuilt when needed after deserialization, and the test suite covers continuing an indicator after a round trip.

Serialized representations are an implementation detail, not a stable wire format. Keep the crate version with persisted state and test migrations before upgrading a long-lived store.

Migrating from the old repository name

GitHub redirects the former austin-starks/ta-rs-improved URL, so dependencies pinned to an existing commit continue to resolve. New dependencies should use the chrono-ta package and URL.

To preserve existing use ta::... imports while moving to a new revision, rename the dependency locally:

[dependencies]
ta = { package = "chrono-ta", git = "https://github.com/austin-starks/chrono-ta" }

The source imports can then remain unchanged even though the published package is named chrono-ta.

Development

cargo fmt --check
cargo test --all-targets --all-features
cargo test --doc --all-features
cargo doc --no-deps --all-features
cargo package --list

See CONTRIBUTING.md for defect reports, test expectations, and pull-request scope. Security problems should be reported privately through SECURITY.md.

Releases

Published versions are available on crates.io, with API documentation built by docs.rs. The release checklist in CONTRIBUTING.md treats the registry upload as a deliberate, irreversible step after the exact commit passes CI.

NexusTrade

chrono-ta powers time-windowed technical indicators in NexusTrade, an AI-assisted platform for researching, testing, optimizing, and deploying systematic trading strategies.

The fork's original RSI correction is described in this development article.

License and upstream credit

Released under the MIT License. chrono-ta is derived from Greyblake's ta, created by Sergey Potapov and its contributors. Austin Starks maintains this timestamp-aware fork.

Contributors

greyblake

119 commits

austin-starks

43 commits

dgunay

22 commits

bartossh

9 commits

Languages

Rust

92.5%

TypeScript

6.9%