Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ['3.10', '3.11', '3.12']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r toptek/requirements-lite.txt
- name: Lint
run: |
ruff check .
black --check .
bandit -r toptek -q
- name: Type check
run: |
mypy --config-file mypy.ini toptek tests
- name: Tests
env:
PERF_CHECK: 0
run: |
pytest --maxfail=1 --disable-warnings -q
- name: Upload reports
if: always()
uses: actions/upload-artifact@v4
with:
name: reports-${{ matrix.os }}-${{ matrix.python-version }}
path: |
reports
models
data/bank
if-no-files-found: ignore
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,33 @@ GUI. Install [`PyYAML`](https://pyyaml.org/) if the command reports a missing
dependency. The same report is serialised back into the `configs["trade"]`
dictionary whenever the guard is refreshed, allowing downstream automation to
respond when the status shifts between `OK` and `DEFENSIVE_MODE`.

## Monitoring surface

Operational dashboards can now import helpers from `toptek.monitor` to surface
data quality and feed health at a glance:

- `toptek.monitor.compute_drift_report` evaluates PSI/KS drift across a
DataFrame slice, returning feature-level and aggregate severities that the UI
can render as badges or alerts.
- `toptek.monitor.build_latency_badge` converts a timestamp for the latest bar
into deterministic status copy (`Live`, `Lagging`, `Stalled`) based on latency
thresholds.

Both utilities return frozen dataclasses to keep the API predictable for
widgets, scripts, or automated monitors.

## Data bank, nightly prep, and live surfaces

- `python -m toptek.databank.bank ingest --symbol ES --timeframe 5m --days 365`
writes deterministic synthetic OHLCV bars under `data/bank/ES/5m` and maintains
a catalog for downstream loaders.
- `python -m toptek.pipelines.prep_nightly --date YYYY-MM-DD` executes the full
nightly prep flow (ingest → features → train → calibrate → drift check) and
emits a daily brief JSON, a threshold curve plot, and a versioned model card.
- `toptek.confidence.score_probabilities` powers the new confidence ring widget
displayed in the Train, Backtest, and Trade tabs.
- `toptek.advisor.engine.AdvisorEngine` streams a synthetic research brief with
risk buckets, ATR%, and three actionable bullets for chat intents.
- `bench/run_bench.py` captures a deterministic baseline; exporting `PERF_CHECK=1`
when running `pytest` enables the gated perf regression test.
38 changes: 38 additions & 0 deletions bench/run_bench.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Synthetic performance harness producing deterministic baselines."""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

import numpy as np


def run_scenario(config: dict[str, Any]) -> dict[str, float]:
seed = int(config.get("seed", 42))
horizon = int(config.get("horizon", 256))
rng = np.random.default_rng(seed)
pnl = rng.normal(config.get("drift", 0.05), config.get("vol", 0.2), size=horizon)
sharpe = float(np.mean(pnl) / (np.std(pnl) + 1e-9))
hit_rate = float((pnl > 0).mean())
return {"sharpe": sharpe, "hit_rate": hit_rate, "horizon": horizon}


def main() -> None:
scenario_path = Path(__file__).with_name("scenario_small.yaml")
if not scenario_path.exists():
scenario_path = Path("bench/scenario_small.yaml")
if not scenario_path.exists():
raise FileNotFoundError("scenario_small.yaml not found")
config = json.loads(scenario_path.read_text(encoding="utf-8"))
metrics = run_scenario(config)
out_dir = Path("reports/baselines")
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / "latest.json").write_text(
json.dumps(metrics, indent=2), encoding="utf-8"
)


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions bench/scenario_small.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"seed": 7, "horizon": 512, "drift": 0.03, "vol": 0.15}
16 changes: 16 additions & 0 deletions tests/test_advisor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from __future__ import annotations

import pytest

pytest.importorskip("numpy")
pytest.importorskip("pandas")

from toptek.advisor import AdvisorEngine


def test_advisor_engine() -> None:
engine = AdvisorEngine()
response = engine.advise("AAPL")
assert response.symbol == "AAPL"
assert len(response.bullets) == 3
assert response.recommendation
23 changes: 23 additions & 0 deletions tests/test_confidence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from __future__ import annotations

import pytest

np = pytest.importorskip("numpy")
pytest.importorskip("scipy")

from toptek.confidence import score_probabilities


def test_score_probabilities_fast() -> None:
probs = np.linspace(0.55, 0.75, 20)
result = score_probabilities(probs, method="fast")
assert 0.6 < result.probability < 0.7
assert result.confidence > 0
assert result.ci_high <= 1.0
assert result.expected_value > 0


def test_score_probabilities_beta() -> None:
probs = [0.6] * 15
result = score_probabilities(probs, method="beta")
assert result.ci_low < result.probability < result.ci_high
19 changes: 19 additions & 0 deletions tests/test_confidence_widget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from __future__ import annotations

import os
import tkinter as tk

import pytest

from toptek.ui.widgets import ConfidenceRing


@pytest.mark.skipif(tk.TkVersion < 8.0, reason="Tk not available")
@pytest.mark.skipif(not os.environ.get("DISPLAY"), reason="no X display available")
def test_confidence_ring_updates() -> None:
root = tk.Tk()
root.withdraw()
widget = ConfidenceRing(root)
widget.update_from_payload({"p": 0.65, "coverage": 0.4, "ev": 0.12})
assert widget.winfo_exists()
root.destroy()
27 changes: 27 additions & 0 deletions tests/test_databank.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from __future__ import annotations

from datetime import datetime, timezone
from pathlib import Path

import pytest

pytest.importorskip("pandas")

from toptek.databank import Bank, SyntheticBars


def test_ingest_and_read(tmp_path: Path) -> None:
bank = Bank(tmp_path / "bank", provider=SyntheticBars(seed=1))
target_end = datetime(2024, 1, 31, tzinfo=timezone.utc)
bank.ingest("ES", "5m", days=2, end=target_end)
df = bank.read("ES", "5m")
assert not df.empty
assert {"open", "high", "low", "close", "volume"}.issubset(df.columns)
catalog = bank.catalog("ES", "5m")
assert catalog["symbol"] == "ES"
assert catalog["timeframe"] == "5m"
partitions = catalog["partitions"]
assert isinstance(partitions, list)
assert len(list((tmp_path / "bank" / "ES" / "5m").glob("*.parquet"))) == len(
partitions
)
63 changes: 63 additions & 0 deletions tests/test_drift_flags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Deterministic checks for the drift severity report."""

from __future__ import annotations

import pytest

from toptek.monitor import Severity, compute_drift_report


def _build_column(zero_count: int, one_count: int) -> dict[str, list[int]]:
return {"feature": [0] * zero_count + [1] * one_count}


def test_compute_drift_report_stable_flag():
reference = _build_column(50, 50)
current = _build_column(50, 50)

report = compute_drift_report(reference, current, bins=2)

assert report.overall == Severity.STABLE
feature_report = report.features["feature"]
assert feature_report.metric.psi == pytest.approx(0.0)
assert feature_report.metric.ks == pytest.approx(0.0)
assert feature_report.severity == Severity.STABLE
assert feature_report.message == "No material drift detected."


def test_compute_drift_report_watch_flag():
reference = _build_column(50, 50)
current = _build_column(68, 32)

report = compute_drift_report(reference, current, bins=2)

feature_report = report.features["feature"]
assert feature_report.psi_severity == Severity.WATCH
assert feature_report.ks_severity == Severity.WATCH
assert feature_report.severity == Severity.WATCH
assert report.overall == Severity.WATCH


def test_compute_drift_report_alert_flag():
reference = _build_column(50, 50)
current = _build_column(80, 20)

report = compute_drift_report(reference, current, bins=2)

feature_report = report.features["feature"]
assert feature_report.severity == Severity.ALERT
assert feature_report.metric.psi > 0.25
assert feature_report.metric.ks > 0.2
assert "alert" in report.summary


def test_compute_drift_report_unknown_when_empty():
reference = _build_column(50, 50)
current = {"feature": []}

report = compute_drift_report(reference, current)

feature_report = report.features["feature"]
assert feature_report.severity == Severity.UNKNOWN
assert report.overall == Severity.UNKNOWN
assert report.summary == "One or more features lacked data for drift assessment."
73 changes: 73 additions & 0 deletions tests/test_latency_badge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Latency badge tests covering severity transitions."""

from __future__ import annotations

from datetime import datetime, timedelta, timezone

import pytest

from toptek.monitor import LatencyBadge, Severity, build_latency_badge


def _now() -> datetime:
return datetime(2024, 1, 1, tzinfo=timezone.utc)


def test_latency_badge_live_severity():
badge = build_latency_badge(
_now() - timedelta(seconds=10),
now=_now(),
warning_threshold=30,
alert_threshold=90,
)

assert isinstance(badge, LatencyBadge)
assert badge.severity == Severity.STABLE
assert badge.label == "Live"
assert "healthy" in badge.message


def test_latency_badge_watch_severity():
badge = build_latency_badge(
_now() - timedelta(seconds=45),
now=_now(),
warning_threshold=30,
alert_threshold=90,
)

assert badge.severity == Severity.WATCH
assert badge.label == "Lagging"
assert badge.latency_seconds == pytest.approx(45.0)


def test_latency_badge_alert_severity():
badge = build_latency_badge(
_now() - timedelta(seconds=120),
now=_now(),
warning_threshold=30,
alert_threshold=90,
)

assert badge.severity == Severity.ALERT
assert badge.label == "Stalled"
assert "stalled" in badge.message.lower()


def test_latency_badge_unknown_without_timestamp():
badge = build_latency_badge(None, now=_now())

assert badge.severity == Severity.UNKNOWN
assert badge.label == "No signal"
assert badge.message == "No bars received yet."


def test_latency_badge_validates_threshold_order():
with pytest.raises(ValueError):
build_latency_badge(
_now(), now=_now(), warning_threshold=90, alert_threshold=30
)

with pytest.raises(ValueError):
build_latency_badge(
_now(), now=_now(), warning_threshold=-1, alert_threshold=30
)
23 changes: 23 additions & 0 deletions tests/test_perf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Performance harness smoke test (gated by PERF_CHECK)."""

from __future__ import annotations

import json
import os
from pathlib import Path

import pytest

pytest.importorskip("numpy")

from bench.run_bench import main as run_bench


@pytest.mark.skipif(os.getenv("PERF_CHECK") != "1", reason="PERF_CHECK env not set")
def test_run_bench(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
run_bench()
output = Path("reports/baselines/latest.json")
assert output.exists()
data = json.loads(output.read_text(encoding="utf-8"))
assert data["horizon"] == 32
Loading