New Time Series Forecasting Technique - All You Need to Know
Looking at the Past Not for Rules, but for Similar Situations
Most forecasting models try to do the same thing. They take past data and try to learn a rule. A formula. A pattern that says, when this happens, that usually follows.
The Local Path Regressor (LPR) takes a different approach. It doesn’t try to learn a rule at all.
Instead, it asks a much simpler question:
“Have we seen something like this before, and what happened next?”
That’s it.
No equations. No training in the usual sense. Just pattern matching and averaging. And surprisingly, that’s enough to build a working forecasting system.
The Core Idea
Imagine you’re looking at the last 12 days of a price chart. Not the entire history. Just the recent shape.
Maybe it went up, pulled back slightly, then drifted sideways. That short sequence is your current situation. Now instead of trying to model it, you go back through history and look for moments where the market behaved similarly over n periods.
Once you find those moments, you look at what happened next.
Did price continue higher?
Did it reverse?
Did it chop sideways?
You collect those “what happened next” paths, and then you average them. That average becomes your forecast. That’s the Local Path Regressor. It’s less like prediction, and more like pattern recall.
This idea is described clearly in the original paper, which frames the model as searching for “similar recent price trajectories” and averaging their future outcomes .
Why This Is Interesting
Most models try to generalize. LPR does the opposite. It stays local. It doesn’t assume markets behave the same way everywhere. It only cares about situations that look like the present one. That makes it:
easy to understand
transparent
surprisingly intuitive
But it also comes with limits, which we’ll get to.
The Three Knobs That Control Everything
There are only three real decisions in this model.
1. Pattern length
How many past points define “what’s happening now”?
Too short → everything looks similar
Too long → nothing matches
2. Forecast horizon
How far into the future you want to look
3. Number of matches (Top K)
How many similar past situations you average together
Too few → noisy
Too many → overly smooth
These trade off against each other. Longer patterns usually mean fewer matches. Longer forecasts require stronger similarity. There’s no perfect setting. Only reasonable ones.
I built Quant Atlas to cut through market noise.
No opinions. Just quantitative, robust, quality forecasts.
Try it yourself in real time.
What the Graphs Show
Black line → historical prices
Blue line → predicted path
Red line → what actually happened
Sometimes the prediction gets the direction right. Sometimes it doesn’t.
Some forecasts look reasonable. Others diverge quickly. That inconsistency is the point. This is not a stable edge. It’s a structured way to explore how much information is really in past price patterns.
The Code
Here’s the actual implementation used in the paper:
import warnings
warnings.filterwarnings("ignore")
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import yfinance as yf
# =========================================================
# CONFIG
# =========================================================
TICKERS = {
"EURUSD": "EURUSD=X",
"EURGBP": "EURGBP=X",
"EURCAD": "EURCAD=X",
"EURAUD": "EURAUD=X",
"EURJPY": "EURJPY=X",
"EURCHF": "EURCHF=X",
}
LOOKBACK_PERIOD = "1250d"
INTERVAL = "1D"
TRAIN_RATIO = 0.80
FORECAST_HORIZON = 24 # next n values to predict
PATTERN_LENGTH = 12 # how many past bars define the local pattern
TOP_K_MATCHES = 5 # number of similar historical patterns to average
# =========================================================
# DATA DOWNLOAD
# =========================================================
def download_data(ticker, period=LOOKBACK_PERIOD, interval=INTERVAL):
df = yf.download(
ticker,
period=period,
interval=interval,
auto_adjust=False,
progress=False
)
if df.empty:
raise ValueError(f"No data returned for {ticker}")
if isinstance(df.columns, pd.MultiIndex):
df.columns = df.columns.get_level_values(0)
needed = ["Open", "High", "Low", "Close"]
missing = [c for c in needed if c not in df.columns]
if missing:
raise ValueError(f"Missing columns for {ticker}: {missing}")
df = df[needed].dropna().copy()
return df
# =========================================================
# PREP
# =========================================================
def prepare_data(df):
out = df.copy()
out["ret"] = out["Close"].pct_change()
out = out.dropna().copy()
return out
# =========================================================
# LOCAL PATH REGRESSOR
# =========================================================
def zscore(x):
x = np.asarray(x, dtype=float)
s = x.std()
if s == 0:
return np.zeros_like(x)
return (x - x.mean()) / s
def get_pattern_returns(close_series, end_idx, pattern_length):
"""
Return the last 'pattern_length' returns ending at end_idx.
"""
start_idx = end_idx - pattern_length + 1
if start_idx < 1:
return None
window = close_series.iloc[start_idx - 1:end_idx + 1].values
rets = window[1:] / window[:-1] - 1.0
return rets
def fit_lpr(train_df, pattern_length=24, horizon=6):
"""
Store the train close series and settings.
"""
if len(train_df) < pattern_length + horizon + 10:
raise ValueError("Training set too small for chosen pattern length and horizon")
model = {
"train_close": train_df["Close"].copy(),
"pattern_length": pattern_length,
"horizon": horizon
}
return model
def forecast_next_n_path_lpr(full_df, train_end_idx, model, top_k=20):
"""
Use the first test point as forecast origin.
Match its recent pattern against historical train patterns.
Average the future paths of the best matches.
"""
close = full_df["Close"]
pattern_length = model["pattern_length"]
horizon = model["horizon"]
origin_idx = train_end_idx
cutoff_timestamp = full_df.index[origin_idx]
origin_price = float(close.iloc[origin_idx])
# Current pattern ends at the forecast origin
current_pattern = get_pattern_returns(close, origin_idx, pattern_length)
if current_pattern is None:
raise ValueError("Not enough data before forecast origin to build current pattern")
current_pattern_z = zscore(current_pattern)
candidate_distances = []
candidate_future_paths = []
# Search only in the training region
# Candidate index = end of historical pattern
min_candidate = pattern_length
max_candidate = train_end_idx - horizon - 1
for candidate_idx in range(min_candidate, max_candidate + 1):
hist_pattern = get_pattern_returns(close, candidate_idx, pattern_length)
if hist_pattern is None:
continue
hist_pattern_z = zscore(hist_pattern)
# Euclidean distance between normalized return patterns
dist = np.linalg.norm(current_pattern_z - hist_pattern_z)
# Historical future path after the candidate
future_prices = close.iloc[candidate_idx + 1:candidate_idx + 1 + horizon].values
current_hist_price = float(close.iloc[candidate_idx])
if len(future_prices) != horizon:
continue
# Convert to relative path from candidate origin
relative_path = future_prices / current_hist_price
candidate_distances.append(dist)
candidate_future_paths.append(relative_path)
if len(candidate_distances) == 0:
raise ValueError("No valid historical candidate patterns found")
candidate_distances = np.array(candidate_distances)
candidate_future_paths = np.array(candidate_future_paths)
# Best matches
order = np.argsort(candidate_distances)
top_idx = order[:min(top_k, len(order))]
top_distances = candidate_distances[top_idx]
top_paths = candidate_future_paths[top_idx]
# Similarity weights
# Smaller distance => larger weight
eps = 1e-12
weights = 1.0 / (top_distances + eps)
weights = weights / weights.sum()
# Weighted average relative future path
avg_relative_path = np.average(top_paths, axis=0, weights=weights)
# Predicted absolute path from current origin price
predicted_prices = origin_price * avg_relative_path
# Realized path from the test set
realized_prices = close.iloc[origin_idx + 1:origin_idx + 1 + horizon].values
if len(realized_prices) != horizon:
raise ValueError("Not enough realized values after the cutoff")
pred_index = full_df.index[origin_idx + 1:origin_idx + 1 + horizon]
return (
pred_index,
np.array(predicted_prices, dtype=float),
np.array(realized_prices, dtype=float),
cutoff_timestamp,
origin_price,
top_distances,
weights
)
# =========================================================
# METRIC
# =========================================================
def directional_hit_ratio_from_path(origin_price, predicted_prices, realized_prices):
pred_dir = np.sign(predicted_prices - origin_price)
real_dir = np.sign(realized_prices - origin_price)
valid = ~np.isnan(real_dir)
if valid.sum() == 0:
return np.nan
return (pred_dir[valid] == real_dir[valid]).mean()
# =========================================================
# PLOT
# =========================================================
def plot_forecast(full_df, cutoff_timestamp, pred_index, predicted_prices, realized_prices, asset_name, horizon):
history = full_df.loc[:cutoff_timestamp, "Close"].tail(250)
plt.figure(figsize=(14, 7))
# Black historical time series until cutoff
plt.plot(
history.index,
history.values,
color="black",
linewidth=1.5,
label="Historical Close"
)
# Vertical line for test start
plt.axvline(
cutoff_timestamp,
color="gray",
linestyle="--",
linewidth=1.2,
label="Test Start"
)
# Blue predicted path
plt.plot(
pred_index,
predicted_prices,
color="blue",
linewidth=2.0,
label=f"Predicted Next {horizon}"
)
# Red realized path
plt.plot(
pred_index,
realized_prices,
color="red",
linewidth=2.0,
label=f"Realized Next {horizon}"
)
plt.title(f"{asset_name} | LPR Forecast Path")
plt.xlabel("Time")
plt.ylabel("Price")
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# =========================================================
# RUN PER ASSET
# =========================================================
def run_asset(asset_name, ticker, train_ratio=0.8, pattern_length=24, horizon=6, top_k=20):
print(f"\nRunning {asset_name} ({ticker})")
df = download_data(ticker)
df = prepare_data(df)
split_idx = int(len(df) * train_ratio)
if split_idx < pattern_length + 10:
raise ValueError("Training split too early for the chosen pattern length")
if split_idx + horizon >= len(df):
raise ValueError("Not enough test data after cutoff for the chosen horizon")
train_df = df.iloc[:split_idx].copy()
model = fit_lpr(
train_df=train_df,
pattern_length=pattern_length,
horizon=horizon
)
(
pred_index,
predicted_prices,
realized_prices,
cutoff_timestamp,
origin_price,
top_distances,
weights
) = forecast_next_n_path_lpr(
full_df=df,
train_end_idx=split_idx,
model=model,
top_k=top_k
)
hit_ratio = directional_hit_ratio_from_path(
origin_price=origin_price,
predicted_prices=predicted_prices,
realized_prices=realized_prices
)
print(f"Train size: {len(train_df)}")
print(f"Cutoff timestamp: {cutoff_timestamp}")
print(f"Pattern length: {pattern_length}")
print(f"Forecast horizon: {horizon}")
print(f"Top-k matches used: {top_k}")
print(f"Directional hit ratio over next {horizon} steps: {hit_ratio:.4f}")
result_table = pd.DataFrame({
"Predicted": predicted_prices,
"Realized": realized_prices
}, index=pred_index)
print("\nForecast vs Realized:")
print(result_table)
print("\nTop match distances:")
print(np.round(top_distances, 6))
print("\nTop match weights:")
print(np.round(weights, 6))
plot_forecast(
full_df=df,
cutoff_timestamp=cutoff_timestamp,
pred_index=pred_index,
predicted_prices=predicted_prices,
realized_prices=realized_prices,
asset_name=asset_name,
horizon=horizon
)
return {
"asset": asset_name,
"ticker": ticker,
"hit_ratio": hit_ratio,
"table": result_table
}
# =========================================================
# MAIN
# =========================================================
if __name__ == "__main__":
results = []
for asset_name, ticker in TICKERS.items():
try:
out = run_asset(
asset_name=asset_name,
ticker=ticker,
train_ratio=TRAIN_RATIO,
pattern_length=PATTERN_LENGTH,
horizon=FORECAST_HORIZON,
top_k=TOP_K_MATCHES
)
results.append(out)
except Exception as e:
print(f"Error for {asset_name}: {e}")
if len(results) > 0:
summary = pd.DataFrame({
"Asset": [r["asset"] for r in results],
"Ticker": [r["ticker"] for r in results],
"Directional Hit Ratio": [r["hit_ratio"] for r in results]
})
print("\nSummary:")
print(summary.to_string(index=False))Let’s walk through what it’s doing, step by step.
1. Download and Prepare Data
The script pulls market data (like EURUSD, EURJPY) and calculates returns.
2. Define the “Current Pattern”
At any point in time, the model looks at the last N bars (pattern length). It converts them into returns so different periods are comparable. Then it standardizes them so the shape matters more than the scale.
In plain terms:
“What did the recent movement look like?”
3. Search the Past
Now the model goes back through historical data and looks for similar patterns.
It compares:
the current pattern
every past pattern of the same length
and measures how close they are.
Closer = more similar.
4. Collect What Happened Next
For each similar past pattern, the model grabs what happened immediately after.
Not just one value, but a short path into the future. So now you have multiple “possible futures” based on history.
5. Average Them
Instead of picking one, the model averages them. But not equally.
Closer matches get more weight. Distant ones matter less. This gives you a single forecast path.
6. Compare With Reality
Finally, the model compares:
predicted path
actual path
and checks whether it got the direction right. That’s how the hit ratio is computed.
What This Model Is Actually Good For
Used alone, LPR is not a strong trading system.
But it is useful in a few ways:
As a baseline to compare more complex models
As a way to visualize analogs in market behavior
As a feature generator (the forecast path itself can be input to other models)
As a sanity check for how much signal exists in price alone
It forces a useful question:
If the market has done something similar before, did it actually matter?
Often, the answer is “not much.”
Where It Breaks
There are a few obvious weaknesses:
It depends heavily on how you define “similar”
It struggles in new regimes
It cannot predict anything that hasn’t happened before
It’s noisy, because markets are noisy
And most importantly:
It assumes patterns repeat in a meaningful way
That assumption is only weakly true in financial markets.
The Local Path Regressor is not powerful because it predicts well. It’s powerful because it’s honest.
It strips forecasting down to something simple:
find similar situations
see what happened next
average the outcomes
No hidden complexity. No black box. And when you run it, the results tell you something important: There’s not a lot of easy signal in price alone.
That might be disappointing. But it’s also the kind of clarity most models quietly avoid.





I’m curious how the forecast’s quality changes with respect to the length of the look back and forecast windows. I would guess that for sufficiently long windows, the model breaks since in the “long run” (however you wanna define that), asset prices are governed by economic factors rather than time series characteristics.