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)
windowintNumber of bars over which to compute the return. `window=1` is the 1-bar return; `window=20` is the 20-bar cumulative return.
marketstr | NoneOptional 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 + 1bars (insufficient data) - Either the start or end price is non-positive (shouldn’t happen.
PriceHistoryrejects 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)