A small, stateful intraday opening-range breakout (ORB) engine for the Indian market (IST), built natively in Rust on top of a TimescaleDB tick store.
The project is split in two:
rust_candle_fetcher(library) — owns the database. It exposes a cleanPivotEngineinterface plus the public types (Candle,AppError, and a re-exportedBigDecimal). Allsqlx/ Postgres mechanics are hidden inside; the binary never touches a database driver.orb-rust(strategy binary,main.rs) — holds all the trading logic and per-symbol day state. It polls the library on 5-minute boundaries and fires order webhooks.
No thiserror / anyhow; errors use only std traits. No futures::join_all;
concurrent queries use a native tokio::task::JoinSet.
| Step | Rule |
|---|---|
| Timeframe | 5-minute candles |
| Opening range | Accumulate the first three candles — buckets 09:15, 09:20, 09:25 (covering 09:15–09:30). Day high = max of their highs, day low = min of their lows |
| Watch window | From the 09:30 candle onward, evaluate every 5-minute candle |
| Long | candle high OR close > day high |
| Short | candle low OR close < day low (long is checked first) |
| Orders | One order per symbol per day, sent as an HTTP POST with an empty body to the symbol's webhook URL |
| Last signal | No new signals after 14:00; the process shuts down completely at 14:00 |
Because a candle is only readable once it closes, the timeline runs one interval behind the bucket label:
09:20 poll → bucket 09:15 (opening #1)
09:25 poll → bucket 09:20 (opening #2)
09:30 poll → bucket 09:25 (opening #3) → opening range set here
09:35 poll → bucket 09:30 (first breakout candle evaluated)
...
13:55 poll → bucket 13:50 (last breakout candle evaluated)
14:00 → shutdown
The last candle that can produce a signal is the 13:50 bucket (read at 13:55).
- Rust (stable) + Cargo
- A reachable TimescaleDB / PostgreSQL instance with a
tickshypertable (symbol,ltt,ltp,cum_volume) - Network access to the Fyers webhook URLs
The engine reads two environment variables and one JSON file.
| Variable | Required | Default | Purpose |
|---|---|---|---|
DATABASE_URL |
yes | — | Postgres/TimescaleDB connection string |
SYMS_FILE |
no | syms.json |
Path to the symbol → webhook map |
The symbol list is taken from the keys of syms.json — it is the single
source of truth for both which symbols to watch and where to send their orders.
{
"NSE:BAJAJ-AUTO-EQ": { "short": "https://.../bajaj-short", "long": "https://.../bajaj-long" },
"NSE:BHEL-EQ": { "short": "https://.../bhel-short", "long": "https://.../bhel-long" },
"NSE:BSE-EQ": { "short": "https://.../bse-short", "long": "https://.../bse-long" }
}The library keeps its existing sqlx (with the bigdecimal and chrono
features), tokio, tracing, and chrono deps, and must re-export the decimal
type so the binary can name it without depending on sqlx:
// in src/lib.rs
pub use sqlx::types::BigDecimal;The binary needs:
[dependencies]
rust-candle-fetcher = { path = "../rust-candle-fetcher" } # adjust to your layout
chrono = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
tracing = "0.1"
tracing-subscriber = "0.3"
tokio = { version = "1", features = ["full"] }The binary does not depend on
sqlxorbigdecimaldirectly. It usesBigDecimalpurely through the library's re-export (rust_candle_fetcher::BigDecimal) withCloneandPartialOrdonly — no extra crate needed.
# via cargo
export DATABASE_URL="postgres://user:pass@host:5432/db"
export SYMS_FILE="syms.json" # optional
cargo run --releaseA Makefile is provided to route configuration (DATABASE_URL, symbol list):
make run- Boundary sync. The loop sleeps until each 5-minute wall-clock boundary,
then polls
PivotEngine::fetch_latest_candles, which runs one concurrent query per symbol viaJoinSetand returns the latest closed candle each. - Dedup. Each symbol remembers its last processed
bucket_ist, so a bucket is never acted on twice. - Opening range. Candles whose bucket falls in
[09:15, 09:30)are accumulated. The range is frozen and logged the moment the third one arrives (the 09:30 poll). - Breakout. From the first
≥ 09:30candle on, each candle is tested against the frozen range. On a break, the matching webhook is POSTed. - Order bookkeeping. A symbol is marked done only after a 2xx webhook response, so a transient network failure simply retries on the next candle. Once done, no further orders fire for that symbol that day.
- Shutdown. At 14:00 IST the loop exits cleanly.
All timestamps are emitted in IST via a custom IstTimer:
2026-06-30 09:30:00 IST INFO [NSE:BHEL-EQ] opening range set H=415.6500 L=411.9500 (from 3 candles)
2026-06-30 09:40:00 IST INFO [NSE:BHEL-EQ] LONG breakout at 09:35:00 (close 416.2000)
2026-06-30 09:40:00 IST INFO Webhook OK -> https://.../bhel-long (200 OK)
- Stateful accumulation. The opening range is built in memory as candles arrive. If the process starts after 09:30 it will have missed part of the opening window and that day's range may be incomplete (a symbol with no collected opening candles is skipped for the day, with a warning).
- Latest closed candle only. The library returns one candle per symbol
(
LIMIT 1). The binary keys all logic offbucket_ist, so timing is driven by bucket timestamps rather than poll order. - Prices as exact decimals. Highs/lows/closes are compared as
BigDecimal(exact decimal), avoiding floating-point rounding on breakout boundaries. - No weekend/holiday calendar. The engine assumes it is launched on a trading day; it does not consult an exchange holiday list.