Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/deniszidbaev-cmyk/polyclaw-trading/llms.txt

Use this file to discover all available pages before exploring further.

The PolyClaw Trading signal engine computes five deterministic indicators from pseudo-OHLC candle data using only the Python standard library. All implementations live in src/polyclaw_engine/indicators.py and are tested at exact boundary values. The 5m candle series is used for execution decisions; the 15m series is used for confirmation. Only candles with "complete": true are passed to indicator functions.

Parameter reference

IndicatorParameterEnv varDefault
EMA (fast)periodEMA_FAST_PERIOD8
EMA (slow)periodEMA_SLOW_PERIOD21
RSIperiodRSI_PERIOD14
ATRperiodATR_PERIOD14
Realized volatilitylookback(hardcoded)20
POC bin sizebin_sizePOC_BIN_SIZE0.01
EMA trend min spreadmin_spreadMIN_EMA_SPREAD_PCT0.003 (0.3%)
EMA overextension capmax_extensionMAX_EMA_EXTENSION_ATR2.0 ATR
Bullish RSI range[min, max]BULLISH_RSI_MIN / BULLISH_RSI_MAX[52, 72]
Bearish RSI range[min, max]BEARISH_RSI_MIN / BEARISH_RSI_MAX[28, 48]
RSI overboughtthresholdRSI_OVERBOUGHT75
RSI oversoldthresholdRSI_OVERSOLD25

EMA — Exponential Moving Average

The EMA uses the first observation in the series as its seed value. Every subsequent value applies the standard exponential smoothing formula. There is no warm-up period beyond the seed: the series is defined from the very first observation. Formula:
alpha = 2 / (period + 1)

EMA[0] = close[0]                           ← first observation seeds the EMA
EMA[i] = alpha * close[i] + (1 - alpha) * EMA[i-1]
Python implementation (indicators.py):
def ema_series(values: Iterable[float], period: int) -> list[float]:
    rows = [float(value) for value in values]
    if not rows:
        return []
    alpha = 2.0 / (period + 1.0)
    output = [rows[0]]
    for value in rows[1:]:
        output.append(alpha * value + (1.0 - alpha) * output[-1])
    return output
The ema() convenience function returns the final value in the series, or None if the input is empty. Default periods: EMA8 (EMA_FAST_PERIOD=8) for execution; EMA21 (EMA_SLOW_PERIOD=21) for trend confirmation.

EMA trend detection

A bullish EMA trend requires EMA8 to be above EMA21 by at least MIN_EMA_SPREAD_PCT (default 0.003, i.e., 0.3%). A bearish trend requires EMA8 to be below EMA21 by the same minimum spread:
ema_spread_pct = (EMA8 - EMA21) / EMA21

bullish: ema_spread_pct >= MIN_EMA_SPREAD_PCT  (default 0.003)
bearish: ema_spread_pct <= -MIN_EMA_SPREAD_PCT (default -0.003)

EMA overextension

If the close price is more than MAX_EMA_EXTENSION_ATR (default 2.0) ATR units away from EMA21, the decision is flagged as overextended and an overextension_penalty is applied to the confidence score. Overextension also becomes a blocking reason that forces a HOLD.

RSI — Relative Strength Index

RSI uses Wilder’s recursive smoothing. The first period deltas are averaged arithmetically to seed the smoothed average gain and loss. All subsequent updates use Wilder’s smoothing formula. Formula:
# Seed (arithmetic mean over first `period` deltas)
avg_gain[period] = mean(gains[1..period])
avg_loss[period] = mean(losses[1..period])

# Wilder smoothing (for i > period)
avg_gain[i] = avg_gain[i-1] + (gain[i] - avg_gain[i-1]) / period
avg_loss[i] = avg_loss[i-1] + (loss[i] - avg_loss[i-1]) / period

# RSI
RS = avg_gain / avg_loss
RSI = 100 - (100 / (1 + RS))

# Special cases
if avg_gain == 0 and avg_loss == 0: RSI = 50
if avg_loss == 0:                   RSI = 100
A minimum of period + 1 input values is required; None is returned if fewer values are available. Python implementation (indicators.py):
def rsi_wilder(values: Iterable[float], period: int = 14) -> float | None:
    rows = list(map(float, values))
    n = len(rows)
    if n < period + 1:
        return None
    average_gain = 0.0
    average_loss = 0.0
    for index in range(1, period + 1):
        delta = rows[index] - rows[index - 1]
        if delta > 0.0:
            average_gain += delta
        else:
            average_loss -= delta
    average_gain /= period
    average_loss /= period
    inv_period = 1.0 / period
    for index in range(period + 1, n):
        delta = rows[index] - rows[index - 1]
        gain = delta if delta > 0.0 else 0.0
        loss = -delta if delta < 0.0 else 0.0
        average_gain += (gain - average_gain) * inv_period
        average_loss += (loss - average_loss) * inv_period
    if average_gain == 0.0 and average_loss == 0.0:
        return 50.0
    if average_loss == 0.0:
        return 100.0
    return 100.0 - (100.0 / (1.0 + average_gain / average_loss))

RSI thresholds

RSI participates in both direction detection and signal scoring:
PurposeEnv varDefault
Bullish zone minimumBULLISH_RSI_MIN52
Bullish zone maximumBULLISH_RSI_MAX72
Bearish zone minimumBEARISH_RSI_MIN28
Bearish zone maximumBEARISH_RSI_MAX48
Overbought guardRSI_OVERBOUGHT75
Oversold guardRSI_OVERSOLD25
The bullish and bearish RSI ranges must not overlap, and the oversold threshold must be strictly below overbought. The RSI score component rewards values near the midpoint of the active range, falling to 0.0 outside the range.

ATR — Average True Range

ATR uses the same Wilder recursive smoothing as RSI. The first period true ranges are averaged arithmetically to seed the smoothed ATR. A minimum of period candles is required. True range formula:
TR[0] = high[0] - low[0]                        ← first bar, no prior close
TR[i] = max(high[i] - low[i],
            |high[i] - close[i-1]|,
            |low[i]  - close[i-1]|)
ATR formula:
ATR[period-1] = mean(TR[0..period-1])            ← arithmetic seed
ATR[i] = ATR[i-1] + (TR[i] - ATR[i-1]) / period ← Wilder smoothing
Python implementation (indicators.py):
def atr_wilder(candles: Iterable[dict[str, Any]], period: int = 14) -> float | None:
    rows = list(candles)
    n = len(rows)
    if n < period:
        return None
    total_tr = 0.0
    previous_close = None
    for idx in range(period):
        high = float(rows[idx]["high"])
        low  = float(rows[idx]["low"])
        if previous_close is None:
            tr = high - low
        else:
            tr_hl = high - low
            tr_hc = abs(high - previous_close)
            tr_lc = abs(low  - previous_close)
            tr = max(tr_hl, tr_hc, tr_lc)
        total_tr += tr
        previous_close = float(rows[idx]["close"])
    output = total_tr / period
    inv_period = 1.0 / period
    for idx in range(period, n):
        high = float(rows[idx]["high"])
        low  = float(rows[idx]["low"])
        tr   = max(high - low, abs(high - previous_close), abs(low - previous_close))
        output += (tr - output) * inv_period
        previous_close = float(rows[idx]["close"])
    return output
ATR is used for position sizing (stop-loss and take-profit levels) and as the denominator for several pattern threshold comparisons (Three White Soldiers, Three Black Crows, Shadow Breakout).

POC — Point of Control

The POC is the price bin with the highest public observation density, not traded volume. It provides context for price clustering and can add a small bonus to the confidence score (poc_context component, weight 0.05), but it never creates a directional signal on its own. Algorithm:
bin_index = floor(price / POC_BIN_SIZE + 0.5)   ← round-to-nearest bin
counts[bin_index] += 1                            ← tally per bin

POC = bin_index_with_max_count * POC_BIN_SIZE
      (ties broken by nearest to latest price, then lowest index)
The POC_BIN_SIZE default is 0.01. The function returns None if the input is empty or the bin size is <= 0. A price is considered “near POC” if:
|close - poc| / poc <= POC_CONTEXT_DISTANCE_PCT   (default 0.01, i.e., 1%)
The POC implementation uses public API price-history observation counts, not exchange order-book volume. It is sample-density context only and is explicitly documented as such in btc_strategy_engine.md.

Realized Volatility

Realized volatility is the population standard deviation of the most recent 20 simple close-to-close returns. It is used as a market-quality and context signal rather than as a direct trading threshold. Formula:
returns[i] = (close[i] - close[i-1]) / close[i-1]    ← simple return
             for i in the latest 20-candle window

mean_r = mean(returns)

realized_vol = sqrt( mean( (r - mean_r)^2 for r in returns ) )  ← population std dev
A minimum of 2 values is required; 0.0 is returned if fewer are available. Python implementation (indicators.py):
def realized_volatility(values: Iterable[float], lookback: int = 20) -> float:
    rows = list(map(float, values))
    n = len(rows)
    if n < 2:
        return 0.0
    start_idx = max(0, n - (lookback + 1))
    returns = []
    for i in range(start_idx + 1, n):
        previous = rows[i - 1]
        if previous == 0.0:
            continue
        returns.append((rows[i] - previous) / previous)
    m = len(returns)
    if m < 2:
        return 0.0
    mean = sum(returns) / m
    diff_sq = [(v - mean) * (v - mean) for v in returns]
    return math.sqrt(sum(diff_sq) / m)

clamp utility

The clamp function is used throughout the scoring and penalty logic to keep all confidence values inside [0.0, 1.0]:
def clamp(value: float, minimum: float = 0.0, maximum: float = 1.0) -> float:
    return max(minimum, min(maximum, value))
It is also exported from indicators.py and used directly by the signal stage to bound the raw score after penalties.

Candle geometry helpers

The following helpers are also defined in indicators.py and used by the pattern engine:
FunctionReturns
candle_body(candle)abs(close - open)
upper_wick(candle)max(0, high - max(open, close))
lower_wick(candle)max(0, min(open, close) - low)
average_geometry(candles, lookback=20, exclude_current=True)Dict of mean body, upper_wick, lower_wick over the lookback window
percentage_distance(value, reference)(value - reference) / reference, or None if reference is 0 or None

Build docs developers (and LLMs) love