candles based alerts
This commit is contained in:
parent
bec3b7de5b
commit
999f675da9
11 changed files with 316 additions and 15 deletions
|
|
@ -11,7 +11,6 @@ import (
|
|||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
|
||||
type Notifier interface {
|
||||
NotifyAlert(ctx context.Context, userID entities.UserID, alert *entities.Alert, currentPrice decimal.Decimal) error
|
||||
}
|
||||
|
|
@ -19,6 +18,8 @@ type Notifier interface {
|
|||
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 {
|
||||
|
|
@ -27,6 +28,7 @@ type Alerter struct {
|
|||
priceProvider provider.Provider
|
||||
notifier Notifier
|
||||
storage Storage
|
||||
lastCheckedAt time.Time
|
||||
}
|
||||
|
||||
const interval = time.Minute
|
||||
|
|
@ -38,6 +40,7 @@ func New(log *slog.Logger, priceProvider provider.Provider, notifier Notifier, s
|
|||
priceProvider: priceProvider,
|
||||
notifier: notifier,
|
||||
storage: storage,
|
||||
lastCheckedAt: time.Now(), // safe default; overwritten by LoadAlerts if DB has a stored value
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +55,17 @@ func (a *Alerter) LoadAlerts(ctx context.Context) error {
|
|||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -87,36 +101,95 @@ func (a *Alerter) Run(ctx context.Context) {
|
|||
}()
|
||||
}
|
||||
|
||||
// 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 {
|
||||
price, err := a.priceProvider.Price(ctx, instrument)
|
||||
candles, err := a.priceProvider.Candles(ctx, instrument, from, now, candleInterval)
|
||||
if err != nil {
|
||||
a.log.Error("failed to get price", "instrument", instrument.ID, "err", err)
|
||||
continue
|
||||
a.log.Error("failed to get candles", "instrument", instrument.ID, "err", err)
|
||||
return fmt.Errorf("failed to get candles: %w", err)
|
||||
}
|
||||
|
||||
alerts := a.cache.AlertsByInstrument(instrument.ID)
|
||||
for _, alert := range alerts {
|
||||
switch alert.Condition {
|
||||
case entities.AlertConditionAbove:
|
||||
if price.Ask.GreaterThanOrEqual(alert.Price) {
|
||||
a.triggerAlert(ctx, alert, price.Ask)
|
||||
}
|
||||
case entities.AlertConditionBelow:
|
||||
if price.Bid.LessThanOrEqual(alert.Price) {
|
||||
a.triggerAlert(ctx, alert, price.Bid)
|
||||
}
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue