package alerter import ( "context" "fmt" "log/slog" "time" "gitea.computernetthings.ru/yash/crypto_alert_bot/internal/entities" "gitea.computernetthings.ru/yash/crypto_alert_bot/internal/provider" "github.com/shopspring/decimal" ) type Notifier interface { NotifyAlert(ctx context.Context, userID entities.UserID, alert *entities.Alert, currentPrice decimal.Decimal) error } type Storage interface { AllActiveAlerts(ctx context.Context) ([]entities.Alert, error) DisableAlert(ctx context.Context, id entities.AlertID) error GetLastAlertCheck(ctx context.Context) (time.Time, error) SetLastAlertCheck(ctx context.Context, t time.Time) error } type Alerter struct { log *slog.Logger cache *alertsCache priceProvider provider.Provider notifier Notifier storage Storage lastCheckedAt time.Time } const interval = time.Minute func New(log *slog.Logger, priceProvider provider.Provider, notifier Notifier, storage Storage) *Alerter { return &Alerter{ log: log, cache: newCache(), priceProvider: priceProvider, notifier: notifier, storage: storage, lastCheckedAt: time.Now(), // safe default; overwritten by LoadAlerts if DB has a stored value } } func (a *Alerter) LoadAlerts(ctx context.Context) error { alerts, err := a.storage.AllActiveAlerts(ctx) if err != nil { return fmt.Errorf("failed to load alerts: %w", err) } for i := range alerts { a.cache.Add(&alerts[i]) } a.log.Info("alerts loaded", "count", len(alerts)) // Restore last check time so missed candles are checked on next tick. t, err := a.storage.GetLastAlertCheck(ctx) if err != nil { return fmt.Errorf("failed to load last alert check time: %w", err) } if !t.IsZero() { a.lastCheckedAt = t a.log.Info("resuming alert checks from", "since", t) } return nil } func (a *Alerter) AddAlert(alert *entities.Alert) { a.cache.Add(alert) } func (a *Alerter) RemoveAlert(id entities.AlertID) { a.cache.Remove(id) } func (a *Alerter) Run(ctx context.Context) { go func() { ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: } a.log.Info("start checking alerts") if err := a.checkAlerts(ctx); err != nil { a.log.Error("failed to check alerts", "err", err) continue } a.log.Info("alerts checked") } }() } // klineMaxCandles matches the per-request limit of the Bybit kline API. // Used to select the coarsest candle interval that still fits in one request. const klineMaxCandles = 1000 // candleIntervalTable lists Bybit kline intervals in ascending duration order. // selectCandleInterval picks the smallest entry whose window covers the gap. var candleIntervalTable = []provider.KlineInterval{ provider.Kline1m, provider.Kline3m, provider.Kline5m, provider.Kline15m, provider.Kline30m, provider.Kline1H, provider.Kline4H, provider.Kline6H, provider.Kline12H, provider.Kline1D, provider.Kline1W, } // selectCandleInterval returns the smallest interval whose single-request window // (klineMaxCandles * intervalDuration) covers the entire gap. func selectCandleInterval(gap time.Duration) provider.KlineInterval { for _, iv := range candleIntervalTable { if gap <= time.Duration(klineMaxCandles)*iv.ToDuration() { return iv } } return candleIntervalTable[len(candleIntervalTable)-1] } // TODO: parallel checking for different instruments. // TODO: get one candle before interval func (a *Alerter) checkAlerts(ctx context.Context) error { now := time.Now() gap := now.Sub(a.lastCheckedAt) candleInterval := selectCandleInterval(gap) // Truncate to the selected interval boundary so we always re-check the candle // that was still forming at the previous tick — its High/Low may have changed // since. from := a.lastCheckedAt.Truncate(candleInterval.ToDuration()) a.log.Debug("checking alerts", "from", from, "to", now, "interval", candleInterval, "gap", gap.Round(time.Second)) instruments := a.cache.Instruments() for _, instrument := range instruments { candles, err := a.priceProvider.Candles(ctx, instrument, from, now, candleInterval) if err != nil { a.log.Error("failed to get candles", "instrument", instrument.ID, "err", err) return fmt.Errorf("failed to get candles: %w", err) } for _, alert := range a.cache.AlertsByInstrument(instrument.ID) { if triggered, price := alertTriggeredByCandles(alert, candles); triggered { a.triggerAlert(ctx, alert, price) } } } a.lastCheckedAt = now if err := a.storage.SetLastAlertCheck(ctx, now); err != nil { a.log.Error("failed to persist last alert check time", "err", err) } return nil } // alertTriggeredByCandles returns true and the triggering price if any candle // caused the alert condition to be met. func alertTriggeredByCandles(alert *entities.Alert, candles []entities.Candle) (bool, decimal.Decimal) { for _, candle := range candles { switch alert.Condition { case entities.AlertConditionAbove: if candle.High.GreaterThanOrEqual(alert.Price) { return true, candle.High } case entities.AlertConditionBelow: if candle.Low.LessThanOrEqual(alert.Price) { return true, candle.Low } } } return false, decimal.Zero } func (a *Alerter) triggerAlert(ctx context.Context, alert *entities.Alert, currentPrice decimal.Decimal) { if err := a.notifier.NotifyAlert(ctx, alert.UserID, alert, currentPrice); err != nil { a.log.Error("failed to notify alert", "alert_id", alert.ID, "err", err) return } a.cache.Remove(alert.ID) if err := a.storage.DisableAlert(ctx, alert.ID); err != nil { a.log.Error("failed to disable alert in db", "alert_id", alert.ID, "err", err) } }