Rich notifications via strategy callback

FreqTrade's built-in entry_fill and exit_fill webhooks send minimal data. Telegram users get richer messages with profit-tier emoji, partial-fill / DCA / final-exit handling, durations, and exit reasons. Replace the built-in fills with the order_filled callback below to get those same messages on FreqDroid — formatted byte-for-byte like Telegram.

← Back to overview · ntfy setup · FCM setup

How the pieces fit together

strategy.order_filled  →  self.dp.send_msg(...)  →  webhook event "strategy_msg"  →  FreqDroid

For the callback to actually reach the device, the webhook block in config.json must have:

  1. "allow_custom_messages": true at the top.
  2. A "strategy_msg" event handler — both ntfy and FCM example configs already include it.
  3. The built-in entry_fill and exit_fill handlers removed — otherwise every fill produces two notifications (the strategy’s rich one and FreqTrade’s minimal one).

The callback fires on every order fill — entries, exits, DCA-in, DCA-out, and final closes of multi-fill trades. Each path produces a different message, mirroring Telegram’s ENTRY_FILL and EXIT_FILL formatters.

Strategy callback

This implementation imports FreqTrade’s own fmt_coin/fmt_coin2/format_pct/round_value helpers so the output matches Telegram exactly — same decimal handling, same coin-name suffix, same percent formatting.

The four NOTIFY_* constants at the top mirror Telegram’s notification_settings — flip any to False to silence that class of fill without touching the message-building code below.

from math import isclose

from freqtrade.persistence import Trade, Order
from freqtrade.util import fmt_coin, fmt_coin2, format_pct, round_value


# ── Notification toggles ─────────────────────────────────────────────────
# Equivalent of Telegram's `notification_settings` block, but scoped to the
# events this callback can emit. Flip any of these to False to silence that
# class of notification without removing the code path.
NOTIFY_NEW_TRADE = True          # First entry fill (the trade opens)
NOTIFY_POSITION_INCREASE = True  # DCA-in fill (subsequent entry on same trade)
NOTIFY_PARTIAL_EXIT = True       # DCA-out fill (partial exit, trade stays open)
NOTIFY_FINAL_EXIT = True         # Trade fully closed (single-shot or final-of-multi)


def _quote_currency(pair: str) -> str:
    """Mirrors exchange.get_pair_quote_currency for typical pair strings.

    BTC/USDT          → USDT
    BTC/USDT:USDT     → USDT
    """
    return pair.split("/", 1)[1].split(":", 1)[0]


def _exit_emoji(profit_ratio: float, exit_reason: str | None) -> str:
    """Mirrors Telegram's _get_exit_emoji exactly (4 buckets, not 5)."""
    if profit_ratio >= 0.05:
        return "\N{ROCKET}"          # 🚀  ≥ 5%
    if profit_ratio >= 0.0:
        return "\N{EIGHT SPOKED ASTERISK}"  # ✳️  0% – 5%
    if exit_reason == "stop_loss":
        return "\N{WARNING SIGN}"    # ⚠️  loss + stop-loss
    return "\N{CROSS MARK}"          # ❌  any other loss


def order_filled(self, pair, trade: Trade, order: Order,
                 current_time, **kwargs):
    exchange = f"{trade.exchange.capitalize()}" \
               f"{' (dry)' if self.config.get('dry_run') else ''}"
    quote_currency = _quote_currency(trade.pair)
    direction = "Short" if trade.is_short else "Long"
    leverage_text = (
        f" ({trade.leverage:.3g}x)"
        if trade.leverage and trade.leverage != 1.0
        else ""
    )
    enter_tag_line = (
        f"*Enter Tag:* `{trade.enter_tag}`\n" if trade.enter_tag else ""
    )

    if order.ft_order_side == trade.entry_side:
        # ── ENTRY FILL ────────────────────────────────────────────
        # Mirrors the rule at freqtradebot.order_close_notify:
        #   sub_trade = order.safe_amount_after_fee != trade.amount
        # Positive when this fill is a DCA-in (position increase) rather
        # than the initial entry that opened the trade.
        sub_trade = not isclose(
            order.safe_amount_after_fee, trade.amount, abs_tol=1e-14
        )
        if sub_trade and not NOTIFY_POSITION_INCREASE:
            return
        if not sub_trade and not NOTIFY_NEW_TRADE:
            return
        wording = "Position increase filled" if sub_trade else "New Trade filled"
        total_label = "New Total" if sub_trade else "Total"

        amount = order.safe_amount_after_fee
        open_rate = order.safe_price
        stake_amount = trade.stake_amount

        msg = (
            f"\N{CHECK MARK} *{exchange}:* {wording} (#{trade.id})\n"
            f"*Pair:* `{trade.pair}`\n"
            f"{enter_tag_line}"
            f"*Amount:* `{round_value(amount, 8)}`\n"
            f"*Direction:* `{direction}{leverage_text}`\n"
            f"*Open Rate:* `{fmt_coin2(open_rate, quote_currency)}`\n"
            f"*{total_label}:* `{fmt_coin(stake_amount, quote_currency)}`"
        )

    elif order.ft_order_side == trade.exit_side:
        # ── EXIT FILL ─────────────────────────────────────────────
        # FreqTrade sets sub_trade=trade.is_open at exit-fill time
        # (see freqtradebot.order_close_notify) — partial vs full close.
        sub_trade = trade.is_open

        if sub_trade and not NOTIFY_PARTIAL_EXIT:
            return
        if not sub_trade and not NOTIFY_FINAL_EXIT:
            return

        if sub_trade:
            order_rate = order.safe_price
            amount = order.safe_filled
            profit = trade.calculate_profit(order_rate, amount, trade.open_rate)
        else:
            order_rate = trade.safe_close_rate
            profit = trade.calculate_profit(rate=order_rate)
            amount = trade.amount

        profit_ratio = profit.profit_ratio
        profit_amount = profit.profit_abs
        cumulative_profit = trade.realized_profit or 0.0
        gain = "profit" if profit_ratio > 0 else "loss"

        is_sub_profit = profit_amount != cumulative_profit
        is_final_exit = (not trade.is_open) and is_sub_profit

        # Wording / profit prefix
        if sub_trade:
            exit_wording = "Partially exited"
            profit_prefix = "Sub "
        elif is_final_exit:
            exit_wording = "Exited"
            profit_prefix = "Sub "
        else:
            exit_wording = "Exited"
            profit_prefix = ""

        # Cumulative / Final Profit line
        cp_extra = ""
        if is_final_exit:
            cp_extra = (
                f"*Final Profit:* `{format_pct(trade.close_profit)} "
                f"({fmt_coin(cumulative_profit, trade.stake_currency)})`\n"
            )
        elif sub_trade and cumulative_profit:
            cp_extra = (
                f"*Cumulative Profit:* "
                f"`{fmt_coin(cumulative_profit, trade.stake_currency)}`\n"
            )

        msg = (
            f"{_exit_emoji(profit_ratio, trade.exit_reason)} *{exchange}:* "
            f"{exit_wording} {trade.pair} (#{trade.id})\n"
            f"*{profit_prefix}Profit:* "
            f"`{format_pct(profit_ratio)} "
            f"({gain}: {fmt_coin(profit_amount, quote_currency)})`\n"
            f"{cp_extra}"
            f"{enter_tag_line}"
            f"*Exit Reason:* `{trade.exit_reason}`\n"
            f"*Direction:* `{direction}{leverage_text}`\n"
            f"*Amount:* `{round_value(amount, 8)}`\n"
            f"*Open Rate:* `{fmt_coin2(trade.open_rate, quote_currency)}`\n"
            f"*Exit Rate:* `{fmt_coin2(order_rate, quote_currency)}`"
        )

        if sub_trade:
            msg += (
                f"\n*Remaining:* `{fmt_coin(trade.stake_amount, quote_currency)}`"
            )
        else:
            close_dt = trade.close_date_utc or current_time
            duration = (
                close_dt.replace(microsecond=0)
                - trade.open_date_utc.replace(microsecond=0)
            )
            duration_min = duration.total_seconds() / 60
            msg += f"\n*Duration:* `{duration} ({duration_min:.1f} min)`"

    else:
        return

    self.dp.send_msg(msg)

What the messages look like

The output is byte-identical to Telegram’s _format_entry_msg / _format_exit_msg for fill events. FreqDroid renders *bold* and `code` markdown the same way Telegram does.

ScenarioHeader line
First entry filled✓ Binance: New Trade filled (#42)
DCA-in fill (position adjustment)✓ Binance: Position increase filled (#42)
Partial exit (DCA-out)✳️ Binance: Partially exited BTC/USDT (#42)
Single-shot full close (winning)🚀 Binance: Exited BTC/USDT (#42)
Final exit of multi-fill trade✳️ Binance: Exited BTC/USDT (#42) followed by *Sub Profit:* and *Final Profit:* lines
Stop-loss exit⚠️ Binance: Exited BTC/USDT (#42)
Other loss❌ Binance: Exited BTC/USDT (#42)

Multi-bot setups — the message header above mirrors Telegram and shows the exchange name. To make each FreqDroid notification’s banner show the per-bot label instead, keep bot_name in the JSON template for the strategy_msg event (ntfy / FCM examples already do this) — FreqDroid’s parser uses that for the notification title.

Tap-to-navigate — every header includes (#{trade.id}). FreqDroid scans the body for #<number> and routes taps to the trade detail screen.