"Notify me when the price crosses a threshold" sounds like it needs a single if-statement. In production, teams hit three predictable problems: alert storms (price oscillating around the threshold fires hundreds of notifications), duplicate alerts (everything re-triggers after a reconnect or restart), and quota overruns (someone implemented polling to feed the alerts and burned through the plan).
This article is about the data layer of an alerting system — not how to wire up notification channels, but how data arrives, how state is managed, and how notifications are throttled.
Three responsibilities in the data layer
Decomposed properly, the data layer has three jobs, and they must be implemented separately:
| Responsibility | Problem it solves | Mechanism |
|---|---|---|
| Ingestion | Get live prices cheaply | WebSocket streaming |
| State evaluation | When is it "triggered" vs "recovered" | Debounce + state machine |
| Notification dispatch | Avoid drowning the user | Rate limiting + quiet windows |
Merging these three is why most alerting systems run out of control. Separated, each layer can be tested and tuned independently.
Layer one: ingestion must be streaming
Alerting is fundamentally about learning of change quickly, which conflicts with polling by construction. REST polling for alerts has two fatal problems:
- Detection latency equals the polling interval. Polling once a minute means up to a minute of delay, and the crossing moment can be missed entirely.
- Quota consumption scales with symbols times frequency. Polling 100 symbols every second is millions of requests a month and blows through any plan quickly.
WebSocket is the only sensible choice: one connection covers every symbol, and the handshake counts toward quota while subsequent tick pushes are not metered per message. That means alerting quota cost is essentially independent of symbol count and depends only on connection count.
Layer two: debouncing is required, not an optimisation
When a price oscillates around a threshold, evaluating every tick produces a storm. Set the threshold at 100 and let the price flicker between 99.98 and 100.02: you can generate dozens of "crossed" and "recovered" events per second.
Two debounce strategies apply to different problems:
- Time debounce — after a state change, ignore further same-direction changes for N seconds. Simple, but powerless against sustained oscillation.
- Amplitude debounce (hysteresis) — use two thresholds: 100 to trigger, 99.5 to recover. The price must retreat far enough to count as recovered. This is the standard fix for oscillation.
In practice you need both: hysteresis handles oscillation, time debounce handles transient spikes.
Layer three: a state machine, not a boolean flag
Tracking "already alerted" in a boolean is the most common implementation and the root cause of duplicate alerts. Boolean state is lost when the process restarts or the WebSocket reconnects, so the system concludes it has never alerted and fires everything again.
Model the state explicitly and persist it:
| State | Meaning | Transition |
|---|---|---|
| ARMED | Watching for a crossing | price ≥ trigger → TRIGGERED |
| TRIGGERED | Fired and notified | price ≤ recover → ARMED (notify recovery) |
| MUTED | Suppressing notifications in a quiet window | window ends → ARMED or TRIGGERED based on current price |
Once state lives in external storage (Redis or a database), a restart will not re-fire alerts. Duplicate ticks from a reconnect will not either, because the machine is already in TRIGGERED.
Layer four: a notification budget
Even with correct debouncing and state, you still need a notification budget. The reason is simple: the volume of notifications a user tolerates is far below the volume a system can generate.
Cap it at three levels:
- Per rule — the same rule notifies at most once every N minutes.
- Per user — at most M notifications per hour, beyond which they are batched into a digest.
- Global — a system-wide circuit breaker so a misconfiguration cannot alert everything at once.
The global breaker is routinely forgotten and routinely saves the day — a threshold mistakenly set to zero will otherwise fire every rule instantly.
Python: debounce, state machine, and throttling
import asyncio, json, os, time
import websockets
URL = "wss://api.tiqex.io/api/v1/market/stream"
API_KEY = os.environ["TIQEX_API_KEY"]
DEBOUNCE_SEC = 5.0 # time debounce window
HYSTERESIS_RATIO = 0.005 # hysteresis band: 0.5%
MIN_NOTIFY_INTERVAL = 60 # minimum seconds between notifications for one rule
class AlertRule:
"""Alert rule with hysteresis and time debounce; state modelled explicitly."""
def __init__(self, symbol: str, threshold: float, above: bool = True):
self.symbol = symbol
self.threshold = threshold
self.above = above
# Recovery threshold sits back from the trigger by HYSTERESIS_RATIO
self.recover = (threshold * (1 - HYSTERESIS_RATIO) if above
else threshold * (1 + HYSTERESIS_RATIO))
self.state = "ARMED"
self.last_notify = 0.0
self.last_change = 0.0
def on_tick(self, price: float):
now = time.monotonic()
if self.state == "ARMED":
crossed = price >= self.threshold if self.above else price <= self.threshold
if crossed and now - self.last_change >= DEBOUNCE_SEC:
self.state = "TRIGGERED"
self.last_change = now
return self._notify("triggered", price)
elif self.state == "TRIGGERED":
recovered = price <= self.recover if self.above else price >= self.recover
if recovered and now - self.last_change >= DEBOUNCE_SEC:
self.state = "ARMED"
self.last_change = now
return self._notify("recovered", price)
return None
def _notify(self, kind: str, price: float):
now = time.monotonic()
if now - self.last_notify < MIN_NOTIFY_INTERVAL:
return None # notification budget hit; drop silently
self.last_notify = now
return f"[{kind}] {self.symbol} @ {price}"
RULES = [
AlertRule("BTCUSDT", 80000, above=True),
AlertRule("ETHUSDT", 2500, above=False),
]
BY_SYMBOL = {r.symbol: r for r in RULES}
async def main():
symbols = list(BY_SYMBOL)
backoff = 1.0
while True:
try:
async with websockets.connect(
URL, additional_headers={"X-API-Key": API_KEY}, open_timeout=10,
) as ws:
await ws.send(json.dumps({
"action": "subscribe", "market": "crypto", "symbols": symbols,
}))
backoff = 1.0
async for raw in ws:
frame = json.loads(raw)
data = frame.get("data") or {}
if data.get("type") != "tick":
continue
tick = data["tick"]
rule = BY_SYMBOL.get(tick["symbol"])
if not rule:
continue
message = rule.on_tick(float(tick["last"]))
if message:
print(message) # replace with your notification channel
except Exception as exc: # noqa: BLE001
print(f"connection lost: {exc}")
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 60)
# note: rule.state lives in memory, so reconnects do not re-fire alerts
asyncio.run(main())
This exercises all three layers: streaming ingestion, state evaluation with hysteresis and debounce, and notification budget throttling. The crucial detail is that rule.state survives a reconnect — network blips cannot duplicate alerts.
In production, persist state and last_notify to external storage so restarts preserve them too.
Sizing and cost estimation
Worth doing the arithmetic before launch:
- Connections. One WebSocket per process covers every symbol; the handshake counts toward quota. Watch the total when deploying multiple processes.
- Notification volume. Estimate as rules × trigger frequency × debounce suppression ratio. In practice debouncing cuts notification volume below 5% of raw trigger counts.
- Downstream pressure. Each alert may trigger a downstream REST lookup (fetching the position, say). Those calls consume quota and need a separate budget.
Separating the third item matters — many teams forget "downstream queries triggered by alerts" and exhaust their quota on volatile days.
FAQ
Why not use REST polling for alerts?
Two reasons: detection latency equals the polling interval, so the crossing moment can be missed entirely; and quota consumption scales with symbol count and frequency, exceeding any plan quickly. With streaming, pushes are not metered per message, so cost is largely independent of symbol count.
How long should the debounce window be?
It depends on volatility. High-volatility symbols such as crypto want a longer window (5–10 seconds) and a wider hysteresis band; calmer symbols can use less. Handle oscillation with hysteresis first, then use time debounce for spikes.
How do I avoid duplicate alerts after a restart?
Persist alert state to external storage (Redis or a database) and restore it on boot. In-memory-only state re-fires every alert after a restart.
How do I eliminate alert storms?
Three layers of throttling: a minimum interval per rule, an hourly quota per user, and a global circuit breaker. The first two protect the user experience; the third prevents a misconfiguration from firing everything.
Do downstream queries triggered by alerts consume quota?
Yes. Those go through REST endpoints and are counted per request. Budget that traffic separately so volatile days do not drain your allowance in a burst.
Next steps
Split ingestion, state evaluation, and dispatch into three layers and your alerting system becomes stable. The streaming subscription protocol is in the WebSocket streaming docs; for hands-on ingestion see the live quotes over WebSocket guide, and the analytics solution for the broader pattern. When sizing connections and downstream queries, use the quota calculator to check whether your plan quota is sufficient.