Реализация уведомлений о ценах криптовалют (Price Alerts) в мобильном приложении
Price Alerts — классически простая на вид задача: пользователь ставит цену, при достижении которой приходит уведомление. На практике это real-time система с ценовым стримом, логикой триггеров и гарантированной доставкой push. Главный вопрос архитектуры — где проверять условия: на сервере или на клиенте.
Серверная vs клиентская проверка
Клиентская — приложение в фоне опрашивает цену и сравнивает с порогом. Не работает: iOS убивает фоновые процессы через несколько минут, Android без foreground service — тоже. Непригодно для production.
Серверная — единственно правильный вариант. Сервер получает ценовой стрим, проверяет алерты всех пользователей, при срабатывании отправляет push. Клиент только создаёт/удаляет алерты и получает уведомления.
Ценовой стрим: источники данных
Варианты для получения real-time цен:
| Источник | Протокол | Задержка | Покрытие |
|---|---|---|---|
| Binance WebSocket | WSS | < 100ms | Все торговые пары Binance |
| CoinGecko API | REST polling | 30–60 сек | 10,000+ монет |
| CryptoCompare WebSocket | WSS | < 500ms | Агрегация бирж |
| Coinbase Advanced Trade | WSS | < 200ms | Только Coinbase пары |
Для real-time алертов — WebSocket. Для менее срочных — polling.
Бэкенд подписывается на Binance WebSocket:
// Node.js — подписка на цены через Binance WebSocket
const WebSocket = require('ws');
const PAIRS = ['btcusdt', 'ethusdt', 'solusdt'];
const ws = new WebSocket(`wss://stream.binance.com:9443/stream?streams=${PAIRS.map(p => p + '@ticker').join('/')}`);
ws.on('message', (data) => {
const { stream, data: ticker } = JSON.parse(data);
const symbol = stream.replace('@ticker', '').toUpperCase();
const price = parseFloat(ticker.c); // текущая цена
priceCache.set(symbol, price);
alertEngine.checkAlerts(symbol, price);
});
Движок алертов
При каждом обновлении цены — проверяем все активные алерты для этой пары:
class AlertEngine {
async checkAlerts(symbol: string, currentPrice: number): Promise<void> {
const alerts = await alertRepository.getActiveAlerts(symbol);
const triggered = alerts.filter(alert => {
if (alert.type === 'ABOVE') return currentPrice >= alert.targetPrice;
if (alert.type === 'BELOW') return currentPrice <= alert.targetPrice;
if (alert.type === 'PERCENT_CHANGE') {
const change = Math.abs((currentPrice - alert.basePrice) / alert.basePrice * 100);
return change >= alert.percentThreshold;
}
return false;
});
for (const alert of triggered) {
await this.fireAlert(alert, currentPrice);
}
}
private async fireAlert(alert: PriceAlert, price: number): Promise<void> {
// Деактивируем алерт чтобы не дублировать уведомление
await alertRepository.deactivate(alert.id);
// Отправляем push
await pushService.sendToUser(alert.userId, {
title: `${alert.symbol} достиг ${formatPrice(price)}`,
body: this.buildAlertMessage(alert, price),
data: { screen: 'price_detail', symbol: alert.symbol }
});
// Сохраняем в историю
await alertRepository.saveTriggeredAlert(alert, price);
}
}
Деактивация до отправки push — важно. Если push-отправка зафейлится и будет retry, алерт уже деактивирован — нет дублей.
Мобильный клиент: создание алерта
// iOS — форма создания алерта
struct CreateAlertView: View {
@State private var targetPrice: String = ""
@State private var alertType: AlertType = .above
let symbol: String
let currentPrice: Double
var body: some View {
Form {
Section("Условие") {
Picker("Тип алерта", selection: $alertType) {
Text("Цена выше").tag(AlertType.above)
Text("Цена ниже").tag(AlertType.below)
Text("Изменение %").tag(AlertType.percentChange)
}
.pickerStyle(.segmented)
HStack {
Text("$")
TextField("0.00", text: $targetPrice)
.keyboardType(.decimalPad)
}
}
Section {
Text("Текущая цена: \(formatPrice(currentPrice))")
.foregroundColor(.secondary)
}
Button("Создать алерт") {
createAlert()
}
.disabled(targetPrice.isEmpty)
}
}
}
Список активных алертов
Пользователь должен видеть все свои алерты и управлять ими. Каждый алерт — отображение условия, текущей цены относительно порога, кнопка удаления.
Для визуализации близости к порогу — progress indicator с текущей ценой между базовой и целевой:
// Android — визуализация расстояния до порога
@Composable
fun AlertProgressBar(currentPrice: Double, targetPrice: Double, basePrice: Double) {
val progress = ((currentPrice - basePrice) / (targetPrice - basePrice)).coerceIn(0.0, 1.0)
LinearProgressIndicator(
progress = progress.toFloat(),
modifier = Modifier.fillMaxWidth(),
color = if (progress > 0.8) Color.Orange else MaterialTheme.colorScheme.primary
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(formatPrice(basePrice), style = MaterialTheme.typography.labelSmall)
Text("Цель: ${formatPrice(targetPrice)}", style = MaterialTheme.typography.labelSmall)
}
}
Повторяемые алерты
По умолчанию алерт срабатывает один раз и деактивируется. Пользователь может выбрать «повторять» — тогда алерт реактивируется через N минут после срабатывания, чтобы не спамить при volatile рынке:
if (alert.isRepeating) {
const cooldownMs = alert.cooldownMinutes * 60 * 1000;
await alertRepository.scheduleReactivation(alert.id, Date.now() + cooldownMs);
}
Сроки
Реализация серверного движка алертов с WebSocket ценовым стримом (Binance/CryptoCompare), мобильный UI создания/управления алертами, push при срабатывании с историей — 8–12 рабочих дней.







