Skip to main content

bot_engine/
config.rs

1//! Bot configuration types and strategy builder.
2//!
3//! This module contains:
4//! - `BotConfig` — V2 unified config format
5//! - Strategy-specific JSON config structs (GridConfigJson, DCAConfigJson, etc.)
6//! - `build_strategy()` — construct `Box<dyn Strategy>` from BotConfig
7//! - `build_instrument_meta()` — construct InstrumentMeta from Market
8
9use anyhow::{Context, Result};
10use bot_core::{
11    AssetId, Environment, HyperliquidMarket, InstrumentId, InstrumentMeta, Market, Strategy,
12    StrategyId,
13};
14use bot_orchestrator::{
15    BotOrchestrator, GroupRiskConfig, OrchestratorCondition, OrchestratorConditions,
16    OrchestratorLeg,
17};
18use rust_decimal::Decimal;
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21use std::collections::HashMap;
22use std::path::PathBuf;
23use std::str::FromStr;
24
25// Strategy imports
26use strategy_arbitrage::{ArbitrageConfig, ArbitrageStrategy};
27use strategy_dca::{DCAConfig, DCADirection, DCAStrategy};
28use strategy_grid::{GridConfig, GridMode, GridStrategy};
29use strategy_market_maker::{MarketMaker, MarketMakerConfig, SkewMode};
30#[cfg(feature = "strategy-rsi")]
31use strategy_rsi::{RsiStrategy, RsiStrategyConfig};
32#[cfg(feature = "strategy-tick-trader")]
33use strategy_tick_trader::{TickTrader, TickTraderConfig};
34
35// =============================================================================
36// Bot Configuration (V2 Format)
37// =============================================================================
38
39fn push_price_bound_errors(market: &Market, prices: &[(&str, Decimal)], errors: &mut Vec<String>) {
40    let Some((lower, upper)) = market.price_bounds() else {
41        return;
42    };
43
44    for (label, price) in prices {
45        if *price <= lower || *price >= upper {
46            errors.push(format!(
47                "{} ({}) must be > {} and < {} for {}",
48                label,
49                price,
50                lower,
51                upper,
52                market.instrument_id()
53            ));
54        }
55    }
56}
57
58/// Bot configuration - V2 format with markets array.
59/// This is the unified config format for all strategies.
60#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
61pub struct BotConfig {
62    /// Environment: "testnet" or "mainnet"
63    pub environment: String,
64
65    /// Private key (hex, with or without 0x prefix).
66    /// Optional — resolved at runtime from `~/.supurr/credentials.json` if absent.
67    #[serde(default)]
68    pub private_key: String,
69
70    /// Wallet address.
71    /// Optional — resolved at runtime from `~/.supurr/credentials.json` if absent.
72    #[serde(default)]
73    pub address: String,
74
75    /// Optional vault address
76    #[serde(default)]
77    pub vault_address: Option<String>,
78
79    /// Optional Hyperliquid base URL override.
80    /// When set, both /info and /exchange requests use this gateway.
81    #[serde(default)]
82    pub base_url_override: Option<String>,
83
84    /// Strategy type: "grid", "mm", "dca", or "arbitrage"
85    pub strategy_type: String,
86
87    /// Markets to trade on (V2 format - uses bot_core::Market enum)
88    pub markets: Vec<Market>,
89
90    /// Polling delay in milliseconds
91    #[serde(default = "default_poll_delay_ms")]
92    pub poll_delay_ms: u64,
93
94    // -------------------------------------------------------------------------
95    // Strategy-specific configs (only one should be set based on strategy_type)
96    // -------------------------------------------------------------------------
97    /// Grid strategy configuration
98    #[serde(default)]
99    pub grid: Option<GridConfigJson>,
100
101    /// Market Maker strategy configuration
102    #[serde(default)]
103    pub mm: Option<MMConfigJson>,
104
105    /// DCA strategy configuration
106    #[serde(default)]
107    pub dca: Option<DCAConfigJson>,
108
109    /// Arbitrage strategy configuration
110    #[serde(default)]
111    pub arbitrage: Option<ArbitrageConfigJson>,
112
113    /// Parent strategy configuration for grouped child strategies.
114    #[serde(default)]
115    pub orchestrator: Option<OrchestratorConfigJson>,
116
117    // -------------------------------------------------------------------------
118    // Common config
119    // -------------------------------------------------------------------------
120    /// Builder fee configuration
121    #[serde(default)]
122    pub builder_fee: Option<BuilderFeeConfig>,
123
124    /// Trade sync configuration for upstream API PnL tracking
125    #[serde(default)]
126    pub sync: Option<SyncConfigJson>,
127
128    /// Simulation configuration (paper/backtest)
129    /// Optional — uses sensible defaults if absent.
130    #[serde(default)]
131    pub simulation: Option<SimulationConfig>,
132
133    // -------------------------------------------------------------------------
134    // Custom strategy configs (captured automatically via serde flatten)
135    // -------------------------------------------------------------------------
136    /// Catch-all for custom strategy configuration sections.
137    /// Any JSON key that doesn't match a named field above lands here.
138    /// Custom strategies read their config via `config.custom_config("mystrategy")`.
139    #[serde(flatten)]
140    pub extra: HashMap<String, serde_json::Value>,
141}
142
143impl BotConfig {
144    /// Get the primary market from the markets array
145    pub fn primary_market(&self) -> &Market {
146        &self.markets[0]
147    }
148
149    /// Check if this is a spot market
150    pub fn is_spot(&self) -> bool {
151        self.primary_market().is_spot()
152    }
153
154    /// Check if the primary market settles like spot with no margin/leverage.
155    pub fn is_spot_like(&self) -> bool {
156        self.primary_market().is_spot_like()
157    }
158
159    /// Check if the primary market uses margin/leverage.
160    pub fn primary_market_uses_margin(&self) -> bool {
161        self.primary_market().uses_margin()
162    }
163
164    /// Check if this is a prediction market outcome
165    pub fn is_outcome(&self) -> bool {
166        self.primary_market().is_outcome()
167    }
168
169    /// Get instrument ID from primary market
170    pub fn instrument_id(&self) -> InstrumentId {
171        self.primary_market().instrument_id()
172    }
173
174    /// Get market index from primary market
175    pub fn market_index(&self) -> bot_core::MarketIndex {
176        self.primary_market().market_index()
177    }
178
179    /// Get HIP-3 config if this is a HIP-3 market
180    pub fn hip3_config(&self) -> Option<bot_core::market::Hip3MarketConfig> {
181        self.primary_market().hip3_config()
182    }
183
184    /// Get custom strategy config as typed struct.
185    /// Looks up the strategy name in the `extra` catch-all map and deserializes.
186    ///
187    /// # Example
188    /// ```rust,ignore
189    /// let my_config: MyConfig = config.custom_config("mystrategy")?;
190    /// ```
191    pub fn custom_config<T: serde::de::DeserializeOwned>(&self, strategy_name: &str) -> Result<T> {
192        let raw = self.extra.get(strategy_name).with_context(|| {
193            format!(
194                "Custom strategy config missing: add '\"{}\"' section to your JSON config",
195                strategy_name
196            )
197        })?;
198        serde_json::from_value(raw.clone())
199            .with_context(|| format!("Failed to parse '{}' config section", strategy_name))
200    }
201
202    /// Load config from environment variables.
203    /// V2 configs require a JSON file, so this returns an error.
204    #[cfg(not(target_arch = "wasm32"))]
205    pub fn from_env() -> Result<Self> {
206        Err(anyhow::anyhow!(
207            "V2 config format requires a JSON file. Use --config <file>"
208        ))
209    }
210
211    /// Resolve wallet credentials: use fields from config if present,
212    /// otherwise fall back to `~/.supurr/credentials.json`.
213    ///
214    /// Returns `(private_key, address)`.
215    #[cfg(not(target_arch = "wasm32"))]
216    pub fn resolve_credentials(&self) -> Result<(String, String)> {
217        if !self.private_key.is_empty() && !self.address.is_empty() {
218            return Ok((self.private_key.clone(), self.address.clone()));
219        }
220
221        let home = std::env::var("HOME")
222            .or_else(|_| std::env::var("USERPROFILE"))
223            .context("Cannot determine home directory")?;
224        let creds_path = std::path::PathBuf::from(home).join(".supurr/credentials.json");
225
226        let content = std::fs::read_to_string(&creds_path).with_context(|| {
227            format!(
228                "No credentials in config and ~/.supurr/credentials.json not found. \
229                 Run 'supurr init' to configure your wallet."
230            )
231        })?;
232
233        #[derive(serde::Deserialize)]
234        struct Creds {
235            address: String,
236            private_key: String,
237        }
238
239        let creds: Creds =
240            serde_json::from_str(&content).context("Failed to parse ~/.supurr/credentials.json")?;
241
242        // Use config values if present, fallback to credentials file
243        let pk = if self.private_key.is_empty() {
244            creds.private_key
245        } else {
246            self.private_key.clone()
247        };
248        let addr = if self.address.is_empty() {
249            creds.address
250        } else {
251            self.address.clone()
252        };
253
254        Ok((pk, addr))
255    }
256
257    /// Load config from a JSON file
258    #[cfg(not(target_arch = "wasm32"))]
259    pub fn from_file(path: &PathBuf) -> Result<Self> {
260        let content = std::fs::read_to_string(path)
261            .with_context(|| format!("Failed to read config file: {:?}", path))?;
262        let config: Self = serde_json::from_str(&content)
263            .with_context(|| format!("Failed to parse config file: {:?}", path))?;
264
265        // Validate markets array is not empty
266        if config.markets.is_empty() {
267            anyhow::bail!("Config must have at least one market in the 'markets' array");
268        }
269
270        let market_errors: Vec<String> = config
271            .markets
272            .iter()
273            .enumerate()
274            .flat_map(|(idx, market)| {
275                market
276                    .validation_errors()
277                    .into_iter()
278                    .map(move |error| format!("markets[{}]: {}", idx, error))
279            })
280            .collect();
281        if !market_errors.is_empty() {
282            anyhow::bail!(
283                "Market configuration validation failed: {}",
284                market_errors.join(", ")
285            );
286        }
287
288        Ok(config)
289    }
290
291    /// Parse environment from string
292    pub fn parse_environment(&self) -> Environment {
293        match self.environment.to_lowercase().as_str() {
294            "mainnet" | "main" | "prod" => Environment::Mainnet,
295            _ => Environment::Testnet,
296        }
297    }
298
299    /// Extract strategy leverage and max_leverage from whichever strategy config is active.
300    /// Returns `Some((leverage, max_leverage))` if the active strategy has leverage settings.
301    pub fn strategy_leverage(&self) -> Option<(Decimal, Decimal)> {
302        if let Some(ref g) = self.grid {
303            let lev = Decimal::from_str(&g.leverage).ok()?;
304            let max = Decimal::from_str(&g.max_leverage).ok()?;
305            Some((lev, max))
306        } else if let Some(ref d) = self.dca {
307            let lev = Decimal::from_str(&d.leverage).ok()?;
308            let max = Decimal::from_str(&d.max_leverage).ok()?;
309            Some((lev, max))
310        } else if let Some(ref a) = self.arbitrage {
311            let lev = Decimal::from_str(&a.perp_leverage).ok()?;
312            // Arb doesn't have explicit max_leverage, use a sensible default
313            Some((lev, Decimal::new(50, 0)))
314        } else {
315            None
316        }
317    }
318
319    /// Capital allocated to the active strategy, used as the performance metric base.
320    ///
321    /// This is deliberately strategy-scoped. Wallet/account equity is not used here because
322    /// unrelated idle wallet capital would dilute APR, Sharpe, and drawdown percentages.
323    pub fn strategy_allocated_capital_usdc(&self) -> Option<Decimal> {
324        let strategy_type = self.strategy_type.to_lowercase();
325
326        if strategy_type == "grid" {
327            return self
328                .grid
329                .as_ref()
330                .and_then(|grid| Decimal::from_str(&grid.max_investment_quote).ok());
331        }
332
333        if strategy_type == "orchestrator" {
334            return self
335                .grid
336                .as_ref()
337                .and_then(|grid| Decimal::from_str(&grid.max_investment_quote).ok());
338        }
339
340        if strategy_type == "arbitrage" || strategy_type == "arb" {
341            return self
342                .arbitrage
343                .as_ref()
344                .and_then(|arb| Decimal::from_str(&arb.order_amount).ok());
345        }
346
347        if strategy_type == "dca" {
348            let dca = self.dca.as_ref()?;
349            let direction = match dca.direction.to_lowercase().as_str() {
350                "short" => DCADirection::Short,
351                _ => DCADirection::Long,
352            };
353            let config = DCAConfig {
354                strategy_id: StrategyId::new("metrics-dca"),
355                environment: self.parse_environment(),
356                market: self.primary_market().clone(),
357                direction,
358                trigger_price: Decimal::from_str(&dca.trigger_price).ok()?,
359                base_order_size: Decimal::from_str(&dca.base_order_size).ok()?,
360                dca_order_size: Decimal::from_str(&dca.dca_order_size).ok()?,
361                max_dca_orders: dca.max_dca_orders,
362                size_multiplier: Decimal::from_str(&dca.size_multiplier).ok()?,
363                price_deviation_pct: Decimal::from_str(&dca.price_deviation_pct).ok()?,
364                deviation_multiplier: Decimal::from_str(&dca.deviation_multiplier).ok()?,
365                take_profit_pct: Decimal::from_str(&dca.take_profit_pct).ok()?,
366                stop_loss: dca
367                    .stop_loss
368                    .as_ref()
369                    .and_then(|value| Decimal::from_str(value).ok()),
370                leverage: Decimal::from_str(&dca.leverage).ok()?,
371                max_leverage: Decimal::from_str(&dca.max_leverage).ok()?,
372                restart_on_complete: dca.restart_on_complete,
373                cooldown_period_secs: dca.cooldown_period_secs,
374            };
375            return Some(config.max_total_investment());
376        }
377
378        None
379    }
380
381    /// Get the effective simulation config, falling back to defaults if absent.
382    pub fn effective_simulation_config(&self) -> SimulationConfig {
383        self.simulation.clone().unwrap_or(SimulationConfig {
384            starting_balance_usdc: default_starting_balance(),
385            fee_rate: default_fee_rate(),
386        })
387    }
388}
389
390// =============================================================================
391// Market Maker Config
392// =============================================================================
393
394/// Market Maker strategy configuration
395#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
396pub struct MMConfigJson {
397    /// Base order size in base asset
398    pub base_order_size: String,
399    /// Base spread between bid and ask
400    pub base_spread: String,
401    /// Maximum position size in base asset
402    pub max_position_size: String,
403    /// Skew mode: "both", "size", "price", or "none"
404    #[serde(default = "default_skew_mode")]
405    pub skew_mode: String,
406    /// Price skew gamma (how aggressively to skew quotes based on position)
407    #[serde(default = "default_price_skew_gamma")]
408    pub price_skew_gamma: String,
409    /// Size skew floor (minimum size for quotes)
410    #[serde(default = "default_size_skew_floor")]
411    pub size_skew_floor: String,
412    /// Minimum price change to update quotes
413    #[serde(default = "default_min_price_change_pct")]
414    pub min_price_change_pct: String,
415    /// Stop loss (optional)
416    #[serde(default)]
417    pub stop_loss: Option<String>,
418    /// Take profit (optional)
419    #[serde(default)]
420    pub take_profit: Option<String>,
421}
422
423// =============================================================================
424// Grid Config
425// =============================================================================
426
427/// Grid strategy configuration from JSON
428#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
429pub struct GridConfigJson {
430    /// Grid mode: "long", "short", "neutral"
431    #[serde(default = "default_grid_mode")]
432    pub mode: String,
433    /// Number of grid levels
434    #[serde(default = "default_grid_levels")]
435    pub levels: u32,
436    /// Start price of the grid
437    pub start_price: String,
438    /// End price of the grid
439    pub end_price: String,
440    /// Maximum investment in quote currency (USDC)
441    pub max_investment_quote: String,
442    /// Leverage to use
443    #[serde(default = "default_leverage")]
444    pub leverage: String,
445    /// Maximum leverage allowed (for liquidation calculation)
446    #[serde(default = "default_max_leverage")]
447    pub max_leverage: String,
448    /// Use post-only orders
449    #[serde(default)]
450    pub post_only: bool,
451    /// Stop loss (optional)
452    #[serde(default)]
453    pub stop_loss: Option<String>,
454    /// Take profit (optional)
455    #[serde(default)]
456    pub take_profit: Option<String>,
457    /// Trailing upper limit price (optional). When set, enables trailing-up:
458    /// the grid slides up as price rises, until the top of the window would
459    /// exceed this ceiling.
460    #[serde(default)]
461    pub trailing_up_limit: Option<String>,
462    /// Trailing lower limit price (optional). When set, enables trailing-down:
463    /// the grid slides down as price falls, until the bottom of the window
464    /// would go below this floor.
465    #[serde(default)]
466    pub trailing_down_limit: Option<String>,
467}
468
469// =============================================================================
470// Arbitrage Config
471// =============================================================================
472
473/// Arbitrage strategy configuration from JSON
474#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
475pub struct ArbitrageConfigJson {
476    /// Order amount in quote asset / USDC (e.g., "100" = $100 worth)
477    pub order_amount: String,
478    /// Leverage for perp position
479    pub perp_leverage: String,
480    /// Minimum spread to open (e.g., "0.003" = 0.3%)
481    pub min_opening_spread_pct: String,
482    /// Minimum spread to close (e.g., "-0.001" = -0.1%)
483    pub min_closing_spread_pct: String,
484    /// Slippage buffer for spot orders
485    #[serde(default = "default_slippage")]
486    pub spot_slippage_buffer_pct: String,
487    /// Slippage buffer for perp orders
488    #[serde(default = "default_slippage")]
489    pub perp_slippage_buffer_pct: String,
490}
491
492// =============================================================================
493// DCA Config
494// =============================================================================
495
496/// DCA strategy configuration from JSON
497#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
498pub struct DCAConfigJson {
499    /// Direction: "long" or "short"
500    #[serde(default = "default_dca_direction")]
501    pub direction: String,
502    /// Price to trigger base order
503    pub trigger_price: String,
504    /// Base order size in base asset
505    pub base_order_size: String,
506    /// DCA order size in base asset
507    pub dca_order_size: String,
508    /// Maximum number of DCA orders
509    #[serde(default = "default_max_dca_orders")]
510    pub max_dca_orders: u32,
511    /// Size multiplier for each subsequent DCA order
512    #[serde(default = "default_size_multiplier")]
513    pub size_multiplier: String,
514    /// Price deviation percentage to trigger first DCA
515    pub price_deviation_pct: String,
516    /// Deviation multiplier for subsequent triggers
517    #[serde(default = "default_deviation_multiplier")]
518    pub deviation_multiplier: String,
519    /// Take profit percentage from average entry
520    pub take_profit_pct: String,
521    /// Optional stop loss as absolute PnL threshold (negative value)
522    #[serde(default)]
523    pub stop_loss: Option<String>,
524    /// Leverage (1 for spot-like)
525    #[serde(default = "default_leverage")]
526    pub leverage: String,
527    /// Max leverage allowed
528    #[serde(default = "default_max_leverage")]
529    pub max_leverage: String,
530    /// Whether to restart cycle after take profit
531    #[serde(default)]
532    pub restart_on_complete: bool,
533    /// Cooldown period in seconds between cycles (default: 60)
534    #[serde(default = "default_cooldown_period")]
535    pub cooldown_period_secs: u64,
536}
537
538// =============================================================================
539// Orchestrator Config
540// =============================================================================
541
542/// Parent strategy configuration for grouped child strategies.
543#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
544pub struct OrchestratorConfigJson {
545    /// V1 kind: "prediction_yes_no_grid"
546    #[serde(default = "default_orchestrator_kind")]
547    pub kind: String,
548    /// Static allocation mode for child legs. V1 supports "static_50_50".
549    #[serde(default = "default_allocation_mode")]
550    pub allocation_mode: String,
551    /// Group take-profit threshold in percentage units. Example: "10" = +10%.
552    #[serde(default)]
553    pub take_profit_pct: Option<String>,
554    /// Group stop-loss threshold in percentage units. Example: "5" = -5%.
555    #[serde(default)]
556    pub stop_loss_pct: Option<String>,
557    /// Conditions that must pass before child strategies start.
558    #[serde(default)]
559    pub start_conditions: Vec<OrchestratorCondition>,
560    /// Conditions that must remain valid while waiting/running.
561    #[serde(default)]
562    pub validation_conditions: Vec<OrchestratorCondition>,
563    /// Conditions that trigger a risk exit.
564    #[serde(default)]
565    pub risk_conditions: Vec<OrchestratorCondition>,
566    /// Generic child strategy plans. V1 generic orchestrator supports grid and dca only.
567    #[serde(default)]
568    pub children: Vec<OrchestratorChildConfigJson>,
569    /// Explicit child-leg ranges supplied by the client from live prices.
570    #[serde(default)]
571    pub legs: Vec<OrchestratorLegConfigJson>,
572}
573
574/// Generic child strategy plan owned by the orchestrator.
575#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
576#[serde(tag = "kind", rename_all = "snake_case")]
577pub enum OrchestratorChildConfigJson {
578    /// Grid child strategy plan.
579    Grid {
580        /// Optional stable child ID used in logs and strategy IDs.
581        #[serde(default)]
582        id: Option<String>,
583        /// Market this child trades.
584        market: Market,
585        /// Grid configuration for this child.
586        grid: GridConfigJson,
587    },
588    /// DCA child strategy plan.
589    Dca {
590        /// Optional stable child ID used in logs and strategy IDs.
591        #[serde(default)]
592        id: Option<String>,
593        /// Market this child trades.
594        market: Market,
595        /// DCA configuration for this child.
596        dca: DCAConfigJson,
597    },
598}
599
600/// Per-leg range override for orchestrated child strategies.
601#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
602pub struct OrchestratorLegConfigJson {
603    /// Outcome side: 0 = Yes, 1 = No
604    pub side: u8,
605    /// Child grid start price for this side.
606    pub start_price: String,
607    /// Child grid end price for this side.
608    pub end_price: String,
609    /// Optional child trailing ceiling for this side.
610    #[serde(default)]
611    pub trailing_up_limit: Option<String>,
612    /// Optional child trailing floor for this side.
613    #[serde(default)]
614    pub trailing_down_limit: Option<String>,
615}
616
617// =============================================================================
618// Common Config Structs
619// =============================================================================
620
621/// Builder fee configuration for JSON config
622#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
623pub struct BuilderFeeConfig {
624    /// Builder address to receive the fee
625    pub address: String,
626    /// Fee in tenths of a basis point (e.g., 30 = 3 bp = 0.03%)
627    pub fee_tenths_bp: u32,
628}
629
630/// Trade syncer configuration for upstream API PnL tracking
631#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
632pub struct SyncConfigJson {
633    /// Bot ID for upstream API (required if enabled)
634    pub bot_id: String,
635    /// Upstream API base URL (e.g., `<https://api.example.com/bot-api>`)
636    pub upstream_url: String,
637    /// Sync interval in milliseconds (default: 10000)
638    #[serde(default = "default_sync_interval_ms")]
639    pub sync_interval_ms: u64,
640    /// HTTP timeout in seconds (default: 10)
641    #[serde(default = "default_sync_timeout")]
642    pub timeout_secs: u64,
643    /// Optional shared secret sent as x-bot-sync-secret.
644    #[serde(default)]
645    pub sync_secret: Option<String>,
646    /// Enable syncing (default: true if sync section is present)
647    #[serde(default = "default_sync_enabled")]
648    pub enabled: bool,
649}
650
651/// Simulation configuration for paper trading and backtesting.
652/// All fields are optional with sensible defaults.
653///
654/// Example JSON:
655/// ```json
656/// { "simulation": { "starting_balance_usdc": "5000", "fee_rate": "0.0002" } }
657/// ```
658#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
659pub struct SimulationConfig {
660    /// Starting USDC balance (default: "10000")
661    #[serde(default = "default_starting_balance")]
662    pub starting_balance_usdc: String,
663    /// Fee rate as decimal (default: "0.00025" = 0.025%)
664    #[serde(default = "default_fee_rate")]
665    pub fee_rate: String,
666}
667
668// =============================================================================
669// Default value functions
670// =============================================================================
671
672fn default_dca_direction() -> String {
673    "long".to_string()
674}
675
676fn default_max_dca_orders() -> u32 {
677    5
678}
679
680fn default_size_multiplier() -> String {
681    "2.0".to_string()
682}
683
684fn default_deviation_multiplier() -> String {
685    "1.0".to_string()
686}
687
688fn default_cooldown_period() -> u64 {
689    60 // 60 seconds like Binance
690}
691
692fn default_slippage() -> String {
693    "0.001".to_string()
694}
695
696fn default_grid_mode() -> String {
697    "long".to_string()
698}
699
700fn default_grid_levels() -> u32 {
701    20
702}
703
704fn default_leverage() -> String {
705    "5".to_string()
706}
707
708fn default_max_leverage() -> String {
709    "50".to_string()
710}
711
712fn default_sync_interval_ms() -> u64 {
713    10_000
714}
715
716fn default_sync_timeout() -> u64 {
717    10
718}
719
720fn default_sync_enabled() -> bool {
721    true
722}
723
724fn default_starting_balance() -> String {
725    "10000".to_string()
726}
727
728fn default_fee_rate() -> String {
729    "0.00025".to_string() // 0.025% taker fee (Hyperliquid default)
730}
731
732fn default_poll_delay_ms() -> u64 {
733    500
734}
735
736fn default_orchestrator_kind() -> String {
737    "prediction_yes_no_grid".to_string()
738}
739
740fn default_allocation_mode() -> String {
741    "static_50_50".to_string()
742}
743
744fn default_skew_mode() -> String {
745    "both".to_string()
746}
747fn default_price_skew_gamma() -> String {
748    "0.05".to_string()
749}
750fn default_size_skew_floor() -> String {
751    "0.2".to_string()
752}
753fn default_min_price_change_pct() -> String {
754    "0.0005".to_string()
755}
756
757// =============================================================================
758// Strategy Builder
759// =============================================================================
760
761fn outcome_side_label(side: u8) -> &'static str {
762    match side {
763        0 => "yes",
764        1 => "no",
765        _ => "unknown",
766    }
767}
768
769fn opposite_outcome_market(market: &Market) -> Result<Market> {
770    match market {
771        Market::Hyperliquid(HyperliquidMarket::Outcome {
772            name,
773            outcome_id,
774            side,
775            instrument_meta,
776        }) => {
777            let opposite_side = if *side == 0 { 1 } else { 0 };
778            Ok(Market::Hyperliquid(HyperliquidMarket::Outcome {
779                name: name.clone(),
780                outcome_id: *outcome_id,
781                side: opposite_side,
782                instrument_meta: instrument_meta.clone(),
783            }))
784        }
785        _ => anyhow::bail!("prediction_yes_no_grid orchestrator requires an outcome market"),
786    }
787}
788
789fn orchestrator_markets(config: &BotConfig) -> Result<Vec<Market>> {
790    let strategy_type = config.strategy_type.to_lowercase();
791    if strategy_type != "orchestrator" {
792        return Ok(config.markets.clone());
793    }
794
795    let orchestrator_json = config
796        .orchestrator
797        .as_ref()
798        .context("Orchestrator config missing: add 'orchestrator' section")?;
799    if orchestrator_json.kind == "generic" {
800        anyhow::ensure!(
801            !orchestrator_json.children.is_empty(),
802            "generic orchestrator requires orchestrator.children"
803        );
804        return Ok(orchestrator_json
805            .children
806            .iter()
807            .map(|child| match child {
808                OrchestratorChildConfigJson::Grid { market, .. }
809                | OrchestratorChildConfigJson::Dca { market, .. } => market.clone(),
810            })
811            .collect());
812    }
813
814    anyhow::ensure!(
815        config.markets.len() == 1,
816        "Orchestrator V1 expects exactly one configured market; it mirrors the opposite outcome side internally"
817    );
818
819    let primary = config.primary_market().clone();
820    let opposite = opposite_outcome_market(&primary)?;
821    Ok(vec![primary, opposite])
822}
823
824fn grid_mode_from_json(mode: &str) -> GridMode {
825    match mode.to_lowercase().as_str() {
826        "short" => GridMode::Short,
827        "neutral" => GridMode::Neutral,
828        _ => GridMode::Long,
829    }
830}
831
832fn build_grid_config_for_market(
833    config: &BotConfig,
834    grid_json: &GridConfigJson,
835    market: Market,
836    strategy_id: StrategyId,
837    allocation_override: Option<Decimal>,
838    force_long: bool,
839    disable_child_exits: bool,
840) -> Result<GridConfig> {
841    Ok(GridConfig {
842        strategy_id,
843        environment: config.parse_environment(),
844        market,
845        grid_mode: if force_long {
846            GridMode::Long
847        } else {
848            grid_mode_from_json(&grid_json.mode)
849        },
850        grid_levels: grid_json.levels,
851        start_price: Decimal::from_str(&grid_json.start_price).context("Invalid start_price")?,
852        end_price: Decimal::from_str(&grid_json.end_price).context("Invalid end_price")?,
853        max_investment_quote: match allocation_override {
854            Some(value) => value,
855            None => Decimal::from_str(&grid_json.max_investment_quote)
856                .context("Invalid max_investment_quote")?,
857        },
858        base_order_size: Decimal::new(1, 3),
859        leverage: Decimal::from_str(&grid_json.leverage).context("Invalid leverage")?,
860        max_leverage: Decimal::from_str(&grid_json.max_leverage).context("Invalid max_leverage")?,
861        post_only: grid_json.post_only,
862        stop_loss: if disable_child_exits {
863            None
864        } else {
865            grid_json
866                .stop_loss
867                .as_ref()
868                .map(|s| Decimal::from_str(s))
869                .transpose()
870                .context("Invalid grid stop_loss")?
871        },
872        take_profit: if disable_child_exits {
873            None
874        } else {
875            grid_json
876                .take_profit
877                .as_ref()
878                .map(|s| Decimal::from_str(s))
879                .transpose()
880                .context("Invalid grid take_profit")?
881        },
882        trailing_up_limit: grid_json
883            .trailing_up_limit
884            .as_ref()
885            .map(|s| Decimal::from_str(s))
886            .transpose()
887            .context("Invalid grid trailing_up_limit")?,
888        trailing_down_limit: grid_json
889            .trailing_down_limit
890            .as_ref()
891            .map(|s| Decimal::from_str(s))
892            .transpose()
893            .context("Invalid grid trailing_down_limit")?,
894    })
895}
896
897#[allow(dead_code)]
898fn build_dca_config_for_market(
899    config: &BotConfig,
900    dca_json: &DCAConfigJson,
901    market: Market,
902    strategy_id: StrategyId,
903) -> Result<DCAConfig> {
904    Ok(DCAConfig {
905        strategy_id,
906        environment: config.parse_environment(),
907        market,
908        direction: match dca_json.direction.to_lowercase().as_str() {
909            "short" => DCADirection::Short,
910            _ => DCADirection::Long,
911        },
912        trigger_price: Decimal::from_str(&dca_json.trigger_price)
913            .context("Invalid trigger_price")?,
914        base_order_size: Decimal::from_str(&dca_json.base_order_size)
915            .context("Invalid base_order_size")?,
916        dca_order_size: Decimal::from_str(&dca_json.dca_order_size)
917            .context("Invalid dca_order_size")?,
918        max_dca_orders: dca_json.max_dca_orders,
919        size_multiplier: Decimal::from_str(&dca_json.size_multiplier)
920            .context("Invalid size_multiplier")?,
921        price_deviation_pct: Decimal::from_str(&dca_json.price_deviation_pct)
922            .context("Invalid price_deviation_pct")?,
923        deviation_multiplier: Decimal::from_str(&dca_json.deviation_multiplier)
924            .context("Invalid deviation_multiplier")?,
925        take_profit_pct: Decimal::from_str(&dca_json.take_profit_pct)
926            .context("Invalid take_profit_pct")?,
927        stop_loss: dca_json
928            .stop_loss
929            .as_ref()
930            .map(|s| Decimal::from_str(s))
931            .transpose()
932            .context("Invalid stop_loss")?,
933        leverage: Decimal::from_str(&dca_json.leverage).context("Invalid leverage")?,
934        max_leverage: Decimal::from_str(&dca_json.max_leverage).context("Invalid max_leverage")?,
935        restart_on_complete: dca_json.restart_on_complete,
936        cooldown_period_secs: dca_json.cooldown_period_secs,
937    })
938}
939
940#[allow(dead_code)]
941fn validate_dca_config_with_price_bounds(dca_config: &DCAConfig) -> Result<()> {
942    let mut errors = dca_config.validate();
943    push_price_bound_errors(
944        &dca_config.market,
945        &[("dca.trigger_price", dca_config.trigger_price)],
946        &mut errors,
947    );
948    if !errors.is_empty() {
949        anyhow::bail!("DCA configuration validation failed: {}", errors.join(", "));
950    }
951    Ok(())
952}
953
954fn leg_overrides_by_side(
955    orchestrator_json: &OrchestratorConfigJson,
956) -> Result<HashMap<u8, OrchestratorLegConfigJson>> {
957    let mut overrides = HashMap::new();
958    for leg in &orchestrator_json.legs {
959        anyhow::ensure!(
960            leg.side == 0 || leg.side == 1,
961            "orchestrator.legs side must be 0 or 1"
962        );
963        anyhow::ensure!(
964            overrides.insert(leg.side, leg.clone()).is_none(),
965            "duplicate orchestrator.legs entry for side {}",
966            leg.side
967        );
968    }
969    Ok(overrides)
970}
971
972fn apply_leg_override(grid_config: &mut GridConfig, leg: &OrchestratorLegConfigJson) -> Result<()> {
973    grid_config.start_price =
974        Decimal::from_str(&leg.start_price).context("Invalid orchestrator leg start_price")?;
975    grid_config.end_price =
976        Decimal::from_str(&leg.end_price).context("Invalid orchestrator leg end_price")?;
977    grid_config.trailing_up_limit = leg
978        .trailing_up_limit
979        .as_ref()
980        .map(|s| Decimal::from_str(s))
981        .transpose()
982        .context("Invalid orchestrator leg trailing_up_limit")?;
983    grid_config.trailing_down_limit = leg
984        .trailing_down_limit
985        .as_ref()
986        .map(|s| Decimal::from_str(s))
987        .transpose()
988        .context("Invalid orchestrator leg trailing_down_limit")?;
989    Ok(())
990}
991
992fn validate_grid_config_with_price_bounds(grid_config: &GridConfig) -> Result<()> {
993    let mut errors = grid_config.validate();
994    let mut bounded_prices = vec![
995        ("grid.start_price", grid_config.start_price),
996        ("grid.end_price", grid_config.end_price),
997    ];
998    if let Some(price) = grid_config.trailing_up_limit {
999        bounded_prices.push(("grid.trailing_up_limit", price));
1000    }
1001    if let Some(price) = grid_config.trailing_down_limit {
1002        bounded_prices.push(("grid.trailing_down_limit", price));
1003    }
1004    push_price_bound_errors(&grid_config.market, &bounded_prices, &mut errors);
1005    if !errors.is_empty() {
1006        anyhow::bail!(
1007            "Grid configuration validation failed: {}",
1008            errors.join(", ")
1009        );
1010    }
1011    Ok(())
1012}
1013
1014/// Build the strategy from a V2 BotConfig.
1015/// Works identically on native and WASM.
1016pub fn build_strategy(config: &BotConfig) -> Result<Box<dyn Strategy>> {
1017    let environment = config.parse_environment();
1018    let strategy_type = config.strategy_type.to_lowercase();
1019
1020    let is_arb = strategy_type == "arbitrage" || strategy_type == "arb";
1021    let is_grid = strategy_type == "grid";
1022    let is_dca = strategy_type == "dca";
1023    let is_mm = strategy_type == "mm" || strategy_type == "market_maker";
1024    let is_orchestrator = strategy_type == "orchestrator";
1025
1026    if is_arb {
1027        // Arbitrage strategy — requires exactly 2 markets: [spot, perp]
1028        anyhow::ensure!(
1029            config.markets.len() >= 2,
1030            "Arbitrage requires 2 markets in config: markets[0]=spot, markets[1]=perp"
1031        );
1032
1033        let arb_json = config
1034            .arbitrage
1035            .as_ref()
1036            .context("Arbitrage config missing: add 'arbitrage' section to config")?;
1037
1038        let spot_market = config.markets[0].clone();
1039        let perp_market = config.markets[1].clone();
1040
1041        let arb_config = ArbitrageConfig {
1042            strategy_id: StrategyId::new(format!("{}-arb", spot_market.base().to_lowercase())),
1043            spot_market,
1044            perp_market,
1045            environment,
1046            order_amount: Decimal::from_str(&arb_json.order_amount)
1047                .context("Invalid order_amount")?,
1048            perp_leverage: Decimal::from_str(&arb_json.perp_leverage)
1049                .context("Invalid perp_leverage")?,
1050            min_opening_spread_pct: Decimal::from_str(&arb_json.min_opening_spread_pct)
1051                .context("Invalid min_opening_spread_pct")?,
1052            min_closing_spread_pct: Decimal::from_str(&arb_json.min_closing_spread_pct)
1053                .context("Invalid min_closing_spread_pct")?,
1054            spot_slippage_buffer_pct: Decimal::from_str(&arb_json.spot_slippage_buffer_pct)
1055                .context("Invalid spot_slippage_buffer_pct")?,
1056            perp_slippage_buffer_pct: Decimal::from_str(&arb_json.perp_slippage_buffer_pct)
1057                .context("Invalid perp_slippage_buffer_pct")?,
1058        };
1059
1060        // Validate arb config
1061        let errors = arb_config.validate();
1062        if !errors.is_empty() {
1063            anyhow::bail!(
1064                "Arbitrage configuration validation failed: {}",
1065                errors.join(", ")
1066            );
1067        }
1068
1069        Ok(Box::new(ArbitrageStrategy::new(arb_config)))
1070    } else if is_orchestrator {
1071        let orchestrator_json = config
1072            .orchestrator
1073            .as_ref()
1074            .context("Orchestrator config missing: add 'orchestrator' section")?;
1075        let grid_json = config
1076            .grid
1077            .as_ref()
1078            .context("Orchestrator V1 requires a child 'grid' section")?;
1079
1080        anyhow::ensure!(
1081            orchestrator_json.kind == "prediction_yes_no_grid",
1082            "Unsupported orchestrator kind '{}'; supported kind: prediction_yes_no_grid",
1083            orchestrator_json.kind
1084        );
1085        anyhow::ensure!(
1086            orchestrator_json.allocation_mode == "static_50_50",
1087            "Unsupported orchestrator allocation_mode '{}'; supported mode: static_50_50",
1088            orchestrator_json.allocation_mode
1089        );
1090
1091        let markets = orchestrator_markets(config)?;
1092        let leg_overrides = leg_overrides_by_side(orchestrator_json)?;
1093        let total_allocation = Decimal::from_str(&grid_json.max_investment_quote)
1094            .context("Invalid max_investment_quote")?;
1095        let child_allocation = total_allocation / Decimal::TWO;
1096
1097        let mut legs = Vec::new();
1098        for market in markets {
1099            let (_, side, _) = market
1100                .outcome_params()
1101                .context("Orchestrator V1 only supports outcome markets")?;
1102            let label = outcome_side_label(side);
1103            let mut child_config = build_grid_config_for_market(
1104                config,
1105                grid_json,
1106                market.clone(),
1107                StrategyId::new(format!("{}-{}-grid", config.primary_market().base(), label)),
1108                Some(child_allocation),
1109                true,
1110                true,
1111            )?;
1112            if let Some(leg_override) = leg_overrides.get(&side) {
1113                apply_leg_override(&mut child_config, leg_override)?;
1114            }
1115            validate_grid_config_with_price_bounds(&child_config)?;
1116            legs.push(OrchestratorLeg::new(
1117                label,
1118                child_config.market.instrument_id(),
1119                Box::new(GridStrategy::new(child_config)),
1120            ));
1121        }
1122
1123        let risk = GroupRiskConfig {
1124            allocated_capital_quote: total_allocation,
1125            take_profit_pct: orchestrator_json
1126                .take_profit_pct
1127                .as_ref()
1128                .map(|s| Decimal::from_str(s))
1129                .transpose()
1130                .context("Invalid orchestrator take_profit_pct")?,
1131            stop_loss_pct: orchestrator_json
1132                .stop_loss_pct
1133                .as_ref()
1134                .map(|s| Decimal::from_str(s))
1135                .transpose()
1136                .context("Invalid orchestrator stop_loss_pct")?,
1137        };
1138        let conditions = OrchestratorConditions {
1139            start_conditions: orchestrator_json.start_conditions.clone(),
1140            validation_conditions: orchestrator_json.validation_conditions.clone(),
1141            risk_conditions: orchestrator_json.risk_conditions.clone(),
1142        };
1143
1144        Ok(Box::new(BotOrchestrator::with_conditions(
1145            StrategyId::new(format!("{}-orchestrator", config.primary_market().base())),
1146            legs,
1147            risk,
1148            conditions,
1149        )))
1150    } else if is_grid {
1151        // Grid strategy
1152        let grid_json = config
1153            .grid
1154            .as_ref()
1155            .context("Grid config missing: add 'grid' section to config")?;
1156
1157        let grid_config = GridConfig {
1158            strategy_id: StrategyId::new(format!("{}-grid", config.primary_market().base())),
1159            environment,
1160            market: config.primary_market().clone(),
1161            grid_mode: match grid_json.mode.to_lowercase().as_str() {
1162                "short" => GridMode::Short,
1163                "neutral" => GridMode::Neutral,
1164                _ => GridMode::Long,
1165            },
1166            grid_levels: grid_json.levels,
1167            start_price: Decimal::from_str(&grid_json.start_price)
1168                .context("Invalid start_price")?,
1169            end_price: Decimal::from_str(&grid_json.end_price).context("Invalid end_price")?,
1170            max_investment_quote: Decimal::from_str(&grid_json.max_investment_quote)
1171                .context("Invalid max_investment_quote")?,
1172            base_order_size: Decimal::new(1, 3), // fallback 0.001
1173            leverage: Decimal::from_str(&grid_json.leverage).context("Invalid leverage")?,
1174            max_leverage: Decimal::from_str(&grid_json.max_leverage)
1175                .context("Invalid max_leverage")?,
1176            post_only: grid_json.post_only,
1177            stop_loss: grid_json
1178                .stop_loss
1179                .as_ref()
1180                .map(|s| Decimal::from_str(s))
1181                .transpose()
1182                .context("Invalid grid stop_loss")?,
1183            take_profit: grid_json
1184                .take_profit
1185                .as_ref()
1186                .map(|s| Decimal::from_str(s))
1187                .transpose()
1188                .context("Invalid grid take_profit")?,
1189            // Trailing is opt-in; enabled by the presence of a limit price.
1190            trailing_up_limit: grid_json
1191                .trailing_up_limit
1192                .as_ref()
1193                .map(|s| Decimal::from_str(s))
1194                .transpose()
1195                .context("Invalid grid trailing_up_limit")?,
1196            trailing_down_limit: grid_json
1197                .trailing_down_limit
1198                .as_ref()
1199                .map(|s| Decimal::from_str(s))
1200                .transpose()
1201                .context("Invalid grid trailing_down_limit")?,
1202        };
1203
1204        // Validate grid config
1205        let mut errors = grid_config.validate();
1206        let mut bounded_prices = vec![
1207            ("grid.start_price", grid_config.start_price),
1208            ("grid.end_price", grid_config.end_price),
1209        ];
1210        if let Some(price) = grid_config.trailing_up_limit {
1211            bounded_prices.push(("grid.trailing_up_limit", price));
1212        }
1213        if let Some(price) = grid_config.trailing_down_limit {
1214            bounded_prices.push(("grid.trailing_down_limit", price));
1215        }
1216        push_price_bound_errors(&grid_config.market, &bounded_prices, &mut errors);
1217        if !errors.is_empty() {
1218            anyhow::bail!(
1219                "Grid configuration validation failed: {}",
1220                errors.join(", ")
1221            );
1222        }
1223
1224        Ok(Box::new(GridStrategy::new(grid_config)))
1225    } else if is_dca {
1226        // DCA strategy
1227        let dca_json = config
1228            .dca
1229            .as_ref()
1230            .context("DCA config missing: add 'dca' section to config")?;
1231
1232        let dca_config = DCAConfig {
1233            strategy_id: StrategyId::new(format!("{}-dca", config.primary_market().base())),
1234            environment,
1235            market: config.primary_market().clone(),
1236            direction: match dca_json.direction.to_lowercase().as_str() {
1237                "short" => DCADirection::Short,
1238                _ => DCADirection::Long,
1239            },
1240            trigger_price: Decimal::from_str(&dca_json.trigger_price)
1241                .context("Invalid trigger_price")?,
1242            base_order_size: Decimal::from_str(&dca_json.base_order_size)
1243                .context("Invalid base_order_size")?,
1244            dca_order_size: Decimal::from_str(&dca_json.dca_order_size)
1245                .context("Invalid dca_order_size")?,
1246            max_dca_orders: dca_json.max_dca_orders,
1247            size_multiplier: Decimal::from_str(&dca_json.size_multiplier)
1248                .context("Invalid size_multiplier")?,
1249            price_deviation_pct: Decimal::from_str(&dca_json.price_deviation_pct)
1250                .context("Invalid price_deviation_pct")?,
1251            deviation_multiplier: Decimal::from_str(&dca_json.deviation_multiplier)
1252                .context("Invalid deviation_multiplier")?,
1253            take_profit_pct: Decimal::from_str(&dca_json.take_profit_pct)
1254                .context("Invalid take_profit_pct")?,
1255            stop_loss: dca_json
1256                .stop_loss
1257                .as_ref()
1258                .map(|s| Decimal::from_str(s))
1259                .transpose()
1260                .context("Invalid stop_loss")?,
1261            leverage: Decimal::from_str(&dca_json.leverage).context("Invalid leverage")?,
1262            max_leverage: Decimal::from_str(&dca_json.max_leverage)
1263                .context("Invalid max_leverage")?,
1264            restart_on_complete: dca_json.restart_on_complete,
1265            cooldown_period_secs: dca_json.cooldown_period_secs,
1266        };
1267
1268        // Validate DCA config
1269        let mut errors = dca_config.validate();
1270        push_price_bound_errors(
1271            &dca_config.market,
1272            &[("dca.trigger_price", dca_config.trigger_price)],
1273            &mut errors,
1274        );
1275        if !errors.is_empty() {
1276            anyhow::bail!("DCA configuration validation failed: {}", errors.join(", "));
1277        }
1278
1279        Ok(Box::new(DCAStrategy::new(dca_config)))
1280    } else if strategy_type == "tick_trader" {
1281        #[cfg(feature = "strategy-tick-trader")]
1282        {
1283            // Tick-trader: custom strategy using custom_config() pattern
1284            let tick_config: TickTraderConfig = config.custom_config("tick_trader")?;
1285            let errors = tick_config.validate();
1286            if !errors.is_empty() {
1287                anyhow::bail!(
1288                    "Tick trader config validation failed: {}",
1289                    errors.join(", ")
1290                );
1291            }
1292            Ok(Box::new(TickTrader::new(tick_config)))
1293        }
1294        #[cfg(not(feature = "strategy-tick-trader"))]
1295        {
1296            anyhow::bail!("Tick trader strategy is not enabled in this build")
1297        }
1298    } else if strategy_type == "rsi" {
1299        #[cfg(feature = "strategy-rsi")]
1300        {
1301            // RSI strategy: uses inline Wilder's RSI indicator + bar aggregation
1302            let rsi_config: RsiStrategyConfig = config.custom_config("rsi")?;
1303            let errors = rsi_config.validate();
1304            if !errors.is_empty() {
1305                anyhow::bail!("RSI config validation failed: {}", errors.join(", "));
1306            }
1307            let market = config.primary_market().clone();
1308            let environment = config.parse_environment();
1309            Ok(Box::new(RsiStrategy::new(rsi_config, market, environment)))
1310        }
1311        #[cfg(not(feature = "strategy-rsi"))]
1312        {
1313            anyhow::bail!("RSI strategy is not enabled in this build")
1314        }
1315    } else if is_mm {
1316        // Market maker strategy
1317        let mm_json = config
1318            .mm
1319            .as_ref()
1320            .context("MM config missing: add 'mm' section to config for strategy_type='mm'")?;
1321
1322        let base_order_size =
1323            Decimal::from_str(&mm_json.base_order_size).context("Invalid base_order_size")?;
1324        let base_spread = Decimal::from_str(&mm_json.base_spread).context("Invalid base_spread")?;
1325        let max_position_size =
1326            Decimal::from_str(&mm_json.max_position_size).context("Invalid max_position_size")?;
1327        let price_skew_gamma =
1328            Decimal::from_str(&mm_json.price_skew_gamma).context("Invalid price_skew_gamma")?;
1329        let size_skew_floor =
1330            Decimal::from_str(&mm_json.size_skew_floor).context("Invalid size_skew_floor")?;
1331        let min_price_change_pct = Decimal::from_str(&mm_json.min_price_change_pct)
1332            .context("Invalid min_price_change_pct")?;
1333
1334        let stop_loss = mm_json
1335            .stop_loss
1336            .as_ref()
1337            .map(|s| Decimal::from_str(s))
1338            .transpose()
1339            .context("Invalid stop_loss")?;
1340        let take_profit = mm_json
1341            .take_profit
1342            .as_ref()
1343            .map(|s| Decimal::from_str(s))
1344            .transpose()
1345            .context("Invalid take_profit")?;
1346
1347        let skew_mode = match mm_json.skew_mode.to_lowercase().as_str() {
1348            "none" => SkewMode::None,
1349            "size" => SkewMode::Size,
1350            "price" => SkewMode::Price,
1351            "both" | _ => SkewMode::Both,
1352        };
1353
1354        let mm_config = MarketMakerConfig {
1355            strategy_id: StrategyId::new(format!("{}-mm", config.primary_market().base())),
1356            environment,
1357            market: config.primary_market().clone(),
1358            base_order_size,
1359            base_spread,
1360            target_position_pct: Decimal::new(5, 1), // 0.5
1361            min_position_pct: Decimal::new(1, 1),    // 0.1
1362            max_position_pct: Decimal::new(9, 1),    // 0.9
1363            max_position_size,
1364            skew_mode,
1365            price_skew_gamma,
1366            size_skew_floor,
1367            min_price_change_pct,
1368            stop_loss,
1369            take_profit,
1370        };
1371
1372        // Validate MM config
1373        let errors = mm_config.validate();
1374        if !errors.is_empty() {
1375            anyhow::bail!("Configuration validation failed: {}", errors.join(", "));
1376        }
1377
1378        Ok(Box::new(MarketMaker::new(mm_config)))
1379    } else {
1380        // =====================================================================
1381        // Custom strategy — registered by AI agents / advanced users.
1382        //
1383        // To register a custom strategy:
1384        // 1. Create your strategy crate (use supurr_skill/templates/strategy-template/)
1385        // 2. Add it to workspace Cargo.toml
1386        // 3. Add dependency in bot-engine/Cargo.toml
1387        // 4. Add a branch here:
1388        //      "mystrategy" => strategy_mystrategy::build_from_json(&config),
1389        // =====================================================================
1390        anyhow::bail!(
1391            "Unknown strategy type: '{}'. Built-in types: grid, dca, mm, arb. \
1392             For custom strategies, see STRATEGY_API.md.",
1393            strategy_type
1394        )
1395    }
1396}
1397
1398/// Build InstrumentMeta from BotConfig's primary market.
1399pub fn build_instrument_meta(config: &BotConfig) -> InstrumentMeta {
1400    let primary_market = config.primary_market();
1401    let quote_currency = primary_market.quote();
1402
1403    // Extract instrument_meta from Market, use defaults if not provided
1404    let (tick_size, lot_size, min_qty, min_notional) = primary_market
1405        .instrument_meta()
1406        .map(|im| (im.tick_size, im.lot_size, im.min_qty, im.min_notional))
1407        .unwrap_or((Decimal::new(1, 1), Decimal::new(1, 4), None, None));
1408
1409    InstrumentMeta {
1410        instrument_id: primary_market.instrument_id(),
1411        market_index: primary_market.market_index(),
1412        base_asset: AssetId::new(primary_market.base()),
1413        quote_asset: AssetId::new(quote_currency),
1414        tick_size,
1415        lot_size,
1416        min_qty,
1417        min_notional,
1418        fee_asset_default: Some(AssetId::new(quote_currency)),
1419        kind: primary_market.instrument_kind(),
1420    }
1421}
1422
1423/// Build InstrumentMeta for ALL markets in `config.markets[]`.
1424///
1425/// For multi-instrument strategies (e.g., Arbitrage with spot + perp),
1426/// this returns one `InstrumentMeta` per market entry.
1427pub fn build_instrument_metas(config: &BotConfig) -> Vec<InstrumentMeta> {
1428    let markets = orchestrator_markets(config).unwrap_or_else(|_| config.markets.clone());
1429
1430    markets
1431        .iter()
1432        .map(|market| {
1433            let quote_currency = market.quote();
1434            let (tick_size, lot_size, min_qty, min_notional) = market
1435                .instrument_meta()
1436                .map(|im| (im.tick_size, im.lot_size, im.min_qty, im.min_notional))
1437                .unwrap_or((Decimal::new(1, 1), Decimal::new(1, 4), None, None));
1438
1439            InstrumentMeta {
1440                instrument_id: market.instrument_id(),
1441                market_index: market.market_index(),
1442                base_asset: AssetId::new(market.base()),
1443                quote_asset: AssetId::new(quote_currency),
1444                tick_size,
1445                lot_size,
1446                min_qty,
1447                min_notional,
1448                fee_asset_default: Some(AssetId::new(quote_currency)),
1449                kind: market.instrument_kind(),
1450            }
1451        })
1452        .collect()
1453}
1454
1455#[cfg(test)]
1456mod tests {
1457    use super::*;
1458    use bot_core::HyperliquidMarket;
1459    use rust_decimal_macros::dec;
1460
1461    fn btc_perp_market() -> Market {
1462        Market::Hyperliquid(HyperliquidMarket::Perp {
1463            base: "BTC".to_string(),
1464            quote: "USDC".to_string(),
1465            index: 0,
1466            instrument_meta: None,
1467        })
1468    }
1469
1470    fn btc_outcome_market() -> Market {
1471        Market::Hyperliquid(HyperliquidMarket::Outcome {
1472            name: "BTC > 78213".to_string(),
1473            outcome_id: 1,
1474            side: 0,
1475            instrument_meta: None,
1476        })
1477    }
1478
1479    fn base_config(strategy_type: &str) -> BotConfig {
1480        BotConfig {
1481            environment: "mainnet".to_string(),
1482            private_key: String::new(),
1483            address: String::new(),
1484            vault_address: None,
1485            base_url_override: None,
1486            strategy_type: strategy_type.to_string(),
1487            markets: vec![btc_perp_market()],
1488            poll_delay_ms: 500,
1489            grid: None,
1490            mm: None,
1491            dca: None,
1492            arbitrage: None,
1493            orchestrator: None,
1494            builder_fee: None,
1495            sync: None,
1496            simulation: None,
1497            extra: HashMap::new(),
1498        }
1499    }
1500
1501    #[test]
1502    fn grid_allocated_capital_uses_max_investment_quote() {
1503        let mut config = base_config("grid");
1504        config.grid = Some(GridConfigJson {
1505            mode: "long".to_string(),
1506            levels: 10,
1507            start_price: "77000".to_string(),
1508            end_price: "78000".to_string(),
1509            max_investment_quote: "123.45".to_string(),
1510            leverage: "20".to_string(),
1511            max_leverage: "40".to_string(),
1512            post_only: false,
1513            stop_loss: None,
1514            take_profit: None,
1515            trailing_up_limit: None,
1516            trailing_down_limit: None,
1517        });
1518
1519        assert_eq!(config.strategy_allocated_capital_usdc(), Some(dec!(123.45)));
1520    }
1521
1522    #[test]
1523    fn orchestrator_registers_both_outcome_legs_from_one_market() {
1524        let mut config = base_config("orchestrator");
1525        config.markets = vec![btc_outcome_market()];
1526        config.grid = Some(GridConfigJson {
1527            mode: "long".to_string(),
1528            levels: 10,
1529            start_price: "0.2".to_string(),
1530            end_price: "0.8".to_string(),
1531            max_investment_quote: "500".to_string(),
1532            leverage: "1".to_string(),
1533            max_leverage: "1".to_string(),
1534            post_only: false,
1535            stop_loss: Some("10".to_string()),
1536            take_profit: Some("10".to_string()),
1537            trailing_up_limit: None,
1538            trailing_down_limit: None,
1539        });
1540        config.orchestrator = Some(OrchestratorConfigJson {
1541            kind: "prediction_yes_no_grid".to_string(),
1542            allocation_mode: "static_50_50".to_string(),
1543            take_profit_pct: Some("10".to_string()),
1544            stop_loss_pct: Some("5".to_string()),
1545            start_conditions: Vec::new(),
1546            validation_conditions: Vec::new(),
1547            risk_conditions: Vec::new(),
1548            children: Vec::new(),
1549            legs: Vec::new(),
1550        });
1551
1552        let strategy = build_strategy(&config).expect("orchestrator strategy should build");
1553        assert_eq!(strategy.id().as_str(), "BTC > 78213-orchestrator");
1554
1555        let metas = build_instrument_metas(&config);
1556        let instruments: Vec<String> = metas
1557            .iter()
1558            .map(|meta| meta.instrument_id.to_string())
1559            .collect();
1560        assert_eq!(instruments, vec!["#10-OUTCOME", "#11-OUTCOME"]);
1561        assert_eq!(config.strategy_allocated_capital_usdc(), Some(dec!(500)));
1562    }
1563
1564    #[test]
1565    fn orchestrator_accepts_client_supplied_leg_ranges() {
1566        let orchestrator = OrchestratorConfigJson {
1567            kind: "prediction_yes_no_grid".to_string(),
1568            allocation_mode: "static_50_50".to_string(),
1569            take_profit_pct: None,
1570            stop_loss_pct: None,
1571            start_conditions: Vec::new(),
1572            validation_conditions: Vec::new(),
1573            risk_conditions: Vec::new(),
1574            children: Vec::new(),
1575            legs: vec![
1576                OrchestratorLegConfigJson {
1577                    side: 0,
1578                    start_price: "0.30".to_string(),
1579                    end_price: "0.36".to_string(),
1580                    trailing_up_limit: None,
1581                    trailing_down_limit: None,
1582                },
1583                OrchestratorLegConfigJson {
1584                    side: 1,
1585                    start_price: "0.64".to_string(),
1586                    end_price: "0.70".to_string(),
1587                    trailing_up_limit: None,
1588                    trailing_down_limit: None,
1589                },
1590            ],
1591        };
1592
1593        let overrides = leg_overrides_by_side(&orchestrator).expect("valid leg overrides");
1594        assert_eq!(overrides[&0].start_price, "0.30");
1595        assert_eq!(overrides[&1].end_price, "0.70");
1596    }
1597
1598    #[test]
1599    fn arbitrage_allocated_capital_uses_order_amount() {
1600        let mut config = base_config("arb");
1601        config.arbitrage = Some(ArbitrageConfigJson {
1602            order_amount: "50".to_string(),
1603            perp_leverage: "5".to_string(),
1604            min_opening_spread_pct: "0.2".to_string(),
1605            min_closing_spread_pct: "0.05".to_string(),
1606            spot_slippage_buffer_pct: "0.1".to_string(),
1607            perp_slippage_buffer_pct: "0.1".to_string(),
1608        });
1609
1610        assert_eq!(config.strategy_allocated_capital_usdc(), Some(dec!(50)));
1611    }
1612
1613    #[test]
1614    fn dca_allocated_capital_uses_full_ladder_not_wallet_balance() {
1615        let mut config = base_config("dca");
1616        config.dca = Some(DCAConfigJson {
1617            direction: "long".to_string(),
1618            trigger_price: "100".to_string(),
1619            base_order_size: "1".to_string(),
1620            dca_order_size: "1".to_string(),
1621            max_dca_orders: 2,
1622            size_multiplier: "2".to_string(),
1623            price_deviation_pct: "10".to_string(),
1624            deviation_multiplier: "1".to_string(),
1625            take_profit_pct: "2".to_string(),
1626            stop_loss: None,
1627            leverage: "1".to_string(),
1628            max_leverage: "10".to_string(),
1629            restart_on_complete: false,
1630            cooldown_period_secs: 60,
1631        });
1632
1633        // Base: 1 * 100 = 100
1634        // DCA 1: 1 * 90 = 90
1635        // DCA 2: 2 * 81 = 162
1636        assert_eq!(config.strategy_allocated_capital_usdc(), Some(dec!(352)));
1637    }
1638
1639    #[test]
1640    fn grid_rejects_outcome_prices_outside_probability_bounds() {
1641        let mut config = base_config("grid");
1642        config.markets = vec![btc_outcome_market()];
1643        config.grid = Some(GridConfigJson {
1644            mode: "long".to_string(),
1645            levels: 2,
1646            start_price: "0.50".to_string(),
1647            end_price: "1.20".to_string(),
1648            max_investment_quote: "20".to_string(),
1649            leverage: "1".to_string(),
1650            max_leverage: "1".to_string(),
1651            post_only: false,
1652            stop_loss: None,
1653            take_profit: None,
1654            trailing_up_limit: None,
1655            trailing_down_limit: None,
1656        });
1657
1658        let error = match build_strategy(&config) {
1659            Ok(_) => panic!("outcome grid should fail bounds"),
1660            Err(error) => error,
1661        };
1662        assert!(error.to_string().contains("grid.end_price"));
1663    }
1664
1665    #[test]
1666    fn dca_rejects_outcome_trigger_outside_probability_bounds() {
1667        let mut config = base_config("dca");
1668        config.markets = vec![btc_outcome_market()];
1669        config.dca = Some(DCAConfigJson {
1670            direction: "long".to_string(),
1671            trigger_price: "1.01".to_string(),
1672            base_order_size: "1".to_string(),
1673            dca_order_size: "1".to_string(),
1674            max_dca_orders: 1,
1675            size_multiplier: "1".to_string(),
1676            price_deviation_pct: "1".to_string(),
1677            deviation_multiplier: "1".to_string(),
1678            take_profit_pct: "1".to_string(),
1679            stop_loss: None,
1680            leverage: "1".to_string(),
1681            max_leverage: "1".to_string(),
1682            restart_on_complete: false,
1683            cooldown_period_secs: 60,
1684        });
1685
1686        let error = match build_strategy(&config) {
1687            Ok(_) => panic!("outcome dca should fail bounds"),
1688            Err(error) => error,
1689        };
1690        assert!(error.to_string().contains("dca.trigger_price"));
1691    }
1692}