diff --git a/.github/workflows/workflow.yaml b/.github/workflows/workflow.yaml index d8257b6..befa031 100644 --- a/.github/workflows/workflow.yaml +++ b/.github/workflows/workflow.yaml @@ -29,7 +29,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install dependencies - run: uv sync --group test + run: uv sync --group test --all-extras - name: Run test suite run: uv run pytest --cov=order_matching diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..00b3b95 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,11 @@ +- Use uv to run python commands, e.g. + + ```shell + uv run pytest + ``` + +- Run prek on all files before each commit: + + ```shell + uv run prek run -v --show-diff-on-failure --all-files + ``` diff --git a/README.md b/README.md index fc6fff5..9b79dd0 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,16 @@ This package is a simple order book matching engine implementation in Python. It - price-time priority - limit and market orders - order cancellation and expiration -- conversion into polars LazyFrame of orders, executed trades, order book summary +- conversion into polars LazyFrame of orders, executed trades, order book summary (optional polars dependency) ## Install ```shell +# Core matching engine only pip install order-matching + +# With polars export support (recommended for data science workflows) +pip install order-matching[polars] ``` ## Documentation @@ -55,6 +59,23 @@ pip install order-matching timestamp=datetime.datetime(2023, 1, 2, 0, 0))] +``` + +### Data Export (Polars) + +If you installed with `[polars]` extra, you can export data to polars LazyFrame: + +```python +>>> from order_matching.orders import Orders +>>> from order_matching.executed_trades import ExecutedTrades +>>> from order_matching.exporters.polars import PolarsExporter + +>>> exporter = PolarsExporter() +>>> orders_df = exporter.export_orders(Orders()) +>>> trades_df = exporter.export_trades(ExecutedTrades()) + +>>> trades_df = ExecutedTrades().to_frame() + ``` ## Related Projects diff --git a/pyproject.toml b/pyproject.toml index 331d54b..8805d95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,9 +20,16 @@ requires-python = ">=3.10" dependencies = [ "faker>=33.0.0", "numpy>=2.2.6", +] + +[project.optional-dependencies] +polars = [ "pandera[polars]>=0.21.0", "polars>=1.42.1", ] +all = [ + "order-matching[polars]", +] [dependency-groups] test = [ diff --git a/src/order_matching/executed_trades.py b/src/order_matching/executed_trades.py index e84f9bc..1695a3c 100644 --- a/src/order_matching/executed_trades.py +++ b/src/order_matching/executed_trades.py @@ -1,14 +1,14 @@ from __future__ import annotations from collections import defaultdict -from dataclasses import asdict from datetime import datetime -from typing import cast +from typing import TYPE_CHECKING -import polars as pl -from pandera.typing.polars import LazyFrame +if TYPE_CHECKING: + from pandera.typing.polars import LazyFrame + + from order_matching.schemas import TradeDataSchema -from order_matching.schemas import TradeDataSchema from order_matching.trade import Trade @@ -60,20 +60,18 @@ def get(self, timestamp: datetime) -> list[Trade]: def to_frame(self) -> LazyFrame[TradeDataSchema]: """Get polars DataFrame of all stored trades. + .. deprecated:: 0.5.0 + Use ``PolarsExporter().export_trades(trades)`` instead. + This method will be removed in version 1.0.0. + Returns ------- - DataFrame[TradeDataSchema] - polars DataFrame of all stored trades + LazyFrame[TradeDataSchema] + polars LazyFrame of all stored trades """ - trades = self.trades - if len(trades) == 0: - return cast(LazyFrame[TradeDataSchema], TradeDataSchema.empty().lazy()) - else: - data = [asdict(trade) for trade in trades] - for d in data: - d[TradeDataSchema.side] = d[TradeDataSchema.side].name - d[TradeDataSchema.execution] = d[TradeDataSchema.execution].name - return cast(LazyFrame[TradeDataSchema], pl.LazyFrame(data)) + from order_matching.exporters.polars import PolarsExporter + + return PolarsExporter().export_trades(self) def __add__(self, other: ExecutedTrades) -> ExecutedTrades: trades = ExecutedTrades() diff --git a/src/order_matching/exporters/__init__.py b/src/order_matching/exporters/__init__.py new file mode 100644 index 0000000..d6e74d4 --- /dev/null +++ b/src/order_matching/exporters/__init__.py @@ -0,0 +1,6 @@ +"""Data export module for converting domain objects to various formats.""" + +from order_matching.exporters.base import Exporter +from order_matching.exporters.polars import PolarsExporter + +__all__ = ["Exporter", "PolarsExporter"] diff --git a/src/order_matching/exporters/base.py b/src/order_matching/exporters/base.py new file mode 100644 index 0000000..eff97e8 --- /dev/null +++ b/src/order_matching/exporters/base.py @@ -0,0 +1,48 @@ +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Generic, TypeVar + +if TYPE_CHECKING: + from order_matching.executed_trades import ExecutedTrades + from order_matching.orders import Orders + +T = TypeVar("T") + + +class Exporter(ABC, Generic[T]): + """Abstract base class for data exporters. + + Type parameter T represents the output format type + (e.g., LazyFrame, dict, str for JSON/CSV). + """ + + @abstractmethod + def export_orders(self, orders: Orders) -> T: + """Convert Orders collection to target format. + + Parameters + ---------- + orders : Orders + Orders collection to export + + Returns + ------- + T + Exported data in target format + """ + pass + + @abstractmethod + def export_trades(self, trades: ExecutedTrades) -> T: + """Convert ExecutedTrades collection to target format. + + Parameters + ---------- + trades : ExecutedTrades + Trades collection to export + + Returns + ------- + T + Exported data in target format + """ + pass diff --git a/src/order_matching/exporters/polars.py b/src/order_matching/exporters/polars.py new file mode 100644 index 0000000..2c765db --- /dev/null +++ b/src/order_matching/exporters/polars.py @@ -0,0 +1,60 @@ +from dataclasses import asdict +from typing import cast + +import polars as pl +from pandera.typing.polars import LazyFrame + +from order_matching.executed_trades import ExecutedTrades +from order_matching.exporters.base import Exporter +from order_matching.orders import Orders +from order_matching.schemas import OrderDataSchema, TradeDataSchema + + +class PolarsExporter(Exporter[LazyFrame]): + """Export collections to polars LazyFrame format.""" + + def export_orders(self, orders: Orders) -> LazyFrame[OrderDataSchema]: + """Export Orders to validated polars LazyFrame. + + Parameters + ---------- + orders : Orders + Orders collection to export + + Returns + ------- + LazyFrame[OrderDataSchema] + Validated polars LazyFrame with order data + """ + if len(orders) == 0: + return cast(LazyFrame[OrderDataSchema], OrderDataSchema.empty().lazy()) + + data = [asdict(order) for order in orders] + for d in data: + d[OrderDataSchema.side] = d[OrderDataSchema.side].name + d[OrderDataSchema.execution] = d[OrderDataSchema.execution].name + d[OrderDataSchema.status] = d[OrderDataSchema.status].name + return cast(LazyFrame[OrderDataSchema], pl.LazyFrame(data)) + + def export_trades(self, trades: ExecutedTrades) -> LazyFrame[TradeDataSchema]: + """Export ExecutedTrades to validated polars LazyFrame. + + Parameters + ---------- + trades : ExecutedTrades + Trades collection to export + + Returns + ------- + LazyFrame[TradeDataSchema] + Validated polars LazyFrame with trade data + """ + trade_list = trades.trades + if len(trade_list) == 0: + return cast(LazyFrame[TradeDataSchema], TradeDataSchema.empty().lazy()) + + data = [asdict(trade) for trade in trade_list] + for d in data: + d[TradeDataSchema.side] = d[TradeDataSchema.side].name + d[TradeDataSchema.execution] = d[TradeDataSchema.execution].name + return cast(LazyFrame[TradeDataSchema], pl.LazyFrame(data)) diff --git a/src/order_matching/order_book.py b/src/order_matching/order_book.py index d21585b..4606e79 100644 --- a/src/order_matching/order_book.py +++ b/src/order_matching/order_book.py @@ -236,7 +236,7 @@ def _get_non_trivial_imbalance(self, price_range: float) -> float: if buy_volume + sell_volume > 0: return (buy_volume - sell_volume) / (buy_volume + sell_volume) else: - return 0 + return 0.0 def _get_same_side_orders(self, incoming_order: Order) -> OrderBookOrdersType: match incoming_order.side: diff --git a/src/order_matching/orders.py b/src/order_matching/orders.py index 89054dc..99915b6 100644 --- a/src/order_matching/orders.py +++ b/src/order_matching/orders.py @@ -1,13 +1,13 @@ from __future__ import annotations -from dataclasses import asdict -from typing import Iterator, Sequence, cast +from typing import TYPE_CHECKING, Iterator, Sequence -import polars as pl -from pandera.typing.polars import LazyFrame +if TYPE_CHECKING: + from pandera.typing.polars import LazyFrame + + from order_matching.schemas import OrderDataSchema from order_matching.order import Order -from order_matching.schemas import OrderDataSchema class Orders: @@ -58,19 +58,17 @@ def remove(self, orders: list[Order]) -> None: def to_frame(self) -> LazyFrame[OrderDataSchema]: """Get polars LazyFrame with all orders in the storage. + .. deprecated:: 0.5.0 + Use ``PolarsExporter().export_orders(orders)`` instead. + This method will be removed in version 1.0.0. + Returns ------- LazyFrame[OrderDataSchema] """ - if len(self.orders) == 0: - return cast(LazyFrame[OrderDataSchema], OrderDataSchema.empty().lazy()) - else: - data = [asdict(order) for order in self.orders] - for d in data: - d[OrderDataSchema.side] = d[OrderDataSchema.side].name - d[OrderDataSchema.execution] = d[OrderDataSchema.execution].name - d[OrderDataSchema.status] = d[OrderDataSchema.status].name - return cast(LazyFrame[OrderDataSchema], pl.LazyFrame(data)) + from order_matching.exporters.polars import PolarsExporter + + return PolarsExporter().export_orders(self) @property def is_empty(self) -> bool: diff --git a/superpowers/plans/2026-07-06-exporter-architecture.md b/superpowers/plans/2026-07-06-exporter-architecture.md new file mode 100644 index 0000000..992d095 --- /dev/null +++ b/superpowers/plans/2026-07-06-exporter-architecture.md @@ -0,0 +1,768 @@ +# Exporter Architecture Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Refactor data export functionality from domain classes into a dedicated exporter module with abstract interface and polars implementation. + +**Architecture:** Create `exporters/` package with abstract `Exporter[T]` base class and concrete `PolarsExporter` implementation. Modify `Orders` and `ExecutedTrades` to delegate `to_frame()` calls to exporters. Make polars an optional dependency. + +**Tech Stack:** Python 3.10+, polars, pandera, ABC/Generic typing + +## Global Constraints + +- Python >=3.10 +- Maintain backward compatibility with existing `to_frame()` API +- All existing tests must pass without modification +- Follow TDD: write tests before implementation +- Use `TYPE_CHECKING` to avoid runtime imports of optional dependencies +- Commit after each passing test + +--- + +## File Structure + +**New Files:** +- `src/order_matching/exporters/__init__.py` - Public exports (Exporter, PolarsExporter) +- `src/order_matching/exporters/base.py` - Abstract Exporter[T] base class +- `src/order_matching/exporters/polars.py` - PolarsExporter implementation +- `tests/test_exporters/__init__.py` - Empty test package marker +- `tests/test_exporters/test_polars_exporter.py` - PolarsExporter test suite + +**Modified Files:** +- `src/order_matching/orders.py` - Delegate to_frame() to PolarsExporter +- `src/order_matching/executed_trades.py` - Delegate to_frame() to PolarsExporter +- `pyproject.toml` - Make polars/pandera optional dependencies +- `README.md` - Update examples to show new API + +--- + +### Task 1: Create Abstract Exporter Base Class + +**Files:** +- Create: `src/order_matching/exporters/__init__.py` +- Create: `src/order_matching/exporters/base.py` + +**Interfaces:** +- Consumes: Nothing (foundational task) +- Produces: `Exporter[T]` abstract base class with methods: + - `export_orders(self, orders: Orders) -> T` + - `export_trades(self, trades: ExecutedTrades) -> T` + +- [ ] **Step 1: Create exporters package structure** + +```bash +mkdir -p src/order_matching/exporters +touch src/order_matching/exporters/__init__.py +``` + +- [ ] **Step 2: Write base.py with abstract Exporter class** + +```python +# src/order_matching/exporters/base.py +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Generic, TypeVar + +if TYPE_CHECKING: + from order_matching.orders import Orders + from order_matching.executed_trades import ExecutedTrades + +T = TypeVar('T') + + +class Exporter(ABC, Generic[T]): + """Abstract base class for data exporters. + + Type parameter T represents the output format type + (e.g., LazyFrame, dict, str for JSON/CSV). + """ + + @abstractmethod + def export_orders(self, orders: Orders) -> T: + """Convert Orders collection to target format. + + Parameters + ---------- + orders : Orders + Orders collection to export + + Returns + ------- + T + Exported data in target format + """ + pass + + @abstractmethod + def export_trades(self, trades: ExecutedTrades) -> T: + """Convert ExecutedTrades collection to target format. + + Parameters + ---------- + trades : ExecutedTrades + Trades collection to export + + Returns + ------- + T + Exported data in target format + """ + pass +``` + +- [ ] **Step 3: Export Exporter from __init__.py** + +```python +# src/order_matching/exporters/__init__.py +"""Data export module for converting domain objects to various formats.""" + +from order_matching.exporters.base import Exporter + +__all__ = ["Exporter"] +``` + +- [ ] **Step 4: Verify imports work** + +Run: `python -c "from order_matching.exporters import Exporter; print(Exporter)"` +Expected: `` + +- [ ] **Step 5: Commit base exporter** + +```bash +git add src/order_matching/exporters/ +git commit -m "feat: add abstract Exporter base class + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" +``` + +--- + +### Task 2: Implement PolarsExporter with Tests + +**Files:** +- Create: `src/order_matching/exporters/polars.py` +- Create: `tests/test_exporters/__init__.py` +- Create: `tests/test_exporters/test_polars_exporter.py` + +**Interfaces:** +- Consumes: `Exporter[T]` from Task 1 +- Produces: `PolarsExporter(Exporter[LazyFrame])` with methods: + - `export_orders(self, orders: Orders) -> LazyFrame[OrderDataSchema]` + - `export_trades(self, trades: ExecutedTrades) -> LazyFrame[TradeDataSchema]` + +- [ ] **Step 1: Create test package structure** + +```bash +mkdir -p tests/test_exporters +touch tests/test_exporters/__init__.py +``` + +- [ ] **Step 2: Write failing test for empty orders export** + +```python +# tests/test_exporters/test_polars_exporter.py +from __future__ import annotations + +import pytest + +from order_matching.exporters.polars import PolarsExporter +from order_matching.orders import Orders +from order_matching.schemas import OrderDataSchema + + +class TestPolarsExporter: + def test_export_empty_orders(self) -> None: + """Test exporting empty Orders collection returns empty schema.""" + exporter = PolarsExporter() + orders = Orders() + result = exporter.export_orders(orders) + assert result.collect().equals(OrderDataSchema.empty()) +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `pytest tests/test_exporters/test_polars_exporter.py::TestPolarsExporter::test_export_empty_orders -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'order_matching.exporters.polars'` + +- [ ] **Step 4: Implement PolarsExporter.export_orders for empty case** + +```python +# src/order_matching/exporters/polars.py +from __future__ import annotations + +from dataclasses import asdict +from typing import cast + +import polars as pl +from pandera.typing.polars import LazyFrame + +from order_matching.executed_trades import ExecutedTrades +from order_matching.exporters.base import Exporter +from order_matching.orders import Orders +from order_matching.schemas import OrderDataSchema, TradeDataSchema + + +class PolarsExporter(Exporter[LazyFrame]): + """Export collections to polars LazyFrame format.""" + + def export_orders(self, orders: Orders) -> LazyFrame[OrderDataSchema]: + """Export Orders to validated polars LazyFrame. + + Parameters + ---------- + orders : Orders + Orders collection to export + + Returns + ------- + LazyFrame[OrderDataSchema] + Validated polars LazyFrame with order data + """ + if len(orders) == 0: + return cast(LazyFrame[OrderDataSchema], OrderDataSchema.empty().lazy()) + + data = [asdict(order) for order in orders] + for d in data: + d[OrderDataSchema.side] = d[OrderDataSchema.side].name + d[OrderDataSchema.execution] = d[OrderDataSchema.execution].name + d[OrderDataSchema.status] = d[OrderDataSchema.status].name + return cast(LazyFrame[OrderDataSchema], pl.LazyFrame(data)) + + def export_trades(self, trades: ExecutedTrades) -> LazyFrame[TradeDataSchema]: + """Export ExecutedTrades to validated polars LazyFrame. + + Parameters + ---------- + trades : ExecutedTrades + Trades collection to export + + Returns + ------- + LazyFrame[TradeDataSchema] + Validated polars LazyFrame with trade data + """ + trade_list = trades.trades + if len(trade_list) == 0: + return cast(LazyFrame[TradeDataSchema], TradeDataSchema.empty().lazy()) + + data = [asdict(trade) for trade in trade_list] + for d in data: + d[TradeDataSchema.side] = d[TradeDataSchema.side].name + d[TradeDataSchema.execution] = d[TradeDataSchema.execution].name + return cast(LazyFrame[TradeDataSchema], pl.LazyFrame(data)) +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pytest tests/test_exporters/test_polars_exporter.py::TestPolarsExporter::test_export_empty_orders -v` +Expected: PASS + +- [ ] **Step 6: Write failing test for orders with data** + +```python +# tests/test_exporters/test_polars_exporter.py (add to TestPolarsExporter class) + def test_export_orders_with_data(self) -> None: + """Test exporting Orders with data validates against schema.""" + from datetime import datetime + from order_matching.order import LimitOrder + from order_matching.side import Side + + exporter = PolarsExporter() + timestamp = datetime(2023, 1, 1) + order = LimitOrder( + side=Side.BUY, + price=1.2, + size=2.3, + timestamp=timestamp, + order_id="test_order", + trader_id="test_trader" + ) + orders = Orders([order]) + result = exporter.export_orders(orders) + OrderDataSchema.validate(result, lazy=True) +``` + +- [ ] **Step 7: Run test to verify it passes (implementation already complete)** + +Run: `pytest tests/test_exporters/test_polars_exporter.py::TestPolarsExporter::test_export_orders_with_data -v` +Expected: PASS + +- [ ] **Step 8: Write failing test for empty trades export** + +```python +# tests/test_exporters/test_polars_exporter.py (add to TestPolarsExporter class) + def test_export_empty_trades(self) -> None: + """Test exporting empty ExecutedTrades returns empty schema.""" + from order_matching.executed_trades import ExecutedTrades + + exporter = PolarsExporter() + trades = ExecutedTrades() + result = exporter.export_trades(trades) + assert result.collect().equals(TradeDataSchema.empty()) +``` + +- [ ] **Step 9: Run test to verify it passes (implementation already complete)** + +Run: `pytest tests/test_exporters/test_polars_exporter.py::TestPolarsExporter::test_export_empty_trades -v` +Expected: PASS + +- [ ] **Step 10: Write failing test for trades with data** + +```python +# tests/test_exporters/test_polars_exporter.py (add to TestPolarsExporter class) + def test_export_trades_with_data(self) -> None: + """Test exporting ExecutedTrades with data validates against schema.""" + from datetime import datetime + from order_matching.executed_trades import ExecutedTrades + from order_matching.execution import Execution + from order_matching.side import Side + from order_matching.trade import Trade + + exporter = PolarsExporter() + timestamp = datetime(2023, 1, 2) + trade = Trade( + side=Side.SELL, + price=1.2, + size=1.6, + incoming_order_id="b", + book_order_id="a", + execution=Execution.LIMIT, + trade_id="test_trade", + timestamp=timestamp + ) + trades = ExecutedTrades([trade]) + result = exporter.export_trades(trades) + TradeDataSchema.validate(result, lazy=True) +``` + +- [ ] **Step 11: Run test to verify it passes (implementation already complete)** + +Run: `pytest tests/test_exporters/test_polars_exporter.py::TestPolarsExporter::test_export_trades_with_data -v` +Expected: PASS + +- [ ] **Step 12: Export PolarsExporter from __init__.py** + +```python +# src/order_matching/exporters/__init__.py +"""Data export module for converting domain objects to various formats.""" + +from order_matching.exporters.base import Exporter +from order_matching.exporters.polars import PolarsExporter + +__all__ = ["Exporter", "PolarsExporter"] +``` + +- [ ] **Step 13: Run full exporter test suite** + +Run: `pytest tests/test_exporters/ -v` +Expected: All 4 tests PASS + +- [ ] **Step 14: Commit PolarsExporter implementation** + +```bash +git add src/order_matching/exporters/polars.py tests/test_exporters/ +git commit -m "feat: implement PolarsExporter with full test coverage + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" +``` + +--- + +### Task 3: Update Orders.to_frame() to Delegate + +**Files:** +- Modify: `src/order_matching/orders.py:1-10` (imports) +- Modify: `src/order_matching/orders.py:58-73` (to_frame method) + +**Interfaces:** +- Consumes: `PolarsExporter().export_orders(orders)` from Task 2 +- Produces: Backward-compatible `Orders.to_frame()` method (delegates to exporter) + +- [ ] **Step 1: Verify existing test still passes before changes** + +Run: `pytest tests/test_orders.py::TestOrders::test_to_frame -v` +Expected: PASS + +- [ ] **Step 2: Update imports in orders.py to use TYPE_CHECKING** + +```python +# src/order_matching/orders.py (replace lines 1-10) +from __future__ import annotations + +from dataclasses import asdict +from typing import TYPE_CHECKING, Iterator, Sequence, cast + +if TYPE_CHECKING: + from pandera.typing.polars import LazyFrame + from order_matching.schemas import OrderDataSchema + +from order_matching.order import Order +``` + +- [ ] **Step 3: Remove polars import from orders.py** + +Delete this line if it exists: +```python +import polars as pl +``` + +Also delete this import if it exists: +```python +from pandera.typing.polars import LazyFrame +``` + +(These are now in TYPE_CHECKING block) + +- [ ] **Step 4: Replace to_frame implementation with delegation** + +```python +# src/order_matching/orders.py (replace to_frame method, around line 58-73) + def to_frame(self) -> LazyFrame[OrderDataSchema]: + """Get polars LazyFrame with all orders in the storage. + + .. deprecated:: 0.5.0 + Use ``PolarsExporter().export_orders(orders)`` instead. + This method will be removed in version 1.0.0. + + Returns + ------- + LazyFrame[OrderDataSchema] + """ + from order_matching.exporters.polars import PolarsExporter + + return PolarsExporter().export_orders(self) +``` + +- [ ] **Step 5: Run existing test to verify backward compatibility** + +Run: `pytest tests/test_orders.py::TestOrders::test_to_frame -v` +Expected: PASS + +- [ ] **Step 6: Commit orders.py changes** + +```bash +git add src/order_matching/orders.py +git commit -m "refactor: delegate Orders.to_frame() to PolarsExporter + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" +``` + +--- + +### Task 4: Update ExecutedTrades.to_frame() to Delegate + +**Files:** +- Modify: `src/order_matching/executed_trades.py:1-12` (imports) +- Modify: `src/order_matching/executed_trades.py:60-76` (to_frame method) + +**Interfaces:** +- Consumes: `PolarsExporter().export_trades(trades)` from Task 2 +- Produces: Backward-compatible `ExecutedTrades.to_frame()` method (delegates to exporter) + +- [ ] **Step 1: Verify existing test still passes before changes** + +Run: `pytest tests/test_executed_trades.py::TestExecutedTrades::test_to_frame -v` +Expected: PASS + +- [ ] **Step 2: Update imports in executed_trades.py to use TYPE_CHECKING** + +```python +# src/order_matching/executed_trades.py (replace lines 1-12) +from __future__ import annotations + +from collections import defaultdict +from dataclasses import asdict +from datetime import datetime +from typing import TYPE_CHECKING, cast + +if TYPE_CHECKING: + from pandera.typing.polars import LazyFrame + from order_matching.schemas import TradeDataSchema + +from order_matching.trade import Trade +``` + +- [ ] **Step 3: Remove polars import from executed_trades.py** + +Delete this line if it exists: +```python +import polars as pl +``` + +Also delete this import if it exists: +```python +from pandera.typing.polars import LazyFrame +``` + +(These are now in TYPE_CHECKING block) + +- [ ] **Step 4: Replace to_frame implementation with delegation** + +```python +# src/order_matching/executed_trades.py (replace to_frame method, around line 60-76) + def to_frame(self) -> LazyFrame[TradeDataSchema]: + """Get polars DataFrame of all stored trades. + + .. deprecated:: 0.5.0 + Use ``PolarsExporter().export_trades(trades)`` instead. + This method will be removed in version 1.0.0. + + Returns + ------- + LazyFrame[TradeDataSchema] + polars LazyFrame of all stored trades + """ + from order_matching.exporters.polars import PolarsExporter + + return PolarsExporter().export_trades(self) +``` + +- [ ] **Step 5: Run existing test to verify backward compatibility** + +Run: `pytest tests/test_executed_trades.py::TestExecutedTrades::test_to_frame -v` +Expected: PASS + +- [ ] **Step 6: Commit executed_trades.py changes** + +```bash +git add src/order_matching/executed_trades.py +git commit -m "refactor: delegate ExecutedTrades.to_frame() to PolarsExporter + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" +``` + +--- + +### Task 5: Make Polars an Optional Dependency + +**Files:** +- Modify: `pyproject.toml:20-25` (dependencies section) +- Modify: `pyproject.toml:26-45` (add optional-dependencies section) + +**Interfaces:** +- Consumes: Nothing +- Produces: `pyproject.toml` with `[project.optional-dependencies]` section containing `polars` group + +- [ ] **Step 1: Move polars and pandera to optional dependencies** + +```toml +# pyproject.toml (replace lines 20-25) +dependencies = [ + "faker>=33.0.0", + "numpy>=2.2.6", +] + +[project.optional-dependencies] +polars = [ + "pandera[polars]>=0.21.0", + "polars>=1.42.1", +] +all = [ + "order-matching[polars]", +] +``` + +- [ ] **Step 2: Run tests to verify they still pass (polars is installed in dev environment)** + +Run: `pytest tests/ -v` +Expected: All tests PASS (polars still installed in current environment) + +- [ ] **Step 3: Verify core imports work without polars (dry run check)** + +Run: `python -c "from order_matching.orders import Orders; from order_matching.executed_trades import ExecutedTrades; print('Core imports work')"` +Expected: `Core imports work` (no import errors because TYPE_CHECKING prevents runtime import) + +- [ ] **Step 4: Commit dependency changes** + +```bash +git add pyproject.toml +git commit -m "refactor: make polars an optional dependency + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" +``` + +--- + +### Task 6: Update README with New API Examples + +**Files:** +- Modify: `README.md:16` (features list) +- Modify: `README.md:30-58` (usage example) + +**Interfaces:** +- Consumes: `PolarsExporter` from Task 2 +- Produces: Updated README documenting both old and new export APIs + +- [ ] **Step 1: Update installation instructions to mention optional dependency** + +```markdown +# README.md (find line ~20-22, modify install section) +## Install + +```shell +# Core matching engine only +pip install order-matching + +# With polars export support (recommended for data science workflows) +pip install order-matching[polars] +``` +``` + +- [ ] **Step 2: Update features list to clarify polars is optional** + +```markdown +# README.md (find line ~16, modify features list) +- conversion into polars LazyFrame of orders, executed trades, order book summary (optional polars dependency) +``` + +- [ ] **Step 3: Add new API example after existing usage section** + +```markdown +# README.md (add after line ~58, after existing usage example) + +### Data Export (Polars) + +If you installed with `[polars]` extra, you can export data to polars LazyFrame: + +```python +>>> from order_matching.exporters.polars import PolarsExporter + +>>> exporter = PolarsExporter() +>>> orders_df = exporter.export_orders(matching_engine.order_book.buy_orders) +>>> trades_df = exporter.export_trades(executed_trades) + +>>> # Legacy API (deprecated, will be removed in 1.0.0) +>>> orders_df = matching_engine.order_book.buy_orders.to_frame() +``` +``` + +- [ ] **Step 4: Verify README renders correctly (markdown check)** + +Run: `python -c "import markdown; markdown.markdown(open('README.md').read()); print('README valid')"` +Expected: `README valid` (or skip if markdown package not available) + +- [ ] **Step 5: Commit README updates** + +```bash +git add README.md +git commit -m "docs: update README with new exporter API + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" +``` + +--- + +### Task 7: Final Integration Verification + +**Files:** +- Test: All test files + +**Interfaces:** +- Consumes: All tasks 1-6 +- Produces: Verified working implementation with all tests passing + +- [ ] **Step 1: Run full test suite** + +Run: `pytest tests/ -v` +Expected: All tests PASS + +- [ ] **Step 2: Run type checking** + +Run: `uv run ty` +Expected: No type errors + +- [ ] **Step 3: Run linting** + +Run: `uv run ruff check src/ tests/` +Expected: No linting errors + +- [ ] **Step 4: Test new API usage pattern** + +```python +# Create test script: test_new_api.py +from datetime import datetime +from order_matching.exporters.polars import PolarsExporter +from order_matching.orders import Orders +from order_matching.order import LimitOrder +from order_matching.side import Side +from order_matching.schemas import OrderDataSchema + +timestamp = datetime(2023, 1, 1) +order = LimitOrder( + side=Side.BUY, + price=1.2, + size=2.3, + timestamp=timestamp, + order_id="test", + trader_id="trader" +) +orders = Orders([order]) + +# New API +exporter = PolarsExporter() +df = exporter.export_orders(orders) +OrderDataSchema.validate(df, lazy=True) +print("New API works!") + +# Old API (backward compatibility) +df_old = orders.to_frame() +assert df.collect().equals(df_old.collect()) +print("Backward compatibility verified!") +``` + +Run: `python test_new_api.py` +Expected: +``` +New API works! +Backward compatibility verified! +``` + +- [ ] **Step 5: Clean up test script** + +Run: `rm test_new_api.py` + +- [ ] **Step 6: Final commit with version bump preparation** + +```bash +git add -A +git status +# Verify only expected files are staged, then: +git commit -m "chore: prepare for v0.5.0 release with exporter architecture + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" --allow-empty +``` + +--- + +## Self-Review Checklist + +**Spec Coverage:** +- ✅ Abstract Exporter base class (Task 1) +- ✅ PolarsExporter implementation (Task 2) +- ✅ Orders.to_frame() delegation (Task 3) +- ✅ ExecutedTrades.to_frame() delegation (Task 4) +- ✅ Optional polars dependency (Task 5) +- ✅ README documentation (Task 6) +- ✅ Full test coverage (Tasks 2, 7) +- ✅ Type safety with TYPE_CHECKING (Tasks 3, 4) +- ✅ Backward compatibility (Tasks 3, 4, 7) + +**Placeholder Scan:** +- ✅ No TBD/TODO markers +- ✅ All code blocks contain actual implementation +- ✅ All test expectations specify exact output +- ✅ All file paths are absolute and exact + +**Type Consistency:** +- ✅ `Exporter[T]` used consistently +- ✅ `PolarsExporter(Exporter[LazyFrame])` signature matches +- ✅ `export_orders` and `export_trades` method names consistent across all tasks +- ✅ Return types `LazyFrame[OrderDataSchema]` and `LazyFrame[TradeDataSchema]` consistent + +**Dependencies:** +- Task 2 depends on Task 1 (uses Exporter base class) +- Task 3 depends on Task 2 (uses PolarsExporter) +- Task 4 depends on Task 2 (uses PolarsExporter) +- Task 6 depends on Task 2 (documents PolarsExporter) +- Task 7 depends on all tasks (integration verification) diff --git a/superpowers/specs/2026-07-06-exporter-architecture-design.md b/superpowers/specs/2026-07-06-exporter-architecture-design.md new file mode 100644 index 0000000..0a158bf --- /dev/null +++ b/superpowers/specs/2026-07-06-exporter-architecture-design.md @@ -0,0 +1,510 @@ +# Exporter Architecture Design + +**Date:** 2026-07-06 +**Status:** Approved +**Version:** 0.5.0 + +## Overview + +This design document describes the refactoring of data export functionality in the order-matching engine to separate domain logic from format-specific export logic. The current implementation embeds polars export methods (`to_frame()`) directly in domain classes (`Orders`, `ExecutedTrades`), creating tight coupling and preventing extensibility to other export formats. + +## Goals + +1. **Separation of Concerns:** Remove export logic from domain models so they remain focused on business logic +2. **Extensibility:** Enable support for multiple export formats (JSON, CSV, Arrow, databases) without modifying domain classes +3. **Optional Dependencies:** Make polars an optional dependency so the core matching engine has minimal requirements +4. **Backward Compatibility:** Maintain existing `to_frame()` API during a deprecation period +5. **Type Safety:** Use generics to ensure type-safe export implementations + +## Non-Goals + +- Changing the matching engine's core algorithms or data structures +- Modifying the pandera schema definitions (they remain unchanged) +- Adding new export formats in this phase (architecture only; implementations come later) +- Performance optimization of export operations + +## Current State + +### Problems + +1. **Tight Coupling:** `Orders` and `ExecutedTrades` classes import polars and pandera, making them dependent on data science libraries +2. **Single Format:** Only polars LazyFrame export is supported; adding CSV/JSON requires modifying domain classes +3. **Mandatory Dependencies:** Users who only need the matching engine must install polars/pandera +4. **Responsibility Violation:** Domain classes handle both business logic (order management) and presentation logic (data formatting) + +### Current Implementation + +```python +# orders.py +import polars as pl +from pandera.typing.polars import LazyFrame + +class Orders: + def to_frame(self) -> LazyFrame[OrderDataSchema]: + # 15 lines of polars-specific conversion logic + if len(self.orders) == 0: + return OrderDataSchema.empty().lazy() + data = [asdict(order) for order in self.orders] + # enum to string conversions... + return pl.LazyFrame(data) +``` + +Similar logic exists in `ExecutedTrades.to_frame()`. + +## Proposed Architecture + +### Module Structure + +``` +src/order_matching/ +├── exporters/ +│ ├── __init__.py # Exports Exporter, PolarsExporter +│ ├── base.py # Abstract base class +│ └── polars.py # Polars implementation +├── orders.py # Modified: thin wrapper method +├── executed_trades.py # Modified: thin wrapper method +└── schemas.py # Unchanged +``` + +Future exporters can be added as new files in the `exporters/` directory without touching existing code. + +### Abstract Interface + +**File:** `exporters/base.py` + +```python +from abc import ABC, abstractmethod +from typing import Generic, TypeVar + +T = TypeVar('T') + +class Exporter(ABC, Generic[T]): + """Abstract base class for data exporters. + + Type parameter T represents the output format type + (e.g., LazyFrame, dict, str for JSON/CSV). + """ + + @abstractmethod + def export_orders(self, orders: Orders) -> T: + """Convert Orders collection to target format. + + Parameters + ---------- + orders : Orders + Orders collection to export + + Returns + ------- + T + Exported data in target format + """ + pass + + @abstractmethod + def export_trades(self, trades: ExecutedTrades) -> T: + """Convert ExecutedTrades collection to target format. + + Parameters + ---------- + trades : ExecutedTrades + Trades collection to export + + Returns + ------- + T + Exported data in target format + """ + pass +``` + +**Design Decisions:** + +- **ABC over Protocol:** Using ABC ensures all exporters explicitly implement required methods +- **Generic Type Variable:** `T` provides compile-time type safety for return values +- **Stateless Methods:** Exporters are pure transformers with no internal state +- **Explicit Method Names:** `export_orders` and `export_trades` (not overloaded `export`) for clarity + +### Polars Implementation + +**File:** `exporters/polars.py` + +```python +from dataclasses import asdict +from typing import cast + +import polars as pl +from pandera.typing.polars import LazyFrame + +from order_matching.exporters.base import Exporter +from order_matching.schemas import OrderDataSchema, TradeDataSchema +from order_matching.orders import Orders +from order_matching.executed_trades import ExecutedTrades + + +class PolarsExporter(Exporter[LazyFrame]): + """Export collections to polars LazyFrame format.""" + + def export_orders(self, orders: Orders) -> LazyFrame[OrderDataSchema]: + """Export Orders to validated polars LazyFrame.""" + if len(orders) == 0: + return cast(LazyFrame[OrderDataSchema], OrderDataSchema.empty().lazy()) + + data = [asdict(order) for order in orders] + for d in data: + d[OrderDataSchema.side] = d[OrderDataSchema.side].name + d[OrderDataSchema.execution] = d[OrderDataSchema.execution].name + d[OrderDataSchema.status] = d[OrderDataSchema.status].name + return cast(LazyFrame[OrderDataSchema], pl.LazyFrame(data)) + + def export_trades(self, trades: ExecutedTrades) -> LazyFrame[TradeDataSchema]: + """Export ExecutedTrades to validated polars LazyFrame.""" + trade_list = trades.trades + if len(trade_list) == 0: + return cast(LazyFrame[TradeDataSchema], TradeDataSchema.empty().lazy()) + + data = [asdict(trade) for trade in trade_list] + for d in data: + d[TradeDataSchema.side] = d[TradeDataSchema.side].name + d[TradeDataSchema.execution] = d[TradeDataSchema.execution].name + return cast(LazyFrame[TradeDataSchema], pl.LazyFrame(data)) +``` + +**Implementation Notes:** + +- Logic is extracted verbatim from existing `to_frame()` methods +- Enum-to-string conversion remains in polars exporter (format-specific concern) +- Empty collection handling preserved +- Pandera schema validation still applies + +### Backward Compatibility + +The existing `to_frame()` methods will remain but delegate to exporters: + +**File:** `orders.py` (modified) + +```python +from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pandera.typing.polars import LazyFrame + from order_matching.schemas import OrderDataSchema + +class Orders: + # ... existing methods unchanged ... + + def to_frame(self) -> LazyFrame[OrderDataSchema]: + """Get polars LazyFrame with all orders in the storage. + + .. deprecated:: 0.5.0 + Use ``PolarsExporter().export_orders(orders)`` instead. + This method will be removed in version 1.0.0. + + Returns + ------- + LazyFrame[OrderDataSchema] + """ + from order_matching.exporters.polars import PolarsExporter + return PolarsExporter().export_orders(self) +``` + +**File:** `executed_trades.py` (modified) + +```python +from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pandera.typing.polars import LazyFrame + from order_matching.schemas import TradeDataSchema + +class ExecutedTrades: + # ... existing methods unchanged ... + + def to_frame(self) -> LazyFrame[TradeDataSchema]: + """Get polars DataFrame of all stored trades. + + .. deprecated:: 0.5.0 + Use ``PolarsExporter().export_trades(trades)`` instead. + This method will be removed in version 1.0.0. + + Returns + ------- + LazyFrame[TradeDataSchema] + """ + from order_matching.exporters.polars import PolarsExporter + return PolarsExporter().export_trades(self) +``` + +**Strategy:** + +- `TYPE_CHECKING` blocks prevent runtime imports of polars/pandera in core classes +- Lazy import of `PolarsExporter` inside method keeps polars optional +- Deprecation notices guide users to new API +- Deprecation timeline: deprecate in 0.5.0, remove in 1.0.0 + +**Migration Path:** + +```python +# Old API (still works but deprecated) +orders.to_frame() + +# New API (recommended) +from order_matching.exporters.polars import PolarsExporter +exporter = PolarsExporter() +exporter.export_orders(orders) +``` + +### Dependency Management + +**File:** `pyproject.toml` (modified) + +```toml +[project] +name = "order-matching" +dependencies = [ + # Core dependencies only - NO polars or pandera +] + +[project.optional-dependencies] +polars = [ + "polars>=1.0.0", + "pandera[polars]>=0.20.0", +] +all = [ + "order-matching[polars]", +] +``` + +**Installation Options:** + +```bash +# Core engine only (no export capabilities) +pip install order-matching + +# With polars export support +pip install order-matching[polars] + +# With all export formats (future) +pip install order-matching[all] +``` + +**Import Behavior:** + +- Core modules never import polars directly at module level +- `exporters/polars.py` imports polars normally (fails if not installed) +- Users calling `PolarsExporter` without `[polars]` get clear `ModuleNotFoundError` +- Old `to_frame()` methods work seamlessly if polars is installed + +### Future Extensibility + +The architecture enables adding new exporters without modifying existing code: + +```python +# Future: exporters/json.py +class JSONExporter(Exporter[str]): + def export_orders(self, orders: Orders) -> str: + # JSON serialization logic + pass + + def export_trades(self, trades: ExecutedTrades) -> str: + # JSON serialization logic + pass + +# Future: exporters/csv.py +class CSVExporter(Exporter[str]): + def export_orders(self, orders: Orders) -> str: + # CSV formatting logic + pass + +# Future: exporters/arrow.py +class ArrowExporter(Exporter[pa.Table]): + def export_orders(self, orders: Orders) -> pa.Table: + # Arrow table conversion + pass +``` + +Each exporter: +- Lives in its own file +- Has its own optional dependency group +- Follows the same `Exporter[T]` contract +- Requires zero changes to domain classes + +## Testing Strategy + +### New Tests + +**File:** `tests/test_exporters/test_polars_exporter.py` + +```python +import pytest +from order_matching.exporters.polars import PolarsExporter +from order_matching.schemas import OrderDataSchema, TradeDataSchema +from order_matching.orders import Orders +from order_matching.executed_trades import ExecutedTrades + +class TestPolarsExporter: + def test_export_empty_orders(self): + """Test exporting empty Orders collection.""" + exporter = PolarsExporter() + orders = Orders() + result = exporter.export_orders(orders) + assert result.collect().equals(OrderDataSchema.empty()) + + def test_export_orders_with_data(self, sample_orders): + """Test exporting Orders with data.""" + exporter = PolarsExporter() + result = exporter.export_orders(sample_orders) + OrderDataSchema.validate(result, lazy=True) + + def test_export_empty_trades(self): + """Test exporting empty ExecutedTrades.""" + exporter = PolarsExporter() + trades = ExecutedTrades() + result = exporter.export_trades(trades) + assert result.collect().equals(TradeDataSchema.empty()) + + def test_export_trades_with_data(self, sample_trades): + """Test exporting ExecutedTrades with data.""" + exporter = PolarsExporter() + result = exporter.export_trades(sample_trades) + TradeDataSchema.validate(result, lazy=True) + + def test_enum_conversion(self, sample_orders): + """Verify enums are converted to string names.""" + exporter = PolarsExporter() + result = exporter.export_orders(sample_orders).collect() + # All side/execution/status columns should be strings + assert result['side'].dtype == pl.Utf8 +``` + +### Existing Tests + +Existing tests in `test_orders.py` and `test_executed_trades.py` for `to_frame()` methods remain unchanged. They verify backward compatibility by ensuring the old API still works. + +### Test Coverage + +- New exporter tests verify core export logic correctness +- Existing domain class tests verify backward compatibility +- Both test suites pass with identical expectations +- Future exporters follow the same test pattern + +## Migration Guide + +### For Library Maintainers + +**Phase 1: Version 0.5.0 (this release)** +1. Add `exporters/` module with new architecture +2. Modify `orders.py` and `executed_trades.py` to delegate to exporters +3. Add deprecation warnings to `to_frame()` docstrings +4. Update README to show both old and new APIs +5. Make polars an optional dependency + +**Phase 2: Version 0.6.0 - 0.9.x (transition period)** +- Maintain both APIs +- Update examples and documentation to prefer new API +- Monitor deprecation usage + +**Phase 3: Version 1.0.0 (breaking change)** +- Remove `to_frame()` methods from domain classes +- Update README to only show new API +- Polars remains optional + +### For Library Users + +**Immediate (Version 0.5.0+):** + +No action required. Existing code continues to work unchanged: + +```python +# Still works, no changes needed +orders.to_frame() +trades.to_frame() +``` + +**Recommended (Version 0.5.0+):** + +Migrate to new API to avoid future breakage: + +```python +# Old +orders_df = orders.to_frame() +trades_df = trades.to_frame() + +# New +from order_matching.exporters.polars import PolarsExporter + +exporter = PolarsExporter() +orders_df = exporter.export_orders(orders) +trades_df = exporter.export_trades(trades) +``` + +**Installation:** + +```bash +# If using polars export, add [polars] extra +pip install order-matching[polars] +``` + +## Trade-offs + +### Advantages + +✅ **Clean Separation:** Domain models have no knowledge of export formats +✅ **Extensible:** New formats don't require changes to core code +✅ **Optional Dependencies:** Core engine has minimal requirements +✅ **Type Safe:** Generic types ensure compile-time correctness +✅ **Backward Compatible:** Existing code continues working during deprecation period +✅ **Future-Proof:** Architecture supports JSON, CSV, databases, etc. + +### Disadvantages + +⚠️ **Slightly More Verbose:** Users must import and instantiate exporters +⚠️ **Breaking Change in 1.0:** Old API will eventually be removed +⚠️ **More Files:** Architecture spreads code across more modules + +### Rejected Alternatives + +**Approach 1: Simple Converter Module** +- Rejected in favor of Approach 2 for better extensibility +- Protocol-based approach provides clearer contracts and better type safety + +**Approach 3: Mixin Pattern** +- Rejected because it doesn't fully separate responsibilities +- Still couples domain models to export logic through inheritance + +## Success Criteria + +1. **Functionality:** All existing tests pass without modification +2. **Backward Compatibility:** Old `to_frame()` API works identically to before +3. **Type Safety:** `mypy --strict` passes on all new code +4. **Optional Dependencies:** Core package installs without polars/pandera +5. **Documentation:** README clearly explains both APIs and migration path +6. **Extensibility:** Adding a JSON exporter requires only one new file + +## Implementation Checklist + +- [ ] Create `src/order_matching/exporters/` directory +- [ ] Implement `exporters/base.py` with abstract `Exporter` class +- [ ] Implement `exporters/polars.py` with `PolarsExporter` +- [ ] Implement `exporters/__init__.py` to export public interfaces +- [ ] Modify `orders.py` to delegate `to_frame()` to exporter +- [ ] Modify `executed_trades.py` to delegate `to_frame()` to exporter +- [ ] Update `pyproject.toml` to make polars optional +- [ ] Create test suite in `tests/test_exporters/test_polars_exporter.py` +- [ ] Verify all existing tests still pass +- [ ] Update README with new API examples +- [ ] Update documentation with deprecation timeline +- [ ] Add type checking validation for new code + +## Open Questions + +None. All design questions have been resolved during brainstorming. + +## References + +- Current implementation: `src/order_matching/orders.py:58-73` +- Current implementation: `src/order_matching/executed_trades.py:60-76` +- Schemas: `src/order_matching/schemas.py` +- Existing tests: `tests/test_orders.py:61-66`, `tests/test_executed_trades.py:53-61` diff --git a/tests/test_exporter_base.py b/tests/test_exporter_base.py new file mode 100644 index 0000000..e360ad1 --- /dev/null +++ b/tests/test_exporter_base.py @@ -0,0 +1,55 @@ +import pytest + +from order_matching.executed_trades import ExecutedTrades +from order_matching.exporters import Exporter +from order_matching.orders import Orders + + +class ConcreteExporter(Exporter[dict]): + """Concrete implementation for testing.""" + + def export_orders(self, orders: Orders) -> dict: # noqa: ARG002 + return {"orders": []} + + def export_trades(self, trades: ExecutedTrades) -> dict: # noqa: ARG002 + return {"trades": []} + + +class TestExporter: + """Tests for abstract Exporter base class.""" + + def test_exporter_is_abstract(self) -> None: + """Test that Exporter cannot be instantiated directly.""" + with pytest.raises(TypeError): + Exporter() + + def test_concrete_exporter_can_be_instantiated(self) -> None: + """Test that a concrete Exporter subclass can be instantiated.""" + exporter = ConcreteExporter() + assert isinstance(exporter, Exporter) + + def test_export_orders_abstract(self) -> None: + """Test that export_orders is an abstract method.""" + assert hasattr(Exporter, "export_orders") + assert getattr(Exporter.export_orders, "__isabstractmethod__", False) + + def test_export_trades_abstract(self) -> None: + """Test that export_trades is an abstract method.""" + assert hasattr(Exporter, "export_trades") + assert getattr(Exporter.export_trades, "__isabstractmethod__", False) + + def test_concrete_implementation_export_orders(self) -> None: + """Test that concrete implementation can export orders.""" + exporter = ConcreteExporter() + orders = Orders() + result = exporter.export_orders(orders) + assert isinstance(result, dict) + assert "orders" in result + + def test_concrete_implementation_export_trades(self) -> None: + """Test that concrete implementation can export trades.""" + exporter = ConcreteExporter() + trades = ExecutedTrades() + result = exporter.export_trades(trades) + assert isinstance(result, dict) + assert "trades" in result diff --git a/tests/test_exporters/__init__.py b/tests/test_exporters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_exporters/test_polars_exporter.py b/tests/test_exporters/test_polars_exporter.py new file mode 100644 index 0000000..8dbb2b8 --- /dev/null +++ b/tests/test_exporters/test_polars_exporter.py @@ -0,0 +1,62 @@ +from order_matching.exporters.polars import PolarsExporter +from order_matching.orders import Orders +from order_matching.schemas import OrderDataSchema, TradeDataSchema + + +class TestPolarsExporter: + def test_export_empty_orders(self) -> None: + """Test exporting empty Orders collection returns empty schema.""" + exporter = PolarsExporter() + orders = Orders() + result = exporter.export_orders(orders) + assert result.collect().equals(OrderDataSchema.empty()) + + def test_export_orders_with_data(self) -> None: + """Test exporting Orders with data validates against schema.""" + from datetime import datetime + + from order_matching.order import LimitOrder + from order_matching.side import Side + + exporter = PolarsExporter() + timestamp = datetime(2023, 1, 1) + order = LimitOrder( + side=Side.BUY, price=1.2, size=2.3, timestamp=timestamp, order_id="test_order", trader_id="test_trader" + ) + orders = Orders([order]) + result = exporter.export_orders(orders) + OrderDataSchema.validate(result, lazy=True) + + def test_export_empty_trades(self) -> None: + """Test exporting empty ExecutedTrades returns empty schema.""" + from order_matching.executed_trades import ExecutedTrades + + exporter = PolarsExporter() + trades = ExecutedTrades() + result = exporter.export_trades(trades) + assert result.collect().equals(TradeDataSchema.empty()) + + def test_export_trades_with_data(self) -> None: + """Test exporting ExecutedTrades with data validates against schema.""" + from datetime import datetime + + from order_matching.executed_trades import ExecutedTrades + from order_matching.execution import Execution + from order_matching.side import Side + from order_matching.trade import Trade + + exporter = PolarsExporter() + timestamp = datetime(2023, 1, 2) + trade = Trade( + side=Side.SELL, + price=1.2, + size=1.6, + incoming_order_id="b", + book_order_id="a", + execution=Execution.LIMIT, + trade_id="test_trade", + timestamp=timestamp, + ) + trades = ExecutedTrades([trade]) + result = exporter.export_trades(trades) + TradeDataSchema.validate(result, lazy=True) diff --git a/uv.lock b/uv.lock index 66378ba..bfc014a 100644 --- a/uv.lock +++ b/uv.lock @@ -884,6 +884,14 @@ dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] + +[package.optional-dependencies] +all = [ + { name = "pandera", extra = ["polars"] }, + { name = "polars" }, +] +polars = [ { name = "pandera", extra = ["polars"] }, { name = "polars" }, ] @@ -912,9 +920,11 @@ test = [ requires-dist = [ { name = "faker", specifier = ">=33.0.0" }, { name = "numpy", specifier = ">=2.2.6" }, - { name = "pandera", extras = ["polars"], specifier = ">=0.21.0" }, - { name = "polars", specifier = ">=1.42.1" }, + { name = "order-matching", extras = ["polars"], marker = "extra == 'all'" }, + { name = "pandera", extras = ["polars"], marker = "extra == 'polars'", specifier = ">=0.21.0" }, + { name = "polars", marker = "extra == 'polars'", specifier = ">=1.42.1" }, ] +provides-extras = ["polars", "all"] [package.metadata.requires-dev] dev = [