Horizon mismatch in return calculation based on skewness

Course Name: Unsupervised Learning in Trading, Section No: 11, Unit No: 3, Unit type: Notebook

Hi,

I have a question about how the strategy return is computed in the codebook. The return is defined as close.pct_change() * direction_skewness, but the two factors seem to be on different time horizons:

  1. The Apple price data consists of 15-minute time bars. Early in the codebook, time_prd = 15 and fut_ret is computed with pct_change(time_prd), so fut_ret is a 15-bar forward return, i.e. 15 × 15 min = 3.75 hours.

  2. In the skewness function of the data module, the trading signal (direction_skewness) for each cluster is generated from fut_ret.skew(), so the signal is based on the distribution of these 3.75-hour returns.

  3. The strategy return, however, multiplies that signal by close.pct_change(), which is a single 15-minute return.

So the direction is chosen from the skewness of 3.75-hour forward returns, but the PnL is realised one 15-minute bar at a time. Is this mismatch intended? If so, what is the reasoning — or should the position be held for the full 15 bars after a signal? When I set time_prd = 1 so that both are on the same horizon, the results differ noticeably from those shown in the codebook, so I want to make sure I understand which configuration is intended.

Thanks in advance for any help.

Hi,

Good question, and you’ve read the code correctly. But there’s one thing the code does that resolves most of the puzzle: the position is not held for just one 15-minute bar.

Every bar in the test set gets a direction from its cluster. As long as the market stays in a cluster with the same direction, the position stays open. The line close.pct_change() * direction_skewness.shift(1) doesn’t mean “trade for one bar and exit.” It just records the profit or loss of an open position bar by bar, the way a broker statement would. The position only closes when the market moves into a cluster with a different or neutral direction.

You can see this in the trade log from get_trades: the hit-ratio version has only 119 trades across 6,885 test bars. That’s an average holding period of dozens of bars, not one. And if a position happens to last exactly 15 bars, those fifteen 15-minute returns compound into exactly the 3.75-hour return the skewness was measured on. So the two horizons line up whenever the market state persists.

The idea behind the design: the cluster describes a market state, built from slow-moving features like 1-day RSI and 14-day volatility. The training data tells us “when the market is in this state, returns over the next 3.75 hours tend to be skewed in a particular direction.” The trading rule is then simply “hold the position while the market stays in that state.” Both entry and exit depend on the state, not on a timer.

Your point is still partly valid, though. Since the exit isn’t fixed at 15 bars, the actual holding period won’t always match the 3.75-hour horizon the analysis was done on. Holding for exactly 15 bars after each signal would be a more literal version of the strategy. The notebook avoids it because a new signal arrives every bar, so you’d need extra rules for overlapping positions. Trying that version yourself and comparing results would be a good exercise.

On time_prd = 1: the results change because you’ve changed the question being asked. Skewness of single 15-minute returns is much noisier, and one outlier bar can flip a cluster’s label from long to neutral or short. The intended setting is time_prd = 15.

Hope that helps.

1 Like

Hi Reknit,

Thank you for your constructive comments. I now understand the logic behind the code: the position is held as long as the market stays in the same regime.

I’m currently curious about how the 15-period window is determined. It seems to be a hyperparameter, and I realize that a longer period reduces the influence of outliers, but what would be a reasonable upper bound for this value? One idea I have is to choose a period that makes the return skewness exceed 1 or fall below -1 more often. Are there any other reasonable rules for selecting this value?

Thank you.

Hi,

Glad the earlier explanation helped. You’re right that the 15-period window is a hyperparameter. Actually, there is no “best” or “correct” value, but you can try different methods to pick that.

Check how long the market actually stays in one cluster. The strategy holds a position while the regime persists, so the analysis horizon should roughly match the expected holding period. You can measure this from the training data: count consecutive bars in the same cluster and look at the median run length. If regimes typically last 10 to 30 bars, a 15-bar horizon makes sense. Stretch the horizon past the typical regime duration and most of the return you’re measuring gets earned in later regimes, so the link between “this cluster” and “this return” breaks. That could be one way to pick an upper bound.

Skewness can be hard to estimate and might need a lot of data. With a k-bar horizon the returns overlap, so your effective sample per cluster is roughly the row count divided by k. Go too long and each cluster’s skew rests on a handful of episodes, which is the outlier problem you were trying to escape, just wearing a different hat. And 15 bars is 3.75 hours, mostly inside one trading day. Past 26 bars, you start crossing overnight gaps, and those behave nothing like intraday moves.

On your proposed rule, picking the period that pushes skewness past ±1 more often, might not be helpful. Aggregated returns become more normal, so skewness shrinks mechanically as the horizon grows. A fixed ±1 threshold means something different at every horizon, and comparing crossing frequencies across horizons is apples to oranges. Also, more signals isn’t the goal. Better out-of-sample performance should be. Picking the value that fires most often on the data you evaluate on is a mild form of data snooping.

You can try a small grid (say 5, 15, 26, 52 bars), run the full pipeline on training data for each, compare Sharpe or return-to-drawdown on a validation slice, then look at the test set once. Check the neighbours too. If 15 works but 13 and 17 fall apart, you’ve found noise, not signal. The run-length approach is the safer of the two since it never consults performance, so there’s nothing to overfit. Treat the grid search as a one-shot exercise, not a loop you keep running against the test set.

Hope that helps.

1 Like