Plugin System

Extend AlgoMLN without touching the engine.

v0.1.0 Stable

AlgoMLN's plugin system lets you add custom indicators, backtest metrics, DSL keywords, UI panels, scheduled tasks, and market data access — all without modifying the core engine. Plugins are sandboxed, capability-gated, and hot-reloadable from the desktop app.

Plugins live in <app_data>/plugins/<plugin-id>/ and are discovered at startup. Each plugin directory must contain a plugin.toml manifest and an entry file (.rhai or .wasm).

Key invariant: Plugins only reach the engine through PluginHost's capability-gated accessors. If a plugin calls a host function it hasn't declared in its manifest, the call returns PluginError::PermissionDenied. Logging is the one always-on capability — no declaration needed.

Manifest — plugin.toml

Every plugin requires a plugin.toml in its directory. The manifest declares the plugin's identity, entry point, and required capabilities.

toml
[plugin]
id      = "my-indicator-plugin"
name    = "My Indicator Plugin"
version = "1.0.0"
entry   = "plugin.rhai"       # or "plugin.wasm"

[permissions]
capabilities = [
    "Indicators",             # register custom indicator functions
    "Storage",                # per-plugin key-value store
]

manifest fields

FieldTypeDescription
idStringUnique identifier. Used for dedup and per-plugin storage keys.
nameStringHuman-readable name shown in the Plugins screen.
versionStringSemver version of the plugin.
entryStringFilename of the entry file. Extension determines runtime: .rhai → Rhai, .wasm → WASM.
capabilitiesString[]List of capabilities the plugin needs. See Capabilities.

Runtimes

Two sandboxed runtimes are supported. The manifest's entry file extension determines which is used.

Rhai Runtime (.rhai)

Rhai is a lightweight, embedded scripting language with Rust-like syntax. AlgoMLN uses a hardened Rhai engine with strict resource limits:

rhai
// plugin.rhai — minimal Rhai plugin skeleton

fn on_load(host) {
    host.log("my-plugin loaded");
    // register a custom indicator
    host.indicators().register("fast_ema", |candles, period| {
        // return a Vec of f64 values
        ema(candles, period / 2)
    });
}

fn on_enable(host) {
    host.log("my-plugin enabled");
}

fn on_disable(host) {
    host.log("my-plugin disabled");
}

fn on_unload(host) {
    host.log("my-plugin unloaded");
}

WASM Runtime (.wasm)

WASM plugins run in a wasmtime 23 instance with strict isolation:

WASI is intentionally not linked because WasiCtx in wasmtime 23 is Send-only, which would violate the Plugin: Send + Sync bound required by the plugin registry.

Capabilities

Plugins must declare every capability they need in plugin.toml. Undeclared capabilities return PermissionDenied at runtime — not at load time. Logging is always available with no declaration required.

Indicators
Register a custom indicator function callable from .algomln strategies by name.
Analytics
Register a custom backtest metric that appears in the backtest results panel.
DslExtension
Register a new keyword the DSL parser and evaluator can resolve via this plugin.
Storage
A sandboxed, per-plugin file-backed key/value store. Data is scoped to the plugin's ID.
UiPanels
Register a UI panel, push toast notifications, and stream data into a custom React component.
Scheduler
Register cron-based recurring tasks that fire on a schedule while the app is running.
MarketData
Read-only access to the same broker client the strategy engine uses. Live ticks and historical OHLCV.
Execution
Reserved — currently a no-op stub. Will be wired to a broker-agnostic execution facade in Phase 7.

Indicators

Register custom indicator functions that can be called from .algomln strategies by name:

rhai
fn on_load(host) {
    // Register "fast_ema" — callable as fast_ema(period) in .algomln
    host.indicators().register("fast_ema", |candles, period| {
        // Must return Vec, one value per candle
        compute_fast_ema(candles, period)
    });
}

In a strategy file, after the plugin is enabled:

algomln
WHEN fast_ema(10) > ma(50)
BUY 5

Analytics

Register custom metrics computed over backtest trade history:

rhai
fn on_load(host) {
    host.analytics().register_metric("max_drawdown", |trades| {
        // trades is a list of completed trade objects
        // return a f64 metric value
        compute_max_drawdown(trades)
    });
}

DSL Extension

Register new keywords that the DSL parser and evaluator can resolve. On disable, all keywords registered by this plugin are unregistered automatically:

rhai
fn on_load(host) {
    host.dsl_extension().register_keyword("market_open", |candle| {
        // Return true/false — the condition value for this keyword
        candle.time.hour() == 9 && candle.time.minute() >= 15
    });
}

Storage

A simple key-value store backed by a file in the plugin's directory. All keys are automatically namespaced to the plugin's ID — no collision possible across plugins:

rhai
fn on_enable(host) {
    let store = host.storage();
    let visits = store.get("visit_count").unwrap_or(0);
    store.set("visit_count", visits + 1);
    host.log(`Plugin enabled ${visits + 1} times`);
}

UI Panels

Register a named panel. The React app subscribes to "plugin-ui-message" Tauri events forwarded by TauriUiApi from a tokio::spawn'd forwarder. Messages are dispatched in the React app based on the UiMessage variant:

rhai
fn on_enable(host) {
    let ui = host.ui_panels();
    ui.register_panel("my-panel", "My Panel Title");
    ui.push_toast("my-plugin", "Plugin enabled!", "success");
    ui.stream_data("my-panel", #{ value: 42, label: "custom" });
}

Scheduler

Schedule recurring tasks using cron expressions. All tasks for a plugin are cancelled automatically on disable via cancel_all_for(plugin_id):

rhai
fn on_enable(host) {
    // Fire every minute at :00 seconds
    host.scheduler().cron("0 * * * * *", || {
        host.log("tick — every minute");
    });
}

Market Data

Read-only access to the same broker client the strategy engine uses:

rhai
fn on_enable(host) {
    let market = host.market_data();
    // Fetch recent OHLCV history
    let candles = market.history("NIFTY", "1min", 100);
    host.log(`Got ${candles.len()} candles`);
}

Execution

The Execution capability is a reserved no-op stub in v0.1.0. All calls return immediately without effect. It will be wired to a broker-agnostic execution facade in Phase 7 (Live Trading).

Event Bus

AlgoMLN has a broadcast event bus that plugins can subscribe to. The engine publishes three event types from on_candle:

EventWhen it fires
RuleFiredAfter TriggerStateMap::should_fire returns true for a rule
TradeExecutedImmediately after a successful execution_target.execute call
CandleProcessedAfter the cross-update pass completes for the candle

Backtests never wire up the event bus. The engine carries an event_bus: Option<Arc<EventBus>> that is deliberately left as None during backtest replay. This ensures plugin callbacks cannot affect backtest determinism. The bus is only attached for paper and live runs.

rhai
fn on_enable(host) {
    let bus = host.event_bus();

    bus.subscribe("RuleFired", |event| {
        host.log(`Rule fired: ${event.rule_id}`);
    });

    bus.subscribe("TradeExecuted", |event| {
        host.log(`Trade: ${event.side} ${event.qty} @ ${event.price}`);
    });

    bus.subscribe("CandleProcessed", |event| {
        // fires after every candle during paper/live
    });
}

Lifecycle

Plugins implement up to four lifecycle hooks. The runtime dispatches these in order:

HookWhen calledRequired
on_load(host)Plugin is scanned and loaded at startup. Use to register indicators, analytics, DSL keywords.Recommended
on_enable(host)User enables the plugin from the Plugins screen. Use to set up subscriptions, scheduler tasks.Optional
on_disable(host)User disables the plugin. Resources registered on enable should be cleaned up here.Optional
on_unload(host)Plugin is removed from memory (app shutdown or reload).Optional

The registry swaps the real plugin out of its entry under a write lock before awaiting any lifecycle callback. This prevents deadlock if a plugin re-enters the registry, since parking_lot guards are !Send and cannot be held across .await.

Resource cleanup is automatic for registered capabilities on disable:

Management

Plugins can be managed from the desktop app's Plugins screen, or via Tauri IPC commands:

CommandDescription
list_pluginsReturns all plugins and their current state (Loaded / Enabled / Disabled / Failed)
enable_plugin(id)Enable a loaded plugin — calls on_enable
disable_plugin(id)Disable an enabled plugin — calls on_disable
reload_pluginsUnload all plugins and re-scan the plugins directory

Plugin states:

StateMeaning
LoadedPlugin manifest parsed and on_load succeeded. Not yet enabled.
Enabledon_enable succeeded. Event bus subscriptions and scheduler tasks are active.
Disabledon_disable called. Plugin is in memory but not active.
FailedManifest parse error, on_load panic, or runtime error. Plugin is not usable.