Реалізація уведомлень про ціни криптовалют в мобільному додатку
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 робочих днів.







