Metadata-Version: 2.4
Name: volas
Version: 2.0.0
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Rust
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Natural Language :: English
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Financial and Insurance Industry
Classifier: Operating System :: OS Independent
Classifier: Development Status :: 4 - Beta
Classifier: Topic :: Office/Business :: Financial :: Investment
Classifier: Topic :: Scientific/Engineering :: Information Analysis
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Dist: numpy>=2.0.0
Requires-Dist: polars>=1.0 ; extra == 'benchmark'
Requires-Dist: pandas-ta>=0.4 ; extra == 'benchmark'
Requires-Dist: coverage ; extra == 'dev'
Requires-Dist: ruff ; extra == 'dev'
Requires-Dist: pytest ; extra == 'dev'
Requires-Dist: pytest-cov ; extra == 'dev'
Requires-Dist: pytest-benchmark ; extra == 'dev'
Requires-Dist: twine ; extra == 'dev'
Requires-Dist: pandas>=3.0,<3.1 ; extra == 'dev'
Requires-Dist: stock-pandas>=5.4,<5.5 ; extra == 'dev'
Requires-Dist: ta-lib>=0.6,<0.7 ; extra == 'dev'
Requires-Dist: pyarrow>=15 ; extra == 'dev'
Requires-Dist: packaging>=24.2 ; extra == 'dev'
Requires-Dist: numpy>=2.0.0 ; extra == 'dev'
Requires-Dist: mypy ; extra == 'dev'
Requires-Dist: pyright ; extra == 'dev'
Requires-Dist: maturin>=1.4 ; extra == 'dev'
Provides-Extra: benchmark
Provides-Extra: dev
License-File: LICENSE
Summary: High-performance, Rust-backed columnar kernel for stock / candlestick (OHLCV) time-series data.
Home-Page: https://github.com/kaelzhang/volas
Author-email: Kael Zhang <i+pypi@kael.me>
License-Expression: MIT
Requires-Python: >=3.11
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Benchmark, https://volas.ost.ai/
Project-URL: Changelog, https://github.com/kaelzhang/volas/releases
Project-URL: Homepage, https://github.com/kaelzhang/volas
Project-URL: Issues, https://github.com/kaelzhang/volas/issues
Project-URL: Repository, https://github.com/kaelzhang/volas

[![ci](https://github.com/kaelzhang/volas/actions/workflows/ci.yml/badge.svg)](https://github.com/kaelzhang/volas/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/kaelzhang/volas/branch/main/graph/badge.svg)](https://codecov.io/gh/kaelzhang/volas)
[![PyPI version](https://img.shields.io/pypi/v/volas.svg)](https://pypi.org/project/volas/)
[![Python versions](https://img.shields.io/pypi/pyversions/volas.svg)](https://pypi.org/project/volas/)

# [volas](https://github.com/kaelzhang/volas)

English | [简体中文](README.zh-CN.md)

> High-performance, Rust-backed columnar kernel for stock / candlestick (OHLCV) time-series data.

**volas** is a Rust-backed, pandas-shaped `DataFrame` for live OHLCV pipelines: [**254** trading-indicators](INDICATORS.md), incremental O(lookback) refresh, and NumPy/Torch-ready output.

It is **not** a general-purpose pandas replacement. It is a narrow, fast DataFrame for candlestick / OHLCV workflows: append a new bar, keep indicator columns cached, and refresh only the stale tail.

**volas** is also a Rust [crate](crates/volas/README.md).

```python
from volas import read_csv

df = read_csv("btc_1m.csv")

# Cache indicator directives as DataFrame columns.
df["rsi:14"]
df[["macd", "macd.signal", "atr:14"]]

# In a live loop:
df.append(new_bar)     # one-row OHLCV frame
df["rsi:14"]           # refreshes only the affected tail, O(lookback)
features = df.to_numpy()
```

- **254** built-in indicators and TA-Lib-compatible directives
- Incremental refresh after `append`: **O(lookback)**, not O(n)
- Rust kernels, no pandas runtime dependency
- pandas-shaped indexing: `.loc` / `.iloc` / `.at` / `read_csv` / `to_numpy`
- NumPy / Torch-ready output

```sh
pip install volas
```

On our reproducible benchmark suite, **volas** is faster than pandas, polars, stock-pandas and TA-Lib on most live-update indicator workloads.

## Why volas

- **pandas-shaped API.** The same `.loc` / `.iloc` / `.at`, `read_csv`,
  `to_numpy` and resampling — for OHLCV workflows, change the import and keep
  your code. It is **not** a general-purpose pandas replacement. (See
  [what's not covered](PANDAS-DIFFERENCES.md#index-limitations))
- **Fast on live OHLCV indicator workloads**, with reproducible benchmarks —
  see the always-current [live benchmark report](https://volas.ost.ai).
  - On the current published report, volas beats TA-Lib on **139 / 157**
    covered indicators by the default ratio — reproducible via `make benchmark`.
  - On incremental update (each new bar), volas is the fastest of **every**
    library across **all** indicators — **~5×** faster than TA-Lib, and up to
    **~360×** faster than pandas.
- **Built for the live tick.** A new bar touches only the affected tail
  (`O(lookback)`, not `O(n)`); indicators refresh in microseconds, never a full
  recompute.
- **Rust inside, NumPy / Torch out.** Compiled kernels — hot paths tuned down to the
  **assembly-instruction level** — zero pandas at runtime; `to_numpy()` feeds NumPy and
  `torch.Tensor` pipelines.

![How volas refreshes only the stale tail after append](https://volas.ost.ai/animated_gif/after-append-indicator-en.gif)

### Why I built volas

I've spent years building quantitative trading-signal systems, and for most of
that time `pandas` was just the tax I paid to get any work done. Loading a single
CSV of a few hundred thousand candlesticks, computing a handful of indicators,
cleaning them up, normalizing — that one data-processing pass routinely took 10
to 20 minutes before any real research could even begin.

Backtesting made it worse. To honestly simulate live trading you feed history in
one bar at a time — usually fine-grained bars, say 1-minute candles — appending
each new bar to the `DataFrame` and recomputing every indicator across the whole
frame again, bar after bar, to mimic the OHLCV stream a live system actually sees.
So much of that was pure waste — the same columns rebuilt from scratch on every
step — and across a few years of 1-minute data that redundant work alone could
drag a single backtest out by **hours**. Every idea I wanted to try, every parameter
I wanted to sweep, paid that tax again. **The tooling, not the thinking, was setting
the pace of my research.**

So I stopped patching around it and rebuilt the whole data layer from the ground
up, `pandas` thrown out entirely. The bet paid off: the data-processing pass that
used to take 10–20 minutes now finishes in seconds, and the per-bar recomputation
that used to drag those runs out collapsed to near nothing. A backtest still has
real work to do — strategy logic, fills, accounting — but the data layer stopped
being the thing I sit and wait on. **volas** is that layer: hundreds of times
faster where it counts, and finally fast enough to get out of the way.

### When to reach for volas

volas is **not** a general-purpose pandas replacement — for plain dataframe
analysis, keep pandas or polars. It is a narrow, fast DataFrame for the case
where a **new OHLCV bar arrives and indicators must refresh now**:

| | pandas | polars | TA-Lib | volas |
| --- | :---: | :---: | :---: | :---: |
| pandas-shaped indexing (`.loc` / `.iloc` / `.at`) | ✅ | ❌ | ❌ | ✅ |
| OHLCV-native indicator directives (`df['rsi:14']`) | ❌ | ❌ | ✅ | ✅ |
| Indicator cache owned by the frame | ❌ | ❌ | ❌ | ✅ |
| Incremental `O(lookback)` refresh on a new bar | ❌ | ❌ | ❌ | ✅ |
| Rust-backed kernels, no pandas at runtime | ❌ | ✅ | C | ✅ |
| NumPy / Torch export | ✅ | ✅ | arrays | ✅ |

## Table of Content
- [Installation](#installation)
- [Quick start](#quick-start)
- [Usage](#usage)
- [Bounded rolling window](#bounded-rolling-window)
- [Cumulation and DatetimeIndex](#cumulation-and-datetimeindex)
- [TimeFrame](#timeframe)
- [Syntax of directive](#syntax-of-directive)
- [Indexing & selection](#indexing--selection)
- [Writing & assignment](#writing--assignment)
- [Timezones](#timezones)
- [Missing values (`volas.NA`)](#missing-values-volasna)
- [pandas interop](#pandas-interop)
- [Arrow & DLPack interop (zero-copy)](#arrow--dlpack-interop-zero-copy)
- [Error handling](#error-handling)
- [Built-in Indicators](#built-in-indicators)
- [License](#license)
- [For Developers](#for-developers)

## Installation

```sh
pip install volas
```

Requires Python >= 3.11. Wheels are published for Linux (x86_64 / aarch64),
macOS (x86_64 / arm64) and Windows (x86_64). For a local build from source, see
[For Developers](#for-developers).

Verify the install in 30 seconds, then see the [`examples/`](examples/) — each
is self-contained and prints an `OK:` line:

```sh
pip install volas
python examples/00_install_check.py
python examples/03_live_ohlcv_append.py   # append a bar, refresh only the stale tail
```

## Quick start

```py
from volas import DataFrame

df = DataFrame({
    'open':   [2.0, 3.0, 4.0, 5.0, 6.0, 7.0],
    'high':   [12.0, 13.0, 14.0, 15.0, 16.0, 17.0],
    'low':    [1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
    'close':  [3.0, 4.0, 5.0, 6.0, 7.0, 8.0],
    'volume': [100, 200, 300, 400, 500, 600],
})

# A plain column -> Series
df['close']
# 0    3.0
# 1    4.0
# 2    5.0
# 3    6.0
# 4    7.0
# 5    8.0
# Name: close, dtype: float64

# An indicator directive -> Series (2-period SMA of `close`)
df['ma:2']
# 0   <NA>
# 1    3.5
# 2    4.5
# 3    5.5
# 4    6.5
# 5    7.5
# Name: ma:2, dtype: float64

# A boolean directive -> bool Series, usable as a row mask
bullish = df['close > open']
df[bullish]                      # DataFrame of the rows where close > open

# Several directives at once -> DataFrame
df[['ma:2', 'ma:3', 'close > open']]

# Export to NumPy (and, zero-copy, to Arrow / DLPack — see the interop section)
df['close'].to_numpy()           # 1-D ndarray
df.to_numpy()                    # 2-D ndarray (rows x columns)
```

## Usage

```py
from volas import (
    DataFrame, Series, read_csv, to_datetime, TimeFrame, Timestamp,
)
```

The sub-sections below follow volas's public surface in order: the `DataFrame`
class, then its instance methods, its static methods, the other classes, and the
top-level package functions — closing with the rest of the **pandas-compatible**
API that behaves exactly as it does in pandas. (A top-level name imported from
`volas`, such as `read_csv`, is written without a `volas.`
prefix.)

### DataFrame(data, columns=None, time_frame=None, cumulators=None)

`DataFrame` has a **pandas-compatible API**, so if you are familiar with
`pandas.DataFrame`, you are already ready to use volas. Unlike pandas, volas is
backed by a Rust kernel and has no pandas runtime dependency.

```py
df = read_csv('stock.csv')
```

We can use `[]`, which is called **pandas indexing** (a.k.a.
`__getitem__` in python) to select out lower-dimensional slices. In addition to
indexing with `colname` (the column name of the `DataFrame`), we could also do
indexing by `directive`s.

```py
df[directive]                  # Gets a Series

df[[directive0, directive1]]   # Gets a DataFrame
```

We have an example to show the most basic indexing using `[directive]`

```py
df = DataFrame({
    'open' : ...,
    'high' : ...,
    'low'  : ...,
    'close': [5, 6, 7, 8, 9]
})

df['ma:2']

# 0   <NA>
# 1    5.5
# 2    6.5
# 3    7.5
# 4    8.5
# Name: ma:2, dtype: float64
```

Which gets the 2-period simple moving average on column `"close"`.

#### Parameters

- **data** `dict[str, list | np.ndarray] | DataFrame` the column data, one of:
  - a **dict** mapping each column name to an equal-length list or NumPy array
    (`float`, `int`, `bool`, `datetime64`, or `string`);
  - another volas **`DataFrame`**, which is then copied (like `pandas.DataFrame(df)`).

  The constructor does **not** accept a `pandas.DataFrame` or an Arrow object — bridge
  those with the dedicated [`DataFrame.from_pandas`](#dataframefrom_pandaspdf---dataframe) /
  [`DataFrame.from_arrow`](#dataframefrom_arrowdata---dataframe) instead. To attach a
  [`DatetimeIndex`](https://pandas.pydata.org/docs/reference/api/pandas.DatetimeIndex.html),
  parse a column with `to_datetime`, promote it with `set_index`, then tag a zone with
  `tz_localize` / `tz_convert`. See [Timezones](#timezones).
- **columns** `Optional[list[str]] = None` Select and order the columns to keep —
  the same projection as `df[[...]]`. A name not present raises `KeyError`; an empty
  list or a duplicate name is rejected, and an absent column is never silently filled.
- **time_frame** `Optional[str | TimeFrame] = None` If set, makes this a
  **tf-aware** (cumulating) DataFrame at this bar interval: the given rows are
  taken as already-final bars at that frame, and later `append`s fold finer
  bars into the forming bar. Requires a `DatetimeIndex`. See
  [Cumulation and DatetimeIndex](#cumulation-and-datetimeindex).
- **cumulators** `Optional[dict[str, str]] = None` Per-column aggregator overrides
  used when folding (e.g. `{'amount': 'sum'}`), only meaningful together with
  `time_frame`. Defaults to OHLCV semantics (`open`=first, `high`=max, `low`=min,
  `close`=last, `volume`=sum; any other column `last`). Each dict **value** is one of:
  - `'first'` — the first value in the bucket
  - `'last'` — the last value in the bucket
  - `'max'` — the maximum
  - `'min'` — the minimum
  - `'sum'` — the sum
- **window** `Optional[int] = None` Make this a **bounded rolling-window** frame
  showing only the last `window` rows (see [Bounded rolling window](#bounded-rolling-window)).
  Requires exactly one of `max_lookback` / `indicators`.
- **max_lookback** `Optional[int] = None` With `window`, the largest indicator
  lookback you intend to use; the frame keeps `window + max_lookback` rows of margin
  so cached indicators stay correct across the automatic front-drop.
- **indicators** `Optional[list[str]] = None` With `window`, derive `max_lookback`
  from the largest lookback among these directives instead of stating it
  (e.g. `['atr:14', 'ma:50']` keeps a 50-row margin).

### Bounded rolling window

Pass `window=` to cap the frame at the last `window` rows. This is the live-trading
/ NN-input shape: you keep `append`-ing bars forever, but memory stays **bounded** —
the frame transparently drops old rows once it has accumulated enough, while
retaining a hidden `max_lookback`-row margin so cached indicators remain **bit-exact**
across each drop (the same values an unbounded frame would compute).

```py
# A bounded 30-bar window; the margin is sized from the indicators you declare.
wf = DataFrame(seed, time_frame='15m', window=30, indicators=['atr:14'])

for bar in feed:            # runs forever; memory never grows
    wf.append(bar)          # fold a 1m bar into the forming 15m bar
    wf.fulfill()            # refresh the cached atr:14 tail (O(lookback))
    if wf.ready:            # warmed up: all 30 rows have valid history
        x = wf[['close', 'atr:14']].to_numpy('float32')   # the 30×2 feature window
```

Every row-facing surface — `len`, `shape`, `index`, indexing (`[]` / `.iloc` /
`.loc` / `.iat` / `.at`), `head` / `tail`, reductions, `to_numpy`, `to_csv`,
`to_pandas`, `repr` — shows **only** the `window` rows; the margin is never visible.

- **`ready`** `bool` Whether the window has warmed up (`window + max_lookback` rows
  accumulated, so every visible row has a full indicator history). Always `True` for
  an unbounded frame.
- **`fill_into(out, columns=None)`** Write the window's values into a caller-preallocated
  C-contiguous `float32` / `float64` array of shape `(len(df), k)`, in place — the
  zero-allocation feature-export hot path (reuse one buffer across every bar). A
  missing cell becomes `NaN`; a string column is rejected (no float value).

### DataFrame.from_pandas(pdf) -> DataFrame

A **volas-specific static method** that builds a `DataFrame` from a `pandas.DataFrame`
(`pdf`) — the inverse of `df.to_pandas()`. pandas is imported lazily (only here, so volas
stays pandas-free at import). A nullable column keeps its dtype + `volas.NA`, and a
`DatetimeIndex` (tz-aware too) round-trips. See [pandas interop](#pandas-interop).

```py
df = DataFrame.from_pandas(pandas_df)   # pandas.DataFrame -> volas DataFrame
```

### DataFrame.from_arrow(data) -> DataFrame

A **volas-specific static method** that builds a `DataFrame` from any object exposing
the Arrow **C-Stream** protocol (`__arrow_c_stream__`) — a `pyarrow.Table` /
`RecordBatch` / `RecordBatchReader`, a polars `DataFrame`, etc. The data buffers are
borrowed where the dtypes match (otherwise a column is copied), a multi-chunk source
is concatenated, and the result carries a fresh `RangeIndex`.

- **data** the Arrow source — any object implementing `__arrow_c_stream__`.

```py
df = DataFrame.from_arrow(pa_table)        # pyarrow.Table     -> DataFrame
df = DataFrame.from_arrow(polars_df)       # polars.DataFrame  -> DataFrame
```

> Arrow is **not** accepted by the `DataFrame(data=...)` constructor (which takes a
> `dict` or another `DataFrame`); build from an Arrow object through `from_arrow`.

### df.exec(directive: str) -> np.ndarray

Evaluates the given directive and returns its values as a numpy ndarray. It is a
pure, **stateless** evaluation — the frame is never modified (no column is
created, and no cache is read or written).

```py
# Compute the directive without touching the frame
df.exec('ma:20')
```

The difference between `df[directive]` and `df.exec(directive)` is that
- `df[directive]` creates a column for the result and caches it (incrementally
  refreshed after an append), while `df.exec(directive)` computes fresh every
  time and leaves the frame untouched — for a cached ndarray, use
  `df[directive].to_numpy()`
- `df[directive]` also accepts other indexing targets (a column name, a list, a
  boolean mask, a slice), while `df.exec(directive)` only accepts a valid
  **volas** directive string
- `df[directive]` returns a `Series` or `DataFrame` object while
  `df.exec(directive)` returns an [`np.ndarray`](https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html)

### df.get_column(key: str) -> Series

Directly gets the column value by `key`, returning a `Series` — and **never
computes**: unlike `df[key]`, which parses an unknown key as an indicator
directive and executes it, `get_column` only fetches an existing column and
raises `KeyError` otherwise. Use it whenever the column name comes from
external data (CSV headers, user input, configuration), so a name that happens
to look like a directive (e.g. `"ma:5"`) can never silently trigger a
computation.

If the given `key` is an alias name, it returns the value of the corresponding
original column. If the column is not found, a `KeyError` is raised.

```py
df = DataFrame({
    'open' : ...,
    'high' : ...,
    'low'  : ...,
    'close': [5, 6, 7, 8, 9]
})

df.get_column('close')
# 0    5
# 1    6
# 2    7
# 3    8
# 4    9
# Name: close, dtype: int64
```

### df.append(other: DataFrame | Row | dict) -> DataFrame

Appends rows of `other` to the end of the caller in place, returns the same
`DataFrame`, and applies the `DatetimeIndex` to the newly-appended row(s) if
possible. Use `copy()` first when the original frame must stay unchanged.

`other` is a `DataFrame`, a `Row`, or a **scalar bar dict** — one bar written as
`{column: value}` with the bar's timestamp under the key equal to the index's name
(a `RangeIndex` auto-increments). The dict form is the fast live path — it builds the
bar straight into the frame with no per-bar 1-row `DataFrame`:

```python
df.append({'time_key': ts, 'open': o, 'high': h, 'low': l, 'close': c, 'volume': v})
```

It is **strict**: every data column must be provided (a missing one raises — unlike a
`DataFrame` / `Row`, where a missing column is NA-padded), and an unknown key raises.
Cached directive columns are not supplied — they are padded and refreshed automatically.

If the caller is a **tf-aware** DataFrame (one built with a `time_frame`, or
the result of `cumulate`), `append` instead **folds** each finer bar into the
forming bar rather than adding a row — see
[Live cumulation](#live-cumulation--a-tf-aware-dataframe).

`append` is lazy: it does not recompute the indicator columns of the new rows.
They stay stale until an indicator-**column read** refreshes them or `df.fulfill()`
is called (see below).

### df.cumulate(time_frame: TimeFrame | str, cumulators: dict | None = None) -> DataFrame

Cumulate (resample) the data frame to a coarser `time_frame`, returning a new
`DataFrame`. Requires a `DatetimeIndex`.

- **time_frame** `TimeFrame | str` the target bar interval, e.g. `TimeFrame.m5`
  or `'5m'`. See [TimeFrame](#timeframe).
- **cumulators?** `dict[str, str] | None = None` per-column aggregator overrides
  (e.g. `{'amount': 'sum'}`). Defaults to OHLCV semantics (`open`=first, `high`=max,
  `low`=min, `close`=last, `volume`=sum; any other column `last`). Each dict **value**
  is one of:
  - `'first'` — the first value in the bucket
  - `'last'` — the last value in the bucket
  - `'max'` — the maximum
  - `'min'` — the minimum
  - `'sum'` — the sum

```py
# from 1-minute klines to 5-minute klines
five_minute = one_minute.cumulate('5m')
fifteen_minute = one_minute.cumulate('15m')

five_minute.append(new_candle_1m)
# appending a 1-minute candle to a 5-minute DataFrame folds it into the 5m bar

fifteen_minute.append(new_candle_1m)
# so 1-minute data conveniently generates 5m and 15m test datasets
```

See [Cumulation and DatetimeIndex](#cumulation-and-datetimeindex) for details.

### df.fulfill() -> None

Batch-refresh every cached indicator column's stale tail in place
(`O(lookback + new rows)` each, not an `O(n)` recompute), and return `None`.

Since `append` is lazy, the cache becomes fresh in one of two ways:

- **Reading an indicator column** — `df['ma:20']` or `df[['ma:20', 'rsi:14']]` —
  auto-refreshes just those columns' stale tails on access, so a column read is
  always fresh and cheap. The single- and multi-column forms behave identically.
- **Every other read** — `to_numpy()`, `.iloc` / `.loc` / `.at`, the reductions
  (`sum` / `mean` / `max` / `describe` / …), `to_csv`, `repr`, … — does **not**
  auto-refresh; while the frame is stale it **raises**, telling you to call
  `fulfill()` first. This is deliberate: a half-updated frame fails loud instead
  of silently returning stale values, and you control when the (bounded) refresh
  cost is paid — which matters on a latency-sensitive live path.

```py
df['ma:20']              # cache + read the 20-period SMA (fresh)
df.append(new_bar)       # lazy: the new row's ma:20 is now stale
df['ma:20']              # a column read auto-refreshes only the tail (fresh again)

df.append(new_bar)       # stale again
df.fulfill()             # batch-refresh every cached column's tail
df.to_numpy()            # now fresh (a bulk read would have raised while stale)
```

### df.alias(as_name: str, src_name: str) -> None

Defines a column alias.

- **as_name** `str` the alias name
- **src_name** `str` the name of an existing column

```py
# Some plot libraries such as `mplfinance` require a column named capitalized
# `Open`, but it is ok, we could create an alias.
df.alias('Open', 'open')
```

The alias resolves everywhere a column is looked up, **including inside
directives**, and survives `drop` / `copy` / slicing.

```py
df['Open']        # same data as df['open']
df['ma:5@Open']   # the alias resolves inside directives too
```

### df.to_numpy(dtype=None, na_value=...) -> np.ndarray

The frame as a 2-D NumPy array (rows × columns). It tracks pandas except for one
deliberate guard: an **integer** `dtype` over a frame that holds missing values
**raises** instead of silently writing garbage (NumPy cannot store `NA` in an
integer array) — give `na_value` to fill instead.

- **dtype** `str | None` — an optional export cast. `None` (the default) gives the
  honest per-dtype representation; otherwise:
  - `'object'` (or `'O'`) — a lossless 2-D array of typed cells (number / `str` /
    `Timestamp` / `volas.NA`); the only dtype that keeps a `str` or datetime column intact.
  - `'int64'`, `'int32'`, `'int16'`, `'int8'` (and the unsigned `'uint*'`) — the **exact**
    `i64` channel (a large int and a datetime's epoch-ns survive without a `float` round
    trip). Over a frame with missing values this **raises** unless `na_value` is given.
  - `'float64'`, `'float32'`, `'float16'` — the (lossy) `float` channel: a missing cell
    is `NaN`, a datetime past 2⁵³ ns quantises.
  - `'bool'` — boolean.
  - `'datetime64[ns]'` — datetime nanoseconds; a `NaT` cell is preserved.
  - A `str` column rejects every numeric / temporal dtype — use `'object'`.
- **na_value** `Any` — the value substituted for each missing cell. Default: the
  NA-model representation (`NaN` / `NaT` / `volas.NA`).

```py
df = DataFrame({'a': [1, 2, 3], 'b': [1.5, 2.5, 3.5]})

df.to_numpy()                 # -> float64 2-D array
df.to_numpy(dtype='int64')    # exact int64 cast (dense frame)
df.to_numpy(dtype='object')   # typed cells — lossless (numbers / str / Timestamp / volas.NA)

# an integer dtype over a missing value raises — unless na_value fills it
DataFrame({'a': [1, None]}).to_numpy(dtype='int64')               # ValueError
DataFrame({'a': [1, None]}).to_numpy(dtype='int64', na_value=0)   # -> [[1], [0]]  (int64)
```

Notes:
- The default (no `dtype`) is the honest representation: an all-numeric/bool frame
  is a `float64` matrix (a missing cell → `NaN`), a frame containing `str` or mixed
  dtypes is an `object` matrix of typed cells, and a datetime frame is `datetime64[ns]`.
- `dtype='object'` is always lossless — each cell keeps its own typed value (a
  number, a `str`, a `Timestamp`, or `volas.NA`).
- A `str` column has no numeric meaning, so any numeric/temporal `dtype` raises —
  use `dtype='object'` to keep the strings.
- A datetime column is **exempt** from the integer-NA raise: under `dtype='int64'`
  a `NaT` exports as its exact epoch-ns sentinel (datetime never round-trips through
  `float`); `na_value` overrides that sentinel when given.
- `to_numpy()` is a bulk read; it does not auto-refresh stale indicator columns and
  raises if any are stale — call [`df.fulfill()`](#dffulfill---none) first.

For a zero-copy hand-off to Arrow / DLPack consumers, see
[Arrow & DLPack interop](#arrow--dlpack-interop-zero-copy).

### df.to_arrow() -> pyarrow.Table

A **volas-specific** export to a `pyarrow.Table`, **zero-copy** where the dtypes
match — the numeric / string / datetime column buffers are shared with Arrow, while
`bool` and the null bitmap are repacked. Requires `pyarrow` (imported lazily, only
here). It is a convenience over volas's Arrow **C-Stream** bridge: any Arrow consumer
can read the frame directly through the standard `__arrow_c_stream__` PyCapsule
protocol, with no `to_arrow()` call and without volas depending on pyarrow.

```py
import pyarrow as pa
tbl = df.to_arrow()        # -> pyarrow.Table (shares the column buffers)
tbl = pa.table(df)         # identical, via the __arrow_c_stream__ protocol
pdf = pl.from_dataframe(df)  # polars reads it through the same protocol
```

Returns a `pyarrow.Table`. See [Arrow & DLPack interop](#arrow--dlpack-interop-zero-copy)
for the full zero-copy contract and the DLPack export.

### df.to_pandas(dtype_backend='numpy') -> pandas.DataFrame

Export to a `pandas.DataFrame` (pandas is imported lazily, only here — it is not a
runtime dependency). A `DatetimeIndex` round-trips, and the reverse bridge is
[`DataFrame.from_pandas`](#dataframefrom_pandaspdf---dataframe).

- **dtype_backend?** `str = 'numpy'` how a missing value is carried into pandas:
  - `'numpy'` — the most ecosystem-compatible form: an int / bool column with a missing
    value becomes `float64` / `object` with `NaN` (like `pandas.Int64.to_numpy()`).
  - `'numpy_nullable'` — a faithful, lossless masked round-trip: an int / bool / str
    column stays `Int64` / `boolean` / `string` with the hole as `pandas.NA`.

```py
pdf = df.to_pandas()                                # 'numpy' backend (NaN-based)
pdf = df.to_pandas(dtype_backend='numpy_nullable')  # lossless masked Int64 / boolean / string
```

See [pandas interop](#pandas-interop) for the round-trip details and `to_csv`.

### Series

`df[col]` and `df[directive]` return a `Series` — a named 1-D column whose API is
pandas-compatible: arithmetic / comparison / logical operators, `.sum()` /
`.mean()` / `.std()` / …, `.shift()` / `.diff()` / `.fillna()`, `.iloc` /
`.loc`, `.to_numpy()` / `.to_list()`. See
[the rest of the pandas-compatible API](#the-rest-of-the-pandas-compatible-api)
for the full list. There is no public `Series` constructor — a `Series` is
always obtained by indexing a `DataFrame`.

```py
s = df['close']
s.name                 # 'close'
(s - s.shift(1)).mean()
df['ma:5 > ma:20']     # a directive likewise returns a Series (here a bool one)
```

Beyond pandas, a `Series` also exposes the 15 TA-Lib **Math Transform** functions
as methods — `acos` `asin` `atan` `ceil` `cos` `cosh` `exp` `floor` `ln`
`log10` `sin` `sinh` `sqrt` `tan` `tanh`:

```py
df['close'].ln()
df['high'].sqrt()
```

A `datetime64[ns]` Series exposes the pandas `.dt` accessor: calendar
components (`year` `month` `day` `hour` `minute` `second` `microsecond`
`nanosecond` `quarter` `dayofweek` `dayofyear` `days_in_month`), calendar
predicates (`is_month_start` … `is_year_end`, `is_leap_year`), names
(`day_name()` / `month_name()`), formatting (`strftime(fmt)`), bar alignment
(`floor(freq)` / `ceil(freq)` / `round(freq)` / `normalize()`), and
`isocalendar()`. A missing element yields `NA` in every component:

```py
t = volas.to_datetime(df['time'])
t.dt.hour                  # int64 Series, 0..23
t.dt.dayofweek             # Monday=0 .. Sunday=6
t.dt.floor('15min')        # datetime Series aligned to the 15-minute bar
```

### Series.from_arrow(data, name=None) -> Series

A **volas-specific static method** that builds a `Series` from any object exposing the
Arrow **C-Data** array protocol (`__arrow_c_array__`) — a `pyarrow.Array`, a polars
`Series`, etc. The data buffer is borrowed where the dtype matches (otherwise copied);
the result carries a fresh `RangeIndex`.

- **data** the Arrow source — any object implementing `__arrow_c_array__`.
- **name?** `str | None = None` the name for the resulting `Series`.

```py
s = Series.from_arrow(pa_array, name='close')   # pyarrow.Array -> Series
```

### series.to_numpy(dtype=None, na_value=...) -> np.ndarray

The column values as a 1-D NumPy array — `pandas.Series.to_numpy` semantics:

- **dtype** `str | None` — an optional export cast. `None` (the default) gives the
  column's native representation; otherwise any NumPy dtype string accepted by
  `numpy.ndarray.astype`, the common values being:
  - `'int64'`, `'int32'`, `'int16'`, `'int8'` (and the unsigned `'uint64'`, `'uint32'`,
    `'uint16'`, `'uint8'`) — integer. Over a column with missing values this **raises**
    unless `na_value` is given (an NA has no integer representation).
  - `'float64'`, `'float32'`, `'float16'` — floating point; a missing cell is `NaN`.
  - `'bool'` — boolean.
  - `'datetime64[ns]'` — datetime nanoseconds; a missing cell is `NaT`.
  - `'object'` (or `'O'`) — Python objects, each cell its own typed value (lossless).
- **na_value** `Any` — the value to substitute for each missing cell. Default: the
  NA-model representation (`NaN` for the float export, `None` in an object array). With
  an explicit integer `dtype`, the values stay **exact** (a large int is not funnelled
  through `float64`) and the holes become `na_value`.

```py
series = DataFrame({'qty': [1, None, 3]})['qty']    # int64 with a missing value

series.to_numpy()                          # -> array([ 1., nan,  3.])   (float64; a missing int -> NaN)
series.to_numpy(dtype='int64', na_value=0) # -> array([1, 0, 3])         (int64; NA filled, dtype kept)
series.to_numpy(na_value=-1)               # -> array([ 1., -1.,  3.])   (default float export, NA -> -1)

# without na_value, an integer dtype over a missing value raises
series.to_numpy(dtype='int64')
# ValueError: cannot convert a column with missing values to integer NumPy dtype 'int64' ...
```

Notes:
- The default (no `dtype`, no `na_value`) is the dtype-specific export: a missing
  int / bool / datetime cell collapses to `NaN` / `NaT` (NumPy has no `NA`), while a
  dense column keeps its native dtype. A float `NaN` is in-band, so a float column
  cast to an integer dtype likewise raises when any value is `NaN` (pass `na_value`).
- Like pandas, `na_value` only changes the missing cells — without an explicit `dtype`
  an int column with NA still exports `float64` (the default), and `na_value` simply
  fills the `NaN` slots.
- For a **lossless** NA round-trip that keeps the native dtype *and* the missing
  positions (no fill, no float collapse), use the Arrow path (`series.to_arrow()` carries
  the null bitmap) or `series.to_pandas(dtype_backend='numpy_nullable')`; the NA mask
  alone is `series.isna().to_numpy()`.

### series.to_arrow() -> pyarrow.Array

A **volas-specific** export of the column to a `pyarrow.Array`, **zero-copy** where
the dtype matches (the numeric / string / datetime buffer is shared; `bool` and the
null bitmap are repacked). Requires `pyarrow` (imported lazily). It is a convenience
over volas's Arrow **C-Data** bridge: any Arrow consumer can read the series directly
through the standard `__arrow_c_array__` PyCapsule protocol.

```py
import pyarrow as pa
arr = series.to_arrow()    # -> pyarrow.Array (shares the buffer)
arr = pa.array(series)     # identical, via the __arrow_c_array__ protocol
```

Returns a `pyarrow.Array`. The column also exports zero-copy to NumPy / PyTorch / JAX
via DLPack (`np.from_dlpack(series)`) — see
[Arrow & DLPack interop](#arrow--dlpack-interop-zero-copy).

### Row

`df.iloc[i]` and `df.loc[label]` return a `Row` — a single record whose `.name`
is its index label. A `Row` has **no public constructor** (`Row(...)` raises
`TypeError: No constructor defined for Row`); you only obtain one by indexing a
frame, and you may pass it to `df.append`.

```py
row = df.iloc[-1]      # the latest bar
row.name               # its index label (e.g. a Timestamp for a DatetimeIndex)
row.to_dict()          # {column: value}
row.to_numpy()         # the numeric cells as a 1-D ndarray
```

### Live cumulation — a tf-aware DataFrame

For **live** streaming, give a DataFrame a `time_frame` and `append` finer bars
into it, instead of re-cumulating the whole frame each tick. `df.cumulate(tf)`
returns such a frame (the forming period kept live), or build one directly with
`DataFrame(data, time_frame=..., cumulators=...)` (the given rows are taken as
already-final bars at that frame; requires a DatetimeIndex).

On a tf-aware frame:

- **df.append(bar)** folds the bar in: one in the open period **updates the
  forming last row** (`df.iloc[-1]`); one in a new period rolls over into a fresh
  row; a re-sent forming bar (same timestamp) updates rather than double-counts.
- **df.iloc[-1]** is the current (still-open) period — the live bar.
- **df[directive]** / **df.exec(directive)** computes indicators over the
  cumulated frame including the forming row — lazily, on read: an `append` only
  marks them stale, and the next read recomputes just the tail.
- **df.cumulate(target)** must be a whole multiple of the source frame (e.g.
  `5m→15m`, not `5m→7m`; a week or 3-day bar does not nest into a month/year);
  the same frame is a `copy()`.

```py
df = history.cumulate('5m')   # a tf-aware 5m frame (history is finer, e.g. 1m)
for bar in stream:            # each `bar` is a finer DataFrame
    df.append(bar)            # folds into the forming 5m bar
    df.iloc[-1]               # the live, still-forming bar
    df['macd']               # indicators over the cumulated frame
```

See [Cumulation and DatetimeIndex](#cumulation-and-datetimeindex) for details.

### read_csv(path, sep=',', header=True, parse_dates=None, index_col=None, na_values=None, keep_default_na=True, tz=None, date_unit=None) -> DataFrame

A top-level function that reads a CSV file into a `DataFrame`, inferring per-column
dtypes — a fast, pandas-subset CSV reader.

- **path** `str | os.PathLike` the CSV file path — a string or any `os.PathLike`
  (e.g. `pathlib.Path`).
- **sep?** `str = ','` the field delimiter (a single character); `delimiter` is an
  accepted alias.
- **header?** `bool = True` `True` (or omitted) treats the first row as the header;
  `False` / `None` means no header (columns are named `'0'`…`'n-1'`).
- **parse_dates?** `list[str] | None = None` column names to parse into datetime
  columns.
- **index_col?** `str | int | None = None` a column name or integer position to move
  into the row index; applied *after* `parse_dates`, so naming a parsed date column
  yields a `DatetimeIndex`.
- **na_values?** `str | list[str] | None = None` extra missing-value tokens.
- **keep_default_na?** `bool = True` also treat the default NA tokens as missing.
- **tz?** `str | None = None` the timezone for the `index_col` datetime: a *naive* date
  string is read in `tz` (stored UTC, the index tagged). Pass the date column via
  `index_col` and do *not* also list it in `parse_dates`. See [Timezones](#timezones).
  Accepts either:
  - a fixed UTC offset, e.g. `'+08:00'` / `'-05:00'`
  - an IANA timezone name, e.g. `'America/New_York'` / `'Asia/Shanghai'` / `'UTC'`
- **date_unit?** `str | None = None` read `index_col` as an epoch integer in this unit
  (absolute UTC; `tz` then only sets the display zone). One of:
  - `'s'` — seconds
  - `'ms'` — milliseconds
  - `'us'` — microseconds
  - `'ns'` — nanoseconds

```py
from volas import read_csv

df = read_csv('klines.csv')                        # RangeIndex
df = read_csv('klines.csv',
              parse_dates=['time_key'],            # parse to datetime
              index_col='time_key')                # -> DatetimeIndex
df = read_csv('data.tsv', sep='\t', header=False,  # no header -> '0'..'n-1'
              na_values=['NA', 'null'])
```

### to_datetime(obj, unit='ns', format=None) -> Series

A top-level function that converts epoch numbers or datetime strings to a
datetime `Series`, mirroring `pandas.to_datetime`. `obj` may be a `Series`, a 1-D
NumPy array, or a list. A **missing** input (a float `NaN`, or a `volas.NA` in an
int column) becomes `NaT`, like `pd.to_datetime`.

- **obj** the values to convert — numeric epochs, datetime strings, or an
  already-datetime `Series` (returned unchanged).
- **unit?** `str = 'ns'` the epoch unit for **numeric** input (sub-unit fractions are
  preserved, like `pd.to_datetime`). One of:
  - `'s'` — seconds
  - `'ms'` — milliseconds
  - `'us'` — microseconds
  - `'ns'` — nanoseconds (the default)
- **format?** `str | None = None` an explicit datetime format for **string** input
  (pandas `format=`, e.g. `'%Y-%m-%d %H:%M:%S'`) — faster and unambiguous; ignored for
  numeric input. Any `strftime`/`strptime` directive string; `None` auto-infers.

Naive strings parse as UTC and offset-aware strings (`…+08:00`) are absolute. To
*display* the resulting index in a zone, make it the index and tag the zone with
`tz_localize` / `tz_convert` (see [Timezones](#timezones)).

```py
from volas import to_datetime

# parse an epoch-seconds column to datetime, then make it the index
df['time'] = to_datetime(df['time'], unit='s')
df = df.set_index('time')                       # -> DatetimeIndex
df = df.tz_localize('America/New_York')         # tag the display zone (see Timezones)
```

For an in-place, **truncating** cast (the NumPy / pandas `astype` idiom), use
`df.astype({'time': 'datetime64[s]'})` instead.

### directive_stringify(directive: str) -> str

Get the canonical full name of a `directive` — the actual column name volas caches
it under. The command name is lowercased and default arguments / series are dropped
to save space.

```py
from volas import directive_stringify

directive_stringify('kdj.j')
# 'kdj.j'

directive_stringify('kdj.j:9,3,2,100@high,close,close')
# 'kdj.j:,,2,100@,close'

# command names are case-insensitive and canonicalize to lowercase
directive_stringify('MACD:12,26')
# 'macd'
```

### directive_lookback(directive: str) -> int

Get the lookback period of a `directive` — the minimum number of prior data points
required before the indicator produces a valid result.

```py
from volas import directive_lookback

directive_lookback('ma:20')
# 19

directive_lookback('boll')
# 19 (default period 20)

# Compound directive: lookback accumulates across nested expressions.
# repeat:5 needs 4 extra points, boll.upper (period 20) needs 19 -> 23
directive_lookback('repeat:5@(close > boll.upper)')
# 23
```

### The rest of the pandas-compatible API

Everything below behaves like its `pandas` counterpart — if you know it from
pandas, it works the same in volas, except for the deliberate
[NA-model divergences](#known-pandas-divergences-the-volasna-model) noted after
the listing.

```py
# --- DataFrame: metadata --------------------------------------------------
df.columns / df.shape / len(df) / df.dtypes      # dtypes -> dict
df.index                          # row labels, as a NumPy array
col in df ; for col in df         # membership / iterate column names
df.tz / df.tz_localize(tz) / df.tz_convert(tz)   # DatetimeIndex tz; see Timezones

# --- DataFrame: selection -------------------------------------------------
df[col]                           # -> Series
df[[col, ...]]                    # -> DataFrame
df[bool_mask]                     # -> DataFrame (filter rows; mask = Series | ndarray)
df.iloc[...] / df.loc[...] / df.at[label, col] / df.iat[i, j]
df.head(n=5) / df.tail(n=5)

# --- DataFrame: reshaping & dtypes ----------------------------------------
df.drop([label, ...], axis=0)     # drop rows by label (axis=1 -> columns)
df.dropna(how='any') / df.sort_index(ascending=True) / df.reset_index(drop=False)
df.rename({old: new}) / df.astype({col: dtype}) / df.set_index(col)
df.astype({col: 'datetime64[s]'})  # numeric epoch -> datetime (unit s|ms|us|ns; truncating)
df.copy() / df.equals(other) / df.to_csv(path=None, ...)   # to_numpy: see its own section (dtype, na_value)

# --- DataFrame: writing ---------------------------------------------------
df[col] = scalar | array | Series          # add / replace a column (positional)
df.loc[mask, col] = value ; df.iloc[i, j] = value ; df.at[label, col] = value

# --- Series ---------------------------------------------------------------
s.name / s.dtype / len(s) / s.tz / s.index
s.to_list()                       # to_numpy has NA caveats -> see its own section (dtype, na_value)
s.iloc[...] / s.loc[...]
s + s, s - 1, -s, ...             # elementwise arithmetic
s > 0, s == t, s != t, ...        # comparison -> bool Series
s & t, s | t, ~s, s ^ t           # logical -> bool Series
s.sum() / s.mean() / s.min() / s.max() / s.std() / s.var() / s.median()   # skip missing
s.shift(n=1) / s.diff(n=1) / s.fillna(v) / s.ffill() / s.bfill()           # see Missing values: NA keeps the dtype
s.isna() / s.notna() / s.dropna() / s.equals(t)
```

#### Window operations (`rolling` / `expanding` / `ewm`) — compatibility only

> **This surface exists so pandas research / labeling code moves over verbatim.
> It is NOT the recommended way to compute indicators, and it should NOT be
> used in a live trading system**: a window result is a plain Series — it does
> not join the directive cache and is **not** incrementally refreshed by
> `append()` / `fulfill()`; every new bar costs a full `O(n)` recompute.
> Prefer the equivalent directive (`df['ma:20']`, `df['median:30']`,
> `df['stddev:20']`, …): same kernels, plus caching and `O(lookback)` per-bar
> refresh.

```py
s.rolling(window, min_periods=None, center=False)   # int window; min_periods defaults to window
s.expanding(min_periods=1)
s.ewm(com=|span=|halflife=|alpha=, min_periods=0, adjust=True, ignore_na=False)
                                                    # exactly ONE decay spelling

# Rolling / Expanding (pandas semantics: NA skipped, min_periods gates):
.count() .nunique()                                 # -> int64 Series (native NA)
.sum() .mean() .median() .min() .max()
.var(ddof=1) .std(ddof=1) .sem(ddof=1) .skew() .kurt()
.quantile(q, interpolation='linear') .rank(method='average', ascending=True, pct=False)
.first() .last()                                    # dtype-preserving
.corr(other) .cov(other, ddof=1)

# Ewm:
.mean() .sum() .var(bias=False) .std(bias=False) .corr(other) .cov(other, bias=False)
```

`center=True` labels each window at its center — it reads **future** bars
relative to the label. That is exactly what a labeling pass wants, and exactly
what a live signal must never do; it is supported for the former.

Time-based windows (`rolling('5min')` / a `timedelta`) are deliberately not
implemented. For multi-timeframe computation, maintain **two tf-aware
DataFrames** (see [Cumulation](#cumulation-and-datetimeindex)) and `append`
each bar to both — that is the supported, `O(lookback)`-per-bar design;
emulating a coarser timeframe through window arithmetic recomputes everything
on every bar.

Not provided (pandas members that conflict with volas's model): `apply` /
`agg` / `pipe` (arbitrary-Python-per-window), `win_type`, `step`, `on`,
`closed`, `method`, `ewm(times=...)`, `ewm.online()` — `append()` + directives
already cover the streaming use case.

#### Known pandas divergences (the `volas.NA` model)

A handful of APIs diverge from pandas **by design**, because volas stores missing
values natively as [`volas.NA`](#missing-values-volasna) (no `object` dtype, no
silent float upcast):

- **`shift` / `diff` / `fillna` and friends** keep the column's dtype — a missing
  value is `volas.NA`, not an int/bool/str column upcast to float/object.
- **Comparisons** (`==` `!=` `<` `<=` `>` `>=`) return a *non-nullable* bool mask:
  a missing value compares `False` (and `!=` compares `True`), following IEEE /
  NumPy — not pandas-nullable's three-valued `NA`. This keeps masks free of `NA`
  so `df[mask]` and assignment stay total.
- **Storage keeps the dtype.** Where pandas upcasts an int/bool column with a missing
  value to `float64` / `object`, volas keeps it `int64` / `boolean` with the hole as
  `volas.NA` — so `to_list()` returns exact ints and `volas.NA`. The numpy *export*
  (`to_numpy()`) still follows pandas 3.0 exactly: a missing cell becomes `NaN` / `NaT`
  by default, an integer `dtype=` over missing values raises, and `na_value=` fills —
  see the dedicated `df.to_numpy` / `series.to_numpy` sections above.

For the full picture — why volas's type system is built this way, where pandas's
breaks, and the migration gotchas — see
[volas vs pandas — the type system](PANDAS-DIFFERENCES.md).

The pandas-shaped indexing and writing details have their own sections —
[Indexing & selection](#indexing--selection) and
[Writing & assignment](#writing--assignment).

## Cumulation and DatetimeIndex

Suppose we have a csv file containing kline data of a stock in the 1-minute time
frame:

```py
csv = read_csv(csv_path)

print(csv)
```

```
                   date   open   high    low  close    volume
0   2020-01-01 00:00:00  329.4  331.6  327.6  328.8  14202519
1   2020-01-01 00:01:00  330.0  332.0  328.0  331.0  13953191
2   2020-01-01 00:02:00  332.8  332.8  328.4  331.0  10339120
3   2020-01-01 00:03:00  332.0  334.2  330.2  331.0   9904468
4   2020-01-01 00:04:00  329.6  330.2  324.9  324.9  13947162
5   2020-01-01 00:04:00  329.6  330.2  324.8  324.8  13947163    <- an update of
                                                                    2020-01-01 00:04:00
...
19  2020-01-01 00:19:00  327.0  327.2  322.0  323.0  15086985
```

> Note that duplicated records of the same timestamp are not cumulated. All
> records except the latest one are discarded.

Read the same csv, but parse the `date` column into a `DatetimeIndex`:

```py
df = read_csv(
    csv_path,
    parse_dates=['date'],
    index_col='date'
)

print(df)
```

```
                      open   high    low  close    volume
2020-01-01 00:00:00  329.4  331.6  327.6  328.8  14202519
2020-01-01 00:01:00  330.0  332.0  328.0  331.0  13953191
...
2020-01-01 00:19:00  327.0  327.2  322.0  323.0  15086985
```

You must have figured it out that the data frame now has a
[`DatetimeIndex`](https://pandas.pydata.org/docs/reference/api/pandas.DatetimeIndex.html).

But it will not become a 5-minute kline unless we cumulate it:

```py
df_5m = df.cumulate('5m')

print(df_5m)
```

Now we get a 5-minute kline:

```
                      open   high    low  close      volume
2020-01-01 00:00:00  329.4  334.2  324.8  324.8  62346461.0
2020-01-01 00:05:00  325.0  327.8  316.2  322.0  82176419.0
2020-01-01 00:10:00  323.0  327.8  314.6  327.6  74409815.0
2020-01-01 00:15:00  330.0  335.2  322.0  323.0  82452902.0
```

`cumulate` defaults to OHLCV semantics — `open`=first, `high`=max, `low`=min,
`close`=last, `volume`=sum — and **any other column falls back to `last`**. Pass
`cumulators=` to override a column's aggregator; the common case is a non-OHLCV
column that should be summed, such as a turnover (`amount`) column that would
otherwise default to `last`:

```py
df.cumulate('1h', cumulators={'amount': 'sum'})
```

The supported aggregators are `first`, `max`, `min`, `last` and `sum`.

The `time_frame` may be a string label or a `TimeFrame` constant — see
[TimeFrame](#timeframe) for the full list.

#### Bar labels are the period start

Every time frame lies on a **fixed grid**, and a cumulated bar is labelled with
its period's grid **start** — even when the first raw bar arrives mid-period. A
bar that opens with a `09:07` tick on a `15m` frame is labelled `09:00`, never
`09:07`, so volas bars line up exactly with exchange klines and with pandas
`resample` (`label='left'`).

The grid origins per frame: intraday frames anchor at **midnight** of the
index's (timezone-aware) trading day — a `15m` bar starts at `:00`/`:15`/`:30`/
`:45`, a `4h` bar at `00:00`/`04:00`/…; `1d` starts at midnight; `1w` on
**Monday**; `3d` is a continuous grid from the Unix epoch; `1M` / `1y` on the
calendar month / year. If a daylight-saving transition removes or repeats a
period's boundary, the label resolves to the period's earliest real instant.

For **live** streaming you do not re-cumulate the whole history on every tick —
you keep the current 5-minute bar *forming* and update it as each finer bar
arrives. A **tf-aware DataFrame** does exactly that: it stays an ordinary
DataFrame (read columns, run directives, slice it), except `append` **folds**
each finer bar into the bar currently forming instead of adding a row. You make
one with `df.cumulate('5m')` or `DataFrame(data, time_frame='5m')`, and the live
loop is then just:

| step                           | call                      |
| ------------------------------ | ------------------------- |
| make a `5m` frame              | `cum = df.cumulate('5m')` |
| feed it the next finer bar     | `cum.append(bar)`         |
| read the current forming bar   | `cum.iloc[-1]`            |
| read an indicator over it      | `cum['macd']`             |

#### Watch the forming bar grow

Build the 5-minute frame from the 1-minute `df` above one bar at a time. Seed it
with the `00:00` bar, then fold in `00:01`. Both fall in the same `00:00`–`00:05`
window, so the frame still holds **one** row — the forming bar — now updated
(`high` rose to `332.0`, `close` to `331.0`, `volume` summed):

```py
cum = df.iloc[0:1].cumulate('5m')   # seed the 5m frame with the 00:00 bar
cum.append(df.iloc[1:2])            # fold in 00:01 (same 5m window)

print(cum)
```

```
                      open   high    low  close      volume
2020-01-01 00:00:00  329.4  332.0  327.6  331.0  28155710.0
```

Fold in `00:02`, `00:03` and `00:04` and the window fills up. That single forming
row is now the **finished** first 5-minute bar — identical to the first row of
the one-shot `df.cumulate('5m')` printed earlier:

```py
for i in range(2, 5):
    cum.append(df.iloc[i:i + 1])

print(cum)
```

```
                      open   high    low  close      volume
2020-01-01 00:00:00  329.4  334.2  324.8  324.8  62346461.0
```

Now fold in `00:05`. It opens the **next** window, so the `00:00` bar is finalized
and a fresh forming bar starts; the frame grows to two rows and `cum.iloc[-1]` is
the new, still-forming `00:05` bar:

```py
cum.append(df.iloc[5:6])

print(cum)
```

```
                      open   high    low  close      volume
2020-01-01 00:00:00  329.4  334.2  324.8  324.8  62346461.0   <- finalized
2020-01-01 00:05:00  325.0  327.8  324.8  327.6  10448427.0   <- still forming
```

Two properties make this safe for a live feed:

- **Indicators are lazy, and fresh on read.** `append` does not recompute
  anything — it only flags the dependent directive columns as stale (their
  valid-row cursor now lags the frame height). The recompute happens when you
  **read** `cum['ema:9']` (or any directive): only the stale tail is refreshed —
  `O(lookback)`, not the whole column — over the frame *including* the forming
  row, bit-identical to a one-shot cumulate-then-compute. (A bulk read such as
  `to_numpy()` does not auto-refresh; call `cum.fulfill()` first, or just read
  the directive.)
- **Re-sent bars do not double-count.** Folding a bar whose timestamp you have
  already seen **updates** that period instead of adding to it — the same dedup
  rule shown at the top of this section — matching exchanges that revise their
  most recent bar.

See [Live cumulation](#live-cumulation--a-tf-aware-dataframe) for the API summary.

## TimeFrame

A `TimeFrame` names a bar interval. It is accepted anywhere volas resamples —
`df.cumulate`, the `time_frame` DataFrame argument, and the `hv` indicator —
either as a `TimeFrame` constant or as its equivalent **string label**. There is no `TimeFrame(...)`
constructor — use one of the constants below or a label string.

```py
TimeFrame.m5            # the 5-minute frame
'5m'                    # the equivalent label string, accepted everywhere too

df.cumulate(TimeFrame.m5)     # identical to df.cumulate('5m')
```

Supported frames (constant ⇄ label):

| Constant | Label | Alignment |
| --- | --- | --- |
| `TimeFrame.s1` | `'1s'` | Civil second. |
| `TimeFrame.m1` | `'1m'` | Civil minute. |
| `TimeFrame.m3` | `'3m'` | Minute-of-hour buckets starting at `00`, `03`, `06`, ... |
| `TimeFrame.m5` | `'5m'` | Minute-of-hour buckets starting at `00`, `05`, `10`, ... |
| `TimeFrame.m15` | `'15m'` | Minute-of-hour buckets starting at `00`, `15`, `30`, `45`. |
| `TimeFrame.m30` | `'30m'` | Minute-of-hour buckets starting at `00` and `30`. |
| `TimeFrame.H1` | `'1h'` | Civil hour. |
| `TimeFrame.H2` | `'2h'` | Hour-of-day buckets starting at `00`, `02`, `04`, ... |
| `TimeFrame.H4` | `'4h'` | Hour-of-day buckets starting at `00`, `04`, `08`, ... |
| `TimeFrame.H6` | `'6h'` | Hour-of-day buckets starting at `00`, `06`, `12`, `18`. |
| `TimeFrame.H8` | `'8h'` | Hour-of-day buckets starting at `00`, `08`, `16`. |
| `TimeFrame.H12` | `'12h'` | Hour-of-day buckets starting at `00` and `12`. |
| `TimeFrame.D1` | `'1d'` | Civil day in the frame timezone. |
| `TimeFrame.D3` | `'3d'` | Continuous 3-day buckets anchored to the Unix epoch; they do not reset at month boundaries. |
| `TimeFrame.W1` | `'1w'` | Continuous Monday-start weeks, including runs that cross month boundaries. |
| `TimeFrame.M1` | `'1M'` | Civil calendar month in the frame timezone. |
| `TimeFrame.Y1` | `'1y'` | Civil calendar year in the frame timezone. |

Every bucket is aligned in the **frame timezone's local wall-clock** while storage
stays UTC: the hour-of-day frames (`2h`/`4h`/`6h`/`8h`/`12h`) start at local `00`
and step in local hours; `3d` counts continuous 3-local-civil-day buckets keyed
from the Unix epoch day in that zone (not reset at month boundaries); `1w` is
Monday-start in local civil time. So a daily/weekly bar follows the local trading
day, and a named zone makes the hour buckets DST-aware.

## Syntax of `directive`

```
command . sub : args @ series  op  command ...
   |      |     |      |
   |      |     |      └── operand column / sub-expression  (e.g. @open, @(boll))
   |      |     └── comma-separated arguments               (e.g. ma:20, kdj.k:9,3)
   |      └── sub-command                                   (e.g. macd.signal)
   └── indicator name                                       (e.g. ma, macd, boll)
```

#### `directive` Example

Here lists several use cases of column names

```py
# The middle band of bollinger bands
#   which is actually a 20-period (default) moving average
df['boll']

# kdj j less than 0
# This returns a series of bool type
df['kdj.j < 0']

# kdj %K cross up kdj %D
df['kdj.k // kdj.d']

# 5-period simple moving average
df['ma:5']

# 10-period simple moving average on (@) open prices
df['ma:10@open']

# A DataFrame of 5-period, 10-period and 30-period ma
df[[
    'ma:5',
    'ma:10',
    'ma:30'
]]

# Which means we use the default values of the first and the second parameters,
# and specify the third parameter (for macd.signal)
df['macd.signal:,,10']

# We must wrap a parameter which is a nested command or directive
df['increase:3@(ma:20@close)']

# volas has a powerful directive parser,
# so we could even write directives like this:
df['''
repeat
    :   5
    @   (
            close > boll.upper
        )
''']
```

#### Operators

```
left operator right
```

- `//` — whether `left` **crosses up** through `right` (from below to above),
  which we call a "gold cross": `df['macd // macd.signal']`.
- `\\` — whether `left` **crosses down** through `right`, a "dead cross". In a
  Python string the backslash must be escaped, so we write `'macd \\ macd.signal'`.
- `><` — whether `left` crosses `right`, either up or down.
- `<` `<=` `==` `!=` `>=` `>` — for the same record, the value comparison between
  `left` and `right`, returning a `bool` series.
- arithmetic `+ - * /`, logical `& | ^`, and unary `~` (not) / `-` (negate).

`df[directive]` **caches** the result as a real column (so repeated reads are
free), then auto-refreshes its stale tail on access after an `append`. Use
`df.exec(directive)` to compute a directive as a NumPy array **without**
caching it (see [Usage](#usage)).

## Indexing & selection

A pandas-compatible subset for label and positional access. The row index may be
a range, a `DatetimeIndex`, an integer index, or a **string index**.

```py
df.iloc[2]          # a Row by position (row.name is its index label)
df.iloc[10:]        # a DataFrame slice by position
df.loc[label]       # a Row by index label
df.loc[lo:hi]       # inclusive label slice (lexicographic for string indexes)
df.at[label, col]   # a scalar by label + column
df.iat[i, j]        # a scalar by position
df.index            # the row labels, as a NumPy array
```

String (symbol) index — `set_index` on a string column, then look up by symbol:

```py
df = DataFrame({'sym': ['aa', 'bb', 'cc'], 'px': [1.0, 2.0, 3.0]}).set_index('sym')
df.loc['bb']           # the row keyed 'bb'
df.loc['aa':'bb']      # inclusive, lexicographic slice
df.at['cc', 'px']      # 3.0
df.drop(['bb'])        # drop by string label
```

### Differences from pandas (vs pandas)

volas is pandas-shaped on the surface, but its **type system is deliberately
different** in more than the index: missing values keep their dtype, there is no
`object` dtype, value-returning methods stay `Series`, and a lossy conversion
raises instead of degrading silently. **See
[volas vs pandas — the type system](PANDAS-DIFFERENCES.md) for the full
comparison — why volas is built this way, where pandas's type system breaks, and
the migration gotchas.**

The index specifically is a **single level** of one homogeneous label type.
Relative to pandas, volas does **not** support:

- **`MultiIndex`** (hierarchical / multi-level indexes), on rows *or* columns —
  columns are a flat list of unique string names.
- **Arbitrary label dtypes** — an index is exactly one of range, datetime
  (`datetime64[ns]`), integer, or string. There is no float, categorical,
  interval, period, timedelta, or mixed-type `object` index.
- **Index algebra** — reindexing, index set operations (union / intersection),
  and automatic alignment-on-index when combining frames.
- **Duplicate-label** lookups (label access assumes unique labels).

If your workflow needs any of these, keep using pandas; volas targets the
single-level, OHLCV-shaped index that candlestick data uses.

## Writing & assignment

Assign a whole column, or write into a positional / label / boolean selection
(copy-on-write under the hood). Series assignment is **positional** (by row order,
not index-aligned).

```py
df['signal'] = 0.0                      # add / replace a column (scalar | array | Series)
df.iat[3, 0] = 99.0                     # one cell by position
df.at[label, 'close'] = 99.0            # one cell by label + column
df.iloc[10:20, 0] = 0.0                 # a column slice
df.loc[df['close'] > df['open'], 'signal'] = 1.0   # masked column assignment
```

Writing a fractional value into an integer column **raises** — the int dtype is
kept, and a lossy write errors rather than silently widening to float (see
[Differences from pandas](PANDAS-DIFFERENCES.md); writing `volas.NA` / `None`
keeps the int dtype and marks the cell missing). Writing into a cached directive
column drops its cached status, so a later `fulfill()` can never silently
overwrite your edit.

## Timezones

Storage is always **UTC epoch-nanoseconds** — the universal axis on which crypto,
US, HK and A-share frames coexist and align on the absolute instant. A
`DatetimeIndex` additionally carries a **per-frame timezone** that governs how
those instants render, how bare-string labels match, and how `cumulate` aligns
day-and-coarser buckets. A timezone is either a **fixed offset** (`'+08:00'`,
cheap; crypto / A-share / HK) or a **named IANA zone** (`'America/New_York'`,
DST-aware via `chrono-tz`; US / EU). The default is UTC.

Here is the whole picture. Build a `DatetimeIndex` by parsing a column with
`to_datetime`, promoting it with `set_index`, then tagging the display zone with
`tz_localize` (reinterpret a naive wall-clock *as* that zone — the instant moves)
or `tz_convert` (keep the instant, restate the zone). A US exchange opens at 09:30
local on 2021-01-04, held as a naive local string:

```py
from volas import DataFrame, to_datetime, Timestamp

# Parse the naive 't' strings to UTC instants and make them the index, then read
# the wall-clock *as New York local time* with tz_localize. The instant is stored
# UTC (14:30Z), but the index renders and matches in New York.
df = DataFrame({'t': ['2021-01-04 09:30:00'], 'close': [100.0]})
df['t'] = to_datetime(df['t'])
df = df.set_index('t').tz_localize('America/New_York')
df.tz       # 'America/New_York'
df.index    # ['2021-01-04T14:30:00.000000000']  (raw .index is UTC, matching pandas .values)

# The tz is what lets a bare local string match the right row — it is parsed in df.tz:
df.at['2021-01-04 09:30:00', 'close']   # 100.0

# A Timestamp is a typed, cross-tz label. The SAME instant in Shanghai is
# 22:30+08:00, and it still matches, regardless of df.tz:
ts = Timestamp('2021-01-04 22:30:00', tz='+08:00')   # == 09:30 New York
df.at[ts, 'close']                       # 100.0
ts.value                                 # its UTC epoch-nanoseconds (int)
ts.tz                                    # '+08:00'

# Integer epochs: to_datetime(unit=...) reads the unit. An epoch is *absolute* —
# anchor it as UTC, then restate the zone for display. 1609770600000 ms == 14:30Z:
e = DataFrame({'t': [1609770600000], 'close': [100.0]})
e['t'] = to_datetime(e['t'], unit='ms')
e.set_index('t').tz_localize('UTC').tz_convert('America/New_York').index
# ['2021-01-04T14:30:00.000000000']

# An offset-aware string is already absolute too — to_datetime resolves the offset:
o = DataFrame({'t': ['2021-01-04T09:30:00+08:00'], 'close': [1.0]})
o['t'] = to_datetime(o['t'])
o.set_index('t').index
# ['2021-01-04T01:30:00.000000000']  (09:30+08:00 == 01:30Z)
```

A frame's time axis is in one of two states (the pandas model): **naive** (an
unanchored wall-clock, `df.tz is None`) or **tz-aware** (anchored, `df.tz` names
the zone — `'UTC'` included). `tz_localize` anchors a naive axis (the instant
moves to match the wall-clock in that zone); `tz_convert` restates an aware
axis in another zone (the instant is unchanged). Each refuses the other state —
converting an unanchored clock or re-anchoring an anchored one would silently
shift instants:

```py
naive = df                                   # df.tz is None
aware = naive.tz_localize('America/New_York')   # anchor: instants move, wall-clock kept
aware.tz_convert('+08:00')                   # restate: instants kept, wall-clock moves
naive.tz_convert('+08:00')                   # TypeError — anchor with tz_localize first
aware.tz_localize('UTC')                     # TypeError — already anchored; use tz_convert
```

`cumulate` to a daily (or coarser) bar aligns buckets to the frame's local
trading day — DST-aware for a named zone — while the raw `.index` numpy export
stays UTC (matching pandas `.values`).

## Missing values (`volas.NA`)

`volas.NA` is the single missing-value marker, and **every dtype supports it** —
crucially, a missing value **never changes the column's dtype**:

| dtype | how missing is stored | element access | console display |
|---|---|---|---|
| `float64` / `float32` | `NaN`, in-band | `np.float64(nan)` | `<NA>` |
| `int64` / `int32` / `bool` / `str` | a validity mask (dtype kept) | `volas.NA` | `<NA>` |
| `datetime64[ns]` | `NaT` | `np.datetime64('NaT')` | `<NA>` |

Whatever the storage, the **console always prints `<NA>`** — one symbol for a
missing value, regardless of dtype (a float `NaN`, a datetime `NaT`, and an int /
bool / str hole all render identically; `to_string(na_rep=...)` overrides it).
Element access and `to_numpy` stay dtype-specific (a float hole reads back as
`np.nan`), so numpy / pandas interop is lossless.

This tracks pandas' own direction ([PDEP-16]) and means volas has **no `object`
dtype**: an `int` / `bool` / `str` column with a hole stays `int` / `bool` /
`str`, where pandas 3.0 upcasts to `float64` / `object`.

```py
import volas
s = volas.DataFrame({'a': [1, None, 3]})['a']
s.dtype                  # 'int64'        (pandas would give float64)
s[1]                     # <NA>           (s[1] is volas.NA; a float hole stays np.nan)
s.sum()                  # np.int64(4)    reductions skip NA
s.fillna(0).to_list()    # [1, 0, 3]
s.isna().to_numpy()      # [False, True, False]
print(s)                 # the missing cell prints as <NA>

# shift / diff keep the int dtype (pandas upcasts to float); the gap is NA:
volas.DataFrame({'a': [10, 20, 30]})['a'].shift(1).to_list()   # [<NA>, 10, 20]
```

- **Producing NA** — `None` (or `volas.NA`) in a constructor list, the `shift` /
  `diff` gap, and the default fill of `where` / `mask`.
- **Consuming NA** — reductions (`sum` / `mean` / `min` / …) and `count` skip it;
  arithmetic propagates it (`x ∘ NA = NA`); `~` / `&` / `|` / `^` use Kleene
  three-valued logic (`NA & False = False`, `NA | True = True`); `cumsum` / `abs`
  / `round` / `clip` / indexing carry it through; `isna` / `notna` / `dropna` /
  `fillna` / `ffill` / `bfill` work on every dtype.
- **Comparisons** treat a missing value IEEE / numpy style: `==`, `<`, `<=`,
  `>`, `>=` involving NA compare `False`, while `!=` compares `True` — so a
  boolean mask is always pure `bool`, clean for `df[mask]`. Note the `!=`
  exception: `s != value` therefore *includes* missing rows.

[PDEP-16]: https://github.com/pandas-dev/pandas/pull/58988

## pandas interop

pandas is **not** a runtime dependency; these bridges import it lazily, only when
called, so `import volas` stays pandas-free.

```py
from volas import DataFrame

df = DataFrame.from_pandas(pandas_df)  # numeric / bool / str / datetime native; a (tz-aware) DatetimeIndex round-trips;
                                   # a nullable Int64 / boolean / string column reads back as int / bool / str + volas.NA
pdf = df.to_pandas()               # -> pandas.DataFrame ('numpy' backend: an int/bool column with NA becomes float64 + NaN)
pdf = df.to_pandas(dtype_backend='numpy_nullable')  # faithful masked Int64 / boolean (a lossless NA round-trip)
df.to_csv('out.csv', index=True)   # subset of pandas to_csv; returns a str if path=None
```

`to_pandas`'s **dtype_backend** (`'numpy'` vs the lossless `'numpy_nullable'`) governs
how a missing value crosses into pandas — see
[`df.to_pandas`](#dfto_pandasdtype_backendnumpy---pandasdataframe) for the per-value
breakdown.

## Arrow & DLPack interop (zero-copy)

A volas column owns one contiguous buffer per dtype (Arrow-native string layout
included), so it crosses to Arrow and DLPack consumers **without a copy** — the
consumer borrows the same bytes, kept alive by volas.

```py
import pyarrow as pa, numpy as np

# Arrow C-Data / C-Stream — pyarrow, polars, … read volas directly via the
# standard PyCapsule protocols (__arrow_c_array__ / __arrow_c_stream__).
pa.array(df['close'])              # Series  -> pyarrow.Array  (shares the buffer)
pa.table(df)                       # DataFrame -> pyarrow.Table (one RecordBatch)
df['close'].to_arrow()             # convenience for pa.array(...)

Series.from_arrow(pa_array, name='close')   # Arrow array  -> Series (borrowed where dtypes match)
DataFrame.from_arrow(pa_table)              # Arrow table  -> DataFrame

# DLPack — NumPy / PyTorch / JAX borrow a dense numeric (or bool) column.
np.from_dlpack(df['close'])        # zero-copy ndarray view
```

The high-level entry points — [`df.to_arrow`](#dfto_arrow---pyarrowtable) /
[`DataFrame.from_arrow`](#dataframefrom_arrowdata---dataframe),
[`series.to_arrow`](#seriesto_arrow---pyarrowarray) /
[`Series.from_arrow`](#seriesfrom_arrowdata-namenone---series) — are documented under
Usage. They sit on these **standard protocol methods**, which Arrow / array consumers
call automatically (so you rarely call them yourself):

- **`Series.__arrow_c_array__`** — the Arrow C-Data array protocol; returns the
  `(schema, array)` PyCapsule pair, so `pa.array(s)` / `pl.Series(s)` read a column.
- **`Series.__arrow_c_schema__`** — the schema-only half (the column's Arrow dtype).
- **`DataFrame.__arrow_c_stream__`** — the Arrow C-Stream protocol; the frame as one
  `RecordBatch`, so `pa.table(df)` / `pl.from_dataframe(df)` read a frame.
- **`Series.__dlpack__` / `Series.__dlpack_device__`** — the DLPack protocol, so
  `np.from_dlpack(s)` / `torch.from_dlpack(s)` borrow a dense numeric / bool column. The
  borrowed view is **read-only** (a versioned-DLPack flag) so writing through it can't
  bypass copy-on-write and corrupt the frame; a pre-1.0 consumer that cannot receive the
  flag is given an independent copy instead. `np.from_dlpack(s, copy=True)` returns an
  independent **writable** copy. A non-CPU device or a stream is refused (`BufferError`);
  an int/bool column with a missing value (DLPack has no null mask) or a str/datetime
  column raises.

**Zero-copy contract.** The *data* buffer is shared (no copy) in both directions for
volas's native numeric (`int32` / `int64` / `float32` / `float64`), string, and
nanosecond-datetime columns. The small repacks are:

- **`bool`** — volas stores one byte per value, Arrow one *bit*;
- the **null bitmap** (≤ `n/8` bytes) — including, on export, a one-pass scan that turns
  a missing `float` (in-band `NaN`) into a real Arrow null;
- on **import only**: a 32-bit-offset `Utf8` (widened to 64-bit offsets) or a
  `string_view` (materialised to contiguous bytes), a coarser-than-ns timestamp
  (rescaled), a `Decimal128` / `Decimal256` (→ `f64`, **lossy** past ~15 digits — keep
  prices as strings for exactness), a narrow / unsigned integer (→ `i64`; a `UInt64` past
  `i64::MAX` has no lossless image and raises), a dictionary / categorical column (decoded
  to its values), a typeless `Null` column (→ an all-NA `f64` column), or a
  `Date32` / `Date64` (→ ns datetime). Other Arrow types raise a clear error.

**NA at the boundary.** Every missing value — int, bool, **and float** — crosses as a
real Arrow null (a missing float is the in-band `NaN`, scanned into the null bitmap on
export), so a downstream `is_null` / `null_count` sees them all consistently. DLPack and
an integer `to_numpy` have no null channel, so they raise on a missing value — pass
`na_value=`, or use the Arrow path, which carries the null bitmap losslessly:

```py
df['qty'].to_numpy(dtype='int64')                # raises if any value is NA (pandas-aligned)
df['qty'].to_numpy(dtype='int64', na_value=0)    # or fill the holes with na_value
pa.array(df['qty'])                              # lossless: keeps int64 + the null bitmap
```

**Timezone.** Arrow tables have no *index*, so the frame's index — and its timezone — is
not carried: a datetime column crosses as naive UTC nanoseconds (the absolute instant is
exact; only the display-zone label is dropped), and `from_arrow` returns a fresh
`RangeIndex`. Re-apply a zone after import with `tz_localize` / `tz_convert`.

## Error handling

Directive problems raise typed exceptions. Both subclass `DirectiveError` and the
built-in `ValueError`, so existing `except ValueError` handling keeps working.

```py
from volas import DirectiveSyntaxError, DirectiveValueError

try:
    df['ma:2,3']                 # too many arguments
except DirectiveValueError as e:
    ...                          # unknown command/sub-command, bad arg, bad value

try:
    df['a >']                    # malformed expression
except DirectiveSyntaxError as e:
    ...                          # message carries the line / column of the error
```

## Built-in Indicators

The complete directive reference lives in [INDICATORS.md](INDICATORS.md). It
covers Volas-exclusive indicators, built-in statistical commands, and
TA-Lib-compatible directives.

# Contributing & feedback

Issues, indicator requests, benchmark challenges, and PRs are welcome — see
[CONTRIBUTING.md](CONTRIBUTING.md) and start a thread in
[Discussions](https://github.com/kaelzhang/volas/discussions). The most useful
feedback is on the API surface and the [benchmark methodology](docs/benchmark-faq.md).

If you build live OHLCV / technical-indicator pipelines in Python, star the repo
to follow new indicators, benchmark results, and releases.

# License

[MIT](LICENSE)

# For Developers

Developer notes, local build commands, dependency groups, and benchmark report
guidance live in [DEVELOPMENT.md](DEVELOPMENT.md).

