Raw values are hard to threshold. Standardized values tell you how unusual the current reading is — which is what actually matters.
Suppose you want to build a strategy that goes long when "buying pressure is high." You use net taker volume as your signal. The raw value might be 8,420 BTC in a quiet week and 85,000 BTC during a volatile period. What threshold do you set? Any fixed number will be too sensitive half the time and too slow the other half.
The same problem shows up with price-derived indicators: an SMA difference of 500 USDT means something very different at $20,000 BTC vs $90,000 BTC.
Standardization converts a raw value into a question: "How far is this from normal, measured in standard deviations?" This makes the reading comparable across different market conditions and different symbols.
A Z-score measures how many standard deviations the current value is from the recent mean:
z = (value - rolling_mean) / rolling_std
With a 30-day rolling window, a Z-score of +2.0 means the current reading is 2 standard deviations above the past month's average — unusual, but not extreme. A Z-score of +3.5 is extremely high by recent standards.
Z-scores have a useful property: they're roughly unit-free and comparable across symbols. A TI Z-score of +2.0 on BTCUSDT means roughly the same thing as a TI Z-score of +2.0 on ETHUSDT — both are unusually high relative to their own recent history.
In a strategy, implementing a rolling Z-score looks like this:
window = 720 # 30 days × 24h for 1h interval
df['z'] = (
(df['raw_indicator'] - df['raw_indicator'].rolling(window).mean())
/ df['raw_indicator'].rolling(window).std()
)
You don't need to standardize Blave's proprietary indicators yourself. They are already delivered as normalized scores comparable across symbols and time periods:
| Indicator | Standardized? | Notes |
|---|---|---|
| Taker Intensity (TI) | ✓ | Standardized against rolling recent history; comparable across symbols |
| Holder Concentration (HC) | ✓ | Standardized; positive = long concentration, negative = short concentration |
| Whale Hunter (WH) | ✓ | OI and volume change, each standardized separately |
| Liquidation (LQ) | ✓ | Standardized liquidation intensity |
| Market Sentiment (MS) | ✓ | Spot-futures spread, standardized |
| Capital Shortage (CS) | ✓ | Stablecoin utilization, standardized |
| Market Direction (MD) | ✓ | BTC-wide directional signal, standardized |
| Squeeze Momentum (SM) | ✓ | Fixed 1d period; includes scolor directional label |
This is why threshold strategies using Blave indicators use values like ENTRY_TH = 1.693 rather than a raw volume number — that threshold has a consistent meaning: "enter when TI is 1.7 standard deviations above its recent average."
Not all indicators need standardization. An SMA crossover signal (SMA_fast > SMA_slow) is already binary — there's nothing to standardize. The issue arises when you use an indicator's magnitude as a threshold.
Using the raw price-minus-SMA as a signal breaks across price regimes:
# ✗ Breaks when BTC moves from $20k to $90k signal[df['Close'] - df['SMA_50'] > 500] = 1.0 # ✓ Z-score of the same difference stays meaningful diff = df['Close'] - df['SMA_50'] df['diff_z'] = (diff - diff.rolling(720).mean()) / diff.rolling(720).std() signal[df['diff_z'] > 1.5] = 1.0
MACD values are in price units — they're not comparable across different symbols:
# ✗ MACD = 800 means very different things on BTCUSDT vs 2330.TW signal[df['MACD'] > 800] = 1.0 # ✓ Standardize per symbol before using as threshold df['macd_z'] = (df['MACD'] - df['MACD'].rolling(720).mean()) / df['MACD'].rolling(720).std() signal[df['macd_z'] > 1.0] = 1.0
RSI (0–100) and raw OI (billions of USD) can't be combined directly. Standardize both first:
# ✗ OI drowns out RSI entirely combined = df['RSI'] + df['OI'] # ✓ Both as Z-scores, then combine rsi_z = (df['RSI'] - df['RSI'].rolling(720).mean()) / df['RSI'].rolling(720).std() oi_z = (df['OI'] - df['OI'].rolling(720).mean()) / df['OI'].rolling(720).std() combined = rsi_z + oi_z signal[combined > 1.5] = 1.0
The number of shareholders in a given bracket (e.g., holders of 1,000–5,000 shares) differs massively between large-cap and small-cap stocks. TSMC may have hundreds of thousands of retail holders; a small-cap stock might have a few thousand. The raw count cannot be used as a threshold across different stocks.
What matters is the change relative to that stock's own recent history — is retail participation increasing unusually fast? Standardize the count (or its rate of change) against each stock's rolling window:
# Raw retail holder count is not comparable across stocks
# ✗ meaningless as a cross-stock threshold
signal[df['retail_holders'] > 50000] = 1.0
# ✓ Standardize per stock: how unusual is the current count?
df['holders_z'] = (
(df['retail_holders'] - df['retail_holders'].rolling(52).mean())
/ df['retail_holders'].rolling(52).std() # 52 weeks rolling window
)
# Z-score > 2: retail unusually crowded → potential contrarian short signal
signal[df['holders_z'] > 2.0] = 0.0
signal[df['holders_z'] < -1.0] = 1.0
The same principle applies to margin balance (margin_balance), foreign institutional net buy/sell (foreign_net), or any other Taiwan stock data field that varies in absolute scale by company size.
| Method | Formula | Best for |
|---|---|---|
| Rolling Z-score | (x − mean) / std | Most indicators; preserves outlier information |
| Percentile rank | rank / count over rolling window | Bounded output [0, 1]; robust to extreme outliers |
| Min-max | (x − min) / (max − min) | Simple but sensitive to extreme values in the window |
For most Type A strategies, rolling Z-score is the right default. Percentile rank is useful when you want a bounded signal (e.g., "top 10% of historical readings") without being affected by extreme outliers that can skew a Z-score.
← Back to Learn