| """Tests for the tick-level trade-size + buy-initiated ratio factors.""" |
|
|
| from __future__ import annotations |
|
|
| import numpy as np |
| import pandas as pd |
| import pytest |
|
|
| from scanner.factor_sources import StubDataSource |
| from scanner.tick_factor import ( |
| _bucket, |
| _sign_trades, |
| compute_tick_factors, |
| compute_tick_factors_batch, |
| ) |
|
|
|
|
| def test_bucket_boundaries(): |
| assert _bucket(50) == "retail" |
| assert _bucket(99) == "retail" |
| assert _bucket(100) == "small" |
| assert _bucket(999) == "small" |
| assert _bucket(1_000) == "medium" |
| assert _bucket(9_999) == "medium" |
| assert _bucket(10_000) == "block" |
| assert _bucket(100_000) == "block" |
|
|
|
|
| def _ticks(prices, sizes, bid=99.99, ask=100.01): |
| """Build a tick frame with the given prices/sizes at a fixed mid.""" |
| n = len(prices) |
| return pd.DataFrame({ |
| "ts": pd.date_range("2026-06-02 09:30", periods=n, freq="1s"), |
| "price": prices, |
| "size": sizes, |
| "bid": [bid] * n, |
| "ask": [ask] * n, |
| }) |
|
|
|
|
| def test_sign_at_ask_is_buy(): |
| df = _ticks(prices=[100.02, 100.02], sizes=[100, 100]) |
| signs = _sign_trades(df) |
| assert (signs == 1).all() |
|
|
|
|
| def test_sign_at_bid_is_sell(): |
| df = _ticks(prices=[99.98, 99.98], sizes=[100, 100]) |
| signs = _sign_trades(df) |
| assert (signs == -1).all() |
|
|
|
|
| def test_sign_at_mid_carries_forward(): |
| df = _ticks( |
| prices=[100.02, 100.00, 100.00, 99.98], |
| sizes=[100, 100, 100, 100], |
| ) |
| signs = _sign_trades(df).tolist() |
| |
| assert signs == [1, 1, 1, -1] |
|
|
|
|
| def test_block_share_calculation(): |
| df = _ticks( |
| prices=[100.02] * 4, |
| sizes=[100, 1000, 5000, 15000], |
| ) |
| f = compute_tick_factors("X", source=_StaticTickSource(df)) |
| |
| assert 0.70 < f["block_share"] < 0.72 |
|
|
|
|
| def test_block_aggression_positive_when_block_buys(): |
| df = _ticks( |
| prices=[100.02] * 3, |
| sizes=[500, 5000, 20000], |
| ) |
| f = compute_tick_factors("X", source=_StaticTickSource(df)) |
| assert f["block_aggression"] == pytest.approx(1.0) |
|
|
|
|
| def test_block_aggression_negative_when_block_sells(): |
| df = _ticks( |
| prices=[99.98] * 3, |
| sizes=[500, 5000, 20000], |
| ) |
| f = compute_tick_factors("X", source=_StaticTickSource(df)) |
| assert f["block_aggression"] == pytest.approx(-1.0) |
|
|
|
|
| def test_buy_ratio_in_unit_interval(): |
| df = _ticks( |
| prices=[100.02, 100.02, 99.98, 99.98], |
| sizes=[100, 200, 300, 400], |
| ) |
| f = compute_tick_factors("X", source=_StaticTickSource(df)) |
| assert 0.0 <= f["buy_ratio"] <= 1.0 |
|
|
|
|
| def test_empty_ticks(): |
| f = compute_tick_factors("X", source=_StaticTickSource(pd.DataFrame())) |
| assert f["block_share"] == 0.0 |
| assert f["block_aggression"] == 0.0 |
| assert f["buy_ratio"] == 0.5 |
|
|
|
|
| def test_no_bid_ask_falls_back_to_rolling_mid(): |
| df = pd.DataFrame({ |
| "ts": pd.date_range("2026-06-02 09:30", periods=50, freq="1s"), |
| "price": 100 + np.cumsum(np.random.default_rng(1).normal(0, 0.01, 50)), |
| "size": [100] * 50, |
| }) |
| f = compute_tick_factors("X", source=_StaticTickSource(df)) |
| assert "buy_ratio" in f |
|
|
|
|
| def test_batch(): |
| df = _ticks(prices=[100.02] * 2, sizes=[500, 10000]) |
| src = _StaticTickSource(df) |
| out = compute_tick_factors_batch(["A", "B"], source=src) |
| assert set(out.index) == {"A", "B"} |
| assert "block_share" in out.columns |
|
|
|
|
| def test_stub_synthesises(): |
| src = StubDataSource(stub_dir="/nonexistent") |
| df = src.get_ticks("AAPL") |
| assert df is not None and not df.empty |
| assert {"ts", "price", "size"}.issubset(df.columns) |
|
|
|
|
| |
| class _StaticTickSource: |
| def __init__(self, df): |
| self._df = df |
| def get_ticks(self, ticker, date=None): |
| return self._df |
|
|