1use 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
25use 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
35fn 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
61pub struct BotConfig {
62 pub environment: String,
64
65 #[serde(default)]
68 pub private_key: String,
69
70 #[serde(default)]
73 pub address: String,
74
75 #[serde(default)]
77 pub vault_address: Option<String>,
78
79 #[serde(default)]
82 pub base_url_override: Option<String>,
83
84 pub strategy_type: String,
86
87 pub markets: Vec<Market>,
89
90 #[serde(default = "default_poll_delay_ms")]
92 pub poll_delay_ms: u64,
93
94 #[serde(default)]
99 pub grid: Option<GridConfigJson>,
100
101 #[serde(default)]
103 pub mm: Option<MMConfigJson>,
104
105 #[serde(default)]
107 pub dca: Option<DCAConfigJson>,
108
109 #[serde(default)]
111 pub arbitrage: Option<ArbitrageConfigJson>,
112
113 #[serde(default)]
115 pub orchestrator: Option<OrchestratorConfigJson>,
116
117 #[serde(default)]
122 pub builder_fee: Option<BuilderFeeConfig>,
123
124 #[serde(default)]
126 pub sync: Option<SyncConfigJson>,
127
128 #[serde(default)]
131 pub simulation: Option<SimulationConfig>,
132
133 #[serde(flatten)]
140 pub extra: HashMap<String, serde_json::Value>,
141}
142
143impl BotConfig {
144 pub fn primary_market(&self) -> &Market {
146 &self.markets[0]
147 }
148
149 pub fn is_spot(&self) -> bool {
151 self.primary_market().is_spot()
152 }
153
154 pub fn is_spot_like(&self) -> bool {
156 self.primary_market().is_spot_like()
157 }
158
159 pub fn primary_market_uses_margin(&self) -> bool {
161 self.primary_market().uses_margin()
162 }
163
164 pub fn is_outcome(&self) -> bool {
166 self.primary_market().is_outcome()
167 }
168
169 pub fn instrument_id(&self) -> InstrumentId {
171 self.primary_market().instrument_id()
172 }
173
174 pub fn market_index(&self) -> bot_core::MarketIndex {
176 self.primary_market().market_index()
177 }
178
179 pub fn hip3_config(&self) -> Option<bot_core::market::Hip3MarketConfig> {
181 self.primary_market().hip3_config()
182 }
183
184 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 #[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 #[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 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 #[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 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 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 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 Some((lev, Decimal::new(50, 0)))
314 } else {
315 None
316 }
317 }
318
319 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 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
396pub struct MMConfigJson {
397 pub base_order_size: String,
399 pub base_spread: String,
401 pub max_position_size: String,
403 #[serde(default = "default_skew_mode")]
405 pub skew_mode: String,
406 #[serde(default = "default_price_skew_gamma")]
408 pub price_skew_gamma: String,
409 #[serde(default = "default_size_skew_floor")]
411 pub size_skew_floor: String,
412 #[serde(default = "default_min_price_change_pct")]
414 pub min_price_change_pct: String,
415 #[serde(default)]
417 pub stop_loss: Option<String>,
418 #[serde(default)]
420 pub take_profit: Option<String>,
421}
422
423#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
429pub struct GridConfigJson {
430 #[serde(default = "default_grid_mode")]
432 pub mode: String,
433 #[serde(default = "default_grid_levels")]
435 pub levels: u32,
436 pub start_price: String,
438 pub end_price: String,
440 pub max_investment_quote: String,
442 #[serde(default = "default_leverage")]
444 pub leverage: String,
445 #[serde(default = "default_max_leverage")]
447 pub max_leverage: String,
448 #[serde(default)]
450 pub post_only: bool,
451 #[serde(default)]
453 pub stop_loss: Option<String>,
454 #[serde(default)]
456 pub take_profit: Option<String>,
457 #[serde(default)]
461 pub trailing_up_limit: Option<String>,
462 #[serde(default)]
466 pub trailing_down_limit: Option<String>,
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
475pub struct ArbitrageConfigJson {
476 pub order_amount: String,
478 pub perp_leverage: String,
480 pub min_opening_spread_pct: String,
482 pub min_closing_spread_pct: String,
484 #[serde(default = "default_slippage")]
486 pub spot_slippage_buffer_pct: String,
487 #[serde(default = "default_slippage")]
489 pub perp_slippage_buffer_pct: String,
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
498pub struct DCAConfigJson {
499 #[serde(default = "default_dca_direction")]
501 pub direction: String,
502 pub trigger_price: String,
504 pub base_order_size: String,
506 pub dca_order_size: String,
508 #[serde(default = "default_max_dca_orders")]
510 pub max_dca_orders: u32,
511 #[serde(default = "default_size_multiplier")]
513 pub size_multiplier: String,
514 pub price_deviation_pct: String,
516 #[serde(default = "default_deviation_multiplier")]
518 pub deviation_multiplier: String,
519 pub take_profit_pct: String,
521 #[serde(default)]
523 pub stop_loss: Option<String>,
524 #[serde(default = "default_leverage")]
526 pub leverage: String,
527 #[serde(default = "default_max_leverage")]
529 pub max_leverage: String,
530 #[serde(default)]
532 pub restart_on_complete: bool,
533 #[serde(default = "default_cooldown_period")]
535 pub cooldown_period_secs: u64,
536}
537
538#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
544pub struct OrchestratorConfigJson {
545 #[serde(default = "default_orchestrator_kind")]
547 pub kind: String,
548 #[serde(default = "default_allocation_mode")]
550 pub allocation_mode: String,
551 #[serde(default)]
553 pub take_profit_pct: Option<String>,
554 #[serde(default)]
556 pub stop_loss_pct: Option<String>,
557 #[serde(default)]
559 pub start_conditions: Vec<OrchestratorCondition>,
560 #[serde(default)]
562 pub validation_conditions: Vec<OrchestratorCondition>,
563 #[serde(default)]
565 pub risk_conditions: Vec<OrchestratorCondition>,
566 #[serde(default)]
568 pub children: Vec<OrchestratorChildConfigJson>,
569 #[serde(default)]
571 pub legs: Vec<OrchestratorLegConfigJson>,
572}
573
574#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
576#[serde(tag = "kind", rename_all = "snake_case")]
577pub enum OrchestratorChildConfigJson {
578 Grid {
580 #[serde(default)]
582 id: Option<String>,
583 market: Market,
585 grid: GridConfigJson,
587 },
588 Dca {
590 #[serde(default)]
592 id: Option<String>,
593 market: Market,
595 dca: DCAConfigJson,
597 },
598}
599
600#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
602pub struct OrchestratorLegConfigJson {
603 pub side: u8,
605 pub start_price: String,
607 pub end_price: String,
609 #[serde(default)]
611 pub trailing_up_limit: Option<String>,
612 #[serde(default)]
614 pub trailing_down_limit: Option<String>,
615}
616
617#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
623pub struct BuilderFeeConfig {
624 pub address: String,
626 pub fee_tenths_bp: u32,
628}
629
630#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
632pub struct SyncConfigJson {
633 pub bot_id: String,
635 pub upstream_url: String,
637 #[serde(default = "default_sync_interval_ms")]
639 pub sync_interval_ms: u64,
640 #[serde(default = "default_sync_timeout")]
642 pub timeout_secs: u64,
643 #[serde(default)]
645 pub sync_secret: Option<String>,
646 #[serde(default = "default_sync_enabled")]
648 pub enabled: bool,
649}
650
651#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
659pub struct SimulationConfig {
660 #[serde(default = "default_starting_balance")]
662 pub starting_balance_usdc: String,
663 #[serde(default = "default_fee_rate")]
665 pub fee_rate: String,
666}
667
668fn 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 }
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() }
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
757fn 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
1014pub 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 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 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 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), 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_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 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 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 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 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 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 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), min_position_pct: Decimal::new(1, 1), max_position_pct: Decimal::new(9, 1), 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 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 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
1398pub fn build_instrument_meta(config: &BotConfig) -> InstrumentMeta {
1400 let primary_market = config.primary_market();
1401 let quote_currency = primary_market.quote();
1402
1403 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
1423pub 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 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}