use anyhow::{Context, Result};
use bot_core::{
AssetId, Environment, HyperliquidMarket, InstrumentId, InstrumentMeta, Market, Strategy,
StrategyId,
};
use bot_orchestrator::{
BotOrchestrator, GroupRiskConfig, OrchestratorCondition, OrchestratorConditions,
OrchestratorLeg,
};
use rust_decimal::Decimal;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::str::FromStr;
use strategy_arbitrage::{ArbitrageConfig, ArbitrageStrategy};
use strategy_dca::{DCAConfig, DCADirection, DCAStrategy};
use strategy_grid::{GridConfig, GridMode, GridStrategy};
use strategy_market_maker::{MarketMaker, MarketMakerConfig, SkewMode};
#[cfg(feature = "strategy-rsi")]
use strategy_rsi::{RsiStrategy, RsiStrategyConfig};
#[cfg(feature = "strategy-tick-trader")]
use strategy_tick_trader::{TickTrader, TickTraderConfig};
fn push_price_bound_errors(market: &Market, prices: &[(&str, Decimal)], errors: &mut Vec<String>) {
let Some((lower, upper)) = market.price_bounds() else {
return;
};
for (label, price) in prices {
if *price <= lower || *price >= upper {
errors.push(format!(
"{} ({}) must be > {} and < {} for {}",
label,
price,
lower,
upper,
market.instrument_id()
));
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct BotConfig {
pub environment: String,
#[serde(default)]
pub private_key: String,
#[serde(default)]
pub address: String,
#[serde(default)]
pub vault_address: Option<String>,
#[serde(default)]
pub base_url_override: Option<String>,
pub strategy_type: String,
pub markets: Vec<Market>,
#[serde(default = "default_poll_delay_ms")]
pub poll_delay_ms: u64,
#[serde(default)]
pub grid: Option<GridConfigJson>,
#[serde(default)]
pub mm: Option<MMConfigJson>,
#[serde(default)]
pub dca: Option<DCAConfigJson>,
#[serde(default)]
pub arbitrage: Option<ArbitrageConfigJson>,
#[serde(default)]
pub orchestrator: Option<OrchestratorConfigJson>,
#[serde(default)]
pub builder_fee: Option<BuilderFeeConfig>,
#[serde(default)]
pub sync: Option<SyncConfigJson>,
#[serde(default)]
pub simulation: Option<SimulationConfig>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
impl BotConfig {
pub fn primary_market(&self) -> &Market {
&self.markets[0]
}
pub fn is_spot(&self) -> bool {
self.primary_market().is_spot()
}
pub fn is_spot_like(&self) -> bool {
self.primary_market().is_spot_like()
}
pub fn primary_market_uses_margin(&self) -> bool {
self.primary_market().uses_margin()
}
pub fn is_outcome(&self) -> bool {
self.primary_market().is_outcome()
}
pub fn instrument_id(&self) -> InstrumentId {
self.primary_market().instrument_id()
}
pub fn market_index(&self) -> bot_core::MarketIndex {
self.primary_market().market_index()
}
pub fn hip3_config(&self) -> Option<bot_core::market::Hip3MarketConfig> {
self.primary_market().hip3_config()
}
pub fn custom_config<T: serde::de::DeserializeOwned>(&self, strategy_name: &str) -> Result<T> {
let raw = self.extra.get(strategy_name).with_context(|| {
format!(
"Custom strategy config missing: add '\"{}\"' section to your JSON config",
strategy_name
)
})?;
serde_json::from_value(raw.clone())
.with_context(|| format!("Failed to parse '{}' config section", strategy_name))
}
#[cfg(not(target_arch = "wasm32"))]
pub fn from_env() -> Result<Self> {
Err(anyhow::anyhow!(
"V2 config format requires a JSON file. Use --config <file>"
))
}
#[cfg(not(target_arch = "wasm32"))]
pub fn resolve_credentials(&self) -> Result<(String, String)> {
if !self.private_key.is_empty() && !self.address.is_empty() {
return Ok((self.private_key.clone(), self.address.clone()));
}
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.context("Cannot determine home directory")?;
let creds_path = std::path::PathBuf::from(home).join(".supurr/credentials.json");
let content = std::fs::read_to_string(&creds_path).with_context(|| {
format!(
"No credentials in config and ~/.supurr/credentials.json not found. \
Run 'supurr init' to configure your wallet."
)
})?;
#[derive(serde::Deserialize)]
struct Creds {
address: String,
private_key: String,
}
let creds: Creds =
serde_json::from_str(&content).context("Failed to parse ~/.supurr/credentials.json")?;
let pk = if self.private_key.is_empty() {
creds.private_key
} else {
self.private_key.clone()
};
let addr = if self.address.is_empty() {
creds.address
} else {
self.address.clone()
};
Ok((pk, addr))
}
#[cfg(not(target_arch = "wasm32"))]
pub fn from_file(path: &PathBuf) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {:?}", path))?;
let config: Self = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse config file: {:?}", path))?;
if config.markets.is_empty() {
anyhow::bail!("Config must have at least one market in the 'markets' array");
}
let market_errors: Vec<String> = config
.markets
.iter()
.enumerate()
.flat_map(|(idx, market)| {
market
.validation_errors()
.into_iter()
.map(move |error| format!("markets[{}]: {}", idx, error))
})
.collect();
if !market_errors.is_empty() {
anyhow::bail!(
"Market configuration validation failed: {}",
market_errors.join(", ")
);
}
Ok(config)
}
pub fn parse_environment(&self) -> Environment {
match self.environment.to_lowercase().as_str() {
"mainnet" | "main" | "prod" => Environment::Mainnet,
_ => Environment::Testnet,
}
}
pub fn strategy_leverage(&self) -> Option<(Decimal, Decimal)> {
if let Some(ref g) = self.grid {
let lev = Decimal::from_str(&g.leverage).ok()?;
let max = Decimal::from_str(&g.max_leverage).ok()?;
Some((lev, max))
} else if let Some(ref d) = self.dca {
let lev = Decimal::from_str(&d.leverage).ok()?;
let max = Decimal::from_str(&d.max_leverage).ok()?;
Some((lev, max))
} else if let Some(ref a) = self.arbitrage {
let lev = Decimal::from_str(&a.perp_leverage).ok()?;
Some((lev, Decimal::new(50, 0)))
} else {
None
}
}
pub fn strategy_allocated_capital_usdc(&self) -> Option<Decimal> {
let strategy_type = self.strategy_type.to_lowercase();
if strategy_type == "grid" {
return self
.grid
.as_ref()
.and_then(|grid| Decimal::from_str(&grid.max_investment_quote).ok());
}
if strategy_type == "orchestrator" {
return self
.grid
.as_ref()
.and_then(|grid| Decimal::from_str(&grid.max_investment_quote).ok());
}
if strategy_type == "arbitrage" || strategy_type == "arb" {
return self
.arbitrage
.as_ref()
.and_then(|arb| Decimal::from_str(&arb.order_amount).ok());
}
if strategy_type == "dca" {
let dca = self.dca.as_ref()?;
let direction = match dca.direction.to_lowercase().as_str() {
"short" => DCADirection::Short,
_ => DCADirection::Long,
};
let config = DCAConfig {
strategy_id: StrategyId::new("metrics-dca"),
environment: self.parse_environment(),
market: self.primary_market().clone(),
direction,
trigger_price: Decimal::from_str(&dca.trigger_price).ok()?,
base_order_size: Decimal::from_str(&dca.base_order_size).ok()?,
dca_order_size: Decimal::from_str(&dca.dca_order_size).ok()?,
max_dca_orders: dca.max_dca_orders,
size_multiplier: Decimal::from_str(&dca.size_multiplier).ok()?,
price_deviation_pct: Decimal::from_str(&dca.price_deviation_pct).ok()?,
deviation_multiplier: Decimal::from_str(&dca.deviation_multiplier).ok()?,
take_profit_pct: Decimal::from_str(&dca.take_profit_pct).ok()?,
stop_loss: dca
.stop_loss
.as_ref()
.and_then(|value| Decimal::from_str(value).ok()),
leverage: Decimal::from_str(&dca.leverage).ok()?,
max_leverage: Decimal::from_str(&dca.max_leverage).ok()?,
restart_on_complete: dca.restart_on_complete,
cooldown_period_secs: dca.cooldown_period_secs,
};
return Some(config.max_total_investment());
}
None
}
pub fn effective_simulation_config(&self) -> SimulationConfig {
self.simulation.clone().unwrap_or(SimulationConfig {
starting_balance_usdc: default_starting_balance(),
fee_rate: default_fee_rate(),
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct MMConfigJson {
pub base_order_size: String,
pub base_spread: String,
pub max_position_size: String,
#[serde(default = "default_skew_mode")]
pub skew_mode: String,
#[serde(default = "default_price_skew_gamma")]
pub price_skew_gamma: String,
#[serde(default = "default_size_skew_floor")]
pub size_skew_floor: String,
#[serde(default = "default_min_price_change_pct")]
pub min_price_change_pct: String,
#[serde(default)]
pub stop_loss: Option<String>,
#[serde(default)]
pub take_profit: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GridConfigJson {
#[serde(default = "default_grid_mode")]
pub mode: String,
#[serde(default = "default_grid_levels")]
pub levels: u32,
pub start_price: String,
pub end_price: String,
pub max_investment_quote: String,
#[serde(default = "default_leverage")]
pub leverage: String,
#[serde(default = "default_max_leverage")]
pub max_leverage: String,
#[serde(default)]
pub post_only: bool,
#[serde(default)]
pub stop_loss: Option<String>,
#[serde(default)]
pub take_profit: Option<String>,
#[serde(default)]
pub trailing_up_limit: Option<String>,
#[serde(default)]
pub trailing_down_limit: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ArbitrageConfigJson {
pub order_amount: String,
pub perp_leverage: String,
pub min_opening_spread_pct: String,
pub min_closing_spread_pct: String,
#[serde(default = "default_slippage")]
pub spot_slippage_buffer_pct: String,
#[serde(default = "default_slippage")]
pub perp_slippage_buffer_pct: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct DCAConfigJson {
#[serde(default = "default_dca_direction")]
pub direction: String,
pub trigger_price: String,
pub base_order_size: String,
pub dca_order_size: String,
#[serde(default = "default_max_dca_orders")]
pub max_dca_orders: u32,
#[serde(default = "default_size_multiplier")]
pub size_multiplier: String,
pub price_deviation_pct: String,
#[serde(default = "default_deviation_multiplier")]
pub deviation_multiplier: String,
pub take_profit_pct: String,
#[serde(default)]
pub stop_loss: Option<String>,
#[serde(default = "default_leverage")]
pub leverage: String,
#[serde(default = "default_max_leverage")]
pub max_leverage: String,
#[serde(default)]
pub restart_on_complete: bool,
#[serde(default = "default_cooldown_period")]
pub cooldown_period_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct OrchestratorConfigJson {
#[serde(default = "default_orchestrator_kind")]
pub kind: String,
#[serde(default = "default_allocation_mode")]
pub allocation_mode: String,
#[serde(default)]
pub take_profit_pct: Option<String>,
#[serde(default)]
pub stop_loss_pct: Option<String>,
#[serde(default)]
pub start_conditions: Vec<OrchestratorCondition>,
#[serde(default)]
pub validation_conditions: Vec<OrchestratorCondition>,
#[serde(default)]
pub risk_conditions: Vec<OrchestratorCondition>,
#[serde(default)]
pub children: Vec<OrchestratorChildConfigJson>,
#[serde(default)]
pub legs: Vec<OrchestratorLegConfigJson>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum OrchestratorChildConfigJson {
Grid {
#[serde(default)]
id: Option<String>,
market: Market,
grid: GridConfigJson,
},
Dca {
#[serde(default)]
id: Option<String>,
market: Market,
dca: DCAConfigJson,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct OrchestratorLegConfigJson {
pub side: u8,
pub start_price: String,
pub end_price: String,
#[serde(default)]
pub trailing_up_limit: Option<String>,
#[serde(default)]
pub trailing_down_limit: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct BuilderFeeConfig {
pub address: String,
pub fee_tenths_bp: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SyncConfigJson {
pub bot_id: String,
pub upstream_url: String,
#[serde(default = "default_sync_interval_ms")]
pub sync_interval_ms: u64,
#[serde(default = "default_sync_timeout")]
pub timeout_secs: u64,
#[serde(default)]
pub sync_secret: Option<String>,
#[serde(default = "default_sync_enabled")]
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SimulationConfig {
#[serde(default = "default_starting_balance")]
pub starting_balance_usdc: String,
#[serde(default = "default_fee_rate")]
pub fee_rate: String,
}
fn default_dca_direction() -> String {
"long".to_string()
}
fn default_max_dca_orders() -> u32 {
5
}
fn default_size_multiplier() -> String {
"2.0".to_string()
}
fn default_deviation_multiplier() -> String {
"1.0".to_string()
}
fn default_cooldown_period() -> u64 {
60 }
fn default_slippage() -> String {
"0.001".to_string()
}
fn default_grid_mode() -> String {
"long".to_string()
}
fn default_grid_levels() -> u32 {
20
}
fn default_leverage() -> String {
"5".to_string()
}
fn default_max_leverage() -> String {
"50".to_string()
}
fn default_sync_interval_ms() -> u64 {
10_000
}
fn default_sync_timeout() -> u64 {
10
}
fn default_sync_enabled() -> bool {
true
}
fn default_starting_balance() -> String {
"10000".to_string()
}
fn default_fee_rate() -> String {
"0.00025".to_string() }
fn default_poll_delay_ms() -> u64 {
500
}
fn default_orchestrator_kind() -> String {
"prediction_yes_no_grid".to_string()
}
fn default_allocation_mode() -> String {
"static_50_50".to_string()
}
fn default_skew_mode() -> String {
"both".to_string()
}
fn default_price_skew_gamma() -> String {
"0.05".to_string()
}
fn default_size_skew_floor() -> String {
"0.2".to_string()
}
fn default_min_price_change_pct() -> String {
"0.0005".to_string()
}
fn outcome_side_label(side: u8) -> &'static str {
match side {
0 => "yes",
1 => "no",
_ => "unknown",
}
}
fn opposite_outcome_market(market: &Market) -> Result<Market> {
match market {
Market::Hyperliquid(HyperliquidMarket::Outcome {
name,
outcome_id,
side,
instrument_meta,
}) => {
let opposite_side = if *side == 0 { 1 } else { 0 };
Ok(Market::Hyperliquid(HyperliquidMarket::Outcome {
name: name.clone(),
outcome_id: *outcome_id,
side: opposite_side,
instrument_meta: instrument_meta.clone(),
}))
}
_ => anyhow::bail!("prediction_yes_no_grid orchestrator requires an outcome market"),
}
}
fn orchestrator_markets(config: &BotConfig) -> Result<Vec<Market>> {
let strategy_type = config.strategy_type.to_lowercase();
if strategy_type != "orchestrator" {
return Ok(config.markets.clone());
}
let orchestrator_json = config
.orchestrator
.as_ref()
.context("Orchestrator config missing: add 'orchestrator' section")?;
if orchestrator_json.kind == "generic" {
anyhow::ensure!(
!orchestrator_json.children.is_empty(),
"generic orchestrator requires orchestrator.children"
);
return Ok(orchestrator_json
.children
.iter()
.map(|child| match child {
OrchestratorChildConfigJson::Grid { market, .. }
| OrchestratorChildConfigJson::Dca { market, .. } => market.clone(),
})
.collect());
}
anyhow::ensure!(
config.markets.len() == 1,
"Orchestrator V1 expects exactly one configured market; it mirrors the opposite outcome side internally"
);
let primary = config.primary_market().clone();
let opposite = opposite_outcome_market(&primary)?;
Ok(vec![primary, opposite])
}
fn grid_mode_from_json(mode: &str) -> GridMode {
match mode.to_lowercase().as_str() {
"short" => GridMode::Short,
"neutral" => GridMode::Neutral,
_ => GridMode::Long,
}
}
fn build_grid_config_for_market(
config: &BotConfig,
grid_json: &GridConfigJson,
market: Market,
strategy_id: StrategyId,
allocation_override: Option<Decimal>,
force_long: bool,
disable_child_exits: bool,
) -> Result<GridConfig> {
Ok(GridConfig {
strategy_id,
environment: config.parse_environment(),
market,
grid_mode: if force_long {
GridMode::Long
} else {
grid_mode_from_json(&grid_json.mode)
},
grid_levels: grid_json.levels,
start_price: Decimal::from_str(&grid_json.start_price).context("Invalid start_price")?,
end_price: Decimal::from_str(&grid_json.end_price).context("Invalid end_price")?,
max_investment_quote: match allocation_override {
Some(value) => value,
None => Decimal::from_str(&grid_json.max_investment_quote)
.context("Invalid max_investment_quote")?,
},
base_order_size: Decimal::new(1, 3),
leverage: Decimal::from_str(&grid_json.leverage).context("Invalid leverage")?,
max_leverage: Decimal::from_str(&grid_json.max_leverage).context("Invalid max_leverage")?,
post_only: grid_json.post_only,
stop_loss: if disable_child_exits {
None
} else {
grid_json
.stop_loss
.as_ref()
.map(|s| Decimal::from_str(s))
.transpose()
.context("Invalid grid stop_loss")?
},
take_profit: if disable_child_exits {
None
} else {
grid_json
.take_profit
.as_ref()
.map(|s| Decimal::from_str(s))
.transpose()
.context("Invalid grid take_profit")?
},
trailing_up_limit: grid_json
.trailing_up_limit
.as_ref()
.map(|s| Decimal::from_str(s))
.transpose()
.context("Invalid grid trailing_up_limit")?,
trailing_down_limit: grid_json
.trailing_down_limit
.as_ref()
.map(|s| Decimal::from_str(s))
.transpose()
.context("Invalid grid trailing_down_limit")?,
})
}
#[allow(dead_code)]
fn build_dca_config_for_market(
config: &BotConfig,
dca_json: &DCAConfigJson,
market: Market,
strategy_id: StrategyId,
) -> Result<DCAConfig> {
Ok(DCAConfig {
strategy_id,
environment: config.parse_environment(),
market,
direction: match dca_json.direction.to_lowercase().as_str() {
"short" => DCADirection::Short,
_ => DCADirection::Long,
},
trigger_price: Decimal::from_str(&dca_json.trigger_price)
.context("Invalid trigger_price")?,
base_order_size: Decimal::from_str(&dca_json.base_order_size)
.context("Invalid base_order_size")?,
dca_order_size: Decimal::from_str(&dca_json.dca_order_size)
.context("Invalid dca_order_size")?,
max_dca_orders: dca_json.max_dca_orders,
size_multiplier: Decimal::from_str(&dca_json.size_multiplier)
.context("Invalid size_multiplier")?,
price_deviation_pct: Decimal::from_str(&dca_json.price_deviation_pct)
.context("Invalid price_deviation_pct")?,
deviation_multiplier: Decimal::from_str(&dca_json.deviation_multiplier)
.context("Invalid deviation_multiplier")?,
take_profit_pct: Decimal::from_str(&dca_json.take_profit_pct)
.context("Invalid take_profit_pct")?,
stop_loss: dca_json
.stop_loss
.as_ref()
.map(|s| Decimal::from_str(s))
.transpose()
.context("Invalid stop_loss")?,
leverage: Decimal::from_str(&dca_json.leverage).context("Invalid leverage")?,
max_leverage: Decimal::from_str(&dca_json.max_leverage).context("Invalid max_leverage")?,
restart_on_complete: dca_json.restart_on_complete,
cooldown_period_secs: dca_json.cooldown_period_secs,
})
}
#[allow(dead_code)]
fn validate_dca_config_with_price_bounds(dca_config: &DCAConfig) -> Result<()> {
let mut errors = dca_config.validate();
push_price_bound_errors(
&dca_config.market,
&[("dca.trigger_price", dca_config.trigger_price)],
&mut errors,
);
if !errors.is_empty() {
anyhow::bail!("DCA configuration validation failed: {}", errors.join(", "));
}
Ok(())
}
fn leg_overrides_by_side(
orchestrator_json: &OrchestratorConfigJson,
) -> Result<HashMap<u8, OrchestratorLegConfigJson>> {
let mut overrides = HashMap::new();
for leg in &orchestrator_json.legs {
anyhow::ensure!(
leg.side == 0 || leg.side == 1,
"orchestrator.legs side must be 0 or 1"
);
anyhow::ensure!(
overrides.insert(leg.side, leg.clone()).is_none(),
"duplicate orchestrator.legs entry for side {}",
leg.side
);
}
Ok(overrides)
}
fn apply_leg_override(grid_config: &mut GridConfig, leg: &OrchestratorLegConfigJson) -> Result<()> {
grid_config.start_price =
Decimal::from_str(&leg.start_price).context("Invalid orchestrator leg start_price")?;
grid_config.end_price =
Decimal::from_str(&leg.end_price).context("Invalid orchestrator leg end_price")?;
grid_config.trailing_up_limit = leg
.trailing_up_limit
.as_ref()
.map(|s| Decimal::from_str(s))
.transpose()
.context("Invalid orchestrator leg trailing_up_limit")?;
grid_config.trailing_down_limit = leg
.trailing_down_limit
.as_ref()
.map(|s| Decimal::from_str(s))
.transpose()
.context("Invalid orchestrator leg trailing_down_limit")?;
Ok(())
}
fn validate_grid_config_with_price_bounds(grid_config: &GridConfig) -> Result<()> {
let mut errors = grid_config.validate();
let mut bounded_prices = vec![
("grid.start_price", grid_config.start_price),
("grid.end_price", grid_config.end_price),
];
if let Some(price) = grid_config.trailing_up_limit {
bounded_prices.push(("grid.trailing_up_limit", price));
}
if let Some(price) = grid_config.trailing_down_limit {
bounded_prices.push(("grid.trailing_down_limit", price));
}
push_price_bound_errors(&grid_config.market, &bounded_prices, &mut errors);
if !errors.is_empty() {
anyhow::bail!(
"Grid configuration validation failed: {}",
errors.join(", ")
);
}
Ok(())
}
pub fn build_strategy(config: &BotConfig) -> Result<Box<dyn Strategy>> {
let environment = config.parse_environment();
let strategy_type = config.strategy_type.to_lowercase();
let is_arb = strategy_type == "arbitrage" || strategy_type == "arb";
let is_grid = strategy_type == "grid";
let is_dca = strategy_type == "dca";
let is_mm = strategy_type == "mm" || strategy_type == "market_maker";
let is_orchestrator = strategy_type == "orchestrator";
if is_arb {
anyhow::ensure!(
config.markets.len() >= 2,
"Arbitrage requires 2 markets in config: markets[0]=spot, markets[1]=perp"
);
let arb_json = config
.arbitrage
.as_ref()
.context("Arbitrage config missing: add 'arbitrage' section to config")?;
let spot_market = config.markets[0].clone();
let perp_market = config.markets[1].clone();
let arb_config = ArbitrageConfig {
strategy_id: StrategyId::new(format!("{}-arb", spot_market.base().to_lowercase())),
spot_market,
perp_market,
environment,
order_amount: Decimal::from_str(&arb_json.order_amount)
.context("Invalid order_amount")?,
perp_leverage: Decimal::from_str(&arb_json.perp_leverage)
.context("Invalid perp_leverage")?,
min_opening_spread_pct: Decimal::from_str(&arb_json.min_opening_spread_pct)
.context("Invalid min_opening_spread_pct")?,
min_closing_spread_pct: Decimal::from_str(&arb_json.min_closing_spread_pct)
.context("Invalid min_closing_spread_pct")?,
spot_slippage_buffer_pct: Decimal::from_str(&arb_json.spot_slippage_buffer_pct)
.context("Invalid spot_slippage_buffer_pct")?,
perp_slippage_buffer_pct: Decimal::from_str(&arb_json.perp_slippage_buffer_pct)
.context("Invalid perp_slippage_buffer_pct")?,
};
let errors = arb_config.validate();
if !errors.is_empty() {
anyhow::bail!(
"Arbitrage configuration validation failed: {}",
errors.join(", ")
);
}
Ok(Box::new(ArbitrageStrategy::new(arb_config)))
} else if is_orchestrator {
let orchestrator_json = config
.orchestrator
.as_ref()
.context("Orchestrator config missing: add 'orchestrator' section")?;
let grid_json = config
.grid
.as_ref()
.context("Orchestrator V1 requires a child 'grid' section")?;
anyhow::ensure!(
orchestrator_json.kind == "prediction_yes_no_grid",
"Unsupported orchestrator kind '{}'; supported kind: prediction_yes_no_grid",
orchestrator_json.kind
);
anyhow::ensure!(
orchestrator_json.allocation_mode == "static_50_50",
"Unsupported orchestrator allocation_mode '{}'; supported mode: static_50_50",
orchestrator_json.allocation_mode
);
let markets = orchestrator_markets(config)?;
let leg_overrides = leg_overrides_by_side(orchestrator_json)?;
let total_allocation = Decimal::from_str(&grid_json.max_investment_quote)
.context("Invalid max_investment_quote")?;
let child_allocation = total_allocation / Decimal::TWO;
let mut legs = Vec::new();
for market in markets {
let (_, side, _) = market
.outcome_params()
.context("Orchestrator V1 only supports outcome markets")?;
let label = outcome_side_label(side);
let mut child_config = build_grid_config_for_market(
config,
grid_json,
market.clone(),
StrategyId::new(format!("{}-{}-grid", config.primary_market().base(), label)),
Some(child_allocation),
true,
true,
)?;
if let Some(leg_override) = leg_overrides.get(&side) {
apply_leg_override(&mut child_config, leg_override)?;
}
validate_grid_config_with_price_bounds(&child_config)?;
legs.push(OrchestratorLeg::new(
label,
child_config.market.instrument_id(),
Box::new(GridStrategy::new(child_config)),
));
}
let risk = GroupRiskConfig {
allocated_capital_quote: total_allocation,
take_profit_pct: orchestrator_json
.take_profit_pct
.as_ref()
.map(|s| Decimal::from_str(s))
.transpose()
.context("Invalid orchestrator take_profit_pct")?,
stop_loss_pct: orchestrator_json
.stop_loss_pct
.as_ref()
.map(|s| Decimal::from_str(s))
.transpose()
.context("Invalid orchestrator stop_loss_pct")?,
};
let conditions = OrchestratorConditions {
start_conditions: orchestrator_json.start_conditions.clone(),
validation_conditions: orchestrator_json.validation_conditions.clone(),
risk_conditions: orchestrator_json.risk_conditions.clone(),
};
Ok(Box::new(BotOrchestrator::with_conditions(
StrategyId::new(format!("{}-orchestrator", config.primary_market().base())),
legs,
risk,
conditions,
)))
} else if is_grid {
let grid_json = config
.grid
.as_ref()
.context("Grid config missing: add 'grid' section to config")?;
let grid_config = GridConfig {
strategy_id: StrategyId::new(format!("{}-grid", config.primary_market().base())),
environment,
market: config.primary_market().clone(),
grid_mode: match grid_json.mode.to_lowercase().as_str() {
"short" => GridMode::Short,
"neutral" => GridMode::Neutral,
_ => GridMode::Long,
},
grid_levels: grid_json.levels,
start_price: Decimal::from_str(&grid_json.start_price)
.context("Invalid start_price")?,
end_price: Decimal::from_str(&grid_json.end_price).context("Invalid end_price")?,
max_investment_quote: Decimal::from_str(&grid_json.max_investment_quote)
.context("Invalid max_investment_quote")?,
base_order_size: Decimal::new(1, 3), leverage: Decimal::from_str(&grid_json.leverage).context("Invalid leverage")?,
max_leverage: Decimal::from_str(&grid_json.max_leverage)
.context("Invalid max_leverage")?,
post_only: grid_json.post_only,
stop_loss: grid_json
.stop_loss
.as_ref()
.map(|s| Decimal::from_str(s))
.transpose()
.context("Invalid grid stop_loss")?,
take_profit: grid_json
.take_profit
.as_ref()
.map(|s| Decimal::from_str(s))
.transpose()
.context("Invalid grid take_profit")?,
trailing_up_limit: grid_json
.trailing_up_limit
.as_ref()
.map(|s| Decimal::from_str(s))
.transpose()
.context("Invalid grid trailing_up_limit")?,
trailing_down_limit: grid_json
.trailing_down_limit
.as_ref()
.map(|s| Decimal::from_str(s))
.transpose()
.context("Invalid grid trailing_down_limit")?,
};
let mut errors = grid_config.validate();
let mut bounded_prices = vec![
("grid.start_price", grid_config.start_price),
("grid.end_price", grid_config.end_price),
];
if let Some(price) = grid_config.trailing_up_limit {
bounded_prices.push(("grid.trailing_up_limit", price));
}
if let Some(price) = grid_config.trailing_down_limit {
bounded_prices.push(("grid.trailing_down_limit", price));
}
push_price_bound_errors(&grid_config.market, &bounded_prices, &mut errors);
if !errors.is_empty() {
anyhow::bail!(
"Grid configuration validation failed: {}",
errors.join(", ")
);
}
Ok(Box::new(GridStrategy::new(grid_config)))
} else if is_dca {
let dca_json = config
.dca
.as_ref()
.context("DCA config missing: add 'dca' section to config")?;
let dca_config = DCAConfig {
strategy_id: StrategyId::new(format!("{}-dca", config.primary_market().base())),
environment,
market: config.primary_market().clone(),
direction: match dca_json.direction.to_lowercase().as_str() {
"short" => DCADirection::Short,
_ => DCADirection::Long,
},
trigger_price: Decimal::from_str(&dca_json.trigger_price)
.context("Invalid trigger_price")?,
base_order_size: Decimal::from_str(&dca_json.base_order_size)
.context("Invalid base_order_size")?,
dca_order_size: Decimal::from_str(&dca_json.dca_order_size)
.context("Invalid dca_order_size")?,
max_dca_orders: dca_json.max_dca_orders,
size_multiplier: Decimal::from_str(&dca_json.size_multiplier)
.context("Invalid size_multiplier")?,
price_deviation_pct: Decimal::from_str(&dca_json.price_deviation_pct)
.context("Invalid price_deviation_pct")?,
deviation_multiplier: Decimal::from_str(&dca_json.deviation_multiplier)
.context("Invalid deviation_multiplier")?,
take_profit_pct: Decimal::from_str(&dca_json.take_profit_pct)
.context("Invalid take_profit_pct")?,
stop_loss: dca_json
.stop_loss
.as_ref()
.map(|s| Decimal::from_str(s))
.transpose()
.context("Invalid stop_loss")?,
leverage: Decimal::from_str(&dca_json.leverage).context("Invalid leverage")?,
max_leverage: Decimal::from_str(&dca_json.max_leverage)
.context("Invalid max_leverage")?,
restart_on_complete: dca_json.restart_on_complete,
cooldown_period_secs: dca_json.cooldown_period_secs,
};
let mut errors = dca_config.validate();
push_price_bound_errors(
&dca_config.market,
&[("dca.trigger_price", dca_config.trigger_price)],
&mut errors,
);
if !errors.is_empty() {
anyhow::bail!("DCA configuration validation failed: {}", errors.join(", "));
}
Ok(Box::new(DCAStrategy::new(dca_config)))
} else if strategy_type == "tick_trader" {
#[cfg(feature = "strategy-tick-trader")]
{
let tick_config: TickTraderConfig = config.custom_config("tick_trader")?;
let errors = tick_config.validate();
if !errors.is_empty() {
anyhow::bail!(
"Tick trader config validation failed: {}",
errors.join(", ")
);
}
Ok(Box::new(TickTrader::new(tick_config)))
}
#[cfg(not(feature = "strategy-tick-trader"))]
{
anyhow::bail!("Tick trader strategy is not enabled in this build")
}
} else if strategy_type == "rsi" {
#[cfg(feature = "strategy-rsi")]
{
let rsi_config: RsiStrategyConfig = config.custom_config("rsi")?;
let errors = rsi_config.validate();
if !errors.is_empty() {
anyhow::bail!("RSI config validation failed: {}", errors.join(", "));
}
let market = config.primary_market().clone();
let environment = config.parse_environment();
Ok(Box::new(RsiStrategy::new(rsi_config, market, environment)))
}
#[cfg(not(feature = "strategy-rsi"))]
{
anyhow::bail!("RSI strategy is not enabled in this build")
}
} else if is_mm {
let mm_json = config
.mm
.as_ref()
.context("MM config missing: add 'mm' section to config for strategy_type='mm'")?;
let base_order_size =
Decimal::from_str(&mm_json.base_order_size).context("Invalid base_order_size")?;
let base_spread = Decimal::from_str(&mm_json.base_spread).context("Invalid base_spread")?;
let max_position_size =
Decimal::from_str(&mm_json.max_position_size).context("Invalid max_position_size")?;
let price_skew_gamma =
Decimal::from_str(&mm_json.price_skew_gamma).context("Invalid price_skew_gamma")?;
let size_skew_floor =
Decimal::from_str(&mm_json.size_skew_floor).context("Invalid size_skew_floor")?;
let min_price_change_pct = Decimal::from_str(&mm_json.min_price_change_pct)
.context("Invalid min_price_change_pct")?;
let stop_loss = mm_json
.stop_loss
.as_ref()
.map(|s| Decimal::from_str(s))
.transpose()
.context("Invalid stop_loss")?;
let take_profit = mm_json
.take_profit
.as_ref()
.map(|s| Decimal::from_str(s))
.transpose()
.context("Invalid take_profit")?;
let skew_mode = match mm_json.skew_mode.to_lowercase().as_str() {
"none" => SkewMode::None,
"size" => SkewMode::Size,
"price" => SkewMode::Price,
"both" | _ => SkewMode::Both,
};
let mm_config = MarketMakerConfig {
strategy_id: StrategyId::new(format!("{}-mm", config.primary_market().base())),
environment,
market: config.primary_market().clone(),
base_order_size,
base_spread,
target_position_pct: Decimal::new(5, 1), min_position_pct: Decimal::new(1, 1), max_position_pct: Decimal::new(9, 1), max_position_size,
skew_mode,
price_skew_gamma,
size_skew_floor,
min_price_change_pct,
stop_loss,
take_profit,
};
let errors = mm_config.validate();
if !errors.is_empty() {
anyhow::bail!("Configuration validation failed: {}", errors.join(", "));
}
Ok(Box::new(MarketMaker::new(mm_config)))
} else {
anyhow::bail!(
"Unknown strategy type: '{}'. Built-in types: grid, dca, mm, arb. \
For custom strategies, see STRATEGY_API.md.",
strategy_type
)
}
}
pub fn build_instrument_meta(config: &BotConfig) -> InstrumentMeta {
let primary_market = config.primary_market();
let quote_currency = primary_market.quote();
let (tick_size, lot_size, min_qty, min_notional) = primary_market
.instrument_meta()
.map(|im| (im.tick_size, im.lot_size, im.min_qty, im.min_notional))
.unwrap_or((Decimal::new(1, 1), Decimal::new(1, 4), None, None));
InstrumentMeta {
instrument_id: primary_market.instrument_id(),
market_index: primary_market.market_index(),
base_asset: AssetId::new(primary_market.base()),
quote_asset: AssetId::new(quote_currency),
tick_size,
lot_size,
min_qty,
min_notional,
fee_asset_default: Some(AssetId::new(quote_currency)),
kind: primary_market.instrument_kind(),
}
}
pub fn build_instrument_metas(config: &BotConfig) -> Vec<InstrumentMeta> {
let markets = orchestrator_markets(config).unwrap_or_else(|_| config.markets.clone());
markets
.iter()
.map(|market| {
let quote_currency = market.quote();
let (tick_size, lot_size, min_qty, min_notional) = market
.instrument_meta()
.map(|im| (im.tick_size, im.lot_size, im.min_qty, im.min_notional))
.unwrap_or((Decimal::new(1, 1), Decimal::new(1, 4), None, None));
InstrumentMeta {
instrument_id: market.instrument_id(),
market_index: market.market_index(),
base_asset: AssetId::new(market.base()),
quote_asset: AssetId::new(quote_currency),
tick_size,
lot_size,
min_qty,
min_notional,
fee_asset_default: Some(AssetId::new(quote_currency)),
kind: market.instrument_kind(),
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use bot_core::HyperliquidMarket;
use rust_decimal_macros::dec;
fn btc_perp_market() -> Market {
Market::Hyperliquid(HyperliquidMarket::Perp {
base: "BTC".to_string(),
quote: "USDC".to_string(),
index: 0,
instrument_meta: None,
})
}
fn btc_outcome_market() -> Market {
Market::Hyperliquid(HyperliquidMarket::Outcome {
name: "BTC > 78213".to_string(),
outcome_id: 1,
side: 0,
instrument_meta: None,
})
}
fn base_config(strategy_type: &str) -> BotConfig {
BotConfig {
environment: "mainnet".to_string(),
private_key: String::new(),
address: String::new(),
vault_address: None,
base_url_override: None,
strategy_type: strategy_type.to_string(),
markets: vec![btc_perp_market()],
poll_delay_ms: 500,
grid: None,
mm: None,
dca: None,
arbitrage: None,
orchestrator: None,
builder_fee: None,
sync: None,
simulation: None,
extra: HashMap::new(),
}
}
#[test]
fn grid_allocated_capital_uses_max_investment_quote() {
let mut config = base_config("grid");
config.grid = Some(GridConfigJson {
mode: "long".to_string(),
levels: 10,
start_price: "77000".to_string(),
end_price: "78000".to_string(),
max_investment_quote: "123.45".to_string(),
leverage: "20".to_string(),
max_leverage: "40".to_string(),
post_only: false,
stop_loss: None,
take_profit: None,
trailing_up_limit: None,
trailing_down_limit: None,
});
assert_eq!(config.strategy_allocated_capital_usdc(), Some(dec!(123.45)));
}
#[test]
fn orchestrator_registers_both_outcome_legs_from_one_market() {
let mut config = base_config("orchestrator");
config.markets = vec![btc_outcome_market()];
config.grid = Some(GridConfigJson {
mode: "long".to_string(),
levels: 10,
start_price: "0.2".to_string(),
end_price: "0.8".to_string(),
max_investment_quote: "500".to_string(),
leverage: "1".to_string(),
max_leverage: "1".to_string(),
post_only: false,
stop_loss: Some("10".to_string()),
take_profit: Some("10".to_string()),
trailing_up_limit: None,
trailing_down_limit: None,
});
config.orchestrator = Some(OrchestratorConfigJson {
kind: "prediction_yes_no_grid".to_string(),
allocation_mode: "static_50_50".to_string(),
take_profit_pct: Some("10".to_string()),
stop_loss_pct: Some("5".to_string()),
start_conditions: Vec::new(),
validation_conditions: Vec::new(),
risk_conditions: Vec::new(),
children: Vec::new(),
legs: Vec::new(),
});
let strategy = build_strategy(&config).expect("orchestrator strategy should build");
assert_eq!(strategy.id().as_str(), "BTC > 78213-orchestrator");
let metas = build_instrument_metas(&config);
let instruments: Vec<String> = metas
.iter()
.map(|meta| meta.instrument_id.to_string())
.collect();
assert_eq!(instruments, vec!["#10-OUTCOME", "#11-OUTCOME"]);
assert_eq!(config.strategy_allocated_capital_usdc(), Some(dec!(500)));
}
#[test]
fn orchestrator_accepts_client_supplied_leg_ranges() {
let orchestrator = OrchestratorConfigJson {
kind: "prediction_yes_no_grid".to_string(),
allocation_mode: "static_50_50".to_string(),
take_profit_pct: None,
stop_loss_pct: None,
start_conditions: Vec::new(),
validation_conditions: Vec::new(),
risk_conditions: Vec::new(),
children: Vec::new(),
legs: vec![
OrchestratorLegConfigJson {
side: 0,
start_price: "0.30".to_string(),
end_price: "0.36".to_string(),
trailing_up_limit: None,
trailing_down_limit: None,
},
OrchestratorLegConfigJson {
side: 1,
start_price: "0.64".to_string(),
end_price: "0.70".to_string(),
trailing_up_limit: None,
trailing_down_limit: None,
},
],
};
let overrides = leg_overrides_by_side(&orchestrator).expect("valid leg overrides");
assert_eq!(overrides[&0].start_price, "0.30");
assert_eq!(overrides[&1].end_price, "0.70");
}
#[test]
fn arbitrage_allocated_capital_uses_order_amount() {
let mut config = base_config("arb");
config.arbitrage = Some(ArbitrageConfigJson {
order_amount: "50".to_string(),
perp_leverage: "5".to_string(),
min_opening_spread_pct: "0.2".to_string(),
min_closing_spread_pct: "0.05".to_string(),
spot_slippage_buffer_pct: "0.1".to_string(),
perp_slippage_buffer_pct: "0.1".to_string(),
});
assert_eq!(config.strategy_allocated_capital_usdc(), Some(dec!(50)));
}
#[test]
fn dca_allocated_capital_uses_full_ladder_not_wallet_balance() {
let mut config = base_config("dca");
config.dca = Some(DCAConfigJson {
direction: "long".to_string(),
trigger_price: "100".to_string(),
base_order_size: "1".to_string(),
dca_order_size: "1".to_string(),
max_dca_orders: 2,
size_multiplier: "2".to_string(),
price_deviation_pct: "10".to_string(),
deviation_multiplier: "1".to_string(),
take_profit_pct: "2".to_string(),
stop_loss: None,
leverage: "1".to_string(),
max_leverage: "10".to_string(),
restart_on_complete: false,
cooldown_period_secs: 60,
});
assert_eq!(config.strategy_allocated_capital_usdc(), Some(dec!(352)));
}
#[test]
fn grid_rejects_outcome_prices_outside_probability_bounds() {
let mut config = base_config("grid");
config.markets = vec![btc_outcome_market()];
config.grid = Some(GridConfigJson {
mode: "long".to_string(),
levels: 2,
start_price: "0.50".to_string(),
end_price: "1.20".to_string(),
max_investment_quote: "20".to_string(),
leverage: "1".to_string(),
max_leverage: "1".to_string(),
post_only: false,
stop_loss: None,
take_profit: None,
trailing_up_limit: None,
trailing_down_limit: None,
});
let error = match build_strategy(&config) {
Ok(_) => panic!("outcome grid should fail bounds"),
Err(error) => error,
};
assert!(error.to_string().contains("grid.end_price"));
}
#[test]
fn dca_rejects_outcome_trigger_outside_probability_bounds() {
let mut config = base_config("dca");
config.markets = vec![btc_outcome_market()];
config.dca = Some(DCAConfigJson {
direction: "long".to_string(),
trigger_price: "1.01".to_string(),
base_order_size: "1".to_string(),
dca_order_size: "1".to_string(),
max_dca_orders: 1,
size_multiplier: "1".to_string(),
price_deviation_pct: "1".to_string(),
deviation_multiplier: "1".to_string(),
take_profit_pct: "1".to_string(),
stop_loss: None,
leverage: "1".to_string(),
max_leverage: "1".to_string(),
restart_on_complete: false,
cooldown_period_secs: 60,
});
let error = match build_strategy(&config) {
Ok(_) => panic!("outcome dca should fail bounds"),
Err(error) => error,
};
assert!(error.to_string().contains("dca.trigger_price"));
}
}