Return

Log return over N periods

Return computes the log return of a market’s price over a specified number of bars. It’s the primary input for momentum strategies.

Import

python
from horizon.features import Return

Signature

python
Return(window: int = 1, market: str | None = None)
windowint
Number of bars over which to compute the return. `window=1` is the 1-bar return; `window=20` is the 20-bar cumulative return.
marketstr | None
Optional pin to a specific market id.

Math

Return(window=N)(t) = log(P(t) / P(t - N))

Where P(t) is the price at the end of bar t and P(t - N) is the price N bars ago.

Log returns are used (not simple returns) because they:

  • Compound additively over time: log_return(t to t+2) = log_return(t to t+1) + log_return(t+1 to t+2)
  • Are approximately symmetric around zero for small moves
  • Behave well under statistical models assuming normality

Behavior

python
def compute(self, market_id, history, feeds):
    prices = history.last_n_prices(self.window + 1)
    if len(prices) < self.window + 1:
        return float("nan")
    start, end = prices[0], prices[-1]
    if start <= 0 or end <= 0:
        return float("nan")
    return math.log(end / start)

Returns NaN when:

  • History has fewer than window + 1 bars (insufficient data)
  • Either the start or end price is non-positive (shouldn’t happen. PriceHistory rejects these)

Use cases

Momentum signal

python
from horizon import Strategy, Signal, Direction
from horizon.asset_classes import Equity
from horizon.features import Return, RealizedVol

class SimpleMomentum(Strategy):
    asset_classes = [Equity]
    features = {
        "r20": Return(window=20),
        "vol": RealizedVol(window=60),
    }

    def evaluate(self, f, universe):
        return [
            Signal(
                market_id=m.id,
                direction=Direction.Increase if f.r20[m.id] > 0 else Direction.Decrease,
                confidence=min(1.0, abs(f.r20[m.id]) * 20),
                expected_edge_bps=50,
                expected_expected_vol_bps=f.vol[m.id] * 10_000,
                horizon=timedelta(days=5),
                reason=f"r20={f.r20[m.id]:+.3f}",
            )
            for m in universe
            if abs(f.r20[m.id]) > 0.02
        ]

This is effectively TSMomentum: which uses Return under the hood.

Cross-asset reference

python
features = {
    "spy_return_5d": Return(window=5, market="SPY"),
    "my_r20": Return(window=20),
}

def evaluate(self, f, universe):
    # Only trade when SPY is up (trend regime)
    spy = f.spy_return_5d[universe[0].id] if universe else 0.0
    if spy < 0:
        return []
    ...

Short vs long lookback comparison

python
features = {
    "r5": Return(window=5),
    "r20": Return(window=20),
    "r60": Return(window=60),
}

def evaluate(self, f, universe):
    # Multi-timeframe confirmation. only long when all positive
    return [
        Signal.increase(m, edge_bps=40)
        for m in universe
        if f.r5[m.id] > 0 and f.r20[m.id] > 0 and f.r60[m.id] > 0
    ]

Pitfalls

Tests

python
# tests/test_features.py::TestReturnFeature
def test_zero_on_flat(self, store_with_history: FeatureStore) -> None:
    r = Return(window=5)
    ns = store_with_history.compute({"r": r}, ["TEST"], {})
    assert math.isclose(ns.r["TEST"], 0.0, abs_tol=1e-9)

def test_trending(self) -> None:
    store = FeatureStore()
    prices = [100.0 * math.exp(0.01 * i) for i in range(50)]
    for i, p in enumerate(prices):
        store.update_feeds({"T": FeedData(market_id="T", price=p)}, ...)
    r = Return(window=10)
    ns = store.compute({"r": r}, ["T"], {})
    # 10 bars each 0.01 log return → total 0.10
    assert math.isclose(ns.r["T"], 0.10, abs_tol=1e-6)

Next