Skip to content

[FEATURE REQUEST] Python Bindings #138

@Verdenroz

Description

@Verdenroz

Summary

Add a finance-query-python crate that compiles the Rust library to a native Python extension via PyO3 and maturin, published to PyPI as finance-query-py. This replaces the legacy v1/ Python implementation with zero-overhead Rust performance, full async support through a bundled tokio runtime, and first-class pandas/numpy integration.

Problem Statement

The v1/ directory contains a legacy Python implementation that duplicates logic now maintained in Rust. Python is the dominant language in quantitative finance and data science — yfinance has 15 M+ monthly downloads. The Rust library has a richer API (backtesting, 42 indicators, streaming, risk analytics, EDGAR, FRED, options, screeners) than any comparable Python package, but it is inaccessible to Python users. PyO3 lets us expose the Rust library directly with no reimplementation cost, no data copies across a process boundary, and the full performance of the native library.

Proposed Solution

New crate: finance-query-python

finance-query-python/
├── Cargo.toml
├── pyproject.toml          # maturin build backend
├── src/
│   ├── lib.rs              # #[pymodule] root
│   ├── ticker.rs           # PyTicker wrapper
│   ├── tickers.rs          # PyTickers wrapper
│   ├── finance.rs          # top-level functions (search, screener, etc.)
│   ├── models/             # #[pyclass] wrappers for response types
│   │   ├── quote.rs
│   │   ├── chart.rs
│   │   ├── options.rs
│   │   └── ...
│   └── constants.rs        # Interval, TimeRange, Frequency, etc. as Python enums
├── finance_query/
│   ├── __init__.py         # re-exports + runtime bootstrap
│   ├── py.typed            # PEP 561 marker
│   └── _finance_query.pyi  # type stubs (generated + hand-polished)
└── tests/
    └── test_ticker.py

Cargo.toml excerpt:

[package]
name = "finance-query-python"
version = "2.5.1"

[lib]
name = "_finance_query"
crate-type = ["cdylib"]

[dependencies]
finance-query = { path = "..", features = ["indicators", "risk", "fred", "crypto", "rss", "backtesting"] }
pyo3 = { version = "0.23", features = ["extension-module", "abi3-py39"] }
pyo3-async-runtimes = { version = "0.23", features = ["tokio-runtime"] }
tokio = { version = "1", features = ["full"] }

Async bridging strategy

Python callers use await natively. Every Rust async fn is exposed as a Python coroutine via pyo3_async_runtimes::tokio::future_into_py. A single shared tokio runtime is initialised once on module import and reused for the lifetime of the process — no asyncio.run() overhead per call.

# Works with asyncio, trio (via anyio), and Jupyter
import asyncio
from finance_query import Ticker

async def main():
    ticker = await Ticker.new("AAPL")
    quote = await ticker.quote()
    print(quote.current_price)

asyncio.run(main())

PyTicker wrapper

#[pyclass(name = "Ticker")]
pub struct PyTicker {
    inner: finance_query::Ticker,
}

#[pymethods]
impl PyTicker {
    #[staticmethod]
    fn new(py: Python<'_>, symbol: &str) -> PyResult<Bound<'_, PyAny>> {
        let symbol = symbol.to_owned();
        pyo3_async_runtimes::tokio::future_into_py(py, async move {
            let ticker = finance_query::Ticker::new(&symbol).await.map_err(into_py_err)?;
            Ok(PyTicker { inner: ticker })
        })
    }

    fn quote<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
        let inner = self.inner.clone();
        pyo3_async_runtimes::tokio::future_into_py(py, async move {
            let quote = inner.quote().await.map_err(into_py_err)?;
            Ok(PyQuote::from(quote))
        })
    }

    fn chart<'py>(&self, py: Python<'py>, interval: PyInterval, range: PyTimeRange)
        -> PyResult<Bound<'py, PyAny>> { ... }

    // Builder pattern exposed as class methods
    #[staticmethod]
    fn builder(symbol: &str) -> PyTickerBuilder { ... }
}

Model types

All #[pyclass] model types:

  • Expose fields as #[getter] properties — no dict copies
  • Implement __repr__ and __str__ via the Rust Debug/Display impls
  • Implement __eq__ where the Rust type is PartialEq
  • Provide to_dict()PyDict for interop with pandas/json/dataclasses
  • Vec<T> fields become Python list; Option<T> becomes T | None
  • HashMap<K, V> fields become dict

Pandas integration

When pandas is installed, model types with list-of-numeric data get a to_dataframe() method:

chart = await ticker.chart(Interval.ONE_DAY, TimeRange.ONE_YEAR)
df = chart.to_dataframe()
# Returns pd.DataFrame with columns: open, high, low, close, volume, date
# Uses zero-copy Arrow IPC via arro3/polars → pandas when the dataframe feature is enabled
# Falls back to dict-of-lists construction otherwise

The Candle, OptionContract, FinancialStatement and Spark types all gain to_dataframe().

Constants as Python enums

from finance_query import Interval, TimeRange, Frequency, StatementType, Region

chart = await ticker.chart(Interval.ONE_DAY, TimeRange.ONE_YEAR)
stmt = await ticker.financials(StatementType.INCOME, Frequency.QUARTERLY)

Rust enums map to Python enum.Enum subclasses via pyo3's #[pyclass] on unit variants.

Top-level finance functions

from finance_query import finance

results = await finance.search("Apple")
trending = await finance.trending(Region.US)
screener = await finance.screener(Screener.DAY_GAINERS, count=25)
fng = await finance.fear_and_greed()

Streaming

from finance_query.streaming import PriceStream

async with PriceStream.subscribe(["AAPL", "NVDA"]) as stream:
    async for update in stream:
        print(update.symbol, update.price)

Exposed via pyo3_async_runtimes::tokio and Python's AsyncIterator protocol.

Feature flag mapping

Python extra Rust features Unlocks
finance-query-py (base) yahoo Ticker, Tickers, finance module, EDGAR, streaming
finance-query-py[indicators] indicators 42 technical indicators
finance-query-py[backtesting] backtesting Strategy backtesting engine
finance-query-py[risk] risk VaR, Sharpe/Sortino/Calmar, beta, drawdown
finance-query-py[fred] fred FRED macro series + Treasury yields
finance-query-py[crypto] crypto CoinGecko market data
finance-query-py[rss] rss RSS/Atom feed aggregation
finance-query-py[all] all above Everything

Pre-built wheels on PyPI eliminate the need for users to have a Rust toolchain.

Type stubs

A _finance_query.pyi stub file is checked into the repo and updated with each release. Combined with py.typed, this gives IDEs (VS Code, PyCharm) full autocomplete and type checking via mypy/pyright.

class Ticker:
    @staticmethod
    async def new(symbol: str) -> Ticker: ...
    async def quote(self) -> Quote: ...
    async def chart(self, interval: Interval, range: TimeRange) -> Chart: ...
    async def financials(
        self, statement: StatementType, frequency: Frequency
    ) -> FinancialStatement: ...
    async def options(self, date: str | None = None) -> Options: ...
    async def news(self) -> list[News]: ...
    async def recommendations(self, limit: int = 5) -> Recommendation: ...
    # ... all other Ticker methods

class Chart:
    candles: list[Candle]
    meta: ChartMeta
    def to_dataframe(self) -> pd.DataFrame: ...

Build and distribution

# finance-query-python/pyproject.toml
[build-system]
requires = ["maturin>=1.7"]
build-backend = "maturin"

[project]
name = "finance-query-py"
requires-python = ">=3.9"
dependencies = []

[project.optional-dependencies]
pandas = ["pandas>=2.0"]
all = ["pandas>=2.0"]

[tool.maturin]
features = ["pyo3/extension-module"]
python-source = "finance_query"
module-name = "finance_query._finance_query"

CI builds wheels for:

  • manylinux_2_28 x86_64, aarch64 (via maturin-action)
  • macOS 12+ x86_64 and arm64
  • Windows x86_64
  • CPython 3.9, 3.10, 3.11, 3.12, 3.13 (abi3 wheels cover all)

Usage examples

Quant workflow:

import asyncio
import pandas as pd
from finance_query import Ticker, Interval, TimeRange, Frequency, StatementType

async def analyse(symbol: str):
    ticker = await Ticker.new(symbol)
    
    # All quote modules in one request, cached
    quote = await ticker.quote()
    print(f"{quote.display_name}: ${quote.current_price}")
    
    # Chart → pandas in two lines
    chart = await ticker.chart(Interval.ONE_DAY, TimeRange.ONE_YEAR)
    df = chart.to_dataframe()
    df["sma50"] = df["close"].rolling(50).mean()
    
    # Financials
    income = await ticker.financials(StatementType.INCOME, Frequency.QUARTERLY)
    
    return df

df = asyncio.run(analyse("AAPL"))

Batch processing:

from finance_query import Tickers, Interval, TimeRange

async def batch():
    tickers = await Tickers.new(["AAPL", "MSFT", "NVDA", "TSLA"])
    quotes = await tickers.quotes()
    # quotes.data: dict[str, Quote], quotes.errors: dict[str, str]
    
    charts = await tickers.charts(Interval.ONE_DAY, TimeRange.SIX_MONTHS)
    dfs = {sym: r.to_dataframe() for sym, r in charts.data.items()}

Backtesting (with [backtesting] extra):

from finance_query import Ticker, Interval, TimeRange
from finance_query.backtesting import SmaCrossover, BacktestConfig

async def backtest():
    ticker = await Ticker.new("AAPL")
    strategy = SmaCrossover(fast=20, slow=50)
    config = BacktestConfig(initial_capital=10_000, commission=0.001)
    result = await ticker.backtest(strategy, Interval.ONE_DAY, TimeRange.FIVE_YEARS, config)
    print(f"Total return: {result.total_return:.1%}, Sharpe: {result.sharpe_ratio:.2f}")

Use Cases

  • Quant researchers and data scientists who live in Jupyter notebooks but want Rust-speed data fetching
  • yfinance users seeking a drop-in that won't break when Yahoo Finance changes its scraping surface
  • ML pipelines that need reliable OHLCV + fundamentals + news + options data in pandas form
  • Backtesting workflows that currently call the Rust CLI or HTTP server from Python scripts

Alternatives Considered

Approach Why rejected
Maintain v1/ Python rewrite Already out of date, duplicates all Rust logic, no indicators/backtesting/EDGAR
HTTP server + requests wrapper Extra process, latency, no type safety, requires server deployment
subprocess calling the fq CLI Shell overhead, no streaming, painful for batch use
cffi / raw C bindings Far more boilerplate than PyO3, no async bridging story

Implementation Phases

Phase 1 — Core (MVP for PyPI alpha):

  • Ticker + Tickers with all quote/chart/news/options/financials methods
  • finance.* top-level functions
  • All constants as Python enums
  • Chart.to_dataframe() + Quote.to_dict()
  • Type stubs + py.typed
  • manylinux + macOS + Windows wheels in CI

Phase 2 — Feature extras:

  • indicators, risk, fred, crypto, rss, backtesting extras
  • to_dataframe() on OptionContract, FinancialStatement, Spark
  • Streaming PriceStream async iterator

Phase 3 — Ecosystem integration:

  • Zero-copy Arrow/polars → pandas via arro3 when dataframe feature is enabled
  • __dataframe__ protocol (PEP 680) for framework-agnostic DataFrames
  • Jupyter display hooks (_repr_html_) on model types
  • Async context manager for Ticker to scope the tokio runtime lifetime

Additional Context

  • v1/ can be archived (moved to v1/legacy/ or removed) once the PyO3 bindings reach feature parity
  • PyPI package name finance-query-py is distinct from finance_query (the Rust crate's Python module name) to avoid collision
  • maturin supports workspace Cargo.toml; the new crate slots cleanly into the existing workspace resolver = "2" setup
  • abi3-py39 produces a single wheel per platform that works on Python 3.9 through 3.13+ without rebuilding

Priority

  • Low
  • Medium
  • High
  • Critical

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions