DEV Community

Cover image for The Best DTW Candidate Scored 9.97. That Still Wasn't a Decision...
Kyrylo Malovychko
Kyrylo Malovychko

Posted on

The Best DTW Candidate Scored 9.97. That Still Wasn't a Decision...

I pointed a DTW scanner at the official Binance Spot ETHUSDT one-minute archive for July 2023.

The reference window was:

2023-07-01 12:13–12:23 UTC
Enter fullscreen mode Exit fullscreen mode

The best non-overlapping candidate in the monthly scan was:

2023-07-27 17:45–17:55 UTC
Enter fullscreen mode Exit fullscreen mode

The scanner returned:

distance=9.967796
path-steps=11
stretch-steps=2
Enter fullscreen mode Exit fullscreen mode

Fine.

Is that a match?

No honest answer yet.

It is the smallest distance found under one preprocessing rule, one local cost, one Sakoe–Chiba window, and one slope penalty. The scanner found an argmin. It did not magically produce a trading decision.

That distinction is where most DTW demos stop being useful.

I also tried to use two Binance UI screenshots as provenance. I rejected them.

The visible price scales did not match the CSV rows used by the scanner: the reference file contains roughly 1925–1927, while the screenshot showed a different price region; the candidate file contains roughly 1864–1867, while its screenshot was around 1923–1929.

Wrong date, wrong market, wrong timezone, or a chart that never actually jumped to the requested archive — it does not matter. A pretty screenshot pointing at different candles is worse than no screenshot.

The figure below is rebuilt directly from the exact OHLC rows fed into the scan.

The exact 11-candle Binance windows used by the scanner

The two selected windows reconstructed from the exported Binance kline rows. Their nominal price levels differ, so the alignment model uses return shape rather than raw-price equality.

The score got four times smaller. It still did not become a probability.

The two windows live at different nominal price levels:

reference: around 1,926 USDT
candidate: around 1,865 USDT
Enter fullscreen mode Exit fullscreen mode

Comparing raw closes would mostly measure that price-level difference. The scanner converts each window to close-to-close returns in basis points:

returnBPS := ((currentClose - previousClose) / previousClose) * 10000
Enter fullscreen mode Exit fullscreen mode

That transformation is visible in the code. lvlath/dtw does not silently normalize the sequence behind the caller's back.

Hidden normalization is the sort of “help” that makes a demo look clean and a production incident miserable to reconstruct.

With strict index pairing, the ten return samples accumulate an absolute cost of:

39.893841
Enter fullscreen mode Exit fullscreen mode

The selected DTW path accumulates:

9.967796
Enter fullscreen mode Exit fullscreen mode

under the same absolute local cost and a 0.05 penalty for every horizontal or vertical move.

The objective fell by roughly 75%.

That is not a 75% similarity score. It only says that allowing a constrained time deformation explains the two return sequences much better than forcing sample i to match sample i.

The exact return series and the selected DTW path

The actual model input. Left: close-to-close returns in basis points. Right: the local absolute-cost surface and selected path.

A percentage without calibration is just a confident UI element.

What the real path says

The scanner called:

dtw.Align(candidateSignal, referenceSignal, ...)
Enter fullscreen mode Exit fullscreen mode

The public coordinate contract is therefore:

Coord.I → candidate index
Coord.J → reference index
Enter fullscreen mode Exit fullscreen mode

The selected path was:

{0,0}
{0,1}   horizontal
{1,2}
{2,3}
{3,4}
{4,5}
{5,6}
{6,7}
{7,8}
{8,9}
{9,9}   vertical
Enter fullscreen mode Exit fullscreen mode

Most of the path runs diagonally with a one-step offset.

Two cells carry the temporal deformation:

{0,0} → {0,1}
Enter fullscreen mode Exit fullscreen mode

The candidate's first return is held while the reference advances once.

At the other end:

{8,9} → {9,9}
Enter fullscreen mode Exit fullscreen mode

The candidate advances while the final reference phase is held.

That is much more useful than “9.97”. It tells the reviewer exactly where the clocks were stretched.

The path also shows what DTW did not fix. The largest local contribution on the selected route is 3.578995, where candidate return -3.321707 is paired with reference return -6.900703. Warping can move time; it cannot turn a genuinely different price move into the same move.

This is why a warping path is evidence, not a permission slip.

“Best candidate” is not “accepted match”

The first scanner version printed only the minimum.

That is enough for ranking, not classification.

Suppose the best monthly distance is 9.967796. Is that excellent, mediocre, or awful?

The answer needs context the DTW recurrence does not own:

  • a labeled positive/negative corpus;
  • a threshold selected against false-positive and false-negative cost;
  • distance distributions for the same preprocessing and policy;
  • perhaps a second-stage rule that inspects path shape, stretch count, or domain constraints.

A threshold copied from a synthetic fixture would be especially bad. The deterministic repository example reports 0.89; that does not make 1.50 a valid production cutoff for this Binance corpus.

A cutoff from the synthetic fixture does not survive a change of data distribution.

The updated article scanner therefore uses two passes and can export every candidate score.

Two-pass DTW service architecture

Scan every candidate with rolling rows. Recover a path only for the shortlist. Keep the business threshold outside the alignment kernel.

The architecture is simple:

1. preprocess explicitly
2. scan every window distance-only
3. rank / apply a calibrated threshold
4. rerun selected windows with path tracking
Enter fullscreen mode Exit fullscreen mode

No need to allocate an O(n×m) backtracking surface for every candidate and then act surprised when the pod gets OOMKilled.

The current lvlath/dtw API

The package exposes three canonical facades because “time series” is not one data type.

Align: scalar sequences

Use Align when one scalar represents one time step:

res, err := dtw.Align(
    candidateReturns,
    referenceReturns,
    dtw.WithWindow(2),
    dtw.WithSlopePenalty(0.05),
)
Enter fullscreen mode Exit fullscreen mode

The default local cost is absolute difference. Callers can select squared cost or provide a deterministic custom CostFunc.

This surface fits returns, temperatures, single-axis sensors, latency traces, or other scalar signals.

AlignMatrix: multivariate features

Use AlignMatrix when each time step has several features:

res, err := dtw.AlignMatrix(
    breakoutTemplate,
    liveWindow,
    dtw.WithWindow(4),
    dtw.WithSlopePenalty(0.15),
    dtw.WithReturnPath(true),
)
Enter fullscreen mode Exit fullscreen mode

Rows are time steps. Columns are features. The two matrices must have the same feature dimension.

The facade builds pairwise local costs with squared L2 distance between rows.

It does not normalize the features.

Leaving preprocessing to the caller is an ownership decision.

OHLC, returns, z-scores, volatility-adjusted values, sensor channels, and embeddings are different mathematical inputs. A library that silently rescales them has changed the problem while pretending to save the caller a few lines.

AlignCostMatrix: bring your own local model

Use AlignCostMatrix when another system already produced the n×m pairwise cost surface.

The voice-command example feeds a 10×14 acoustic local-cost matrix. An embedding model, phoneme recognizer, frame matcher, or domain-specific rule engine could own the same layer.

DTW then does one job: find the cheapest admissible monotone path through that surface.

The local costs still have a contract. They must be finite and non-negative.

Window and slope are not decorative tuning knobs

The window

WithWindow(w) defines the Sakoe–Chiba band:

w = -1   no band constraint
w =  0   strict diagonal
w >= 0   only cells with |i-j| <= w
Enter fullscreen mode Exit fullscreen mode

0 does not mean “disabled”. It means no temporal deformation at all.

A finite band blocks ridiculous alignments: the first phoneme should not match the end of a command; the onset of a vibration event should not match recovery.

The policy can also make alignment impossible. If the band contains no valid path:

Reachable=false
Distance=+Inf
Enter fullscreen mode Exit fullscreen mode

That is a legitimate model result, not a panic.

The slope penalty

WithSlopePenalty(p) adds p only to horizontal and vertical steps.

Those are exactly the moves that hold one sequence while the other advances.

A positive penalty makes temporal stretching cost something. Zero leaves it free except for local mismatch.

Neither choice is universally correct.

A voice command may tolerate a held vowel. A machinery signature may treat an extra peak frame as suspicious. A market scanner may allow a one- or two-candle delay but reject a pattern smeared across half an hour.

The number belongs to the domain, not to DTW folklore.

The path is deterministic on purpose

lvlath/dtw returns one deterministic representative optimal path.

type Coord struct {
    I int
    J int
}

type Path []Coord
Enter fullscreen mode Exit fullscreen mode

Several predecessor cells can have equal accumulated cost. The package uses a fixed backtracking order:

diagonal
then vertical/up
then horizontal/left
Enter fullscreen mode Exit fullscreen mode

This does not claim the mathematical optimum is unique.

It means a stable distance also produces a stable explanation.

That matters when tests assert phase anchors, incident reports compare paths, or downstream logic counts stretch steps. Without a tie-break law, an internal refactor can leave the scalar unchanged and quietly rewrite the witness.

Ask for a path and the bill changes. It should.

The benchmark suite measures separate DTW contracts rather than pretending they are one workload.

On this machine:

darwin/amd64
Intel Core i9-9880H @ 2.30GHz
10 benchmark runs
Enter fullscreen mode Exit fullscreen mode

The saved output did not include go version. Add the actual toolchain before publication; guessing it later would undermine the reproduction block.

benchstat reported:

Contract Workload Time/op B/op allocs/op
Scalar distance, TwoRows 500×500 2.169 ms 8.156 KiB 4
Reachable band, w=10 500×500 527.8 µs 8.156 KiB 4
Strict-diagonal no path, w=0 500×501 455.0 µs 8.156 KiB 4
FullMatrix + path 250×250 1.232 ms 1004.2 KiB 9
AlignMatrix, squared L2, d=8 200×200 1.935 ms 1007.3 KiB 20

This is not a speed ranking. The dimensions and returned artifacts differ.

It does expose three useful facts.

First, the finite w=10 band ran about 4.1× faster than unconstrained scalar 500×500 distance on this fixture while keeping the same rolling-row allocation profile.

Second, the smaller 250×250 path workload allocated roughly 123× more bytes per operation than scalar 500×500 distance-only mode.

That is the cost of retaining accumulated state and recovering a witness. The lower runtime is not a contradiction; the path benchmark has only one quarter as many grid cells.

Third, AlignMatrix also sits around one megabyte per operation at 200×200×8 because the facade materializes the multivariate local-cost surface.

So the honest memory statement is not:

DTW uses O(m) memory.

It is:

Scalar distance-only Align can keep accumulated DP state in O(m). Path recovery needs O(n×m), and AlignMatrix has an additional O(n×m) local-cost surface.

Measured DTW result contracts

Different workloads, different artifacts. The table is not a universal solver ranking; it exposes the resource contract of each mode.

Returning nil and making the caller play detective is lazy API design

The result carries the state needed to interpret it:

type Result struct {
    Distance  float64
    Reachable bool

    Path        Path
    PathTracked bool

    Window       int
    SlopePenalty float64
    MemoryMode   MemoryMode

    Accumulated *matrix.Dense
    LocalCost   *matrix.Dense
}
Enter fullscreen mode Exit fullscreen mode

PathOrError() distinguishes:

nil result
    → ErrNilResult

no admissible alignment
    → ErrNoPath

finite alignment, path not requested
    → ErrPathNotTracked
Enter fullscreen mode Exit fullscreen mode

A distance-only scan is a successful operation. It should not look like an alignment failure merely because no path slice was allocated.

A too-tight window is a different outcome. Caller logic may widen the band, reject the candidate, or record a policy failure.

Different decisions deserve different states.

Crypto is only one workload

The same result model appears in two other runnable examples.

Voice command

distance=0.83
path-steps=14
stretch-steps=4
decision=accepted with stretched pronunciation
Enter fullscreen mode Exit fullscreen mode

The local acoustic-cost surface is computed elsewhere; AlignCostMatrix recovers the monotone correspondence.

Vibration signature

distance=0.0776
path-steps=17
stretch-steps=3
peak={6,8}
decision=signature match
Enter fullscreen mode Exit fullscreen mode

Here AlignMatrix compares three sensor features per time step and accepts a delayed-but-normal spindle impact.

Other plausible uses follow the same shape:

  • gesture and motion alignment;
  • production-cycle timing;
  • latency-profile comparison;
  • ECG or respiratory phase alignment;
  • video-frame or embedding correspondence;
  • event-sequence template matching.

The API does not make those domains equivalent. It gives each one the same honest set of artifacts:

distance
path
local cost
accumulated cost
reachability
memory policy
Enter fullscreen mode Exit fullscreen mode

Reproduce the real-data scan

The source dataset is the official Binance Spot monthly kline archive:

ETHUSDT
1-minute candles
July 2023
UTC
Enter fullscreen mode Exit fullscreen mode

The article package includes the exact selected windows, path CSV, screenshots, and an updated two-pass scanner.

Run:

go run ./dtw_binance_real_scan_v2.go \
  -csv ETHUSDT-1m-2023-07.csv \
  -reference-start 2023-07-01T12:13:00Z \
  -candles 11 \
  -window 2 \
  -slope 0.05 \
  -exclude 30 \
  -top 10 \
  -out-prefix ethusdt-dtw
Enter fullscreen mode Exit fullscreen mode

The scanner deliberately prints:

classification=not-configured
Enter fullscreen mode Exit fullscreen mode

until the caller supplies a calibrated threshold.

That line is not missing functionality. It prevents the monthly argmin from being smuggled into business logic as a verified pattern match.

The useful boundary

A distance answers:

Which candidate is closest under this preprocessing and policy?

A path answers:

Which time steps correspond, and where did the clock stretch?

A local-cost matrix answers:

Which feature pairs created the mismatch?

An accumulated matrix answers:

Why did dynamic programming choose this route through the cost surface?

A threshold answers another question entirely:

Is this candidate good enough for my application?

One float should not pretend to answer all five.


Source and reproduction material

Top comments (0)