candle close alert

This commit is contained in:
yash 2026-04-27 20:34:48 +03:00
parent 30a7f1b68c
commit dd03cae0f3
10 changed files with 328 additions and 60 deletions

View file

@ -9,6 +9,7 @@ import (
"sync"
"gitea.computernetthings.ru/yash/crypto_alert_bot/internal/entities"
"gitea.computernetthings.ru/yash/crypto_alert_bot/internal/provider"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/shopspring/decimal"
)
@ -49,17 +50,36 @@ const (
type flowStep string
const (
stepAddAlertPrice flowStep = "add_alert_price"
stepEditAlertPrice flowStep = "edit_alert_price"
stepAddAlertPrice flowStep = "add_alert_price"
stepAddAlertAwaitType flowStep = "add_alert_await_type" // price entered, waiting for type callback
stepEditAlertPrice flowStep = "edit_alert_price"
)
type userState struct {
step flowStep
instrument entities.Instrument // set during add_alert flow
currentPrice *entities.Price // fetched when instrument is selected
targetPrice decimal.Decimal // set after price is entered, before type selection
alertID entities.AlertID // set during edit flow
}
// alertTimeframe pairs a KlineInterval with its display label.
type alertTimeframe struct {
interval provider.KlineInterval
label string
}
// offeredTimeframes are the timeframes shown to the user when creating a candle-close alert.
var offeredTimeframes = []alertTimeframe{
{provider.Kline1m, "1m"},
{provider.Kline5m, "5m"},
{provider.Kline15m, "15m"},
{provider.Kline1H, "1H"},
{provider.Kline4H, "4H"},
{provider.Kline1D, "1D"},
{provider.Kline1W, "1W"},
}
// Bot is the Telegram bot handling all user interactions.
type Bot struct {
api *tgbotapi.BotAPI
@ -122,11 +142,12 @@ func (b *Bot) NotifyAlert(ctx context.Context, userID entities.UserID, alert *en
}
text := fmt.Sprintf(
"Alert triggered!\n\n%s/%s %s %s\nCurrent price: %s",
"Alert triggered!\n\n%s/%s %s %s%s\nClose price: %s",
alert.Instrument.BaseCurrency,
alert.Instrument.QuoteCurrency,
alert.Condition,
formatCondition(alert.Condition),
alert.Price.String(),
formatTimeframeSuffix(alert),
currentPrice.String(),
)
msg := tgbotapi.NewMessage(int64(user.TelegramID), text)
@ -137,6 +158,30 @@ func (b *Bot) NotifyAlert(ctx context.Context, userID entities.UserID, alert *en
return err
}
// formatCondition returns a human-readable alert condition label.
func formatCondition(c entities.AlertCondition) string {
switch c {
case entities.AlertConditionAbove:
return "above"
case entities.AlertConditionBelow:
return "below"
case entities.AlertConditionCloseAbove:
return "close above"
case entities.AlertConditionCloseBelow:
return "close below"
default:
return string(c)
}
}
// formatTimeframeSuffix returns " (4H)" for candle-close alerts, empty string otherwise.
func formatTimeframeSuffix(alert *entities.Alert) string {
if alert.Timeframe == "" {
return ""
}
return fmt.Sprintf(" (%s)", alert.Timeframe)
}
// --- Routing ---
func (b *Bot) handleUpdate(ctx context.Context, update tgbotapi.Update) {
@ -175,6 +220,8 @@ func (b *Bot) handleMessage(ctx context.Context, msg *tgbotapi.Message) {
switch state.step {
case stepAddAlertPrice:
b.handleAddAlertPrice(ctx, tgID, chatID, msg.Text, state)
case stepAddAlertAwaitType:
b.send(chatID, "Please select the alert type using the buttons above.")
case stepEditAlertPrice:
b.handleEditAlertPrice(ctx, tgID, chatID, msg.Text, state)
default:
@ -253,6 +300,16 @@ func (b *Bot) handleCallback(ctx context.Context, cb *tgbotapi.CallbackQuery) {
alertID := entities.AlertID(rest[:idx])
page, _ := strconv.Atoi(rest[idx+1:])
b.handleAlertSelect(ctx, chatID, messageID, alertID, page)
case data == "alert_type:crossing":
b.handleAlertTypeCrossing(ctx, tgID, chatID)
case data == "alert_type:close":
b.handleAlertTypeClose(tgID, chatID)
case strings.HasPrefix(data, "alert_timeframe:"):
tf := provider.KlineInterval(strings.TrimPrefix(data, "alert_timeframe:"))
b.handleAlertTimeframe(ctx, tgID, chatID, tf)
}
}
@ -362,12 +419,13 @@ func buildAlertsPage(alerts []entities.Alert, page int) (string, tgbotapi.Inline
var sb strings.Builder
fmt.Fprintf(&sb, "Your active alerts (page %d/%d):\n\n", page+1, totalPages)
for i, alert := range pageAlerts {
fmt.Fprintf(&sb, "%d. %s/%s %s %s\n",
fmt.Fprintf(&sb, "%d. %s/%s %s %s%s\n",
start+i+1,
alert.Instrument.BaseCurrency,
alert.Instrument.QuoteCurrency,
alert.Condition,
formatCondition(alert.Condition),
alert.Price.String(),
formatTimeframeSuffix(&alert),
)
}
@ -438,11 +496,12 @@ func (b *Bot) handleAlertSelect(ctx context.Context, chatID int64, messageID int
return
}
text := fmt.Sprintf("%s/%s\nCondition: %s %s",
text := fmt.Sprintf("%s/%s\nCondition: %s %s%s",
alert.Instrument.BaseCurrency,
alert.Instrument.QuoteCurrency,
alert.Condition,
formatCondition(alert.Condition),
alert.Price.String(),
formatTimeframeSuffix(alert),
)
kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(
@ -507,8 +566,7 @@ func (b *Bot) handleInstrumentSelected(ctx context.Context, tgID entities.Telegr
))
}
// handleAddAlertPrice parses the target price, auto-determines the condition from the current
// market price (target >= ask → above, target < ask → below), and creates the alert.
// handleAddAlertPrice parses the target price, stores it in state, then asks for the alert type.
func (b *Bot) handleAddAlertPrice(ctx context.Context, tgID entities.TelegramID, chatID int64, text string, state *userState) {
target, err := decimal.NewFromString(strings.TrimSpace(text))
if err != nil || !target.IsPositive() {
@ -516,27 +574,40 @@ func (b *Bot) handleAddAlertPrice(ctx context.Context, tgID entities.TelegramID,
return
}
// Use the price already fetched at instrument-selection time; re-fetch only if missing.
currentPrice := state.currentPrice
if currentPrice == nil {
currentPrice, err = b.provider.Price(ctx, state.instrument)
// Re-fetch price if missing (e.g. failed earlier).
if state.currentPrice == nil {
state.currentPrice, err = b.provider.Price(ctx, state.instrument)
if err != nil {
b.log.Error("failed to fetch current price", "instrument", state.instrument.ID, "err", err)
b.send(chatID, "Could not fetch current price. Please try again:")
return
}
state.currentPrice = currentPrice
b.setState(tgID, state)
}
// Determine condition automatically: above ask or below ask.
var condition entities.AlertCondition
if target.GreaterThanOrEqual(currentPrice.Ask) {
condition = entities.AlertConditionAbove
} else {
condition = entities.AlertConditionBelow
state.targetPrice = target
state.step = stepAddAlertAwaitType
b.setState(tgID, state)
kb := tgbotapi.NewInlineKeyboardMarkup(
tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("Price crossing", "alert_type:crossing"),
tgbotapi.NewInlineKeyboardButtonData("Candle close", "alert_type:close"),
),
)
msg := tgbotapi.NewMessage(chatID, fmt.Sprintf("Target price: %s\n\nSelect alert type:", target.String()))
msg.ReplyMarkup = kb
b.sendMsg(msg)
}
// handleAlertTypeCrossing creates a standard crossing alert (High/Low vs target).
func (b *Bot) handleAlertTypeCrossing(ctx context.Context, tgID entities.TelegramID, chatID int64) {
state := b.getState(tgID)
if state.step != stepAddAlertAwaitType {
return
}
condition := b.detectCondition(state)
user, err := b.requireUser(ctx, tgID, chatID)
if err != nil {
return
@ -544,7 +615,7 @@ func (b *Bot) handleAddAlertPrice(ctx context.Context, tgID entities.TelegramID,
alert := &entities.Alert{
UserID: user.ID,
Price: target,
Price: state.targetPrice,
Condition: condition,
Instrument: state.instrument,
}
@ -561,12 +632,98 @@ func (b *Bot) handleAddAlertPrice(ctx context.Context, tgID entities.TelegramID,
b.alerter.AddAlert(alert)
b.setState(tgID, &userState{})
b.sendMenu(chatID, fmt.Sprintf(
"Alert created!\n\n%s/%s %s %s\n\nYou will be notified when the price reaches your target.",
"Alert created!\n\n%s/%s %s %s\n\nYou will be notified when the price crosses your target.",
state.instrument.BaseCurrency, state.instrument.QuoteCurrency,
condition, target.String(),
formatCondition(condition), state.targetPrice.String(),
))
}
// handleAlertTypeClose shows a timeframe selection keyboard for candle-close alerts.
func (b *Bot) handleAlertTypeClose(tgID entities.TelegramID, chatID int64) {
state := b.getState(tgID)
if state.step != stepAddAlertAwaitType {
return
}
// Build one row of timeframe buttons.
var row []tgbotapi.InlineKeyboardButton
for _, tf := range offeredTimeframes {
row = append(row, tgbotapi.NewInlineKeyboardButtonData(
tf.label,
fmt.Sprintf("alert_timeframe:%s", tf.interval),
))
}
msg := tgbotapi.NewMessage(chatID, "Select the candle timeframe:")
msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup(row)
b.sendMsg(msg)
}
// handleAlertTimeframe creates a candle-close alert for the selected timeframe.
func (b *Bot) handleAlertTimeframe(ctx context.Context, tgID entities.TelegramID, chatID int64, tf provider.KlineInterval) {
state := b.getState(tgID)
if state.step != stepAddAlertAwaitType {
return
}
condition := b.detectCloseCondition(state)
user, err := b.requireUser(ctx, tgID, chatID)
if err != nil {
return
}
alert := &entities.Alert{
UserID: user.ID,
Price: state.targetPrice,
Condition: condition,
Instrument: state.instrument,
Timeframe: string(tf),
}
id, err := b.usecase.CreateAlert(ctx, alert)
if err != nil {
b.log.Error("failed to create alert", "err", err)
b.sendMenu(chatID, "Failed to create alert. Please try again.")
b.setState(tgID, &userState{})
return
}
alert.ID = id
b.alerter.AddAlert(alert)
b.setState(tgID, &userState{})
b.sendMenu(chatID, fmt.Sprintf(
"Alert created!\n\n%s/%s %s %s (%s)\n\nYou will be notified when a %s candle closes %s the target.",
state.instrument.BaseCurrency, state.instrument.QuoteCurrency,
formatCondition(condition), state.targetPrice.String(), string(tf),
string(tf),
closeDirectionWord(condition),
))
}
// detectCondition returns above/below based on target vs current ask.
func (b *Bot) detectCondition(state *userState) entities.AlertCondition {
if state.currentPrice != nil && state.targetPrice.GreaterThanOrEqual(state.currentPrice.Ask) {
return entities.AlertConditionAbove
}
return entities.AlertConditionBelow
}
// detectCloseCondition returns close_above/close_below based on target vs current ask.
func (b *Bot) detectCloseCondition(state *userState) entities.AlertCondition {
if state.currentPrice != nil && state.targetPrice.GreaterThanOrEqual(state.currentPrice.Ask) {
return entities.AlertConditionCloseAbove
}
return entities.AlertConditionCloseBelow
}
func closeDirectionWord(c entities.AlertCondition) string {
if c == entities.AlertConditionCloseAbove {
return "above"
}
return "below"
}
// handleEditAlertStart begins the edit flow for a specific alert.
func (b *Bot) handleEditAlertStart(tgID entities.TelegramID, chatID int64, alertID entities.AlertID) {
b.setState(tgID, &userState{