Precompute ATR series for paper-trade trailing exits

Both _atr_trailing_close (scheduled) and _atr_trailing_level (dashboard
read path) recomputed ATR from scratch on every post-entry bar via
compute_atr(rows[:idx+1]) — O(n*k) per trade. Replace with a single O(n)
Wilder pass, _atr_series_from_rows, that stores round(running, 4) at each
index. compute_atr keeps its running ATR unrounded through the recurrence
and rounds only at return, so this reproduces its per-prefix value exactly
(no behavior change; live-vs-backtest atr_trail3 parity still byte-identical).

Remove the now-unused _atr_from_rows and its compute_atr import. Add a
per-index parity test against compute_atr; existing ATR tests now mock
_atr_series_from_rows (same effect as the old fixed-ATR mock).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 09:13:19 +02:00
co-authored by Claude Opus 4.8
parent 1155c9ed1b
commit ca42e1b28d
2 changed files with 65 additions and 18 deletions
+31 -14
View File
@@ -12,7 +12,6 @@ from app.models.ohlcv import OHLCVRecord
from app.models.paper_trade import PaperTrade
from app.models.ticker import Ticker
from app.services import benchmark_service, settings_store
from app.services.indicator_service import compute_atr
from app.services.outcome_service import (
OUTCOME_AMBIGUOUS,
OUTCOME_STOP_HIT,
@@ -181,17 +180,33 @@ def _trailing_close(
return None
def _atr_from_rows(rows: list[tuple], idx: int) -> float | None:
try:
result = compute_atr(
[float(r[2]) for r in rows[: idx + 1]],
[float(r[3]) for r in rows[: idx + 1]],
[float(r[4]) for r in rows[: idx + 1]],
)
except Exception:
return None
atr = result.get("atr")
return float(atr) if atr and atr > 0 else None
def _atr_series_from_rows(rows: list[tuple], period: int = 14) -> list[float | None]:
"""ATR at each index i, equal to ``compute_atr(rows[: i + 1])["atr"]`` but
computed in a single O(n) Wilder pass instead of re-smoothing the whole
prefix per bar. None where there are fewer than ``period + 1`` bars or the
rounded ATR is non-positive. ``period`` mirrors ``compute_atr``'s default;
keep them in sync.
Exactness: ``compute_atr`` keeps its running ATR unrounded through the
recurrence and rounds only at return, so storing ``round(running, 4)`` at
each index reproduces its per-prefix value bit-for-bit.
"""
n = len(rows)
out: list[float | None] = [None] * n
if n < period + 1:
return out
tr = [0.0] * n
for i in range(1, n):
high, low, prev_close = float(rows[i][2]), float(rows[i][3]), float(rows[i - 1][4])
tr[i] = max(high - low, abs(high - prev_close), abs(low - prev_close))
running = sum(tr[1 : period + 1]) / period
rounded = round(running, 4)
out[period] = rounded if rounded > 0 else None
for j in range(period + 1, n):
running = (running * (period - 1) + tr[j]) / period
rounded = round(running, 4)
out[j] = rounded if rounded > 0 else None
return out
def _atr_trailing_level(
@@ -206,11 +221,12 @@ def _atr_trailing_level(
long = direction == "long"
stop = float(init_stop)
anchor = float(entry)
atr_by_idx = _atr_series_from_rows(rows)
for idx, (d, _, _, _, close) in enumerate(rows):
if d <= opened_on:
continue
close = float(close)
atr = _atr_from_rows(rows, idx)
atr = atr_by_idx[idx]
if long:
anchor = max(anchor, close)
if atr is not None:
@@ -244,6 +260,7 @@ def _atr_trailing_close(
stop = float(init_stop)
anchor = float(entry)
bars_held = 0
atr_by_idx = _atr_series_from_rows(rows)
for idx, (d, open_, high, low, close) in enumerate(rows):
if d <= opened_on:
continue
@@ -265,7 +282,7 @@ def _atr_trailing_close(
if bars_held >= hold_days:
return close, d, "time"
atr = _atr_from_rows(rows, idx)
atr = atr_by_idx[idx]
if long:
anchor = max(anchor, close)
if atr is not None: