Notification controls + channels for Elixir trading bots. A small, dependency-light layer that decides what to notify about, for which strategy, and where to send it — then fans a plain-text message out to pluggable delivery channels (Telegram, Discord, generic webhook, or the Logger).

It was extracted from a Polymarket prediction-market trading bot and generalized: nothing here is Polymarket-specific. Strategies are identified by an arbitrary string tag, so a user-written strategy gets its own notification settings with no code change.

This package is part of the Polymarket Elixir ecosystem (alongside paper_ex and friends), but it has no dependency on any of the others and works with any bot.

Two layers

  • Controls (PolymarketNotify.Controls) — the core: a pure data model of per-category defaults (:fills, :resolutions, :errors, :daily_summary, :consensus) with per-strategy overrides. Route fills for the AI strategy to Telegram while muting fills for the surge bots, apply a min_value threshold, etc. Pure data + pure resolution — no I/O, trivially testable, and a UI/editor can read and write it later.
  • Channels (PolymarketNotify.Channel) — pluggable delivery. A channel is any module implementing the deliver/2 behaviour that ships text somewhere. Built-ins: Telegram, Discord, Webhook, Log, and a Multi fan-out. Bring your own by implementing the behaviour.

Design notes

  • Best-effort. PolymarketNotify.notify/3 consults the controls and, if the (category, strategy, value) passes, fans the message out. Nothing configured / not allowed ⇒ a silent no-op. It never raises into the caller and always returns :ok; a channel that errors or raises is isolated.
  • No process, no state. The controls are plain maps you own and persist however you like. The library adds no supervision tree.
  • Injectable HTTP. Every network channel accepts an :http function (url, json -> {:ok, status} | {:error, term}), so tests need no real network and you can swap the client.
  • Secret-aware config. Any option value may be given as {:system, "VAR"} to read from the environment at delivery time instead of baking a token into config.

Installation

Add polymarket_notify to your deps in mix.exs:

def deps do
  [
    {:polymarket_notify, "~> 0.1"}
  ]
end

Usage

Configure channels and (optionally) a default control set:

config :polymarket_notify,
  channels: %{
    telegram: {PolymarketNotify.Telegram,
               [bot_token: {:system, "TELEGRAM_BOT_TOKEN"},
                chat_id: {:system, "TELEGRAM_CHAT_ID"}]},
    log: {PolymarketNotify.Log, []}
  },
  controls: PolymarketNotify.Controls.default()

Then notify from anywhere in your bot:

# Simple: consults app config for controls + channels.
PolymarketNotify.notify(:fills, "Filled 100 @ 0.62 (BTC-up)")

# With a strategy tag (for per-strategy overrides) and a magnitude
# (for the category's :min_value threshold):
PolymarketNotify.notify(:fills, "Big fill!", strategy: "ai", value: 250.0)

# Inject controls/channels directly (handy in tests):
PolymarketNotify.notify(:errors, "boom",
  controls: my_controls,
  channels: %{log: {PolymarketNotify.Log, [level: :error]}})

The control model

%{
  categories: %{
    fills:         %{enabled: true, min_value: 25.0, channels: [:telegram]},
    resolutions:   %{enabled: true, channels: [:telegram]},
    errors:        %{enabled: true, channels: [:telegram, :log]},
    daily_summary: %{enabled: true, channels: [:telegram]}
  },
  # per-strategy overrides: tag => %{category => partial pref}
  strategies: %{
    "surge" => %{fills: %{enabled: false}},
    "ai"    => %{fills: %{enabled: true, min_value: 0.0}}
  },
  default_channels: [:log]
}

Resolution merges the category default with any per-strategy override for that (strategy, category). Start from PolymarketNotify.Controls.default/0 (every category on, routed to default_channels), or seed one entry per strategy from a catalog:

PolymarketNotify.Controls.from_catalog(["surge", %{tag: "ai"}, "my_custom_strat"])

Writing a channel

defmodule MyApp.SlackChannel do
  @behaviour PolymarketNotify.Channel

  @impl true
  def deliver(message, opts) do
    url = PolymarketNotify.resolve(Keyword.get(opts, :webhook_url))
    # ... POST message, return :ok or {:error, reason}
    :ok
  end
end

License

MIT. See LICENSE.