candle close alert
This commit is contained in:
parent
30a7f1b68c
commit
dd03cae0f3
10 changed files with 328 additions and 60 deletions
|
|
@ -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{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue