Alerts
Alerts & channels
Raise a user-facing alert from anywhere in a plugin — the platform fans it out to the log, a sound, and any remote channel (email, Telegram, …) the user has configured. Producers describe what happened; routing and delivery are the user's concern, never yours.
On this page
What it is & why it exists
The alert system is a single neutral pipeline shared by every alert-raising surface in TradeStrike —
indicator alerts, Market Analyzer cell rules, the condition-alert engine. A producer hands over a semantic
Alert (or, from an indicator, calls a one-line context method) and knows nothing about
channels, queues, threads or retries. The dispatcher applies the user's routing policy and fans the alert
out to every enabled IAlertChannel asynchronously.
This separation is the whole point: a producer that wanted to "send this to Telegram" would otherwise have to know about transports, credentials and backoff. Instead it emits a fact; the user's configuration decides where it lands. New delivery destinations plug in by implementing one interface — nothing in the producers, routing or dispatcher changes (Open/Closed).
graph LR; P[Producer: indicator / MA rule / engine] -->|Alert| S[IAlertSink dispatcher]; S -->|routing policy| C1[Log channel]; S --> C2[Sound channel]; S --> C3[Telegram channel];
The contracts live in TradeStrike.Pipeline.Alerts, with the indicator-facing entry point on
IIndicatorContext in TradeStrike.Pipeline.Indicators.
Raising an alert from an indicator
The common case never touches the Alert record at all. An indicator (or strategy) raises an
alert through its context, mirroring NinjaTrader's Alert(…) API:
IIndicatorContext.cs (alert members)using TradeStrike.Pipeline.Alerts;
using TradeStrike.Pipeline.Indicators;
// Simplest form — the message doubles as the re-arm id, so identical
// back-to-back messages de-duplicate.
void Alert(string message);
// Full control — mirrors NinjaTrader's Alert(id, priority, message, sound, rearm).
void Alert(
string id, // re-arm key (stable id throttles repeats)
string message, // text shown to the user
string? soundPath = null, // .wav path or bare filename; null/empty = no sound
AlertSeverity severity = AlertSeverity.Info, // presentation hint
double rearmSeconds = 0); // min seconds before the same id may re-fire
IsLive is true. Historical
and backtest passes are silent, so a backfill never replays thousands of alerts. You do not have to gate
the call yourself — but do not rely on an alert firing during OnDataLoaded or catch-up.
Use a stable id (e.g. the signal name) to throttle a repeating condition, or a unique id (include the bar time) to fire every time. A typical crossover indicator:
RsiAlert.cs (in OnBarUpdate)using TradeStrike.Pipeline.Alerts;
// _ctx is the IIndicatorContext passed to the lifecycle hook.
if (rsi[0] > 70 && rsi[1] <= 70)
{
_ctx.Alert(
id: "RSI-overbought", // one re-arm key for this signal
message: $"RSI crossed above 70 ({rsi[0]:F1})",
soundPath: "Alert1.wav",
severity: AlertSeverity.Warning,
rearmSeconds: 300); // don't re-fire for 5 minutes
}
Internally the host wraps that call into an IndicatorAlert — a compact payload carrying
the message, severity, optional sound and the raising instrument — and forwards it to the host's
IIndicatorAlertSink, which bridges into the unified dispatcher below. As a plugin author you
raise alerts through the context; you never construct IndicatorAlert yourself.
The alert model
Alert is the single neutral payload the unified dispatcher delivers. It is deliberately
consumer- and venue-agnostic: it carries semantic facts about what happened, never how or where to
deliver. Channel choice is the user's routing, not the producer's.
Alert.csusing System;
using System.Collections.Generic;
using TradeStrike.Pipeline.Alerts;
public sealed record Alert(
string Id, // stable logical id for de-dup / re-arm; dispatcher keys on Source:Id
string Source, // producer category: "Indicator", "MarketAnalyzer", "alert-engine"
string Title, // short headline (the indicator / rule name)
string Message, // user-facing text
DateTime TimeUtc, // when it was raised (UTC)
AlertSeverity Severity = AlertSeverity.Info, // presentation hint + routing threshold
string? Symbol = null, // instrument it concerns (drives symbol routing); null if none
string? SoundPath = null, // .wav hint — only the sound channel reads it; NOT a selector
double RearmSeconds = 0, // min seconds before the same Id may re-fire (<= 0 = no backstop)
IReadOnlyList<string>? Channels = null, // explicit per-alert channel ids; null = use the global policy
IReadOnlyDictionary<string, string>? Data = null); // structured key/values for templating; never secrets
Channels null and the global AlertRoutingPolicy
decides delivery. Set it to specific channel ids to express "send THIS one to Telegram" — the
dispatcher still intersects with the enabled channels and honours each route's minimum severity.
Severity
One three-level vocabulary is shared by every alert-raising surface, so a plugin indicator and the
Market Analyzer speak the same language. Routes filter on it (Info ≤ Warning ≤ Critical)
and channels use it for presentation (log category, sound pitch, toast colour).
AlertSeverity.csnamespace TradeStrike.Pipeline.Alerts;
public enum AlertSeverity
{
Info,
Warning,
Critical,
}
The dispatcher seam (IAlertSink)
IAlertSink is THE producer-facing seam for raising an alert directly (the indicator context
bridges onto it for you). It is fire-and-forget: Dispatch is called from trading / render / UI
threads and must never block on delivery — the implementation queues and fans out asynchronously.
IAlertSink.csusing System;
using TradeStrike.Pipeline.Alerts;
public interface IAlertSink
{
// Hand one alert to the delivery system. Fire-and-forget; never blocks, never throws.
void Dispatch(Alert alert);
}
// The owning dispatcher: a sink with a lifecycle. Disposal drains the queue and
// stops the worker with a bounded timeout, so shutdown can't hang on a slow send.
public interface IAlertDispatcher : IAlertSink, IAsyncDisposable
{
}
Routing (AlertRoute)
Routing is pure data the user owns — producers never see it. Each AlertRoute is one
declarative rule "deliver alerts matching these filters to this channel"; the full set is the
AlertRoutingPolicy the dispatcher applies when an alert specifies no explicit channels.
AlertRoute.csusing System.Collections.Generic;
using TradeStrike.Pipeline.Alerts;
public sealed record AlertRoute(
string ChannelId, // target channel (IAlertChannel.Id)
bool Enabled = true,
AlertSeverity MinSeverity = AlertSeverity.Info, // only alerts at/above this severity match
IReadOnlySet<string>? SourceAllowList = null, // null = any Source
IReadOnlySet<string>? SymbolAllowList = null) // null = any Symbol; a symbol-less alert is NOT excluded
{
// True when the alert should be delivered to this route's channel.
public bool Accepts(Alert alert);
}
public sealed record AlertRoutingPolicy(IReadOnlyList<AlertRoute> Routes)
{
public static AlertRoutingPolicy Empty { get; } // safe default before config loads
public IEnumerable<AlertRoute> RoutesFor(Alert alert); // routes that accept the alert, in order
}
Cooldown / re-arm
AlertCooldownGate is the shared throttle: TryFire returns true at most once per
cooldown window for a given key. It is the same primitive the indicator Alert(…, rearmSeconds)
re-arm and the dispatcher's de-dup backstop use. It is pure — you supply "now" explicitly, so it has no
scheduler dependency and tests stay deterministic.
AlertCooldownGate.csusing System;
using TradeStrike.Pipeline.Alerts;
public sealed class AlertCooldownGate
{
public int CooldownSeconds { get; }
public AlertCooldownGate(int cooldownSeconds);
// Fire under key at 'now' using the instance cooldown. True when never fired
// OR the previous fire was longer ago than CooldownSeconds.
public bool TryFire(string key, DateTime now);
// Per-call cooldown override (cooldownSeconds <= 0 ⇒ always fires).
public bool TryFire(string key, DateTime now, double cooldownSeconds);
public void Reset(); // drop all per-key timestamps
}
Use it inside a producer that raises alerts on its own schedule (e.g. a volume-profile engine) when you want throttling independent of the per-message re-arm id:
VolumeSpikeMonitor.csusing System;
using TradeStrike.Pipeline.Alerts;
private readonly AlertCooldownGate _gate = new(cooldownSeconds: 60);
private void OnVolumeSpike(string symbol, DateTime nowUtc, IAlertSink sink)
{
if (!_gate.TryFire(symbol, nowUtc)) return; // at most one spike alert per symbol per minute
sink.Dispatch(new Alert(
Id: $"vol-spike:{symbol}",
Source: "alert-engine",
Title: "Volume spike",
Message: $"{symbol} traded 3× its average volume",
TimeUtc: nowUtc,
Severity: AlertSeverity.Warning,
Symbol: symbol));
}
Writing a custom channel
A channel is one delivery destination. Implement IAlertChannel and register it; nothing else
changes. Each channel advertises AlertChannelCapabilities (sound / rich text / remote) so the
dispatcher and settings UI can reason about it without knowing the concrete type — e.g. only retrying
IsRemote channels.
IAlertChannel.csusing System.Threading;
using System.Threading.Tasks;
using TradeStrike.Pipeline.Alerts;
public interface IAlertChannel
{
string Id { get; } // stable id used in routing + per-alert targeting
string DisplayName { get; } // for the settings UI
AlertChannelCapabilities Capabilities { get; } // sound / rich text / remote
bool Enabled { get; } // configured + turned on; dispatcher skips disabled channels
// Deliver one alert. Returns a result rather than throwing.
Task<AlertSendResult> SendAsync(Alert alert, CancellationToken ct);
}
public readonly record struct AlertChannelCapabilities(
bool SupportsSound, // can play / attach the alert's sound
bool SupportsRichText, // renders markdown/HTML rather than plain text
bool IsRemote); // crosses the network — subject to transient failure + retry
SendAsync returns an AlertSendResult rather than
throwing. A remote channel — which alone knows whether a failure is transient — classifies it so
the retry/backoff policy stays centralized in the dispatcher. Return Transient for a timeout /
429 / 5xx, Fail for a permanent error (bad credentials, 4xx).
AlertSendResult.csusing TradeStrike.Pipeline.Alerts;
public readonly record struct AlertSendResult(bool Ok, string? FailureReason = null, bool Retryable = false)
{
public static AlertSendResult Success { get; } // a successful send
public static AlertSendResult Fail(string reason); // permanent — do not retry
public static AlertSendResult Transient(string reason); // transient — dispatcher may retry with backoff
}
A complete remote channel that posts to a webhook:
WebhookAlertChannel.csusing System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using TradeStrike.Pipeline.Alerts;
// Delivers alerts to a chat webhook (Slack/Discord-style). Remote, so the dispatcher
// retries transient failures with backoff.
public sealed class WebhookAlertChannel : IAlertChannel
{
private readonly HttpClient _http;
private readonly Uri? _webhook;
public WebhookAlertChannel(HttpClient http, string? webhookUrl)
{
_http = http;
_webhook = string.IsNullOrWhiteSpace(webhookUrl) ? null : new Uri(webhookUrl);
}
public string Id => "webhook";
public string DisplayName => "Chat webhook";
// Plain text, remote — no sound. Marking IsRemote opts into the dispatcher's retry policy.
public AlertChannelCapabilities Capabilities => new(SupportsSound: false, SupportsRichText: false, IsRemote: true);
// A half-configured channel reports Enabled == false, so the dispatcher never calls it.
public bool Enabled => _webhook is not null;
public async Task<AlertSendResult> SendAsync(Alert alert, CancellationToken ct)
{
if (_webhook is null) return AlertSendResult.Fail("Webhook URL not configured.");
var prefix = alert.Severity switch
{
AlertSeverity.Critical => "[CRITICAL] ",
AlertSeverity.Warning => "[WARN] ",
_ => "",
};
var payload = new { text = $"{prefix}{alert.Title}: {alert.Message}" };
try
{
var resp = await _http.PostAsJsonAsync(_webhook, payload, ct).ConfigureAwait(false);
if (resp.IsSuccessStatusCode) return AlertSendResult.Success;
var status = (int)resp.StatusCode;
// 429 / 5xx are worth a backed-off retry; other 4xx are permanent.
return status == 429 || status >= 500
? AlertSendResult.Transient($"HTTP {status}")
: AlertSendResult.Fail($"HTTP {status}");
}
catch (HttpRequestException ex)
{
return AlertSendResult.Transient(ex.Message); // network blip — let the dispatcher retry
}
catch (TaskCanceledException)
{
return AlertSendResult.Transient("timeout");
}
}
}
IAlertChannel. Your channel needs no other coupling: once
it is enabled, the user can route any alert to it by its Id through an AlertRoute
or a per-alert Alert.Channels list.