Dev.to · 8 min read

Finding charts that look like this one

Finding charts that look like this one

Every charting tool eventually gets the same feature request: "show me other times this stock looked like this." It sounds like a lookup. It is not. The retrieval is the easy half. The hard half is that a correct implementation can still produce results that are quietly meaningless, and nothing in the code will tell you. Here is the method, and the failure modes worth knowing before you ship it. No claims about predictive power anywhere in this piece — the last section explains why that is a deliberate choice, not a hedge. The naive version, and why it fails immediately The obvious first attempt: take the last 30 days of closing prices as a query vector, slide it across history, compute Euclidean distance, return the closest matches. import numpy as np def naive_search(history, query, k=5): m = len(query) windows = np.lib.stride_tricks.sliding_window_view(history, m) dists = np.linalg.norm(windows - query, axis=1) idx = np.argsort(dists)[:k] return idx, dists[idx] Run this and you get garbage — but instructively specific garbage. Every match comes from whatever period had a similar price level. Query a stock trading at $180 and you get back the other times it traded near $180. The shape is irrelevant to the metric; the offset dominates it. Scale is the same problem in a different coat. A stock that moved 2% over the window and one that moved 40% can trace an identical shape, and raw distance calls them unrelated. Normalize per window, not globally The fix is to z-normalize each window independently: def znorm(x, axis=-1, eps=1e-8): mu = x.mean(axis=axis, keepdims=True) sd = x.std(axis=axis, keepdims=True) return (x - mu) / (sd + eps) Per-window is the load-bearing part. Normalizing the whole series once preserves the relative offsets you were trying to remove. Each candidate window has to be centered and scaled on its own terms before it's compared. There's a satisfying identity waiting here. For z-normalized vectors of length m, squared Euclidean distance and Pearson correlation are the same measurement wearing different clothes: d²(x̂, ŷ) = 2m · (1 − r(x, y)) So "closest z-normalized Euclidean match" and "highest correlation match" return the same ranking. Pick whichever is faster in your stack; they cannot disagree. Worth knowing before you spend an afternoon benchmarking two metrics that are secretly one metric. One more decision at this layer: prices or returns? Log returns, taken before normalizing, are the safer default. returns = np.diff(np.log(prices)) Prices are non-stationary — the variance depends on when you're looking — and returns are much closer to stationary. Shape matching on returns compares how the stock moved, which is the question, rather than where it sat, which isn't. Window length is a real parameter, not a default Window length decides what "similar" means, and there is no correct value. Thirty bars finds swing structure. Five bars finds candlestick shapes and mostly finds noise. Two hundred bars finds regimes, and finds very few matches, because long shapes are close to unique. Supporting several lengths and letting the query pick one is usually worth the complexity, because a user asking "does this look like a breakout" and a user asking "does this look like 2019" are asking different questions in the same words. The thing worth internalizing: as window length grows, the number of genuinely similar historical windows collapses fast. At 200 bars you're often looking at the nearest neighbor in a space where nothing is actually near. Euclidean or DTW? Dynamic time warping allows stretching along the time axis, so a pattern that took 20 days matches one that took 30. Appealing, and expensive: O(mn) per comparison against O(m) for Euclidean, before any pruning. For most price-history search, z-normalized Euclidean wins. Two reasons. First, cost. Windowed search over decades of daily bars across thousands of tickers is a lot of comparisons, and DTW turns an interactive feature into a batch job. Second, and more interesting: on financial series, warping is not obviously desirable. A move that took 20 days and the same-shaped move that took 60 are different events with different mechanics. DTW deliberately erases a distinction you probably want to keep. If you do go DTW, use a Sakoe–Chiba band to cap the warping window. Unconstrained DTW will happily match things no human would call similar. Making it fast The naive scan is O(n·m) per query. Two things fix that. Precompute the rolling statistics. Rolling mean and standard deviation over a fixed window come from cumulative sums in O(n) total, so normalization stops being per-comparison work: def rolling_stats(x, m): c1 = np.concatenate(([0.0], np.cumsum(x))) c2 = np.concatenate(([0.0], np.cumsum(x**2))) s = c1[m:] - c1[:-m] ss = c2[m:] - c2[:-m] mean = s / m var = np.maximum(ss / m - mean**2, 0.0) return mean, np.sqrt(var) Then stop writing it yourself. The general problem — sliding-window z-normalized similarity across a long series — is the matrix profile, and it has a well-optimized open-source implementation in STUMPY. It uses an FFT-based approach to compute all pairwise window distances far below the naive bound. If your dataset fits the shape of the problem, use it rather than reinventing MASS. For cross-ticker search at scale, treat normalized windows as vectors and put them in an ANN index (FAISS, HNSW). You trade exactness for latency. For a feature whose output is "here are some historically similar periods," approximate neighbors are entirely acceptable — the ranking is a suggestion, not a proof. Four ways this lies to you Everything above produces a working feature. Here is what makes a working feature dishonest. 1. Look-ahead in the window definition. If your window ends at the current bar but any part of your normalization, feature engineering, or filtering touched data after it, your matches are contaminated. This is easy to introduce accidentally — a resampling step, an adjusted-close series revised after a split, a "clean the data" pass run over the whole history at once. Audit the boundary explicitly. Assume you got it wrong until you've checked. 2. Overlapping windows inflate everything. Adjacent windows share most of their data, so the top-k results are frequently the same event returned five times with a one-day offset, wearing five different dates. It reads as five independent confirmations. It is one event. Deduplicate by requiring matches to be at least one window length apart — the standard exclusion-zone trick from motif discovery, and skipping it is the most common way these features overstate their case. 3. Survivorship. If your historical universe contains only tickers that exist today, every match you return comes from a company that didn't go to zero. Whatever followed those patterns is conditioned on survival before you compute anything. 4. Regime. A shape from 2009 and the same shape from 2021 sit inside different rate environments, different liquidity, different market structure. Shape similarity is silent about all of it. Two windows can be nearest neighbors in your metric and share no economic mechanism at all. None of these are solvable in the metric. They're properties of what the data can support, and the only honest response is to build them into what you show the user. The part that isn't engineering There is one product decision waiting at the end of this, and it is larger than any of the technical ones. Show the matched windows and what followed them, and you have a research tool. Aggregate those outcomes into a single number — "72% of similar patterns went up" — and you have made a prediction. The code is nearly identical. The claim is not. That percentage is where every trap above becomes load-bearing. Overlapping windows inflate the sample. Survivorship skews which outcomes exist to be counted. Regime differences mean the sample is not a sample of any coherent population. None of that matters while you are showing analogues for a human to inspect. All of it matters the moment you average them into a probability, because now you are asserting something the data cannot support. The honest version shows the material and stops. It is less satisfying — people want the number, and a number is what makes a feature feel finished. But a percentage computed on a contaminated sample is not a weaker version of a good answer. It is a confident version of a wrong one. Worth saying plainly: "presented as context" and "read as a signal" are different events, and a builder only controls the first. If you have solved that framing problem better than showing analogues and refusing to total them, I would like to hear it. I build MarketPug, a free stock-research app where chart similarity sits alongside sector heatmaps, fundamentals and institutional holdings. The web version needs no account. Corrections to any of the above are welcome — the DTW tradeoff in particular is arguable in both directions.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

Related stories