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
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:
"allow_custom_messages": true at the top."strategy_msg" event handler — both ntfy and FCM example configs already include it.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_FILLandEXIT_FILLformatters.
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)
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.
| Scenario | Header 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_namein the JSON template for thestrategy_msgevent (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.