Time-Weighted Moving Average over Irregular Ticks

Updated · By

Weight each price by how long it prevailed instead of counting ticks. Keep the window's ticks in a queue with a running sum of price times duration between consecutive ticks. On a query, evict ticks that no longer prevail, trim the first interval to the window start and extend the latest price to now. O(1) amortized per tick.

mediumPattern: Sliding windowFrom Devana's Bloomberg question set

Problem

Ticks for an instrument arrive at irregular times: sometimes thousands a second, sometimes none for a minute. You need the time-weighted average price over the last five minutes, where each price counts for as long as it was the latest price.

A plain average of the last n ticks is wrong: a price that held for four minutes and one that lasted a second would count the same. Write a class with push(ts, price) and value(now), which returns the time-weighted average over [now - window, now].

class TimeWeightedAverage:
    def __init__(self, window: int = 300): ...
    def push(self, ts: int, price: float) -> None: ...
    def value(self, now: int) -> float | None: ...

This is one of Devana's own questions, written in the style of Bloomberg's interviews. It is not a question leaked from Bloomberg, and Devana is not affiliated with Bloomberg.

Constraints

  • Each price holds from its tick until the next tick; the latest one holds until now.
  • Timestamps passed to push and value never decrease, and now is at least the latest tick.
  • If the oldest tick still held is after the window start, average over the time since that tick.
  • With no ticks, return None; when the covered time is zero, return the latest price.
  • O(1) amortized per tick, with memory bounded by the ticks inside the window.

Examples

Example 1

Input:  window = 300
        push(0, 100); push(240, 200); value(300)
Output: 120.0

100 held for 240 seconds and 200 for 60: (100 × 240 + 200 × 60) / 300 = 120. A simple average of the two ticks would say 150.

Example 2

Input:  window = 300
        push(0, 100); push(240, 200); value(600)
Output: 200.0

The window is 300 to 600. The price of 100 stopped prevailing at 240, so only 200 counts, for all 300 seconds.

Hints

Open one at a time, and try the problem again after each.

Hint 1

Think of the price as a step function over time. The average is the area under it divided by the width of the window.

Hint 2

The area between two consecutive ticks never changes once the second tick has arrived. Can you keep a running total of those areas?

Hint 3

When can the oldest tick be thrown away? Not when its timestamp leaves the window, but when the tick after it is also at or before the window start.

Hint 4

Two pieces are partial at query time: the first interval is cut at the window start, and the latest price runs to now. Compute both on the fly.

Approach: from brute force to optimal

Brute force: recompute the area

Keep every tick in the window and, on each query, walk them summing price times duration, clipped to the window.

Complexity: O(1) per push, O(k) per query for k ticks in the window

A running area with a queue

Maintain area, the sum of price times duration over consecutive pairs of ticks in the queue. A push adds one term and an eviction subtracts one, so the sum is always current. A query adjusts only the two partial ends, which is O(1) once eviction is done.

Evict the front tick while the second tick is at or before the window start: from then on, the front price cannot prevail inside this window or any later one. Each tick is evicted once, so eviction is O(1) amortized.

Complexity: O(1) amortized per push and per query; O(k) space

How to say it in the interview

Describe the step function, even in words: "price against time; the average is area over width." That framing makes the eviction rule and the two partial ends obvious to the interviewer. Then state O(1) amortized, and raise floating-point drift in the running sum before they do.

Solution

Python

from collections import deque

class TimeWeightedAverage:
    def __init__(self, window=300):
        self.window = window
        self.ticks = deque()   # (ts, price), oldest first
        self.area = 0.0        # sum of price * duration between consecutive ticks

    def push(self, ts, price):
        if self.ticks:
            last_ts, last_price = self.ticks[-1]
            self.area += last_price * (ts - last_ts)
        self.ticks.append((ts, price))
        self._evict(ts - self.window)

    def value(self, now):
        start = now - self.window
        self._evict(start)
        if not self.ticks:
            return None
        first_ts, first_price = self.ticks[0]
        last_ts, last_price = self.ticks[-1]
        begin = max(first_ts, start)
        area = (self.area
                + last_price * (now - last_ts)          # the latest price runs to now
                - first_price * (begin - first_ts))     # cut the first interval at the start
        span = now - begin
        return area / span if span > 0 else last_price

    def _evict(self, start):
        # The front price no longer prevails once the next tick is at or before start.
        while len(self.ticks) >= 2 and self.ticks[1][0] <= start:
            (t0, p0), (t1, _) = self.ticks[0], self.ticks[1]
            self.area -= p0 * (t1 - t0)
            self.ticks.popleft()

JavaScript

class TimeWeightedAverage {
  constructor(window = 300) {
    this.window = window;
    this.ticks = []; // [ts, price], oldest first, from this.head
    this.head = 0;
    this.area = 0;   // sum of price * duration between consecutive ticks
  }

  push(ts, price) {
    if (this.size() > 0) {
      const [lastTs, lastPrice] = this.ticks[this.ticks.length - 1];
      this.area += lastPrice * (ts - lastTs);
    }
    this.ticks.push([ts, price]);
    this.evict(ts - this.window);
  }

  value(now) {
    const start = now - this.window;
    this.evict(start);
    if (this.size() === 0) return null;
    const [firstTs, firstPrice] = this.ticks[this.head];
    const [lastTs, lastPrice] = this.ticks[this.ticks.length - 1];
    const begin = Math.max(firstTs, start);
    const area = this.area
      + lastPrice * (now - lastTs)       // the latest price runs to now
      - firstPrice * (begin - firstTs);  // cut the first interval at the start
    const span = now - begin;
    return span > 0 ? area / span : lastPrice;
  }

  size() {
    return this.ticks.length - this.head;
  }

  evict(start) {
    // The front price no longer prevails once the next tick is at or before start.
    while (this.size() >= 2 && this.ticks[this.head + 1][0] <= start) {
      const [t0, p0] = this.ticks[this.head];
      this.area -= p0 * (this.ticks[this.head + 1][0] - t0);
      this.head++;
    }
    if (this.head > 1024 && this.head * 2 > this.ticks.length) {
      this.ticks = this.ticks.slice(this.head);
      this.head = 0;
    }
  }
}

Both versions are tested against the same cases, including every example on this page.

  • value evicts too, because time moves on between pushes: a query long after the last tick must still drop prices that stopped prevailing.
  • The latest tick is never evicted. It is the price in force now, however old it is.
  • A running sum of floats drifts after millions of additions and subtractions. In production, keep prices in integer units such as cents, or recompute the sum from the queue every so often.

Follow-up questions

Why is a count-based average wrong for this?

It weights every tick equally, so a burst of a thousand ticks in one second outweighs the price that held for the other four minutes. Time weighting counts each price for as long as anyone could trade at it.

How do you handle a period with no ticks at all?

The last price carries forward, so the average converges to it. If the instrument is halted, a stale price can mislead, so return the age of the last tick alongside the average and let the caller decide.

How would you extend this to a volume-weighted average?

Weight by traded volume instead of time: keep running sums of price times volume and of volume for the ticks in the window, evicted the same way from the front of the queue.

What changes with thousands of instruments in one process?

One instance per instrument in a hash map. Memory is the ticks from the last five minutes across all of them, so the busiest instruments dominate it.

Common mistakes

  • Averaging the last n ticks, which ignores how long each price held.
  • Evicting a tick as soon as its own timestamp leaves the window, when it may still be the price in force at the window start.
  • Forgetting the open interval from the latest tick to now.
  • Recomputing the whole window on every tick, which is O(k) when thousands arrive a second.

Practice it in a mock interview

Reading a solution and explaining one under questioning are different skills. The button below starts this problem as a voice mock interview with Josh, Devana's AI interviewer, with an editor that runs your code.

Practice this problem in a mock interview

Practice this problem out loud

Josh, Devana's AI interviewer, runs this exact problem as a voice mock interview, asks follow-ups like the ones above, and scores you out of 100. The Free plan has 3 interviews a month, no card needed.

Practice this problem in a mock interview