Footprint Chart Development
Footprint Chart is a candle with internal structure: at each price level shows how many contracts were bought and sold. This is not just OHLCV data — it's delta analysis showing real interaction between buyers and sellers inside each candle.
What is Footprint and Why
A regular candle shows: opened at $42,000, closed at $42,150, volume 120 BTC. Footprint shows what happened inside: at level $42,050 there were 8.5 BTC of buys and 2.1 BTC of sells, at $42,100 — 3.2 buys and 12.4 sells. This is the "footprint" — the market's trace.
Key concepts:
- Ask volume: aggressive buys (trades executed at ask price)
- Bid volume: aggressive sells (trades executed at bid price)
- Delta: ask volume - bid volume. Positive = buyer dominance
- Imbalance: level where one type significantly prevails (usually 300%+ threshold)
- Point of Control (POC): price level with maximum volume inside candle
Data Collection: Trade Classification
Footprint is built from tick data — each individual trade. Need to classify each trade as buy (aggressive) or sell (aggressive):
type Trade struct {
Price decimal.Decimal
Quantity decimal.Decimal
Timestamp int64
IsBuy bool // true = aggressive buy (executed at ask)
}
// Classification by tick rule or quote rule
type TradeClassifier struct {
lastPrice decimal.Decimal
lastBid decimal.Decimal
lastAsk decimal.Decimal
}
// Quote rule: more accurate method (requires bid/ask at moment of trade)
func (tc *TradeClassifier) ClassifyByQuote(trade RawTrade) bool {
midPrice := tc.lastBid.Add(tc.lastAsk).Div(decimal.New(2, 0))
return trade.Price.GreaterThanOrEqual(midPrice) // >= mid = buy
}
// Tick rule: fallback when bid/ask unavailable
func (tc *TradeClassifier) ClassifyByTick(trade RawTrade) bool {
if trade.Price.GreaterThan(tc.lastPrice) {
return true // uptick = buy
}
if trade.Price.LessThan(tc.lastPrice) {
return false // downtick = sell
}
// Zero tick — use previous classification
return tc.lastWasBuy
}
Exchanges often provide trade direction directly in trade data. Binance: field isBuyerMaker — if true, maker was buyer (taker was seller). Logic is inverted:
# Binance aggTrades: isBuyerMaker=True → maker on bid side → aggressive SELL
# isBuyerMaker=False → maker on ask side → aggressive BUY
def classify_binance_trade(trade: dict) -> bool:
return not trade['isBuyerMaker'] # True = aggressive buy
Footprint Candle Aggregation
type FootprintLevel struct {
Price decimal.Decimal
BidVol decimal.Decimal // aggressive sells
AskVol decimal.Decimal // aggressive buys
Delta decimal.Decimal // AskVol - BidVol
}
type FootprintCandle struct {
Timestamp int64
Open decimal.Decimal
High decimal.Decimal
Low decimal.Decimal
Close decimal.Decimal
Volume decimal.Decimal
Delta decimal.Decimal // total candle delta
Levels map[string]*FootprintLevel // price -> level data
POC decimal.Decimal // level with max volume
BuyPOC decimal.Decimal // level with max ask volume
SellPOC decimal.Decimal // level with max bid volume
}
type FootprintBuilder struct {
tickSize decimal.Decimal // price step for grouping (e.g. 10 USD for BTC)
candles map[int64]*FootprintCandle // timestamp -> candle
mu sync.Mutex
}
func (fb *FootprintBuilder) AddTrade(trade Trade, timeframe time.Duration) {
fb.mu.Lock()
defer fb.mu.Unlock()
// Compute bucket for timeframe
bucket := (trade.Timestamp / int64(timeframe)) * int64(timeframe)
candle := fb.getOrCreateCandle(bucket, trade.Price)
// Group price by tick size
priceBucket := trade.Price.Div(fb.tickSize).Floor().Mul(fb.tickSize)
level := fb.getOrCreateLevel(candle, priceBucket)
if trade.IsBuy {
level.AskVol = level.AskVol.Add(trade.Quantity)
} else {
level.BidVol = level.BidVol.Add(trade.Quantity)
}
level.Delta = level.AskVol.Sub(level.BidVol)
// Update OHLCV
candle.Volume = candle.Volume.Add(trade.Quantity)
candle.Delta = candle.Delta.Add(trade.IsBuyDelta(trade.Quantity))
if trade.Price.GreaterThan(candle.High) { candle.High = trade.Price }
if trade.Price.LessThan(candle.Low) { candle.Low = trade.Price }
candle.Close = trade.Price
// Update POC
candle.POC = fb.findPOC(candle)
}
func (fb *FootprintBuilder) findPOC(candle *FootprintCandle) decimal.Decimal {
var maxVol decimal.Decimal
var poc decimal.Decimal
for price, level := range candle.Levels {
total := level.AskVol.Add(level.BidVol)
if total.GreaterThan(maxVol) {
maxVol = total
poc, _ = decimal.NewFromString(price)
}
}
return poc
}
Imbalance Detection
Imbalance is a key footprint pattern. A level with ask volume 3x bid volume — glass floor (buyers dominated). A level with bid 3x ask — glass ceiling.
type ImbalanceDetector struct {
threshold decimal.Decimal // usually 300% (3x)
}
type Imbalance struct {
Price decimal.Decimal
Type string // "bid" or "ask"
Ratio decimal.Decimal
Volume decimal.Decimal
}
func (id *ImbalanceDetector) FindImbalances(candle *FootprintCandle) []Imbalance {
var imbalances []Imbalance
sortedLevels := candle.SortedLevels() // by price ascending
for i, level := range sortedLevels {
if i == 0 { continue }
below := sortedLevels[i-1]
// Compare ask of current level with bid below
// "Stacked imbalance" — several in a row
if level.AskVol.IsPositive() && below.BidVol.IsPositive() {
ratio := level.AskVol.Div(below.BidVol).Mul(decimal.New(100, 0))
if ratio.GreaterThan(id.threshold) {
imbalances = append(imbalances, Imbalance{
Price: level.Price,
Type: "ask",
Ratio: ratio,
Volume: level.AskVol,
})
}
}
}
return imbalances
}
Frontend Footprint Rendering
Footprint is more complex than regular candle: each price level contains numbers. On 1-minute candle with $10 tick for BTC this might be 20–30 levels.
Canvas Rendering
HTML Canvas is the only option for performant rendering of hundreds of candles with detail.
class FootprintRenderer {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
renderCandle(candle: FootprintCandle, x: number, candleWidth: number,
priceToY: (price: number) => number) {
const ctx = this.ctx;
const levels = candle.getSortedLevels();
const levelHeight = Math.abs(priceToY(levels[0].price) - priceToY(levels[1]?.price || levels[0].price - candle.tickSize));
for (const level of levels) {
const y = priceToY(level.price);
// Background bar — volume visualized by width
const maxLevelVol = candle.maxLevelVolume;
const askWidth = (level.askVol / maxLevelVol) * (candleWidth * 0.45);
const bidWidth = (level.bidVol / maxLevelVol) * (candleWidth * 0.45);
// Ask side (right)
ctx.fillStyle = 'rgba(0, 177, 94, 0.3)';
ctx.fillRect(x + candleWidth/2, y, askWidth, levelHeight - 1);
// Bid side (left)
ctx.fillStyle = 'rgba(232, 66, 66, 0.3)';
ctx.fillRect(x + candleWidth/2 - bidWidth, y, bidWidth, levelHeight - 1);
// POC highlight
if (level.price === candle.poc) {
ctx.strokeStyle = '#FFD700';
ctx.lineWidth = 1;
ctx.strokeRect(x, y, candleWidth, levelHeight - 1);
}
// Numbers: bid × ask
if (levelHeight > 12) { // draw text only if space
ctx.fillStyle = '#6b7087';
ctx.font = `${Math.min(levelHeight - 2, 10)}px JetBrains Mono`;
ctx.textAlign = 'left';
ctx.fillText(formatVol(level.bidVol), x + 2, y + levelHeight - 3);
ctx.textAlign = 'right';
ctx.fillText(formatVol(level.askVol), x + candleWidth - 2, y + levelHeight - 3);
}
// Imbalance highlight
if (level.imbalanceType === 'ask') {
ctx.fillStyle = 'rgba(0, 177, 94, 0.8)';
ctx.fillRect(x, y, 3, levelHeight);
} else if (level.imbalanceType === 'bid') {
ctx.fillStyle = 'rgba(232, 66, 66, 0.8)';
ctx.fillRect(x, y, 3, levelHeight);
}
}
}
renderDeltaBar(candle: FootprintCandle, x: number, candleWidth: number, baseY: number) {
const ctx = this.ctx;
const delta = candle.delta;
const maxDelta = this.maxAbsDelta;
const barWidth = Math.abs(delta / maxDelta) * (candleWidth / 2);
const color = delta >= 0 ? '#00B15E' : '#E84242';
ctx.fillStyle = color;
if (delta >= 0) {
ctx.fillRect(x + candleWidth / 2, baseY, barWidth, 8);
} else {
ctx.fillRect(x + candleWidth / 2 - barWidth, baseY, barWidth, 8);
}
}
}
Delta Profile of Candle
Cumulative delta across internal bars of candle shows the course of struggle between buyers and sellers:
function calculateCumulativeDelta(trades: Trade[], bucketSize: number): CumDeltaPoint[] {
const points: CumDeltaPoint[] = [];
let cumDelta = 0;
for (const trade of trades) {
cumDelta += trade.isBuy ? trade.quantity : -trade.quantity;
points.push({ ts: trade.timestamp, price: trade.price, cumDelta });
}
return points;
}
Footprint Data Storage
Footprint data is much larger than regular OHLCV. For BTC/USDT 1m with $10 tick — ~15 levels per candle. Per day = 1440 candles × 15 levels × 2 values = 43,200 records per day for one timeframe only.
Optimal storage — TimescaleDB with compression:
CREATE TABLE footprint_levels (
candle_ts TIMESTAMPTZ NOT NULL,
pair_id SMALLINT NOT NULL,
timeframe VARCHAR(10) NOT NULL,
price_level NUMERIC(18,2) NOT NULL,
bid_vol NUMERIC(18,8) NOT NULL,
ask_vol NUMERIC(18,8) NOT NULL,
PRIMARY KEY (candle_ts, pair_id, timeframe, price_level)
);
SELECT create_hypertable('footprint_levels', 'candle_ts');
SELECT add_compression_policy('footprint_levels', INTERVAL '1 day');
TimescaleDB compression reduces data size 5–20x — critical for footprint volumes.
Footprint Visualization Types
| Type | Display | Application |
|---|---|---|
| Bid×Ask | Numbers on each level | Detailed analysis |
| Delta | Only level delta | Quick reading |
| Volume Profile | Histogram horizontally | Key levels |
| Imbalance | Only marked levels | Signals |
Development Timeline
- Trade classifier + footprint builder: 2–3 weeks
- TimescaleDB storage + aggregation: 2–3 weeks
- Canvas renderer for footprint: 4–6 weeks
- Imbalance detection + POC: 1–2 weeks
- WebSocket streaming + real-time: 2–3 weeks
- UI controls (timeframe, tick size, display mode): 2–3 weeks
Full Footprint Chart with historical data and real-time updates: 3–4 months.







