Cross-market volume comparison is one of the easiest analyses to get wrong and one of the hardest to audit. Every market on TIQEX returns a volume field: US equity candles have it, crypto candles have it, quote snapshots have it. The field name is identical; the semantics are not. Divide or rank two markets by volume and the conclusion is neither reproducible nor repeatable on the next data revision. This article sets out four preconditions that must hold before any cross-market volume comparison, plus a runnable validator.
1. The Same Name, Four Incompatible Semantics
Lay the volume-related fields side by side and the differences surface immediately. What matters is not what a field is called, but which question it answers.
| Endpoint | Field | Documented semantics |
|---|---|---|
/api/v1/market/equity/kline | bars[].volume | The V in OHLCV; volume within the bar. Unit is not declared in the response. |
/api/v1/market/crypto/kline | bars[].volume | Base-asset quantity within the bar |
/api/v1/market/crypto/kline | bars[].quoteVolume | Quote-asset amount within the bar |
/api/v1/market/crypto/kline | bars[].takerBuyVolume | Base-asset quantity, taker-buy side only |
/api/v1/market/crypto/kline | bars[].trades | Trade count |
/api/v1/market/quote | volume | Snapshot volume; the aggregation window is not declared |
Three failure modes follow directly. First, mixing quoteVolume with volume — the gap between them is the instrument's own price. Second, differencing a snapshot volume against a bar volume to derive an increment; the two have different time semantics and the result can go negative. Third, treating takerBuyVolume as total volume, which counts one side only and understates systematically.
Field meaning is the "what" layer, already covered by the glossary. This article covers the "why you cannot compare them directly" layer. For the fields themselves, start with OHLCV candle fields.
2. Precondition One: Matching Denomination
The first degree of freedom is which asset the number is denominated in. Crypto candles split this explicitly: volume is in the base asset (BTC on BTCUSDT) and quoteVolume is in the quote asset (USDT). Their ratio gives the price scale of that bar. It is a magnitude check, not a price to trade on.
Equity candles expose only volume, with no quoteVolume. If you need a traded amount, rebuild it from the same bar's vwap: amount ≈ vwap × volume. That is legitimate, but the rebuilt value must be labelled as derived in your schema. Otherwise downstream code treats it as a source field and fails silently whenever vwap is missing or anomalous.
Putting crypto volume and equity volume in one ranked column is the most common error: coins against shares, with no shared unit. The comparable substitute is traded amount, and only once both sides are converted to one currency. See the crypto market page for coverage and the crypto candle endpoint for field details.
3. Precondition Two: Matching What Is Counted
The second degree of freedom is what is being counted. volume is a quantity, trades is a count, takerBuyVolume is a directional slice. No conversion exists between them: many trades do not imply large quantity, and a high taker-buy share does not imply high turnover.
Using takerBuyVolume as total volume is the most insidious case. Its difference from volume is exactly the taker-sell side, so the error understates consistently — but by less in choppy tape and by more in one-directional tape. Because the bias moves with the market, no fixed correction factor fixes it. Treat it as a derived ratio, takerBuyVolume / volume, and use it to describe pressure rather than size.
Trade counts are equally poor for cross-market ranking. Matching granularity, tick size and order-splitting rules differ, so the same economic volume yields trade counts that can differ by orders of magnitude. Counts work well for time-series monitoring inside one market, not for ranking across markets.
4. Preconditions Three and Four: Time and Price Semantics, Declared Together
The third degree of freedom is the time bucket. A candle bar is an interval quantity carrying openTime/closeTime; a quote snapshot's volume is a point-in-time value. Interval quantities add and align across buckets; point-in-time values do neither. Worse, bucket vocabularies differ across markets: equity candles take 1T/5T/30T/1H/2H/4H/1D/1W/1M (T for minutes), crypto candles follow the 1m/1h style, and the session field appears only on equity bars. Different buckets mean the same word describes a different quantity.
The fourth degree of freedom is price semantics, often assumed irrelevant to volume. The data.adjustmentMode on a candle response decides whether the price series is adjusted. Volume and price semantics must be declared as a pair; otherwise any price-volume relationship distorts around splits and ex-dividend dates. A practical rule: once a price series has been adjusted, every price-volume ratio spanning that adjustment date needs its semantics re-confirmed rather than inherited.
The pricing page states the product-side premise: data semantics, delay tiers and history depth vary by plan and entitlement, the console and docs are authoritative, and parity with any single exchange's book is not guaranteed. In other words, "the consumer declares and records the semantics" is a built-in assumption of this API. See pricing and plans.
5. Putting Semantics into Code: A Runnable Validator
A declared semantic only counts once it is in code. The validator below registers every market-plus-field combination as a spec, then compares unit, counted object and time bucket before allowing a comparison. A single mismatch rejects the comparison and reports which attribute differed. No third-party dependency is required.
from dataclasses import dataclass
@dataclass(frozen=True)
class VolumeSpec:
market: str
field: str
unit: str # BASE | QUOTE | UNKNOWN
kind: str # BAR_RANGE | SNAPSHOT | TRADE_COUNT | TAKER_BUY
bucket: str # time bucket, e.g. "1h" / "1d" / "snapshot"
SPECS = {
("crypto", "volume"): VolumeSpec("crypto", "volume", "BASE", "BAR_RANGE", "1h"),
("crypto", "quoteVolume"): VolumeSpec("crypto", "quoteVolume", "QUOTE", "BAR_RANGE", "1h"),
("crypto", "takerBuyVolume"): VolumeSpec("crypto", "takerBuyVolume", "BASE", "TAKER_BUY", "1h"),
("equity", "volume"): VolumeSpec("equity", "volume", "BASE", "BAR_RANGE", "1d"),
("quote", "volume"): VolumeSpec("quote", "volume", "UNKNOWN", "SNAPSHOT", "snapshot"),
}
class NotComparable(Exception):
pass
def assert_comparable(left, right):
for attr in ("unit", "kind", "bucket"):
a, b = getattr(left, attr), getattr(right, attr)
if a != b:
raise NotComparable(
"%s.%s vs %s.%s: %s mismatch (%s != %s)"
% (left.market, left.field, right.market, right.field, attr, a, b)
)
return True
if __name__ == "__main__":
pairs = [
(SPECS[("crypto", "volume")], SPECS[("crypto", "volume")]),
(SPECS[("crypto", "volume")], SPECS[("crypto", "quoteVolume")]),
(SPECS[("crypto", "volume")], SPECS[("equity", "volume")]),
(SPECS[("quote", "volume")], SPECS[("equity", "volume")]),
(SPECS[("crypto", "volume")], SPECS[("crypto", "takerBuyVolume")]),
]
for a, b in pairs:
try:
assert_comparable(a, b)
print("PASS %s.%s vs %s.%s" % (a.market, a.field, b.market, b.field))
except NotComparable as e:
print("REJECT " + str(e))
The run yields one pass and four rejections: same market and field passes; volume against quoteVolume is rejected on unit; crypto against equity volume on bucket; snapshot against bar on counted object; total against taker-buy on directional slice.
The second snippet fetches from two markets and normalises to one denomination. To keep it runnable offline, if __name__ == "__main__" rebinds the transport function inside the module, so the script needs no network and burns no quota. Note that the response envelope must be checked alongside the HTTP status: when succ is false, a 2xx status still means failure.
import json
import urllib.request
API = "https://api.tiqex.io/api/v1/market"
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36"
def http_get(path, api_key):
req = urllib.request.Request(API + path)
req.add_header("X-API-Key", api_key)
req.add_header("User-Agent", UA)
with urllib.request.urlopen(req, timeout=10) as r:
return r.status, json.loads(r.read().decode())
def fetch_series(path, api_key, field):
status, body = http_get(path, api_key)
if status != 200 or not body.get("succ"):
raise RuntimeError("HTTP %s code=%s msg=%s" % (status, body.get("code"), body.get("msg")))
bars = body["data"]["bars"]
return [(b.get("openTimeMs") or b.get("openTime"), b.get(field)) for b in bars
if b.get(field) is not None]
if __name__ == "__main__":
def http_get(path, api_key): # offline stub: delete when running live
return 200, {"succ": True, "code": "OK", "msg": "ok", "data": {"bars": [
{"openTimeMs": 1758931200000, "volume": 1240.5, "quoteVolume": 74120000.0},
{"openTimeMs": 1758934800000, "volume": 980.2, "quoteVolume": 58550000.0},
]}}
series = fetch_series("/crypto/kline?symbol=BTCUSDT&interval=1h", "YOUR_API_KEY", "quoteVolume")
total = sum(v for _, v in series)
print("bars=%d quoteVolume total=%.2f" % (len(series), total))
Use them together: gate cross-denomination comparisons at the collection layer with assert_comparable(), then normalise to the target denomination at the presentation layer. Keep the spec table in version control next to the code, so every semantic change has a paper trail.
6. FAQ
Can quoteVolume / volume serve as the bar's average price?
For a magnitude check, yes; as a pricing input, no. The ratio reflects the weighted scale inside the bar and is not equivalent to the bar's open/high/low/close or to vwap. Large single prints or gaps widen the divergence.
Is ranking markets by volume meaningful?
Only after unit, counted object, time bucket and price semantics all align. Otherwise compare traded amount, or compare each market's own time series, rather than ranking absolute cross-market levels.
Can I sum WebSocket tick volume to reconstruct candle volume?
Not recommended. The tick volume is a snapshot field in the pushed frame and is not the same source as a REST bar's interval quantity; reconciling them requires first pinning each one's aggregation window. Use REST bars[] as the authority for history and reconciliation, and the live stream to drive updates.
How much quota does a semantics audit consume?
REST quote and kline calls count toward the daily quota and per-minute rate limit; live data runs over WebSocket, where the handshake counts and pushed ticks are not re-counted. Keep limit small, reuse a local cache, and avoid re-pulling history inside tight loops.
The correct order is: declare semantics, validate comparability, then compute. For wiring multiple markets through one interface, see the multi-market integration guide. The free plan is enough to run the validator above; confirm available markets and delay tiers in the console before upgrading for live pushes.