How to calculate price velocity and market momentum from Betfair Streaming data in Java — tracking LTP movement rate, detecting steam and drift signals, and combining velocity with Weight of Money for a composite entry signal.
If Weight of Money tells you where the pressure is sitting in the order book, price velocity tells you whether that pressure is actually moving the market. I’ve found velocity to be one of the more reliable pre-race signals precisely because it measures what has already happened — the Last Traded Price has moved — rather than what market participants are queuing to do. Queued orders can be cancelled in milliseconds; executed trades cannot be undone.
This post builds directly on the WoM work from an earlier post. By the end you’ll have a composite signal combining both — which is where the real edge starts to emerge.
Price velocity is the rate of change of the Last Traded Price (LTP) over a fixed time window. A positive velocity means the price is shortening (steaming); a negative velocity means it’s lengthening (drifting). The magnitude tells you how fast.
Unlike a simple comparison of first and last price, a velocity calculation over a rolling window is more resistant to single-tick noise — one large trade that immediately corrects won’t dominate the signal if the surrounding window is flat.
The formula I use:
velocity = (recentAvgLtp - earlyAvgLtp) / windowDurationSeconds
This gives you price change per second, which is meaningful across different window sizes and race types.
The Streaming API delivers LTP as ltp in each RunnerChange. It only appears when a trade executes, so between trades the field is absent — you’re working with sparse updates, not a continuous feed. That matters for how you store the data:
public class LtpObservation {
private final Instant timestamp;
private final double price;
public LtpObservation(Instant timestamp, double price) {
this.timestamp = timestamp;
this.price = price;
}
public Instant timestamp() { return timestamp; }
public double price() { return price; }
}
Record every LTP update with its arrival timestamp. Don’t interpolate between observations — missing ticks mean no trades executed, which is itself informative.
public class PriceVelocityCalculator {
private final Duration windowDuration;
private final Deque<LtpObservation> observations = new ArrayDeque<>();
public PriceVelocityCalculator(Duration windowDuration) {
this.windowDuration = windowDuration;
}
public void record(double ltp) {
Instant now = Instant.now();
observations.addLast(new LtpObservation(now, ltp));
evictExpired(now);
}
public OptionalDouble velocity() {
if (observations.size() < 4) return OptionalDouble.empty();
List<LtpObservation> obs = new ArrayList<>(observations);
int half = obs.size() / 2;
double earlyAvg = obs.subList(0, half).stream()
.mapToDouble(LtpObservation::price).average().orElse(0);
double recentAvg = obs.subList(half, obs.size()).stream()
.mapToDouble(LtpObservation::price).average().orElse(0);
double windowSeconds = windowDuration.toMillis() / 1000.0;
return OptionalDouble.of((recentAvg - earlyAvg) / windowSeconds);
}
public OptionalDouble currentLtp() {
if (observations.isEmpty()) return OptionalDouble.empty();
return OptionalDouble.of(observations.peekLast().price());
}
private void evictExpired(Instant now) {
Instant cutoff = now.minus(windowDuration);
while (!observations.isEmpty() && observations.peekFirst().timestamp().isBefore(cutoff)) {
observations.removeFirst();
}
}
}
A minimum of 4 observations before emitting a signal avoids noise at market open when only one or two trades have executed. The split into early/recent halves is the same approach used in the WoM trend tracker — it’s consistent and easy to reason about.
Price shortening (steam) means the price is moving down — more people are backing. Price drifting means it’s moving up — layers are dominating. The sign of the velocity tells you direction; the magnitude tells you conviction:
public enum PriceSignal { STRONG_STEAM, STEAM, NEUTRAL, DRIFT, STRONG_DRIFT }
public class VelocitySignalClassifier {
private static final double STRONG_THRESHOLD = 0.03; // ticks per second
private static final double WEAK_THRESHOLD = 0.01;
public PriceSignal classify(double velocity) {
if (velocity < -STRONG_THRESHOLD) return PriceSignal.STRONG_STEAM;
if (velocity < -WEAK_THRESHOLD) return PriceSignal.STEAM;
if (velocity > STRONG_THRESHOLD) return PriceSignal.STRONG_DRIFT;
if (velocity > WEAK_THRESHOLD) return PriceSignal.DRIFT;
return PriceSignal.NEUTRAL;
}
}
The thresholds here are starting points — the right values depend on the market type (horse racing moves faster than greyhounds in the final minutes) and the time to the off. I calibrate these from logged data after live sessions.
@Component
public class RunnerSignalService {
private final Map<Long, PriceVelocityCalculator> velocityTrackers = new ConcurrentHashMap<>();
private final VelocitySignalClassifier classifier = new VelocitySignalClassifier();
private static final Duration VELOCITY_WINDOW = Duration.ofSeconds(30);
public void onRunnerChange(long selectionId, RunnerChange rc) {
if (rc.getLtp() == null) return;
PriceVelocityCalculator tracker = velocityTrackers.computeIfAbsent(
selectionId, id -> new PriceVelocityCalculator(VELOCITY_WINDOW));
tracker.record(rc.getLtp());
}
public PriceSignal getVelocitySignal(long selectionId) {
PriceVelocityCalculator tracker = velocityTrackers.get(selectionId);
if (tracker == null) return PriceSignal.NEUTRAL;
return tracker.velocity()
.stream()
.mapToObj(classifier::classify)
.findFirst()
.orElse(PriceSignal.NEUTRAL);
}
public OptionalDouble getCurrentLtp(long selectionId) {
PriceVelocityCalculator tracker = velocityTrackers.get(selectionId);
return tracker == null ? OptionalDouble.empty() : tracker.currentLtp();
}
}
Used alone, velocity is a lagging signal — the price has already moved before you act. WoM can be leading — it shows pressure building before trades execute. The combination is more powerful than either alone:
@Component
@RequiredArgsConstructor
public class CompositeSignalService {
private final RunnerSignalService velocityService;
private final MarketSignalService womService; // from the WoM post
public CompositeSignal evaluate(long selectionId) {
PriceSignal velocity = velocityService.getVelocitySignal(selectionId);
WomSignal wom = womService.getSignal(selectionId);
// Both signals agree: high confidence
if (velocity == PriceSignal.STEAM && wom == WomSignal.STEAM) {
return CompositeSignal.HIGH_CONFIDENCE_STEAM;
}
if (velocity == PriceSignal.DRIFT && wom == WomSignal.DRIFT) {
return CompositeSignal.HIGH_CONFIDENCE_DRIFT;
}
// Velocity confirmed but WoM neutral: moderate confidence
if (velocity == PriceSignal.STRONG_STEAM) return CompositeSignal.STEAM;
if (velocity == PriceSignal.STRONG_DRIFT) return CompositeSignal.DRIFT;
// Signals conflict: stand aside
if ((velocity == PriceSignal.STEAM && wom == WomSignal.DRIFT) ||
(velocity == PriceSignal.DRIFT && wom == WomSignal.STEAM)) {
return CompositeSignal.CONFLICTED;
}
return CompositeSignal.NEUTRAL;
}
}
CONFLICTED is a signal in its own right. When velocity and WoM disagree, the market is in a tug-of-war — entering a position in either direction carries elevated risk. Doing nothing is a valid strategy.
If velocity is the speed of price movement, momentum is whether that speed is increasing or decreasing. A market that was moving at 0.02 ticks/second five minutes ago and is now moving at 0.05 ticks/second has accelerating steam — the probability of further movement is higher than a market already moving fast but slowing down.
public class MomentumTracker {
private final Deque<TimestampedVelocity> velocityHistory = new ArrayDeque<>();
private final int maxEntries;
public MomentumTracker(int maxEntries) {
this.maxEntries = maxEntries;
}
public void record(double velocity) {
velocityHistory.addLast(new TimestampedVelocity(Instant.now(), velocity));
while (velocityHistory.size() > maxEntries) {
velocityHistory.removeFirst();
}
}
/** Positive = acceleration (momentum building), negative = deceleration */
public OptionalDouble momentum() {
if (velocityHistory.size() < 4) return OptionalDouble.empty();
List<TimestampedVelocity> entries = new ArrayList<>(velocityHistory);
int half = entries.size() / 2;
double earlyVelocity = entries.subList(0, half).stream()
.mapToDouble(TimestampedVelocity::velocity).average().orElse(0);
double recentVelocity = entries.subList(half, entries.size()).stream()
.mapToDouble(TimestampedVelocity::velocity).average().orElse(0);
return OptionalDouble.of(recentVelocity - earlyVelocity);
}
record TimestampedVelocity(Instant timestamp, double velocity) {}
}
Feed the output of velocity() into MomentumTracker.record() each time you compute velocity. Positive momentum on a steam signal gives you higher conviction; decelerating momentum suggests the move may be running out of steam (literally).
If you’re building or extending an automated trading system on Betfair and want an experienced Java engineer on the project, get in touch.