431 lines
14 KiB
Python
431 lines
14 KiB
Python
|
|
# piker: trading gear for hackers
|
||
|
|
# Copyright (C) 2018-present Tyler Goodlet (in stewardship of pikers)
|
||
|
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||
|
|
|
||
|
|
# This program is free software: you can redistribute it and/or
|
||
|
|
# modify it under the terms of the GNU Affero General Public License
|
||
|
|
# as published by the Free Software Foundation, either version 3
|
||
|
|
# of the License, or (at your option) any later version.
|
||
|
|
|
||
|
|
# This program is distributed in the hope that it will be useful,
|
||
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
|
|
# GNU Affero General Public License for more details.
|
||
|
|
|
||
|
|
# You should have received a copy of the GNU Affero General Public
|
||
|
|
# License along with this program. If not, see
|
||
|
|
# <https://www.gnu.org/licenses/>.
|
||
|
|
|
||
|
|
'''
|
||
|
|
Independent batch FHLS oracle and pinned original-Lua observations.
|
||
|
|
|
||
|
|
The oracle materializes complete windows and an expanded sequence of
|
||
|
|
FHLS summaries. It shares neither compiled helpers nor mutable window
|
||
|
|
state with the production calculator. See docs/trend_calculus.rst.
|
||
|
|
|
||
|
|
'''
|
||
|
|
from dataclasses import dataclass
|
||
|
|
from itertools import product
|
||
|
|
import json
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import TypedDict
|
||
|
|
|
||
|
|
import numpy as np
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from piker.fsp._trend import (
|
||
|
|
TrendCalculator,
|
||
|
|
TrendHistory,
|
||
|
|
TrendUpdate,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class _LuaCase(TypedDict):
|
||
|
|
'''
|
||
|
|
Describe the input and observed events of one pinned Lua run.
|
||
|
|
|
||
|
|
'''
|
||
|
|
window: int
|
||
|
|
values: list[float]
|
||
|
|
events: list[list[int|float]]
|
||
|
|
|
||
|
|
|
||
|
|
class _LuaFixture(TypedDict):
|
||
|
|
'''
|
||
|
|
Type the captured Lua cases consumed by the parity regression.
|
||
|
|
|
||
|
|
'''
|
||
|
|
cases: list[_LuaCase]
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True)
|
||
|
|
class Point:
|
||
|
|
'''
|
||
|
|
Retain a pivot's source position and earliest availability row.
|
||
|
|
|
||
|
|
'''
|
||
|
|
row: int
|
||
|
|
price: float
|
||
|
|
available: int
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True)
|
||
|
|
class Window:
|
||
|
|
'''
|
||
|
|
Summarize an entire immutable observation window using extrema.
|
||
|
|
|
||
|
|
'''
|
||
|
|
high: Point
|
||
|
|
low: Point
|
||
|
|
available: int
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def summarize(
|
||
|
|
cls: type['Window'],
|
||
|
|
points: list[Point],
|
||
|
|
) -> 'Window':
|
||
|
|
'''
|
||
|
|
Python min/max retain the earliest equal extremum.
|
||
|
|
|
||
|
|
'''
|
||
|
|
return cls(
|
||
|
|
high=max(points, key=lambda point: point.price),
|
||
|
|
low=min(points, key=lambda point: point.price),
|
||
|
|
available=points[-1].available,
|
||
|
|
)
|
||
|
|
|
||
|
|
def compare(self, previous: 'Window') -> int:
|
||
|
|
'''
|
||
|
|
Compare extrema ordinally, avoiding floating subtraction.
|
||
|
|
|
||
|
|
'''
|
||
|
|
if (
|
||
|
|
(
|
||
|
|
self.high.price >= previous.high.price
|
||
|
|
and self.low.price >= previous.low.price
|
||
|
|
)
|
||
|
|
and
|
||
|
|
(
|
||
|
|
self.high.price > previous.high.price
|
||
|
|
or self.low.price > previous.low.price
|
||
|
|
)
|
||
|
|
):
|
||
|
|
return 1
|
||
|
|
if (
|
||
|
|
(
|
||
|
|
self.high.price <= previous.high.price
|
||
|
|
and self.low.price <= previous.low.price
|
||
|
|
)
|
||
|
|
and
|
||
|
|
(
|
||
|
|
self.high.price < previous.high.price
|
||
|
|
or self.low.price < previous.low.price
|
||
|
|
)
|
||
|
|
):
|
||
|
|
return -1
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
def reference_order(
|
||
|
|
points: list[Point],
|
||
|
|
size: int,
|
||
|
|
*,
|
||
|
|
lua_seed: bool = False,
|
||
|
|
) -> tuple[list[tuple[int, int]], list[tuple[Point, int]]]:
|
||
|
|
'''
|
||
|
|
Expand complex windows, then derive directions and pivot events.
|
||
|
|
|
||
|
|
Direction changes from unknown zero establish a baseline and do
|
||
|
|
not manufacture a reversal. ``Point.available`` carries detection
|
||
|
|
availability from the closing observation, including at recursive
|
||
|
|
orders.
|
||
|
|
|
||
|
|
'''
|
||
|
|
start: int # noqa: F842
|
||
|
|
windows: list[Window] = [
|
||
|
|
Window.summarize(points[start:start + size])
|
||
|
|
for start in range(0, len(points) - size + 1, size)
|
||
|
|
]
|
||
|
|
if lua_seed and windows:
|
||
|
|
last: Point = points[size - 1]
|
||
|
|
windows.insert(0, Window(last, last, last.available))
|
||
|
|
if len(windows) < 2:
|
||
|
|
return [], []
|
||
|
|
|
||
|
|
expanded: list[Window] = [windows[0]]
|
||
|
|
current: Window
|
||
|
|
previous: Window
|
||
|
|
for previous, current in zip(windows, windows[1:]):
|
||
|
|
if current.compare(previous) == 0:
|
||
|
|
second: Point = max(
|
||
|
|
(previous.low, previous.high),
|
||
|
|
key=lambda point: point.row,
|
||
|
|
)
|
||
|
|
first: Point = min(
|
||
|
|
(current.low, current.high),
|
||
|
|
key=lambda point: point.row,
|
||
|
|
)
|
||
|
|
# Lua's synthetic bridge breaks equal-value ties toward
|
||
|
|
# its second endpoint for high and first for low.
|
||
|
|
bridge_high: Point
|
||
|
|
bridge_low: Point
|
||
|
|
bridge_low, bridge_high = sorted(
|
||
|
|
[second, first], key=lambda point: point.price,
|
||
|
|
)
|
||
|
|
expanded.append(Window(
|
||
|
|
high=bridge_high,
|
||
|
|
low=bridge_low,
|
||
|
|
available=current.available,
|
||
|
|
))
|
||
|
|
expanded.append(current)
|
||
|
|
|
||
|
|
trend: int = 0
|
||
|
|
directions: list[tuple[int, int]] = []
|
||
|
|
events: list[tuple[Point, int]] = []
|
||
|
|
previous: Window
|
||
|
|
current: Window
|
||
|
|
for previous, current in zip(expanded, expanded[1:]):
|
||
|
|
direction: int = current.compare(previous) or trend
|
||
|
|
if trend and direction != trend:
|
||
|
|
pivot: Point = (
|
||
|
|
previous.low if direction == 1 else previous.high
|
||
|
|
)
|
||
|
|
events.append((
|
||
|
|
Point(pivot.row, pivot.price, current.available),
|
||
|
|
direction,
|
||
|
|
))
|
||
|
|
trend = direction
|
||
|
|
directions.append((current.available, trend))
|
||
|
|
return directions, events
|
||
|
|
|
||
|
|
|
||
|
|
def reference_hierarchy(
|
||
|
|
values: np.ndarray,
|
||
|
|
sizes: tuple[int, ...],
|
||
|
|
*,
|
||
|
|
lua_seed: bool = False,
|
||
|
|
) -> tuple[
|
||
|
|
np.ndarray,
|
||
|
|
np.ndarray,
|
||
|
|
list[tuple[int, int, int, int]],
|
||
|
|
]:
|
||
|
|
'''
|
||
|
|
Repeatedly reduce whole point sequences, separately per order.
|
||
|
|
|
||
|
|
The returned event tuples are pivot row, direction, order and
|
||
|
|
detection row. Sorting by detection row and order reproduces
|
||
|
|
causal publication while retaining stable same-order ordering.
|
||
|
|
|
||
|
|
'''
|
||
|
|
row: int
|
||
|
|
price: np.floating|np.integer # noqa: F842
|
||
|
|
points: list[Point] = [
|
||
|
|
Point(row, float(price), row)
|
||
|
|
for row, price in enumerate(values)
|
||
|
|
]
|
||
|
|
trends: np.ndarray = np.zeros(
|
||
|
|
(len(values), len(sizes)), dtype=np.int8,
|
||
|
|
)
|
||
|
|
ready: np.ndarray = np.zeros_like(trends, dtype=np.bool_)
|
||
|
|
all_events: list[tuple[int, int, int, int]] = []
|
||
|
|
level: int
|
||
|
|
size: int
|
||
|
|
for level, size in enumerate(sizes):
|
||
|
|
directions: list[tuple[int, int]]
|
||
|
|
events: list[tuple[Point, int]]
|
||
|
|
directions, events = reference_order(
|
||
|
|
points, size, lua_seed=lua_seed,
|
||
|
|
)
|
||
|
|
if len(points) >= 2 * size:
|
||
|
|
ready[points[2 * size - 1].available:, level] = True
|
||
|
|
row: int
|
||
|
|
direction: int
|
||
|
|
for row, direction in directions:
|
||
|
|
trends[row:, level] = direction
|
||
|
|
point: Point
|
||
|
|
direction: int
|
||
|
|
for point, direction in events:
|
||
|
|
all_events.append((
|
||
|
|
point.row, direction, level + 1, point.available,
|
||
|
|
))
|
||
|
|
point: Point
|
||
|
|
direction: int
|
||
|
|
points = [point for point, direction in events]
|
||
|
|
all_events.sort(key=lambda event: (event[3], event[2]))
|
||
|
|
# Public direction remains unresolved until two real windows,
|
||
|
|
# including when the research seed establishes internal state.
|
||
|
|
trends[~ready] = 0
|
||
|
|
return trends, ready, all_events
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize('sizes', [(2, 2), (3, 2), (4, 3)])
|
||
|
|
@pytest.mark.parametrize('lua_seed', [False, True])
|
||
|
|
def test_exhaustive_ternary_histories(
|
||
|
|
sizes: tuple[int, ...],
|
||
|
|
lua_seed: bool,
|
||
|
|
) -> None:
|
||
|
|
'''
|
||
|
|
Enumerate ties, inside/outside bars and unresolved flat startup.
|
||
|
|
|
||
|
|
Every eight-point sequence over three prices is compared with
|
||
|
|
independently expanded ``reference_hierarchy()`` windows.
|
||
|
|
Six-point sequences also exercise the optional Lua seed, without
|
||
|
|
importing its CLI's output-suppression bugs. Readiness still
|
||
|
|
requires two actual complete windows. Comparing every
|
||
|
|
``TrendHistory`` field catches incorrect first/second-extremum
|
||
|
|
ordering and warmup decisions that agreement between two calls
|
||
|
|
into the same production kernel cannot detect.
|
||
|
|
|
||
|
|
'''
|
||
|
|
length: int = 6 if lua_seed else 8
|
||
|
|
sequence: tuple[int, ...]
|
||
|
|
for sequence in product((-1, 0, 1), repeat=length):
|
||
|
|
values: np.ndarray = np.array(sequence, dtype=np.float64)
|
||
|
|
expected: np.ndarray
|
||
|
|
readiness: np.ndarray
|
||
|
|
events: list[tuple[int, int, int, int]]
|
||
|
|
(
|
||
|
|
expected,
|
||
|
|
readiness,
|
||
|
|
events,
|
||
|
|
) = reference_hierarchy(
|
||
|
|
values, sizes, lua_seed=lua_seed,
|
||
|
|
)
|
||
|
|
actual: TrendHistory = TrendCalculator(
|
||
|
|
sizes, lua_seed=lua_seed,
|
||
|
|
).extend(
|
||
|
|
values, np.arange(len(values), dtype=np.float64),
|
||
|
|
)
|
||
|
|
assert np.array_equal(actual.trends, expected), sequence
|
||
|
|
assert np.array_equal(actual.ready, readiness), sequence
|
||
|
|
pulses: np.ndarray = np.zeros_like(expected)
|
||
|
|
prices: np.ndarray = np.full(
|
||
|
|
(len(values), len(sizes), 2), np.nan,
|
||
|
|
)
|
||
|
|
times: np.ndarray = np.full_like(prices, np.nan)
|
||
|
|
pivot: int
|
||
|
|
direction: int
|
||
|
|
order: int
|
||
|
|
detected: int
|
||
|
|
for (
|
||
|
|
pivot,
|
||
|
|
direction,
|
||
|
|
order,
|
||
|
|
detected,
|
||
|
|
) in events:
|
||
|
|
side: int = int(direction == 1)
|
||
|
|
pulses[detected, order - 1] = direction
|
||
|
|
prices[detected, order - 1, side] = values[pivot]
|
||
|
|
times[detected, order - 1, side] = pivot
|
||
|
|
assert np.array_equal(actual.pulses, pulses), sequence
|
||
|
|
assert np.array_equal(
|
||
|
|
actual.pivot_prices, prices, equal_nan=True,
|
||
|
|
), sequence
|
||
|
|
assert np.array_equal(
|
||
|
|
actual.pivot_times, times, equal_nan=True,
|
||
|
|
), sequence
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize('sizes', [(2,) * 5, (3, 2, 4), (5, 3, 2)])
|
||
|
|
@pytest.mark.parametrize('seed', range(8))
|
||
|
|
@pytest.mark.parametrize('lua_seed', [False, True])
|
||
|
|
def test_random_recursive_events(
|
||
|
|
sizes: tuple[int, ...],
|
||
|
|
seed: int,
|
||
|
|
lua_seed: bool,
|
||
|
|
) -> None:
|
||
|
|
'''
|
||
|
|
Preserve recursive pivot causality across irregular coordinates.
|
||
|
|
|
||
|
|
Higher-order pivots occur earlier than their availability, often
|
||
|
|
sharing one detection row. Rounded random walks create tied and
|
||
|
|
engulfing windows; ``reference_hierarchy()`` predicts every
|
||
|
|
event independently of ``TrendCalculator.push()``. Gapped source
|
||
|
|
indices and nonuniform timestamps prove that propagation retains
|
||
|
|
source identity and detection time. Field equality and complete
|
||
|
|
ordered-event equality detect lost or backdated reversals.
|
||
|
|
|
||
|
|
'''
|
||
|
|
rng: np.random.Generator = np.random.default_rng(seed)
|
||
|
|
values: np.ndarray = np.cumsum(rng.integers(-3, 4, 512))
|
||
|
|
times: np.ndarray = 1000 + np.cumsum(rng.uniform(.1, 2, 512))
|
||
|
|
indices: np.ndarray = 800 + np.cumsum(rng.integers(1, 4, 512))
|
||
|
|
expected: np.ndarray
|
||
|
|
readiness: np.ndarray
|
||
|
|
events: list[tuple[int, int, int, int]]
|
||
|
|
(
|
||
|
|
expected,
|
||
|
|
readiness,
|
||
|
|
events,
|
||
|
|
) = reference_hierarchy(
|
||
|
|
values, sizes, lua_seed=lua_seed,
|
||
|
|
)
|
||
|
|
calculator: TrendCalculator = TrendCalculator(
|
||
|
|
sizes, lua_seed=lua_seed,
|
||
|
|
)
|
||
|
|
actual_events: list[tuple[int, int, int, int]] = []
|
||
|
|
row: int
|
||
|
|
value: np.integer
|
||
|
|
for row, value in enumerate(values):
|
||
|
|
update: TrendUpdate = calculator.push(
|
||
|
|
float(times[row]), float(value), index=int(indices[row]),
|
||
|
|
)
|
||
|
|
assert update.trends == tuple(expected[row])
|
||
|
|
assert update.ready == tuple(readiness[row])
|
||
|
|
event: np.void
|
||
|
|
for event in update.reversals:
|
||
|
|
pivot_row: int = int(np.searchsorted(
|
||
|
|
indices, event['index'],
|
||
|
|
))
|
||
|
|
assert event['price'] == values[pivot_row]
|
||
|
|
assert event['time'] == times[pivot_row]
|
||
|
|
assert event['detected_time'] == times[row]
|
||
|
|
assert event['detected_index'] == indices[row]
|
||
|
|
assert pivot_row <= row
|
||
|
|
actual_events.append((
|
||
|
|
pivot_row, int(event['direction']),
|
||
|
|
int(event['order']), row,
|
||
|
|
))
|
||
|
|
assert actual_events == events
|
||
|
|
|
||
|
|
|
||
|
|
def test_original_lua_fixture() -> None:
|
||
|
|
'''
|
||
|
|
Compare production events with actual, unmodified upstream Lua.
|
||
|
|
|
||
|
|
The fixture uses increasing initial windows to establish a trend
|
||
|
|
before reversals, avoiding Lua's documented flat-start crash and
|
||
|
|
initial simple-reversal suppression. Every later pivot, sign and
|
||
|
|
earliest detection prefix must agree, including tied extrema.
|
||
|
|
Comparing complete ``TrendUpdate.reversals`` sequences against
|
||
|
|
captured Lua output checks an implementation independent of the
|
||
|
|
Python oracle. Fixture provenance records the exact program and
|
||
|
|
input hashes; this case does not claim parity at Lua's startup
|
||
|
|
failures.
|
||
|
|
|
||
|
|
'''
|
||
|
|
path: Path = (
|
||
|
|
Path(__file__).parent
|
||
|
|
/ '_inputs'
|
||
|
|
/ 'trend_calculus_lua.json'
|
||
|
|
)
|
||
|
|
fixture: _LuaFixture = json.loads(path.read_text())
|
||
|
|
case: _LuaCase
|
||
|
|
for case in fixture['cases']:
|
||
|
|
calculator: TrendCalculator = TrendCalculator(
|
||
|
|
(case['window'],),
|
||
|
|
)
|
||
|
|
events: list[list[int|float]] = []
|
||
|
|
row: int
|
||
|
|
value: float
|
||
|
|
for row, value in enumerate(case['values']):
|
||
|
|
update: TrendUpdate = calculator.push(float(row), value)
|
||
|
|
event: np.void
|
||
|
|
for event in update.reversals:
|
||
|
|
events.append([
|
||
|
|
int(event['index']), float(event['price']),
|
||
|
|
int(event['direction']),
|
||
|
|
int(event['detected_index']),
|
||
|
|
])
|
||
|
|
assert events == case['events'], case['window']
|