From 35e4759983da913f2503d19e1af28ed43b0e7538 Mon Sep 17 00:00:00 2001 From: finnfujimura Date: Tue, 28 Apr 2026 16:22:43 -0700 Subject: [PATCH 01/24] Bump bot deps: rand for maker, transaction-parser for taker --- Cargo.lock | 2 ++ services/maker-bot/Cargo.toml | 1 + services/taker-bot/Cargo.toml | 1 + 3 files changed, 4 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 031fd48ac..dcb7ed236 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1608,6 +1608,7 @@ dependencies = [ "dropset-services-shared", "itertools 0.14.0", "price", + "rand 0.10.0", "reqwest 0.13.2", "rust_decimal", "serde", @@ -1675,6 +1676,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "transaction-parser", ] [[package]] diff --git a/services/maker-bot/Cargo.toml b/services/maker-bot/Cargo.toml index 22a96af73..ba904dc1d 100644 --- a/services/maker-bot/Cargo.toml +++ b/services/maker-bot/Cargo.toml @@ -12,6 +12,7 @@ dropset-interface = { path = "../../interface" } dropset-services-shared = { path = "../shared" } itertools.workspace = true price = { path = "../../price", features = ["client" ] } +rand.workspace = true reqwest.workspace = true rust_decimal = { workspace = true, features = ["macros", "serde-with-arbitrary-precision"] } solana-address.workspace = true diff --git a/services/taker-bot/Cargo.toml b/services/taker-bot/Cargo.toml index 1991eb751..e07066cae 100644 --- a/services/taker-bot/Cargo.toml +++ b/services/taker-bot/Cargo.toml @@ -22,6 +22,7 @@ rand.workspace = true tokio = { workspace = true, features = ["time"] } tracing.workspace = true tracing-subscriber = { workspace = true, features = ["env-filter"] } +transaction-parser = { path = "../../transaction-parser" } [dev-dependencies] solana-account.workspace = true From f5fe8309a352cf5ad675306193968dd255b6c2a2 Mon Sep 17 00:00:00 2001 From: finnfujimura Date: Tue, 28 Apr 2026 16:22:54 -0700 Subject: [PATCH 02/24] taker: add MarketSnapshot/ExecutionProfile and stateful parent-order execution --- services/taker-bot/src/taker.rs | 556 +++++++++++++++++++++++++++++--- 1 file changed, 504 insertions(+), 52 deletions(-) diff --git a/services/taker-bot/src/taker.rs b/services/taker-bot/src/taker.rs index bd960f8f4..917b67c1a 100644 --- a/services/taker-bot/src/taker.rs +++ b/services/taker-bot/src/taker.rs @@ -1,15 +1,9 @@ use std::time::Duration; use anyhow::Context; -use rand::{ - prelude::*, - random, -}; -use rand_distr::{ - Distribution, - LogNormal, - Poisson, -}; +use rand::{prelude::*, random}; +use rand_distr::{Distribution, LogNormal, Poisson}; +use rust_decimal::Decimal; use tokio::time::Interval; #[derive(Debug, Copy, Clone, PartialEq)] @@ -28,6 +22,8 @@ impl Side { pub struct TakerFill { pub side: Side, pub size: u64, + pub planned_levels: usize, + pub parent_remaining: u64, } /// Controls the activity profile for a taker. This indicates how frequently a taker places orders @@ -81,6 +77,224 @@ impl ActivityProfile { } } +#[derive(Debug, Clone)] +pub struct BookLevel { + pub price: Decimal, + pub base_remaining: u64, + pub quote_remaining: u64, +} + +#[derive(Debug, Clone, Default)] +pub struct BookSideSnapshot { + pub levels: Vec, + pub total_base_depth: u64, +} + +impl BookSideSnapshot { + pub fn best_base_depth(&self) -> u64 { + self.levels.first().map_or(0, |level| level.base_remaining) + } + + pub fn visible_base_depth(&self, levels: usize) -> u64 { + self.levels + .iter() + .take(levels) + .map(|level| level.base_remaining) + .sum() + } +} + +#[derive(Debug, Clone, Default)] +pub struct MarketSnapshot { + pub bids: BookSideSnapshot, + pub asks: BookSideSnapshot, + pub spread_bps: Option, + pub mid_price: Option, + pub microprice: Option, + pub imbalance: f64, +} + +impl MarketSnapshot { + pub fn synthetic(best_level_depth: u64) -> Self { + let make_level = |price: Decimal, multiplier: u64| BookLevel { + price, + base_remaining: best_level_depth.saturating_mul(multiplier), + quote_remaining: best_level_depth.saturating_mul(multiplier), + }; + + Self { + bids: BookSideSnapshot { + levels: vec![ + make_level(Decimal::new(9990, 4), 1), + make_level(Decimal::new(9980, 4), 2), + make_level(Decimal::new(9970, 4), 3), + ], + total_base_depth: best_level_depth.saturating_mul(6), + }, + asks: BookSideSnapshot { + levels: vec![ + make_level(Decimal::new(10010, 4), 1), + make_level(Decimal::new(10020, 4), 2), + make_level(Decimal::new(10030, 4), 3), + ], + total_base_depth: best_level_depth.saturating_mul(6), + }, + spread_bps: Some(20.0), + mid_price: Some(Decimal::ONE), + microprice: Some(Decimal::ONE), + imbalance: 0.0, + } + } + + fn opposing_side(&self, side: Side) -> &BookSideSnapshot { + match side { + Side::Buy => &self.asks, + Side::Sell => &self.bids, + } + } +} + +#[derive(Debug, Copy, Clone)] +pub struct ExecutionProfile { + pub parent_multiplier_min: f64, + pub parent_multiplier_max: f64, + pub child_depth_fraction_min: f64, + pub child_depth_fraction_max: f64, + pub max_sweep_levels: usize, + pub max_spread_bps: f64, + pub cooldown_ticks: u8, + pub parent_slice_count_min: u8, + pub parent_slice_count_max: u8, + pub imbalance_bias: f64, + pub patience_ticks: u8, +} + +impl ExecutionProfile { + pub fn patient() -> Self { + Self { + parent_multiplier_min: 1.5, + parent_multiplier_max: 4.0, + child_depth_fraction_min: 0.04, + child_depth_fraction_max: 0.12, + max_sweep_levels: 2, + max_spread_bps: 8.0, + cooldown_ticks: 2, + parent_slice_count_min: 2, + parent_slice_count_max: 6, + imbalance_bias: 0.04, + patience_ticks: 3, + } + } + + pub fn balanced() -> Self { + Self { + parent_multiplier_min: 2.0, + parent_multiplier_max: 6.0, + child_depth_fraction_min: 0.08, + child_depth_fraction_max: 0.22, + max_sweep_levels: 3, + max_spread_bps: 15.0, + cooldown_ticks: 1, + parent_slice_count_min: 2, + parent_slice_count_max: 5, + imbalance_bias: 0.08, + patience_ticks: 2, + } + } + + pub fn aggressive() -> Self { + Self { + parent_multiplier_min: 4.0, + parent_multiplier_max: 12.0, + child_depth_fraction_min: 0.18, + child_depth_fraction_max: 0.85, + max_sweep_levels: 5, + max_spread_bps: 30.0, + cooldown_ticks: 0, + parent_slice_count_min: 1, + parent_slice_count_max: 4, + imbalance_bias: 0.12, + patience_ticks: 1, + } + } + + pub fn noise() -> Self { + Self { + parent_multiplier_min: 1.0, + parent_multiplier_max: 2.5, + child_depth_fraction_min: 0.03, + child_depth_fraction_max: 0.10, + max_sweep_levels: 1, + max_spread_bps: 10.0, + cooldown_ticks: 0, + parent_slice_count_min: 1, + parent_slice_count_max: 3, + imbalance_bias: 0.03, + patience_ticks: 1, + } + } + + /// Opportunistic single-shot taker: only acts when the spread is tight, + /// follows imbalance strongly, and pauses between hits. + pub fn sniper() -> Self { + Self { + parent_multiplier_min: 1.0, + parent_multiplier_max: 2.0, + child_depth_fraction_min: 0.05, + child_depth_fraction_max: 0.20, + max_sweep_levels: 1, + max_spread_bps: 4.0, + cooldown_ticks: 3, + parent_slice_count_min: 1, + parent_slice_count_max: 2, + imbalance_bias: 0.20, + patience_ticks: 0, + } + } +} + +#[derive(Debug, Default, Copy, Clone)] +pub struct TakerStepStats { + pub attempted_children: u64, + pub submitted_children: u64, + pub parent_orders_started: u64, + pub skipped_empty_book: u64, + pub skipped_wide_spread: u64, + pub skipped_cooldown: u64, +} + +impl TakerStepStats { + pub fn accumulate(&mut self, other: Self) { + self.attempted_children += other.attempted_children; + self.submitted_children += other.submitted_children; + self.parent_orders_started += other.parent_orders_started; + self.skipped_empty_book += other.skipped_empty_book; + self.skipped_wide_spread += other.skipped_wide_spread; + self.skipped_cooldown += other.skipped_cooldown; + } +} + +#[derive(Debug, Default)] +pub struct TakerStep { + pub fills: Vec, + pub stats: TakerStepStats, +} + +#[derive(Debug, Default, Copy, Clone)] +pub(crate) struct PlannedTick { + pub attempts: u64, + pub stats: TakerStepStats, +} + +#[derive(Debug, Clone, Copy)] +struct ParentOrder { + side: Side, + remaining_base: u64, + children_remaining: u8, + urgency: f64, + patience_ticks_remaining: u8, +} + pub struct TakerStrategy { /// Controls burst/quiet switching, arrival rates, and tick interval. activity_profile: ActivityProfile, @@ -93,8 +307,11 @@ pub struct TakerStrategy { /// `sigma` for the LogNormal order size distribution. Computed as `ln(spread_multiplier)`. /// A `spread_multiplier` of 2 means sizes range roughly from `median/2` to `median*2`. size_sigma: f64, + execution_profile: ExecutionProfile, /// Whether the taker is currently in an active burst period. in_burst: bool, + parent_order: Option, + cooldown_ticks_remaining: u8, /// Random number generator, seeded from config or randomly at startup. rng: StdRng, } @@ -108,6 +325,7 @@ impl TakerStrategy { median_order_size: u64, spread_multiplier: f64, buy_bias: f64, + execution_profile: ExecutionProfile, seed: Option, ) -> anyhow::Result { if median_order_size == 0 { @@ -137,6 +355,39 @@ impl TakerStrategy { anyhow::bail!("burst_exit_prob must be between 0.0 and 1.0"); } + if execution_profile.parent_multiplier_min <= 0.0 + || execution_profile.parent_multiplier_max < execution_profile.parent_multiplier_min + { + anyhow::bail!("Execution profile parent multipliers must be positive and ordered"); + } + + if execution_profile.child_depth_fraction_min <= 0.0 + || execution_profile.child_depth_fraction_max + < execution_profile.child_depth_fraction_min + { + anyhow::bail!("Execution profile child depth fractions must be positive and ordered"); + } + + if execution_profile.max_sweep_levels == 0 { + anyhow::bail!("Execution profile max_sweep_levels must be greater than zero"); + } + + if execution_profile.parent_slice_count_min == 0 + || execution_profile.parent_slice_count_max < execution_profile.parent_slice_count_min + { + anyhow::bail!("Execution profile parent slice counts must be positive and ordered"); + } + + if !execution_profile.imbalance_bias.is_finite() + || execution_profile.imbalance_bias.abs() > 1.0 + { + anyhow::bail!("Execution profile imbalance_bias must be finite and within [-1.0, 1.0]"); + } + + if execution_profile.patience_ticks > 32 { + anyhow::bail!("Execution profile patience_ticks must be <= 32"); + } + Poisson::new(activity_profile.lambda_burst).with_context(|| { let msg = format!( "Invalid `lambda_burst` when calculating Poisson::new({})", @@ -159,7 +410,10 @@ impl TakerStrategy { bias_reversion: 0.05, size_mu, size_sigma, + execution_profile, in_burst: false, + parent_order: None, + cooldown_ticks_remaining: 0, rng: StdRng::seed_from_u64(seed.unwrap_or(random::())), }) } @@ -168,10 +422,32 @@ impl TakerStrategy { self.activity_profile.interval.tick().await; } - /// A single moment of market activity between idle intervals. - /// Called repeatedly by the taker's task loop every `interval_ms`. - /// Returns zero or more fills depending on burst state and Poisson draw. - pub fn step(&mut self) -> Vec { + /// A single moment of market activity between idle intervals. Takers now + /// maintain a parent order and adapt child-order sizing to visible book depth. + pub fn step(&mut self, snapshot: &MarketSnapshot) -> TakerStep { + self.step_with_snapshot_provider(|| snapshot.clone()) + } + + fn step_with_snapshot_provider( + &mut self, + mut snapshot: impl FnMut() -> MarketSnapshot, + ) -> TakerStep { + let planned = self.begin_tick(); + let mut step = TakerStep { + fills: Vec::with_capacity(planned.attempts as usize), + stats: planned.stats, + }; + + for _ in 0..planned.attempts { + let attempt = self.execute_attempt(&snapshot()); + step.stats.accumulate(attempt.stats); + step.fills.extend(attempt.fills); + } + + step + } + + pub(crate) fn begin_tick(&mut self) -> PlannedTick { let bp = &self.activity_profile; // Burst state machine @@ -196,9 +472,28 @@ impl TakerStrategy { .sample(&mut self.rng) as u64; if n_orders == 0 { - return vec![]; + return PlannedTick::default(); + } + + if self.cooldown_ticks_remaining > 0 { + self.cooldown_ticks_remaining -= 1; + return PlannedTick { + attempts: 0, + stats: TakerStepStats { + skipped_cooldown: n_orders, + ..TakerStepStats::default() + }, + }; } + PlannedTick { + attempts: n_orders, + stats: TakerStepStats::default(), + } + } + + pub(crate) fn execute_attempt(&mut self, snapshot: &MarketSnapshot) -> TakerStep { + let mut step = TakerStep::default(); let size_dist = LogNormal::new(self.size_mu, self.size_sigma).unwrap_or_else(|_| { panic!( "LogNormal::new({}, {}) was checked in the constructor", @@ -206,35 +501,164 @@ impl TakerStrategy { ) }); - (0..n_orders) - .map(|_| { - let side = if self.rng.random_bool(self.buy_bias) { - Side::Buy - } else { - Side::Sell - }; + step.stats.attempted_children += 1; + + if self.parent_order.is_none() { + match self.start_parent_order(snapshot, &size_dist) { + Some(parent) => { + self.parent_order = Some(parent); + step.stats.parent_orders_started += 1; + } + None => { + step.stats.skipped_empty_book += 1; + return step; + } + } + } - // Convex combination toward 0.5 — keeps buy_bias in [0.0, 1.0]. - self.buy_bias += self.bias_reversion * (0.5 - self.buy_bias); + let mut parent = self + .parent_order + .expect("parent_order is set above when start_parent_order returns Some"); + let opposing_side = snapshot.opposing_side(parent.side); + if opposing_side.levels.is_empty() { + self.parent_order = None; + step.stats.skipped_empty_book += 1; + return step; + } - let size = size_dist.sample(&mut self.rng).max(1.0) as u64; + let spread_bps = snapshot.spread_bps.unwrap_or_default(); + let too_wide = spread_bps > self.execution_profile.max_spread_bps && parent.urgency < 0.85; + if too_wide { + if parent.patience_ticks_remaining > 0 { + parent.patience_ticks_remaining -= 1; + } else { + parent.urgency = (parent.urgency + 0.10).min(1.0); + } + self.parent_order = Some(parent); + step.stats.skipped_wide_spread += 1; + return step; + } - TakerFill { side, size } - }) - .collect() + let visible_depth = opposing_side + .visible_base_depth(self.execution_profile.max_sweep_levels) + .max(opposing_side.best_base_depth()); + let child_sample = size_dist.sample(&mut self.rng).max(1.0) as u64; + let depth_fraction = self.rng.random_range( + self.execution_profile.child_depth_fraction_min + ..=self.execution_profile.child_depth_fraction_max, + ); + let urgency_multiplier = 0.5 + parent.urgency; + let depth_target = + ((visible_depth as f64) * depth_fraction * urgency_multiplier).round() as u64; + let min_child_size = ((opposing_side.best_base_depth() as f64) + * self.execution_profile.child_depth_fraction_min.max(0.01)) + .round() as u64; + + let size = child_sample + .max(depth_target) + .max(min_child_size.max(1)) + .min(parent.remaining_base) + .min(visible_depth.max(1)); + + let planned_levels = planned_levels_for_size(size, &opposing_side.levels); + parent.remaining_base = parent.remaining_base.saturating_sub(size); + parent.children_remaining = parent.children_remaining.saturating_sub(1); + let fill = TakerFill { + side: parent.side, + size, + planned_levels, + parent_remaining: parent.remaining_base, + }; + + let parent_done = parent.remaining_base == 0 || parent.children_remaining == 0; + self.parent_order = (!parent_done).then_some(parent); + if parent_done { + self.cooldown_ticks_remaining = self.execution_profile.cooldown_ticks; + } + + step.stats.submitted_children += 1; + step.fills.push(fill); + step + } + + fn start_parent_order( + &mut self, + snapshot: &MarketSnapshot, + size_dist: &LogNormal, + ) -> Option { + let imbalance_adjustment = snapshot.imbalance * self.execution_profile.imbalance_bias; + let buy_probability = (self.buy_bias + imbalance_adjustment).clamp(0.05, 0.95); + let side = if self.rng.random_bool(buy_probability) { + Side::Buy + } else { + Side::Sell + }; + + self.buy_bias += self.bias_reversion * (0.5 - self.buy_bias); + + let opposing_side = snapshot.opposing_side(side); + if opposing_side.levels.is_empty() { + return None; + } + + let child_base = size_dist.sample(&mut self.rng).max(1.0) as u64; + let parent_multiplier = self.rng.random_range( + self.execution_profile.parent_multiplier_min + ..=self.execution_profile.parent_multiplier_max, + ); + let visible_depth = opposing_side + .visible_base_depth(self.execution_profile.max_sweep_levels) + .max(opposing_side.best_base_depth()); + let capped_visible_depth = + ((visible_depth as f64) * (1.0 + self.rng.random_range(0.1..=0.6))).round() as u64; + let remaining_base = ((child_base as f64) * parent_multiplier).round() as u64; + let remaining_base = remaining_base + .max(child_base) + .min(capped_visible_depth.max(1)); + let children_remaining = self.rng.random_range( + self.execution_profile.parent_slice_count_min + ..=self.execution_profile.parent_slice_count_max, + ); + + Some(ParentOrder { + side, + remaining_base, + children_remaining, + urgency: self.rng.random_range(0.25..=1.0), + patience_ticks_remaining: self.execution_profile.patience_ticks, + }) } /// Creates the interval loop based on the taker's activity profile and taker strategy, calling /// `on_fill` every [ActivityProfile::interval] milliseconds. - pub async fn interval_loop(mut self, mut on_fill: impl FnMut(TakerFill)) { + pub async fn interval_loop( + mut self, + mut snapshot: impl FnMut() -> MarketSnapshot, + mut on_fill: impl FnMut(TakerFill), + ) { loop { self.activity_profile.interval.tick().await; - for fill in self.step() { + for fill in self.step_with_snapshot_provider(&mut snapshot).fills { on_fill(fill); } } } } + +fn planned_levels_for_size(size: u64, levels: &[BookLevel]) -> usize { + let mut remaining = size; + let mut touched = 0; + + for level in levels { + if remaining == 0 { + break; + } + touched += 1; + remaining = remaining.saturating_sub(level.base_remaining); + } + + touched +} #[cfg(test)] mod tests { use std::sync::atomic; @@ -242,31 +666,22 @@ mod tests { use client::{ e2e_helpers::test_accounts, mollusk_helpers::{ - market_checker::MarketChecker, - new_dropset_mollusk_context_with_default_market, + market_checker::MarketChecker, new_dropset_mollusk_context_with_default_market, utils::create_mock_user_account, }, }; use dropset_interface::{ instructions::{ - BatchReplaceInstructionData, - MarketOrderInstructionData, - UnvalidatedOrders, + BatchReplaceInstructionData, MarketOrderInstructionData, UnvalidatedOrders, }, state::{ - sector::{ - MAX_PERMITTED_SECTOR_INCREASE, - NIL, - }, + sector::{MAX_PERMITTED_SECTOR_INCREASE, NIL}, user_order_sectors::MAX_ORDERS_USIZE, }, }; use price::client_helpers::to_order_info_args; use rust_decimal::{ - prelude::{ - FromPrimitive, - ToPrimitive, - }, + prelude::{FromPrimitive, ToPrimitive}, Decimal, }; use solana_account::Account; @@ -283,6 +698,7 @@ mod tests { median_order_size, 2.0, 0.5, + ExecutionProfile::balanced(), Some(seed), ) .expect("Should be valid inputs") @@ -296,6 +712,7 @@ mod tests { median_order_size, 5.0, 0.6, + ExecutionProfile::aggressive(), Some(seed), ) .expect("Should be valid inputs") @@ -309,6 +726,7 @@ mod tests { median_order_size, 1.5, 0.5, + ExecutionProfile::patient(), Some(seed), ) .expect("Should be valid inputs") @@ -341,20 +759,20 @@ mod tests { /// Step each taker `n_steps` times, collecting all fills. pub fn run(&mut self) -> Vec { + let snapshot = MarketSnapshot::synthetic(500_000); let all_fills: Vec = (0..self.n_steps) .flat_map(|_| { self.taker_addresses .iter() .zip(&mut self.taker_strategies) .flat_map(|(address, strategy)| { - strategy - .step() - .into_iter() - .map(|fill| TakerFillWithAddress { + strategy.step(&snapshot).fills.into_iter().map(|fill| { + TakerFillWithAddress { address: *address, side: fill.side, size: fill.size, - }) + } + }) }) .collect::>() }) @@ -415,6 +833,40 @@ mod tests { println!("Total fills: {}", fills.len()); } + #[tokio::test] + async fn step_refreshes_liquidity_between_child_attempts() { + let mut strategy = whale(50_000, 7); + let deep = MarketSnapshot::synthetic(500_000); + let shallow = MarketSnapshot::synthetic(25); + + for _ in 0..256 { + let mut snapshot_calls = 0; + let step = strategy.step_with_snapshot_provider(|| { + snapshot_calls += 1; + if snapshot_calls == 1 { + deep.clone() + } else { + shallow.clone() + } + }); + + if step.fills.len() < 2 { + continue; + } + + let second_fill = &step.fills[1]; + let second_visible_depth = shallow + .opposing_side(second_fill.side) + .visible_base_depth(strategy.execution_profile.max_sweep_levels) + .max(1); + + assert!(second_fill.size <= second_visible_depth); + return; + } + + panic!("expected a multi-fill step to validate refreshed liquidity"); + } + #[tokio::test(start_paused = true)] async fn poisson_takers_dropset_market() -> anyhow::Result<()> { // This isn't a literal duration; with the `tokio` flag `start_paused = true`, each @@ -547,7 +999,7 @@ mod tests { let increment = || num_takes.fetch_add(1, atomic::Ordering::Relaxed); tokio::select! { - _ = taker_1.strategy.interval_loop(|TakerFill { side, size }| { + _ = taker_1.strategy.interval_loop(|| MarketSnapshot::synthetic(100_000_000), |TakerFill { side, size, .. }| { let res = mollusk.process_instruction(&market_ctx.market_order( taker_1.address, MarketOrderInstructionData::new(size, side.is_buy(), true), @@ -555,7 +1007,7 @@ mod tests { increment(); assert!(res.program_result.is_ok()); }) => {}, - _ = taker_2.strategy.interval_loop(|TakerFill { side, size }| { + _ = taker_2.strategy.interval_loop(|| MarketSnapshot::synthetic(100_000_000), |TakerFill { side, size, .. }| { let res = mollusk.process_instruction(&market_ctx.market_order( taker_2.address, MarketOrderInstructionData::new(size, side.is_buy(), true), @@ -563,7 +1015,7 @@ mod tests { increment(); assert!(res.program_result.is_ok()); }) => {}, - _ = taker_3.strategy.interval_loop(|TakerFill { side, size }| { + _ = taker_3.strategy.interval_loop(|| MarketSnapshot::synthetic(100_000_000), |TakerFill { side, size, .. }| { let res = mollusk.process_instruction(&market_ctx.market_order( taker_3.address, MarketOrderInstructionData::new(size, side.is_buy(), true), @@ -571,7 +1023,7 @@ mod tests { increment(); assert!(res.program_result.is_ok()); }) => {}, - _ = taker_4.strategy.interval_loop(|TakerFill { side, size }| { + _ = taker_4.strategy.interval_loop(|| MarketSnapshot::synthetic(100_000_000), |TakerFill { side, size, .. }| { let res = mollusk.process_instruction(&market_ctx.market_order( taker_4.address, MarketOrderInstructionData::new(size, side.is_buy(), true), @@ -579,7 +1031,7 @@ mod tests { increment(); assert!(res.program_result.is_ok()); }) => {}, - _ = taker_5.strategy.interval_loop(|TakerFill { side, size }| { + _ = taker_5.strategy.interval_loop(|| MarketSnapshot::synthetic(100_000_000), |TakerFill { side, size, .. }| { let res = mollusk.process_instruction(&market_ctx.market_order( taker_5.address, MarketOrderInstructionData::new(size, side.is_buy(), true), From 45b1f19aa3ddea7b131449213a4718ddc96bd0df Mon Sep 17 00:00:00 2001 From: finnfujimura Date: Tue, 28 Apr 2026 16:23:03 -0700 Subject: [PATCH 03/24] taker: rewire archetype defaults to ExecutionProfile presets --- services/taker-bot/src/archetype.rs | 84 +++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 services/taker-bot/src/archetype.rs diff --git a/services/taker-bot/src/archetype.rs b/services/taker-bot/src/archetype.rs new file mode 100644 index 000000000..f40d778e6 --- /dev/null +++ b/services/taker-bot/src/archetype.rs @@ -0,0 +1,84 @@ +use std::time::Duration; + +use serde::Deserialize; + +use crate::taker::{ActivityProfile, ExecutionProfile}; + +/// Named presets that bundle sensible defaults for [`ActivityProfile`] + +/// [`crate::taker::TakerStrategy`] parameters. Each `[[agent]]` in the taker +/// config selects one of these, then optionally overrides individual fields. +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Archetype { + Passive, + Retail, + Aggressive, + Whale, + Sniper, + Noise, +} + +pub struct ArchetypeDefaults { + pub profile: ActivityProfile, + pub median_order_size: u64, + pub spread_multiplier: f64, + pub buy_bias: f64, + pub execution_profile: ExecutionProfile, +} + +impl Archetype { + pub fn defaults(self) -> ArchetypeDefaults { + match self { + Self::Passive => ArchetypeDefaults { + profile: ActivityProfile::passive(), + median_order_size: 2_000, + spread_multiplier: 1.5, + buy_bias: 0.5, + execution_profile: ExecutionProfile::patient(), + }, + Self::Retail => ArchetypeDefaults { + profile: ActivityProfile::retail(), + median_order_size: 3_000, + spread_multiplier: 2.0, + buy_bias: 0.5, + execution_profile: ExecutionProfile::balanced(), + }, + Self::Aggressive => ArchetypeDefaults { + profile: ActivityProfile::aggressive(), + median_order_size: 5_000, + spread_multiplier: 2.5, + buy_bias: 0.5, + execution_profile: ExecutionProfile::aggressive(), + }, + Self::Whale => ArchetypeDefaults { + profile: ActivityProfile::aggressive(), + median_order_size: 15_000, + spread_multiplier: 5.0, + buy_bias: 0.5, + execution_profile: ExecutionProfile::aggressive(), + }, + Self::Sniper => ArchetypeDefaults { + profile: ActivityProfile::passive(), + median_order_size: 3_000, + spread_multiplier: 1.5, + buy_bias: 0.5, + execution_profile: ExecutionProfile::sniper(), + }, + // A steady, high-frequency noise trader — baseline CLOB chatter + // with no directional bias and tight size variance. + Self::Noise => ArchetypeDefaults { + profile: ActivityProfile { + interval: tokio::time::interval(Duration::from_millis(500)), + lambda_quiet: 1.5, + lambda_burst: 2.0, + burst_entry_prob: 0.05, + burst_exit_prob: 0.5, + }, + median_order_size: 1_000, + spread_multiplier: 1.5, + buy_bias: 0.5, + execution_profile: ExecutionProfile::noise(), + }, + } + } +} From 89cdd58507f96a1e48f1d84c9c079dee5e646828 Mon Sep 17 00:00:00 2001 From: finnfujimura Date: Tue, 28 Apr 2026 16:23:35 -0700 Subject: [PATCH 04/24] taker: plumb execution-profile knobs through agent config --- services/taker-bot/src/config.rs | 207 ++++++++++++++++++++++++------- 1 file changed, 160 insertions(+), 47 deletions(-) diff --git a/services/taker-bot/src/config.rs b/services/taker-bot/src/config.rs index 4b8c0c027..8ea9a7e50 100644 --- a/services/taker-bot/src/config.rs +++ b/services/taker-bot/src/config.rs @@ -1,15 +1,18 @@ -use std::time::Duration; +use std::{ + path::{Path, PathBuf}, + time::Duration, +}; +use anyhow::Context; use dropset_services_shared::config::{ - deserialize_service_config, - ServiceConfig, - ValidSharedConfig, + deserialize_service_config, ServiceConfig, ValidSharedConfig, }; use serde::Deserialize; +use solana_keypair::{read_keypair_file, Keypair}; -use crate::taker::{ - ActivityProfile, - TakerStrategy, +use crate::{ + archetype::{Archetype, ArchetypeDefaults}, + taker::{ActivityProfile, ExecutionProfile, TakerStrategy}, }; const SERVICE: ServiceConfig = ServiceConfig::Taker; @@ -17,80 +20,190 @@ const SERVICE: ServiceConfig = ServiceConfig::Taker; pub struct ValidTakerConfig { pub shared: ValidSharedConfig, pub verbose: bool, - pub taker_strategy: TakerStrategy, + pub agents: Vec, +} + +pub struct ValidAgent { + pub name: String, + pub keypair: Keypair, + pub strategy: TakerStrategy, } #[derive(Deserialize)] pub struct TakerConfigInput { #[serde(default)] pub verbose: bool, - pub strategy: TakerStrategyConfig, + #[serde(default, rename = "agent")] + pub agents: Vec, } -/// Flat config for an [ActivityProfile] + [TakerStrategy], suitable for TOML deserialization. +/// One `[[agent]]` entry in `taker-bot/config.toml`. Every field except +/// `name`, `archetype`, and `keypair_path` is an optional override on top of +/// the archetype's preset defaults. #[derive(Deserialize)] -pub struct TakerStrategyConfig { - /// Milliseconds between ticks of the interval loop. - pub interval_ms: u64, - /// Base arrival rate (orders/tick) during quiet periods. - pub lambda_quiet: f64, - /// Arrival rate during an active burst. - pub lambda_burst: f64, - /// Probability of entering a burst each quiet tick. - pub burst_entry_prob: f64, - /// Probability of exiting the burst each tick (controls burst duration). - pub burst_exit_prob: f64, - /// Median order size in atoms. - pub median_order_size: u64, - /// Spread multiplier: order sizes range roughly from `median / x` to `median * x`. - pub spread_multiplier: f64, - /// Probability the next order is a buy (0.0–1.0). - pub buy_bias: f64, - /// Optional RNG seed for reproducible runs. +pub struct AgentConfigInput { + pub name: String, + pub archetype: Archetype, + pub keypair_path: PathBuf, + pub interval_ms: Option, + pub lambda_quiet: Option, + pub lambda_burst: Option, + pub burst_entry_prob: Option, + pub burst_exit_prob: Option, + pub median_order_size: Option, + pub spread_multiplier: Option, + pub buy_bias: Option, + pub parent_multiplier_min: Option, + pub parent_multiplier_max: Option, + pub child_depth_fraction_min: Option, + pub child_depth_fraction_max: Option, + pub max_sweep_levels: Option, + pub max_spread_bps: Option, + pub cooldown_ticks: Option, + pub parent_slice_count_min: Option, + pub parent_slice_count_max: Option, + pub imbalance_bias: Option, + pub patience_ticks: Option, pub seed: Option, } -impl TakerStrategyConfig { - fn into_strategy(self) -> anyhow::Result { - let mut interval = tokio::time::interval(Duration::from_millis(self.interval_ms)); - // The taker doesn't need to make up for missed intervals, the idea is for it to be - // stochastic behavior. The desired behavior here is to skip missed ticks. - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); +impl AgentConfigInput { + fn into_valid(self) -> anyhow::Result { + let ArchetypeDefaults { + profile: default_profile, + median_order_size: default_size, + spread_multiplier: default_spread, + buy_bias: default_bias, + execution_profile: default_execution, + } = self.archetype.defaults(); + + let interval = match self.interval_ms { + Some(0) => anyhow::bail!("Agent `{}`: interval_ms must be > 0", self.name), + Some(ms) => { + let mut i = tokio::time::interval(Duration::from_millis(ms)); + i.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + i + } + None => { + let mut i = default_profile.interval; + i.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + i + } + }; let profile = ActivityProfile { interval, - lambda_quiet: self.lambda_quiet, - lambda_burst: self.lambda_burst, - burst_entry_prob: self.burst_entry_prob, - burst_exit_prob: self.burst_exit_prob, + lambda_quiet: self.lambda_quiet.unwrap_or(default_profile.lambda_quiet), + lambda_burst: self.lambda_burst.unwrap_or(default_profile.lambda_burst), + burst_entry_prob: self + .burst_entry_prob + .unwrap_or(default_profile.burst_entry_prob), + burst_exit_prob: self + .burst_exit_prob + .unwrap_or(default_profile.burst_exit_prob), + }; + + let execution_profile = ExecutionProfile { + parent_multiplier_min: self + .parent_multiplier_min + .unwrap_or(default_execution.parent_multiplier_min), + parent_multiplier_max: self + .parent_multiplier_max + .unwrap_or(default_execution.parent_multiplier_max), + child_depth_fraction_min: self + .child_depth_fraction_min + .unwrap_or(default_execution.child_depth_fraction_min), + child_depth_fraction_max: self + .child_depth_fraction_max + .unwrap_or(default_execution.child_depth_fraction_max), + max_sweep_levels: self + .max_sweep_levels + .unwrap_or(default_execution.max_sweep_levels), + max_spread_bps: self + .max_spread_bps + .unwrap_or(default_execution.max_spread_bps), + cooldown_ticks: self + .cooldown_ticks + .unwrap_or(default_execution.cooldown_ticks), + parent_slice_count_min: self + .parent_slice_count_min + .unwrap_or(default_execution.parent_slice_count_min), + parent_slice_count_max: self + .parent_slice_count_max + .unwrap_or(default_execution.parent_slice_count_max), + imbalance_bias: self + .imbalance_bias + .unwrap_or(default_execution.imbalance_bias), + patience_ticks: self + .patience_ticks + .unwrap_or(default_execution.patience_ticks), }; - TakerStrategy::new( + + let strategy = TakerStrategy::new( profile, - self.median_order_size, - self.spread_multiplier, - self.buy_bias, + self.median_order_size.unwrap_or(default_size), + self.spread_multiplier.unwrap_or(default_spread), + self.buy_bias.unwrap_or(default_bias), + execution_profile, self.seed, ) + .with_context(|| format!("Invalid strategy for agent `{}`", self.name))?; + + let keypair_path = resolve_keypair_path(&self.keypair_path); + let keypair = read_keypair_file(&keypair_path).map_err(|e| { + anyhow::anyhow!( + "Agent `{}`: couldn't open keypair file `{}`: {e}", + self.name, + keypair_path.display() + ) + })?; + + Ok(ValidAgent { + name: self.name, + keypair, + strategy, + }) + } +} + +/// Relative keypair paths are resolved against the taker-bot service directory +/// (so a config like `keypair_path = "keypairs/retail-1.json"` works without +/// every caller having to care about the current working directory). +fn resolve_keypair_path(path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + SERVICE.config_dir().join(path) } } pub async fn validate_config_and_endpoint( input: TakerConfigInput, ) -> anyhow::Result { - let TakerConfigInput { verbose, strategy } = input; + let TakerConfigInput { verbose, agents } = input; + + if agents.is_empty() { + anyhow::bail!("At least one `[[agent]]` entry is required in the taker-bot config"); + } - if strategy.interval_ms == 0 { - anyhow::bail!("The taker strategy's interval value must be greater than zero"); + let mut seen_names = std::collections::HashSet::new(); + for agent in &agents { + if !seen_names.insert(agent.name.as_str()) { + anyhow::bail!("Duplicate agent name `{}` in taker-bot config", agent.name); + } } let shared = ValidSharedConfig::new_validated(SERVICE).await?; - let taker_strategy = strategy.into_strategy()?; + let agents = agents + .into_iter() + .map(AgentConfigInput::into_valid) + .collect::>>()?; Ok(ValidTakerConfig { shared, verbose, - taker_strategy, + agents, }) } From 4af62ead4ce3baf39cc80b03f82a4b0eecda0ec3 Mon Sep 17 00:00:00 2001 From: finnfujimura Date: Tue, 28 Apr 2026 16:23:44 -0700 Subject: [PATCH 05/24] taker: cache snapshot per tick, log per-agent execution metrics --- services/taker-bot/src/main.rs | 242 +++++++++++++++++++++++++++------ 1 file changed, 201 insertions(+), 41 deletions(-) diff --git a/services/taker-bot/src/main.rs b/services/taker-bot/src/main.rs index 34119a476..b849bed94 100644 --- a/services/taker-bot/src/main.rs +++ b/services/taker-bot/src/main.rs @@ -1,25 +1,30 @@ +pub mod archetype; pub mod config; pub mod taker; pub mod taker_context; -use std::collections::HashSet; +use std::{ + collections::HashSet, + sync::Arc, + time::{Duration, Instant}, +}; -use client::transactions::{ - CustomRpcClient, - SendTransactionConfig, - TransactionSubmitError, +use client::{ + context::market::MarketContext, + transactions::{CustomRpcClient, SendTransactionConfig, TransactionSubmitError}, }; use dropset_interface::error::DropsetError; -use solana_client::{ - nonblocking::rpc_client::RpcClient, - rpc_config::CommitmentConfig, -}; +use dropset_services_shared::faucet_client::FaucetClient; +use solana_client::{nonblocking::rpc_client::RpcClient, rpc_config::CommitmentConfig}; use spl_token_2022_interface::error::TokenError as Token2022Error; use spl_token_interface::error::TokenError; +use tracing::Instrument; use tracing_subscriber::EnvFilter; +use transaction_parser::events::dropset_event::DropsetEvent; use crate::{ - config::get_validated_config, + config::{get_validated_config, ValidAgent}, + taker::{TakerStepStats, TakerStrategy}, taker_context::TakerContext, }; @@ -35,7 +40,7 @@ async fn main() -> anyhow::Result<()> { let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); tracing_subscriber::fmt().with_env_filter(env_filter).init(); - let rpc = CustomRpcClient::new( + let rpc = Arc::new(CustomRpcClient::new( Some(RpcClient::new_with_commitment( cfg.shared.rpc_url.clone().to_string(), CommitmentConfig::confirmed(), @@ -45,45 +50,200 @@ async fn main() -> anyhow::Result<()> { debug_logs: Some(cfg.verbose), program_id_filter: HashSet::from([dropset_interface::program::ID]), }), - ); + )); + + let faucet_client = match FaucetClient::new(&rpc, &cfg.shared).await { + Ok(c) => Some(Arc::new(c)), + Err(e) => { + tracing::warn!(error = %e, "Faucet client is unavailable"); + None + } + }; + + let market_ctx = Arc::new(MarketContext::new( + cfg.shared.base.clone(), + cfg.shared.quote.clone(), + )); + + tracing::info!(count = cfg.agents.len(), "Launching taker agents"); - let taker_ctx = TakerContext::init(rpc, cfg.shared).await?; - let mut strategy = cfg.taker_strategy; + let mut handles = Vec::with_capacity(cfg.agents.len()); + for ValidAgent { + name, + keypair, + strategy, + } in cfg.agents + { + let ctx = TakerContext::new( + rpc.clone(), + market_ctx.clone(), + faucet_client.clone(), + keypair, + ); + let span = tracing::info_span!("agent", name = %name); + tracing::info!(agent = %name, address = %ctx.address(), "Agent starting"); + let task = agent_loop(name.clone(), ctx, strategy, cfg.verbose).instrument(span); + handles.push((name, tokio::spawn(task))); + } + + // Wait for every agent task. If a task panics, log and keep the remaining + // agents running — a single misbehaving archetype shouldn't kill the + // container. The process exits once the last agent has ended. + for (name, handle) in handles { + match handle.await { + Ok(()) => tracing::warn!(agent = %name, "Agent exited cleanly"), + Err(e) if e.is_panic() => { + tracing::error!(agent = %name, error = %e, "Agent panicked"); + } + Err(e) => tracing::error!(agent = %name, error = %e, "Agent join error"), + } + } + Ok(()) +} + +/// Runs a single agent's strategy loop forever. Non-fatal errors (empty book, +/// out-of-token) are logged and the loop keeps going; a panic is caught by +/// the `tokio::spawn` `JoinHandle` in `main`. +async fn agent_loop(name: String, ctx: TakerContext, mut strategy: TakerStrategy, verbose: bool) { + let mut metrics = AgentMetrics::new(); loop { strategy.tick().await; - for fill in strategy.step() { - match taker_ctx.submit_fill(&fill).await { - Ok(_) => {} - - // Note that this is a match on the token program error, not a dropset error, - // because takers spend with their token balances, not their seat balances. - Err(TransactionSubmitError::Token(error)) => match error { - TokenError::InsufficientFunds => { - taker_ctx.submit_faucet_request(fill.side).await?; + let planned = strategy.begin_tick(); + let mut step_stats = planned.stats; + // Reuse one snapshot across child attempts within a tick; only refetch + // after a successful fill submission consumes depth. + let mut snapshot: Option = None; + + for _ in 0..planned.attempts { + if snapshot.is_none() { + match ctx.fetch_market_snapshot().await { + Ok(s) => snapshot = Some(s), + Err(e) => { + tracing::error!(agent = %name, error = %e, "Failed to fetch market snapshot"); + break; + } + } + } + let snap = snapshot.as_ref().expect("snapshot is set above"); + + let attempt = strategy.execute_attempt(snap); + step_stats.accumulate(attempt.stats); + + let mut consumed_depth = false; + for fill in attempt.fills { + match ctx.submit_fill(&fill).await { + Ok(result) => { + metrics.observe_execution(&result); + consumed_depth = true; } - _ => return Err(error.into()), - }, - // Note that this is a match on the token program error, not a dropset error, - // because takers spend with their token balances, not their seat balances. - Err(TransactionSubmitError::Token2022(error)) => match error { - Token2022Error::InsufficientFunds => { - taker_ctx.submit_faucet_request(fill.side).await?; + + // The taker's ATA is out of the token being spent; ask the + // faucet to top it up so the next tick can trade again. + Err(TransactionSubmitError::Token(TokenError::InsufficientFunds)) + | Err(TransactionSubmitError::Token2022(Token2022Error::InsufficientFunds)) => { + metrics.faucet_refills += 1; + if let Err(e) = ctx.submit_faucet_request(fill.side).await { + tracing::error!(agent = %name, error = ?e, "Faucet request failed"); + } } - _ => return Err(error.into()), - }, - Err(TransactionSubmitError::Dropset(err)) => match err { - // Book is dry — most likely there is no liquidity to fill against, skip. - DropsetError::AmountCannotBeZero => { - let log_message = "Fill returned zero amount, book likely empty — skipping"; - if cfg.verbose { - tracing::error!("{log_message}"); + + // Book is dry — no liquidity to fill against on this side. + Err(TransactionSubmitError::Dropset(DropsetError::AmountCannotBeZero)) => { + metrics.skipped_empty_book += 1; + if verbose { + tracing::error!( + agent = %name, + "Fill returned zero amount, book likely empty — skipping" + ); } } - _ => return Err(TransactionSubmitError::Dropset(err).into()), - }, - Err(e) => return Err(e.into()), + + Err(e) => { + tracing::error!(agent = %name, error = ?e, "Order submission error"); + } + } + } + if consumed_depth { + snapshot = None; } } + + metrics.observe_step(step_stats); + metrics.maybe_log(&name); + } +} + +struct AgentMetrics { + ticks: u64, + submitted_children: u64, + parent_orders_started: u64, + skipped_empty_book: u64, + skipped_wide_spread: u64, + skipped_cooldown: u64, + swept_levels: u64, + base_filled: u64, + quote_filled: u64, + faucet_refills: u64, + last_report: Instant, +} + +impl AgentMetrics { + fn new() -> Self { + Self { + ticks: 0, + submitted_children: 0, + parent_orders_started: 0, + skipped_empty_book: 0, + skipped_wide_spread: 0, + skipped_cooldown: 0, + swept_levels: 0, + base_filled: 0, + quote_filled: 0, + faucet_refills: 0, + last_report: Instant::now(), + } + } + + fn observe_step(&mut self, stats: TakerStepStats) { + self.ticks += 1; + self.submitted_children += stats.submitted_children; + self.parent_orders_started += stats.parent_orders_started; + self.skipped_empty_book += stats.skipped_empty_book; + self.skipped_wide_spread += stats.skipped_wide_spread; + self.skipped_cooldown += stats.skipped_cooldown; + } + + fn observe_execution(&mut self, result: &client::transactions::ParsedTransactionWithEvents) { + for event in &result.events { + if let DropsetEvent::Fill(fill) = event { + self.swept_levels += 1; + self.base_filled += fill.base_filled; + self.quote_filled += fill.quote_filled; + } + } + } + + fn maybe_log(&mut self, name: &str) { + if self.last_report.elapsed() < Duration::from_secs(15) { + return; + } + + tracing::info!( + agent = %name, + ticks = self.ticks, + parents = self.parent_orders_started, + submitted = self.submitted_children, + swept_levels = self.swept_levels, + base_filled = self.base_filled, + quote_filled = self.quote_filled, + skipped_empty_book = self.skipped_empty_book, + skipped_wide_spread = self.skipped_wide_spread, + skipped_cooldown = self.skipped_cooldown, + faucet_refills = self.faucet_refills, + "Agent execution stats" + ); + + self.last_report = Instant::now(); } } From 61b957309dff96aa99ad124ab1e79ca2104eb680 Mon Sep 17 00:00:00 2001 From: finnfujimura Date: Tue, 28 Apr 2026 16:24:06 -0700 Subject: [PATCH 06/24] maker: switch external price anchor to S5/12-candle window --- services/maker-bot/src/main.rs | 38 ++++++++-------------------------- 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/services/maker-bot/src/main.rs b/services/maker-bot/src/main.rs index d3f89b6bb..d8f62644a 100644 --- a/services/maker-bot/src/main.rs +++ b/services/maker-bot/src/main.rs @@ -11,38 +11,21 @@ pub mod order_flow; pub mod tasks; pub mod utils; -use std::{ - cell::RefCell, - collections::HashSet, - fmt, - rc::Rc, -}; - -use client::transactions::{ - CustomRpcClient, - SendTransactionConfig, -}; -use dropset_services_shared::{ - faucet_client::FaucetClient, - oanda_types::CandlestickGranularity, -}; +use std::{cell::RefCell, collections::HashSet, fmt, rc::Rc}; + +use client::transactions::{CustomRpcClient, SendTransactionConfig}; +use dropset_services_shared::{faucet_client::FaucetClient, oanda_types::CandlestickGranularity}; use maker_state::*; use order_flow::*; use rust_decimal::Decimal; -use solana_client::{ - nonblocking::rpc_client::RpcClient, - rpc_config::CommitmentConfig, -}; +use solana_client::{nonblocking::rpc_client::RpcClient, rpc_config::CommitmentConfig}; use tokio::sync::watch; use tracing_subscriber::EnvFilter; -use crate::{ - config::get_validated_config, - maker_context::MakerContext, -}; +use crate::{config::get_validated_config, maker_context::MakerContext}; -pub const GRANULARITY: CandlestickGranularity = CandlestickGranularity::M15; -pub const NUM_CANDLES: u64 = 1; +pub const GRANULARITY: CandlestickGranularity = CandlestickGranularity::S5; +pub const NUM_CANDLES: u64 = 12; #[derive(Debug, Copy, Clone)] pub enum TaskUpdate { @@ -50,10 +33,7 @@ pub enum TaskUpdate { Price(Decimal), } -use client::{ - fmt_kv, - LogColor, -}; +use client::{fmt_kv, LogColor}; impl fmt::Display for TaskUpdate { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { From c106d4822d1b64f0c865644f052041f2a31f697f Mon Sep 17 00:00:00 2001 From: finnfujimura Date: Tue, 28 Apr 2026 16:24:06 -0700 Subject: [PATCH 07/24] maker: add maker-style presets (tight/balanced/defensive) and quote knobs --- services/maker-bot/src/config.rs | 167 ++++++++++++++++++++++++++++--- 1 file changed, 152 insertions(+), 15 deletions(-) diff --git a/services/maker-bot/src/config.rs b/services/maker-bot/src/config.rs index 069a816bb..b7e1626ef 100644 --- a/services/maker-bot/src/config.rs +++ b/services/maker-bot/src/config.rs @@ -2,30 +2,85 @@ use std::path::Path; use anyhow::Context; use dropset_services_shared::{ - config::{ - deserialize_service_config, - ServiceConfig, - ValidSharedConfig, - }, - oanda_types::{ - CurrencyPair, - OandaCandlestickResponse, - }, + config::{deserialize_service_config, ServiceConfig, ValidSharedConfig}, + oanda_types::{CurrencyPair, OandaCandlestickResponse}, }; use reqwest::Url; use serde::Deserialize; use crate::{ - oanda_price_feed::{ - query_price_feed, - OandaArgs, - }, - GRANULARITY, - NUM_CANDLES, + oanda_price_feed::{query_price_feed, OandaArgs}, + GRANULARITY, NUM_CANDLES, }; const SERVICE: ServiceConfig = ServiceConfig::Maker; +#[derive(Clone, Copy, Debug, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MakerStyle { + Tight, + #[default] + Balanced, + Defensive, +} + +#[derive(Clone, Copy)] +pub struct MakerStyleDefaults { + pub quote_ttl_ms: u64, + pub min_refill_delay_ms: u64, + pub max_refill_delay_ms: u64, + pub replenish_ratio_bps: u16, + pub size_jitter_bps: u16, + pub price_jitter_bps: u16, + pub hit_widening_bps: u16, + pub local_book_weight_bps: u16, + pub max_quote_levels: usize, + pub spread_multiplier_bps: u16, +} + +impl MakerStyle { + pub fn defaults(self) -> MakerStyleDefaults { + match self { + Self::Tight => MakerStyleDefaults { + quote_ttl_ms: 1_500, + min_refill_delay_ms: 250, + max_refill_delay_ms: 900, + replenish_ratio_bps: 9_000, + size_jitter_bps: 1_000, + price_jitter_bps: 6, + hit_widening_bps: 10, + local_book_weight_bps: 2_500, + max_quote_levels: 10, + spread_multiplier_bps: 9_000, + }, + Self::Balanced => MakerStyleDefaults { + quote_ttl_ms: 2_500, + min_refill_delay_ms: 600, + max_refill_delay_ms: 2_000, + replenish_ratio_bps: 7_000, + size_jitter_bps: 1_800, + price_jitter_bps: 12, + hit_widening_bps: 18, + local_book_weight_bps: 4_000, + max_quote_levels: 8, + spread_multiplier_bps: 12_000, + }, + Self::Defensive => MakerStyleDefaults { + quote_ttl_ms: 3_500, + min_refill_delay_ms: 1_200, + max_refill_delay_ms: 4_000, + replenish_ratio_bps: 5_500, + size_jitter_bps: 2_500, + price_jitter_bps: 18, + hit_widening_bps: 28, + local_book_weight_bps: 5_500, + max_quote_levels: 6, + spread_multiplier_bps: 17_500, + }, + } + } +} + pub struct ValidMakerConfig { pub shared: ValidSharedConfig, pub target_base: u64, @@ -36,6 +91,18 @@ pub struct ValidMakerConfig { pub ws_url: Url, pub price_feed_poll_interval: u64, pub order_update_throttle_window: u64, + pub style: MakerStyle, + pub quote_ttl_ms: u64, + pub min_refill_delay_ms: u64, + pub max_refill_delay_ms: u64, + pub replenish_ratio_bps: u16, + pub size_jitter_bps: u16, + pub price_jitter_bps: u16, + pub hit_widening_bps: u16, + pub local_book_weight_bps: u16, + pub max_quote_levels: usize, + pub spread_multiplier_bps: u16, + pub seed: u64, pub oanda_args: OandaArgs, pub initial_price_feed_response: OandaCandlestickResponse, } @@ -52,6 +119,19 @@ pub struct MakerConfigInput { pub price_feed_poll_interval: u64, pub order_update_throttle_window: u64, #[serde(default)] + pub style: MakerStyle, + pub quote_ttl_ms: Option, + pub min_refill_delay_ms: Option, + pub max_refill_delay_ms: Option, + pub replenish_ratio_bps: Option, + pub size_jitter_bps: Option, + pub price_jitter_bps: Option, + pub hit_widening_bps: Option, + pub local_book_weight_bps: Option, + pub max_quote_levels: Option, + pub spread_multiplier_bps: Option, + pub seed: Option, + #[serde(default)] pub visualize: bool, } @@ -69,9 +149,34 @@ pub async fn validate_config_and_endpoint( ws_url: ws_url_input, price_feed_poll_interval, order_update_throttle_window, + style, + quote_ttl_ms, + min_refill_delay_ms, + max_refill_delay_ms, + replenish_ratio_bps, + size_jitter_bps, + price_jitter_bps, + hit_widening_bps, + local_book_weight_bps, + max_quote_levels, + spread_multiplier_bps, + seed, visualize, } = input; + let defaults = style.defaults(); + let quote_ttl_ms = quote_ttl_ms.unwrap_or(defaults.quote_ttl_ms); + let min_refill_delay_ms = min_refill_delay_ms.unwrap_or(defaults.min_refill_delay_ms); + let max_refill_delay_ms = max_refill_delay_ms.unwrap_or(defaults.max_refill_delay_ms); + let replenish_ratio_bps = replenish_ratio_bps.unwrap_or(defaults.replenish_ratio_bps); + let size_jitter_bps = size_jitter_bps.unwrap_or(defaults.size_jitter_bps); + let price_jitter_bps = price_jitter_bps.unwrap_or(defaults.price_jitter_bps); + let hit_widening_bps = hit_widening_bps.unwrap_or(defaults.hit_widening_bps); + let local_book_weight_bps = local_book_weight_bps.unwrap_or(defaults.local_book_weight_bps); + let max_quote_levels = max_quote_levels.unwrap_or(defaults.max_quote_levels); + let spread_multiplier_bps = spread_multiplier_bps.unwrap_or(defaults.spread_multiplier_bps); + let seed = seed.unwrap_or(7); + if oanda_auth_token.is_empty() || oanda_auth_token == "your-token-here" { anyhow::bail!( "oanda_auth_token in '{}' is not set.\n\ @@ -80,6 +185,26 @@ pub async fn validate_config_and_endpoint( ); } + if quote_ttl_ms == 0 { + anyhow::bail!("quote_ttl_ms must be greater than zero"); + } + if min_refill_delay_ms == 0 || max_refill_delay_ms < min_refill_delay_ms { + anyhow::bail!("Refill delay window must be positive and ordered"); + } + if max_quote_levels == 0 || max_quote_levels > 10 { + anyhow::bail!("max_quote_levels must be between 1 and 10"); + } + for (name, value) in [ + ("replenish_ratio_bps", replenish_ratio_bps), + ("size_jitter_bps", size_jitter_bps), + ("price_jitter_bps", price_jitter_bps), + ("hit_widening_bps", hit_widening_bps), + ("local_book_weight_bps", local_book_weight_bps), + ("spread_multiplier_bps", spread_multiplier_bps), + ] { + anyhow::ensure!(value <= 20_000, "{name} must be <= 20000 bps"); + } + let oanda_args = OandaArgs { auth_token: oanda_auth_token, pair, @@ -106,6 +231,18 @@ pub async fn validate_config_and_endpoint( ws_url, price_feed_poll_interval, order_update_throttle_window, + style, + quote_ttl_ms, + min_refill_delay_ms, + max_refill_delay_ms, + replenish_ratio_bps, + size_jitter_bps, + price_jitter_bps, + hit_widening_bps, + local_book_weight_bps, + max_quote_levels, + spread_multiplier_bps, + seed, oanda_args, initial_price_feed_response, }) From 0d0dabd378f764dbb8390b9a1d1ffd7e9caa2e13 Mon Sep 17 00:00:00 2001 From: finnfujimura Date: Tue, 28 Apr 2026 16:24:06 -0700 Subject: [PATCH 08/24] maker: add local-book signals, hit detection, dynamic spread, ladder jitter --- services/maker-bot/src/maker_context.rs | 527 ++++++++++++++++++++---- 1 file changed, 436 insertions(+), 91 deletions(-) diff --git a/services/maker-bot/src/maker_context.rs b/services/maker-bot/src/maker_context.rs index e3d7e57af..54d9665b0 100644 --- a/services/maker-bot/src/maker_context.rs +++ b/services/maker-bot/src/maker_context.rs @@ -1,67 +1,46 @@ -use anyhow::Context; -use client::{ - context::market::MarketContext, - fmt_kv, - transactions::CustomRpcClient, +use std::{ + collections::{HashSet, VecDeque}, + time::{Duration, Instant}, }; + +use anyhow::Context; +use client::{context::market::MarketContext, fmt_kv, transactions::CustomRpcClient}; use colored::Colorize; use dropset_interface::{ - instructions::{ - BatchReplaceInstructionData, - UnvalidatedOrders, - }, - state::{ - sector::SectorIndex, - user_order_sectors::MAX_ORDERS_USIZE, - }, + instructions::{BatchReplaceInstructionData, UnvalidatedOrders}, + state::{sector::SectorIndex, user_order_sectors::MAX_ORDERS_USIZE}, }; use dropset_services_shared::{ config::ValidSharedConfig, - oanda_types::{ - CurrencyPair, - OandaCandlestickResponse, - }, + oanda_types::{CurrencyPair, OandaCandlestickResponse}, }; use itertools::Itertools; use price::{ - client_helpers::{ - to_order_info_args, - try_encoded_u32_to_decoded_decimal, - }, - OrderInfoArgs, -}; -use rust_decimal::{ - prelude::ToPrimitive, - Decimal, + client_helpers::{to_order_info_args, try_encoded_u32_to_decoded_decimal}, + to_order_info, }; +use rand::{rngs::StdRng, RngExt, SeedableRng}; +use rust_decimal::{prelude::ToPrimitive, Decimal}; use solana_address::Address; use solana_keypair::Signer; -use solana_sdk::{ - message::Instruction, - signature::Keypair, -}; -use transaction_parser::views::{ - try_market_view_all_from_owner_and_data, - MarketViewAll, -}; +use solana_sdk::{message::Instruction, signature::Keypair}; +use transaction_parser::views::{try_market_view_all_from_owner_and_data, MarketViewAll}; use crate::{ - config::ValidMakerConfig, + config::{MakerStyle, ValidMakerConfig}, get_non_redundant_order_flow, - logger::{ - divider, - Logger, - BUY_COLOR, - SELL_COLOR, - }, - model::calculate_spreads::{ - half_spread, - reservation_price, - }, + logger::{divider, Logger, BUY_COLOR, SELL_COLOR}, + model::calculate_spreads::{half_spread, reservation_price}, utils::get_normalized_mid_price, MakerState, }; +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct HitSignals { + ask_was_lifted: bool, + bid_was_hit: bool, +} + pub struct MakerContext { /// The maker's keypair. pub keypair: Keypair, @@ -72,6 +51,8 @@ pub struct MakerContext { pub pair: CurrencyPair, /// The maker's latest state. latest_state: MakerState, + /// The latest market-wide view. Used for local imbalance and microprice signals. + latest_market: MarketViewAll, /// The target base amount in the maker's seat, in atoms. /// /// If the maker starts with 1,000 base atoms and the target base amount is 10,000, `q` will be @@ -110,6 +91,24 @@ pub struct MakerContext { /// The maker's SOL balance in lamports, updated after each successful transaction. sol_lamports: u64, + style: MakerStyle, + quote_ttl: Duration, + min_refill_delay: Duration, + max_refill_delay: Duration, + replenish_ratio: Decimal, + size_jitter_bps: u16, + price_jitter_bps: u16, + hit_widening_bps: u16, + local_book_weight: Decimal, + max_quote_levels: usize, + spread_multiplier: Decimal, + recent_fair_prices: VecDeque, + buy_pressure: u8, + sell_pressure: u8, + bid_refill_blocked_until: Instant, + ask_refill_blocked_until: Instant, + last_quote_refresh: Instant, + rng: StdRng, } impl MakerContext { @@ -122,6 +121,18 @@ impl MakerContext { bid_order_size, ask_order_size, visualize, + style, + quote_ttl_ms, + min_refill_delay_ms, + max_refill_delay_ms, + replenish_ratio_bps, + size_jitter_bps, + price_jitter_bps, + hit_widening_bps, + local_book_weight_bps, + max_quote_levels, + spread_multiplier_bps, + seed, oanda_args, initial_price_feed_response, .. @@ -146,14 +157,16 @@ impl MakerContext { market_ctx.market ) })?; - let market = + let latest_market = try_market_view_all_from_owner_and_data(market_account.owner, &market_account.data)?; - let latest_state = MakerState::new_from_market(keypair.pubkey(), market)?; + let latest_state = MakerState::new_from_market(keypair.pubkey(), latest_market.clone())?; let mid_price = get_normalized_mid_price(initial_price_feed_response, &oanda_args.pair, &market_ctx)?; let maker_address = keypair.pubkey(); // Maker may temporarily show as `0` balance until the balance fetch succeeds. let sol_lamports = rpc.client.get_balance(&maker_address).await.unwrap_or(0); + let now = Instant::now(); + let recent_fair_prices = VecDeque::from([mid_price]); let mut ctx = Self { keypair, @@ -161,6 +174,7 @@ impl MakerContext { maker_address, pair: oanda_args.pair, latest_state, + latest_market, base_target_atoms, mid_price_atoms: mid_price, batch_replace, @@ -170,7 +184,26 @@ impl MakerContext { needs_expand: false, logger: Logger::new(visualize), sol_lamports, + style, + quote_ttl: Duration::from_millis(quote_ttl_ms), + min_refill_delay: Duration::from_millis(min_refill_delay_ms), + max_refill_delay: Duration::from_millis(max_refill_delay_ms), + replenish_ratio: Decimal::from(replenish_ratio_bps) / Decimal::from(10_000u64), + size_jitter_bps, + price_jitter_bps, + hit_widening_bps, + local_book_weight: Decimal::from(local_book_weight_bps) / Decimal::from(10_000u64), + max_quote_levels, + spread_multiplier: Decimal::from(spread_multiplier_bps) / Decimal::from(10_000u64), + recent_fair_prices, + buy_pressure: 0, + sell_pressure: 0, + bid_refill_blocked_until: now, + ask_refill_blocked_until: now, + last_quote_refresh: now, + rng: StdRng::seed_from_u64(seed), }; + ctx.record_fair_price(mid_price); ctx.render_chart(); Ok(ctx) } @@ -212,31 +245,10 @@ impl MakerContext { .expand(self.maker_address, (MAX_ORDERS_USIZE * 2) as u16) }); - let (bid_price, ask_price) = self.get_bid_and_ask_prices(); - let step = half_spread(); - - // Generate MAX_ORDERS_USIZE layered (price, base_size) pairs per side, stepping outward by - // half_spread per layer with order sizes scaling linearly (1x, 2x, ... Nx base size). - let bid_layers: Vec<(Decimal, u64)> = (0..MAX_ORDERS_USIZE as u32) - .map(|i| { - let price = bid_price - step * Decimal::from(i); - - let error_msg = - || format!("Couldn't convert bid size to u64 at layer {i}: price={price}"); - let size = (Decimal::from(self.bid_order_size) * Decimal::from(i + 1) / price) - .round() - .to_u64() - .with_context(error_msg)?; - Ok((price, size)) - }) - .collect::>()?; - - let ask_layers: Vec<(Decimal, u64)> = (0..MAX_ORDERS_USIZE as u32) - .map(|i| { - let price = ask_price + step * Decimal::from(i); - Ok((price, self.ask_order_size * u64::from(i + 1))) - }) - .collect::>()?; + let now = Instant::now(); + let (bid_price, ask_price, step) = self.get_bid_and_ask_prices(); + let bid_layers = self.build_layers(bid_price, step, true, now)?; + let ask_layers = self.build_layers(ask_price, step, false, now)?; let (cancels, posts) = get_non_redundant_order_flow( self.latest_state.bids.clone(), @@ -251,26 +263,24 @@ impl MakerContext { return Ok(expand_ix.into_iter().collect()); } - let bid_args: [OrderInfoArgs; MAX_ORDERS_USIZE] = bid_layers + let bid_args = bid_layers .into_iter() .map(|(price, size)| to_order_info_args(price, size).map_err(anyhow::Error::from)) - .collect::>>()? - .try_into() - .expect("exactly MAX_ORDERS_USIZE layers"); + .collect::>>()?; - let ask_args: [OrderInfoArgs; MAX_ORDERS_USIZE] = ask_layers + let ask_args = ask_layers .into_iter() .map(|(price, size)| to_order_info_args(price, size).map_err(anyhow::Error::from)) - .collect::>>()? - .try_into() - .expect("exactly MAX_ORDERS_USIZE layers"); + .collect::>>()?; let ixn = self.market_ctx.batch_replace( self.maker_address, BatchReplaceInstructionData::new( self.seat_index(), - UnvalidatedOrders::new(bid_args), - UnvalidatedOrders::new(ask_args), + UnvalidatedOrders::new_from_slice(&bid_args) + .map_err(|e| anyhow::anyhow!("{e:?}"))?, + UnvalidatedOrders::new_from_slice(&ask_args) + .map_err(|e| anyhow::anyhow!("{e:?}"))?, ), ); @@ -307,7 +317,13 @@ impl MakerContext { } pub fn update_maker_state(&mut self, new_market_state: MarketViewAll) -> anyhow::Result<()> { - self.latest_state = MakerState::new_from_market(self.maker_address, new_market_state)?; + let new_state = MakerState::new_from_market(self.maker_address, new_market_state.clone())?; + self.observe_market_hits(&new_state); + self.latest_state = new_state; + self.latest_market = new_market_state; + // Use the same fair-value series everywhere `recent_fair_prices` is fed + // so the volatility estimate doesn't mix incompatible sources. + self.record_fair_price(self.effective_mid_price()); self.render_chart(); Ok(()) } @@ -332,8 +348,8 @@ impl MakerContext { .collect() }; - let mut asks = decode(&self.latest_state.asks); - let mut bids = decode(&self.latest_state.bids); + let mut asks = decode(&self.latest_market.asks); + let mut bids = decode(&self.latest_market.bids); asks.sort_by_key(|(p, _)| std::cmp::Reverse(*p)); bids.sort_by_key(|(p, _)| std::cmp::Reverse(*p)); @@ -434,15 +450,28 @@ impl MakerContext { }) .unwrap_or(2); - let mut lines = Vec::with_capacity(4 + MAX_ORDERS_USIZE * 2 + 1); + let mut lines = Vec::with_capacity(8 + MAX_ORDERS_USIZE * 2 + 1); let base_msg = format!("{bs:>col_w$} seat | {bo:>col_w$} orders | {bt:>col_w$} total"); let quote_msg = format!("{qs:>col_w$} seat | {qo:>col_w$} orders | {qt:>col_w$} total"); let value_msg = format!("{total_value_display:.value_precision$} quote"); + let book_mid = self + .local_microprice_atoms() + .map(|p| p.to_string()) + .unwrap_or_else(|| "n/a".to_string()); + let spread_msg = self + .current_market_spread_bps() + .map(|spread| format!("{spread:.2} bps")) + .unwrap_or_else(|| "n/a".to_string()); + let imbalance_msg = format!("{:.2}%", self.book_imbalance() * 100.0); lines.push(fmt_kv!("SOL ", format!("{sol:.6}"))); lines.push(fmt_kv!("Base ", base_msg)); lines.push(fmt_kv!("Quote", quote_msg)); lines.push(fmt_kv!("Value", value_msg)); + lines.push(fmt_kv!("Style", format!("{:?}", self.style))); + lines.push(fmt_kv!("Mid ", book_mid)); + lines.push(fmt_kv!("Sprd ", spread_msg)); + lines.push(fmt_kv!("Imbal", imbalance_msg)); lines.push(divider()); // Only render the order book once there are orders to show. @@ -479,6 +508,7 @@ impl MakerContext { ) -> anyhow::Result<()> { self.mid_price_atoms = get_normalized_mid_price(candlestick_response, &self.pair, &self.market_ctx)?; + self.record_fair_price(self.effective_mid_price()); Ok(()) } @@ -487,11 +517,326 @@ impl MakerContext { /// and the maker's current state. /// /// Note that these prices are already normalized to being in atoms. - fn get_bid_and_ask_prices(&self) -> (Decimal, Decimal) { - let reservation_price = reservation_price(self.get_mid_price_atoms(), self.q()); - let bid_price = reservation_price - half_spread(); - let ask_price = reservation_price + half_spread(); + fn get_bid_and_ask_prices(&self) -> (Decimal, Decimal, Decimal) { + let effective_mid = self.effective_mid_price(); + let imbalance_skew_bps = + (self.book_imbalance() * f64::from(self.hit_widening_bps)).round() as i64; + let pressure_skew_bps = i64::from(self.buy_pressure) * i64::from(self.hit_widening_bps / 2) + - i64::from(self.sell_pressure) * i64::from(self.hit_widening_bps / 2); + let skew = effective_mid * Decimal::from(imbalance_skew_bps + pressure_skew_bps) + / Decimal::from(10_000u64); + let reservation_price = reservation_price(effective_mid + skew, self.q()); + let step = self.dynamic_half_spread(); + let bid_price = reservation_price - step; + let ask_price = reservation_price + step; + + (bid_price, ask_price, step) + } + + pub fn quote_ttl(&self) -> Duration { + self.quote_ttl + } + + pub fn mark_orders_submitted(&mut self) { + self.last_quote_refresh = Instant::now(); + self.buy_pressure = self.buy_pressure.saturating_sub(1); + self.sell_pressure = self.sell_pressure.saturating_sub(1); + } + + pub fn should_refresh_quotes(&self) -> bool { + self.last_quote_refresh.elapsed() >= self.quote_ttl + } + + fn record_fair_price(&mut self, price: Decimal) { + self.recent_fair_prices.push_back(price); + while self.recent_fair_prices.len() > 24 { + self.recent_fair_prices.pop_front(); + } + } + + fn observe_market_hits(&mut self, new_state: &MakerState) { + let prev_ask_base: u64 = self + .latest_state + .asks + .iter() + .map(|o| o.base_remaining) + .sum(); + let next_ask_base: u64 = new_state.asks.iter().map(|o| o.base_remaining).sum(); + let prev_bid_base: u64 = self + .latest_state + .bids + .iter() + .map(|o| o.base_remaining) + .sum(); + let next_bid_base: u64 = new_state.bids.iter().map(|o| o.base_remaining).sum(); + let now = Instant::now(); + let signals = detect_hit_signals( + prev_ask_base, + next_ask_base, + prev_bid_base, + next_bid_base, + self.latest_state.base_inventory, + new_state.base_inventory, + self.latest_state.quote_inventory, + new_state.quote_inventory, + ); + + if signals.ask_was_lifted { + self.buy_pressure = self.buy_pressure.saturating_add(1).min(8); + self.ask_refill_blocked_until = now + self.random_refill_delay(); + } + + if signals.bid_was_hit { + self.sell_pressure = self.sell_pressure.saturating_add(1).min(8); + self.bid_refill_blocked_until = now + self.random_refill_delay(); + } + } + + fn random_refill_delay(&mut self) -> Duration { + let min_ms = self.min_refill_delay.as_millis() as u64; + let max_ms = self.max_refill_delay.as_millis() as u64; + let delay_ms = if min_ms == max_ms { + min_ms + } else { + self.rng.random_range(min_ms..=max_ms) + }; + Duration::from_millis(delay_ms) + } + + fn build_layers( + &mut self, + anchor_price: Decimal, + step: Decimal, + is_bid: bool, + now: Instant, + ) -> anyhow::Result> { + let refill_blocked = if is_bid { + now < self.bid_refill_blocked_until + } else { + now < self.ask_refill_blocked_until + }; + let start_offset = usize::from(refill_blocked); + let target_levels = self.max_quote_levels.saturating_sub(start_offset).max(1); + let replenish_ratio = if refill_blocked { + self.replenish_ratio + } else { + Decimal::ONE + }; + let mut raw_layers = Vec::with_capacity(target_levels); + + for i in 0..target_levels { + let level = i + start_offset; + let ladder_index = Decimal::from((i + 1) as u64); + let mut price = if is_bid { + anchor_price - step * Decimal::from(level as u64) + } else { + anchor_price + step * Decimal::from(level as u64) + }; + let price_jitter = + step * Decimal::from(self.rng.random_range( + -(self.price_jitter_bps as i32)..=(self.price_jitter_bps as i32), + )) / Decimal::from(100u64); + price += price_jitter; + if price <= Decimal::ZERO { + continue; + } + + let base_size = if is_bid { + (Decimal::from(self.bid_order_size) * ladder_index * replenish_ratio / price) + .round() + } else { + (Decimal::from(self.ask_order_size) * ladder_index * replenish_ratio).round() + }; + let size_jitter = + Decimal::ONE + + Decimal::from(self.rng.random_range( + -(self.size_jitter_bps as i32)..=(self.size_jitter_bps as i32), + )) / Decimal::from(10_000u64); + let size = (base_size * size_jitter) + .round() + .max(Decimal::ONE) + .to_u64() + .with_context(|| format!("Couldn't convert order size to u64 at level {level}"))?; + raw_layers.push((price, size)); + } + + if is_bid { + raw_layers.sort_by(|a, b| b.0.cmp(&a.0)); + } else { + raw_layers.sort_by(|a, b| a.0.cmp(&b.0)); + } + + let mut seen_prices = HashSet::new(); + let mut layers = Vec::with_capacity(raw_layers.len()); + for (price, size) in raw_layers { + let info = + to_order_info(to_order_info_args(price, size).map_err(anyhow::Error::from)?)?; + if seen_prices.insert(info.encoded_price.as_u32()) { + layers.push((price, size)); + } + } + + Ok(layers) + } + + fn effective_mid_price(&self) -> Decimal { + let external = self.get_mid_price_atoms(); + match self.local_microprice_atoms() { + Some(local) => { + external * (Decimal::ONE - self.local_book_weight) + local * self.local_book_weight + } + None => external, + } + } + + fn dynamic_half_spread(&self) -> Decimal { + let base = half_spread() * self.spread_multiplier; + let mid = self.effective_mid_price(); + let pressure_units = u16::from(self.buy_pressure.max(self.sell_pressure)).min(4); + let pressure_component = mid + * Decimal::from(u64::from(self.hit_widening_bps) * u64::from(pressure_units)) + / Decimal::from(40_000u64); + let volatility_component = mid + * Decimal::from(self.recent_volatility_bps().round().clamp(0.0, 100.0) as u64) + / Decimal::from(40_000u64); + + base + pressure_component + volatility_component + } + + fn recent_volatility_bps(&self) -> f64 { + if self.recent_fair_prices.len() < 2 { + return 0.0; + } + + let min = self.recent_fair_prices.iter().min().copied(); + let max = self.recent_fair_prices.iter().max().copied(); + let latest = self.recent_fair_prices.back().copied(); + match (min, max, latest) { + (Some(min), Some(max), Some(latest)) if latest > Decimal::ZERO => max + .checked_sub(min) + .and_then(|range| range.checked_mul(Decimal::from(10_000u64))) + .and_then(|scaled| scaled.checked_div(latest)) + .and_then(|bps| bps.to_f64()) + .unwrap_or_default(), + _ => 0.0, + } + } + + fn local_microprice_atoms(&self) -> Option { + let best_bid = self.latest_market.bids.first()?; + let best_ask = self.latest_market.asks.first()?; + let bid_price = try_encoded_u32_to_decoded_decimal(best_bid.encoded_price.as_u32()).ok()?; + let ask_price = try_encoded_u32_to_decoded_decimal(best_ask.encoded_price.as_u32()).ok()?; + let bid_weight = Decimal::from(best_bid.base_remaining); + let ask_weight = Decimal::from(best_ask.base_remaining); + let denom = bid_weight + ask_weight; + if denom <= Decimal::ZERO { + return None; + } + + (ask_price * bid_weight + bid_price * ask_weight).checked_div(denom) + } + + fn current_market_spread_bps(&self) -> Option { + let best_bid = self.latest_market.bids.first()?; + let best_ask = self.latest_market.asks.first()?; + let bid_price = try_encoded_u32_to_decoded_decimal(best_bid.encoded_price.as_u32()).ok()?; + let ask_price = try_encoded_u32_to_decoded_decimal(best_ask.encoded_price.as_u32()).ok()?; + let mid = (bid_price + ask_price) / Decimal::from(2u8); + if mid <= Decimal::ZERO { + return None; + } - (bid_price, ask_price) + (ask_price - bid_price) + .checked_mul(Decimal::from(10_000u64)) + .and_then(|scaled| scaled.checked_div(mid)) + .and_then(|bps| bps.to_f64()) + } + + fn book_imbalance(&self) -> f64 { + let bid_depth: u64 = self + .latest_market + .bids + .iter() + .take(3) + .map(|order| order.base_remaining) + .sum(); + let ask_depth: u64 = self + .latest_market + .asks + .iter() + .take(3) + .map(|order| order.base_remaining) + .sum(); + let denom = bid_depth + ask_depth; + if denom == 0 { + 0.0 + } else { + (bid_depth as f64 - ask_depth as f64) / denom as f64 + } + } +} + +fn detect_hit_signals( + prev_ask_base: u64, + next_ask_base: u64, + prev_bid_base: u64, + next_bid_base: u64, + prev_base_inventory: u64, + next_base_inventory: u64, + prev_quote_inventory: u64, + next_quote_inventory: u64, +) -> HitSignals { + HitSignals { + ask_was_lifted: next_ask_base < prev_ask_base + && next_base_inventory < prev_base_inventory + && next_quote_inventory > prev_quote_inventory, + bid_was_hit: next_bid_base < prev_bid_base + && next_quote_inventory < prev_quote_inventory + && next_base_inventory > prev_base_inventory, + } +} + +#[cfg(test)] +mod tests { + use super::{detect_hit_signals, HitSignals}; + + #[test] + fn self_requotes_do_not_look_like_hits() { + let signals = detect_hit_signals(1_000, 600, 900, 700, 10_000, 10_000, 20_000, 20_000); + + assert_eq!(signals, HitSignals::default()); + } + + #[test] + fn unilateral_cancels_do_not_look_like_hits() { + // Ask depth shrank with no inventory transfer — a self-initiated cancel. + let ask_cancel = detect_hit_signals(1_000, 600, 900, 900, 10_000, 10_000, 20_000, 20_000); + assert_eq!(ask_cancel, HitSignals::default()); + + // Bid depth shrank with no inventory transfer — also a cancel, not a hit. + let bid_cancel = detect_hit_signals(1_000, 1_000, 900, 500, 10_000, 10_000, 20_000, 20_000); + assert_eq!(bid_cancel, HitSignals::default()); + } + + #[test] + fn inventory_transfer_confirms_real_hits() { + let ask_fill = detect_hit_signals(1_000, 600, 900, 900, 10_000, 9_600, 20_000, 20_400); + assert_eq!( + ask_fill, + HitSignals { + ask_was_lifted: true, + bid_was_hit: false, + } + ); + + let bid_fill = detect_hit_signals(1_000, 1_000, 900, 500, 10_000, 10_400, 20_000, 19_600); + assert_eq!( + bid_fill, + HitSignals { + ask_was_lifted: false, + bid_was_hit: true, + } + ); } } From 70e9ca27ed345ba32436507d264587d4fa7d3159 Mon Sep 17 00:00:00 2001 From: finnfujimura Date: Tue, 28 Apr 2026 16:24:06 -0700 Subject: [PATCH 09/24] maker: refresh resting liquidity on quote-TTL timeout --- .../src/tasks/throttled_order_update.rs | 42 +++++++++++-------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/services/maker-bot/src/tasks/throttled_order_update.rs b/services/maker-bot/src/tasks/throttled_order_update.rs index 8fb6647ce..0f10836a7 100644 --- a/services/maker-bot/src/tasks/throttled_order_update.rs +++ b/services/maker-bot/src/tasks/throttled_order_update.rs @@ -1,26 +1,15 @@ -use std::{ - cell::RefCell, - rc::Rc, - time::Duration, -}; +use std::{cell::RefCell, rc::Rc, time::Duration}; use client::{ fmt_kv, - transactions::{ - CustomRpcClient, - ParsedTransactionWithEvents, - TransactionSubmitError, - }, + transactions::{CustomRpcClient, ParsedTransactionWithEvents, TransactionSubmitError}, }; use dropset_interface::error::DropsetError; use dropset_services_shared::faucet_client::FaucetClient; use solana_keypair::Signer; use tokio::sync::watch; -use crate::{ - maker_context::MakerContext, - TaskUpdate, -}; +use crate::{maker_context::MakerContext, TaskUpdate}; /// The indefinite task loop to update orders whenever the [`watch::Receiver`] receives a message /// from another task that indicates a [`TaskUpdate`] has occurred. Order submissions are @@ -36,11 +25,27 @@ pub async fn throttled_order_update( faucet_client: Option, ) -> anyhow::Result<()> { loop { - // Wait until the value has changed. Not equality wise, but a sender posting a new value. - rx.changed().await?; + let quote_ttl = maker_ctx.try_borrow()?.quote_ttl(); + let update = match tokio::time::timeout(quote_ttl, rx.changed()).await { + Ok(res) => { + res?; + Some(*rx.borrow()) + } + Err(_) => None, + }; - let update = *rx.borrow(); - maker_ctx.try_borrow_mut()?.logger.log(update.to_string()); + { + let mut ctx = maker_ctx.try_borrow_mut()?; + match update { + Some(update) => ctx.logger.log(update.to_string()), + None if ctx.should_refresh_quotes() => ctx.logger.log(fmt_kv!( + "Quote TTL", + "refreshing resting liquidity", + client::LogColor::Info, + )), + None => continue, + } + } // Then cancel all orders and post new ones. let (maker_keypair, instructions) = { @@ -59,6 +64,7 @@ pub async fn throttled_order_update( let balance = rpc.client.get_balance(&maker_keypair.pubkey()).await; let mut ctx = maker_ctx.try_borrow_mut()?; ctx.needs_expand = false; + ctx.mark_orders_submitted(); if let Ok(balance) = balance { ctx.update_sol_balance(balance); } From 8e822c2b617d263431f05d29a5529940f1be193c Mon Sep 17 00:00:00 2001 From: finnfujimura Date: Tue, 28 Apr 2026 17:04:42 -0700 Subject: [PATCH 10/24] taker: only advance parent orders after successful submit --- services/taker-bot/src/main.rs | 4 +- services/taker-bot/src/taker.rs | 121 +++++++++++++++++++++++++++++--- 2 files changed, 116 insertions(+), 9 deletions(-) diff --git a/services/taker-bot/src/main.rs b/services/taker-bot/src/main.rs index b849bed94..5b1b72c54 100644 --- a/services/taker-bot/src/main.rs +++ b/services/taker-bot/src/main.rs @@ -131,9 +131,11 @@ async fn agent_loop(name: String, ctx: TakerContext, mut strategy: TakerStrategy step_stats.accumulate(attempt.stats); let mut consumed_depth = false; - for fill in attempt.fills { + for fill in attempt.fills.iter().cloned() { match ctx.submit_fill(&fill).await { Ok(result) => { + strategy.confirm_attempt(&attempt); + step_stats.submitted_children += 1; metrics.observe_execution(&result); consumed_depth = true; } diff --git a/services/taker-bot/src/taker.rs b/services/taker-bot/src/taker.rs index 917b67c1a..393423a77 100644 --- a/services/taker-bot/src/taker.rs +++ b/services/taker-bot/src/taker.rs @@ -278,6 +278,7 @@ impl TakerStepStats { pub struct TakerStep { pub fills: Vec, pub stats: TakerStepStats, + fill_commit: Option, } #[derive(Debug, Default, Copy, Clone)] @@ -286,6 +287,12 @@ pub(crate) struct PlannedTick { pub stats: TakerStepStats, } +#[derive(Debug, Clone, Copy)] +struct FillCommit { + next_parent_order: Option, + enters_cooldown: bool, +} + #[derive(Debug, Clone, Copy)] struct ParentOrder { side: Side, @@ -436,11 +443,16 @@ impl TakerStrategy { let mut step = TakerStep { fills: Vec::with_capacity(planned.attempts as usize), stats: planned.stats, + fill_commit: None, }; for _ in 0..planned.attempts { let attempt = self.execute_attempt(&snapshot()); step.stats.accumulate(attempt.stats); + if !attempt.fills.is_empty() { + self.confirm_attempt(&attempt); + step.stats.submitted_children += 1; + } step.fills.extend(attempt.fills); } @@ -561,26 +573,36 @@ impl TakerStrategy { .min(visible_depth.max(1)); let planned_levels = planned_levels_for_size(size, &opposing_side.levels); - parent.remaining_base = parent.remaining_base.saturating_sub(size); - parent.children_remaining = parent.children_remaining.saturating_sub(1); let fill = TakerFill { side: parent.side, size, planned_levels, - parent_remaining: parent.remaining_base, + parent_remaining: parent.remaining_base.saturating_sub(size), }; + parent.remaining_base = fill.parent_remaining; + parent.children_remaining = parent.children_remaining.saturating_sub(1); let parent_done = parent.remaining_base == 0 || parent.children_remaining == 0; - self.parent_order = (!parent_done).then_some(parent); - if parent_done { - self.cooldown_ticks_remaining = self.execution_profile.cooldown_ticks; - } - step.stats.submitted_children += 1; step.fills.push(fill); + step.fill_commit = Some(FillCommit { + next_parent_order: (!parent_done).then_some(parent), + enters_cooldown: parent_done, + }); step } + pub(crate) fn confirm_attempt(&mut self, attempt: &TakerStep) { + let Some(commit) = attempt.fill_commit else { + return; + }; + + self.parent_order = commit.next_parent_order; + if commit.enters_cooldown { + self.cooldown_ticks_remaining = self.execution_profile.cooldown_ticks; + } + } + fn start_parent_order( &mut self, snapshot: &MarketSnapshot, @@ -867,6 +889,89 @@ mod tests { panic!("expected a multi-fill step to validate refreshed liquidity"); } + fn test_strategy(seed: u64) -> TakerStrategy { + let mut interval = tokio::time::interval(Duration::from_millis(1)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + TakerStrategy::new( + ActivityProfile { + interval, + lambda_quiet: 1.0, + lambda_burst: 1.0, + burst_entry_prob: 0.0, + burst_exit_prob: 1.0, + }, + 100, + 1.1, + 0.5, + ExecutionProfile { + parent_multiplier_min: 2.0, + parent_multiplier_max: 2.0, + child_depth_fraction_min: 0.1, + child_depth_fraction_max: 0.1, + max_sweep_levels: 3, + max_spread_bps: 100.0, + cooldown_ticks: 2, + parent_slice_count_min: 2, + parent_slice_count_max: 2, + imbalance_bias: 0.0, + patience_ticks: 0, + }, + Some(seed), + ) + .expect("test strategy should be valid") + } + + #[tokio::test] + async fn failed_submission_keeps_parent_order_open() { + let mut strategy = test_strategy(11); + let snapshot = MarketSnapshot::synthetic(10_000); + + let attempt = strategy.execute_attempt(&snapshot); + let fill = attempt + .fills + .first() + .expect("attempt should plan a child fill"); + let parent_before_submit = strategy + .parent_order + .expect("starting a parent order should persist before submission"); + + assert_eq!( + parent_before_submit.remaining_base, + fill.size + fill.parent_remaining + ); + assert_eq!(parent_before_submit.children_remaining, 2); + } + + #[tokio::test] + async fn successful_submission_commits_parent_progress() { + let mut strategy = test_strategy(11); + let snapshot = MarketSnapshot::synthetic(10_000); + + let attempt = strategy.execute_attempt(&snapshot); + let fill = attempt + .fills + .first() + .cloned() + .expect("attempt should plan a child fill"); + + strategy.confirm_attempt(&attempt); + + if fill.parent_remaining == 0 { + assert!(strategy.parent_order.is_none()); + assert_eq!( + strategy.cooldown_ticks_remaining, + strategy.execution_profile.cooldown_ticks + ); + } else { + let parent_after_submit = strategy + .parent_order + .expect("a partially filled parent order should remain open"); + assert_eq!(parent_after_submit.remaining_base, fill.parent_remaining); + assert_eq!(parent_after_submit.children_remaining, 1); + } + } + #[tokio::test(start_paused = true)] async fn poisson_takers_dropset_market() -> anyhow::Result<()> { // This isn't a literal duration; with the `tokio` flag `start_paused = true`, each From e591b0f60488413620b11464adc26c5eadaa05ae Mon Sep 17 00:00:00 2001 From: finnfujimura Date: Tue, 28 Apr 2026 19:23:59 -0700 Subject: [PATCH 11/24] taker: restore fetch_market_snapshot and Arc-based TakerContext::new --- services/taker-bot/src/taker_context.rs | 136 +++++++++++++++++------- 1 file changed, 96 insertions(+), 40 deletions(-) diff --git a/services/taker-bot/src/taker_context.rs b/services/taker-bot/src/taker_context.rs index fa22927ef..17ee83cf8 100644 --- a/services/taker-bot/src/taker_context.rs +++ b/services/taker-bot/src/taker_context.rs @@ -1,65 +1,121 @@ +use std::sync::Arc; + use client::{ context::market::MarketContext, - transactions::{ - CustomRpcClient, - ParsedTransactionWithEvents, - TransactionSubmitError, - }, + transactions::{CustomRpcClient, ParsedTransactionWithEvents, TransactionSubmitError}, }; use dropset_interface::instructions::MarketOrderInstructionData; -use dropset_services_shared::{ - config::ValidSharedConfig, - faucet_client::FaucetClient, -}; +use dropset_services_shared::faucet_client::FaucetClient; +use price::client_helpers::try_encoded_u32_to_decoded_decimal; +use rust_decimal::{prelude::ToPrimitive, Decimal}; use solana_address::Address; -use solana_keypair::{ - Keypair, - Signer, -}; +use solana_keypair::{Keypair, Signer}; +use transaction_parser::views::try_market_view_all_from_owner_and_data; -use crate::taker::{ - Side, - TakerFill, -}; +use crate::taker::{BookLevel, BookSideSnapshot, MarketSnapshot, Side, TakerFill}; +/// Per-agent submission context. Each `[[agent]]` in the taker config gets one +/// of these: its own keypair, sharing the service-wide RPC and faucet clients. pub struct TakerContext { - pub rpc: CustomRpcClient, + pub rpc: Arc, pub keypair: Keypair, - pub market_ctx: MarketContext, - pub faucet_client: Option, + pub market_ctx: Arc, + pub faucet_client: Option>, } impl TakerContext { - pub async fn init(rpc: CustomRpcClient, shared: ValidSharedConfig) -> anyhow::Result { - let faucet_client = match FaucetClient::new(&rpc, &shared).await { - Ok(c) => Some(c), - Err(e) => { - tracing::warn!(error = %e, "Faucet client is unavailable"); - None - } - }; - - let ValidSharedConfig { - keypair, - base, - quote, - .. - } = shared; - - let market_ctx = MarketContext::new(base, quote); - - Ok(Self { + pub fn new( + rpc: Arc, + market_ctx: Arc, + faucet_client: Option>, + keypair: Keypair, + ) -> Self { + Self { rpc, keypair, market_ctx, faucet_client, - }) + } } pub fn address(&self) -> Address { self.keypair.pubkey() } + pub async fn fetch_market_snapshot(&self) -> anyhow::Result { + let market_account = self.rpc.client.get_account(&self.market_ctx.market).await?; + let market = + try_market_view_all_from_owner_and_data(market_account.owner, &market_account.data)?; + + let decode_side = + |orders: &[transaction_parser::views::OrderView]| -> anyhow::Result { + let levels = orders + .iter() + .map(|order| { + Ok(BookLevel { + price: try_encoded_u32_to_decoded_decimal( + order.encoded_price.as_u32(), + )?, + base_remaining: order.base_remaining, + quote_remaining: order.quote_remaining, + }) + }) + .collect::>>()?; + let total_base_depth = levels.iter().map(|level| level.base_remaining).sum(); + Ok(BookSideSnapshot { + levels, + total_base_depth, + }) + }; + + let bids = decode_side(&market.bids)?; + let asks = decode_side(&market.asks)?; + let visible_bid = bids.visible_base_depth(3); + let visible_ask = asks.visible_base_depth(3); + let imbalance_denom = visible_bid + visible_ask; + let imbalance = if imbalance_denom == 0 { + 0.0 + } else { + (visible_bid as f64 - visible_ask as f64) / (imbalance_denom as f64) + }; + + let best_bid = bids.levels.first(); + let best_ask = asks.levels.first(); + let mid_price = match (best_bid, best_ask) { + (Some(bid), Some(ask)) => Some((bid.price + ask.price) / Decimal::from(2u8)), + _ => None, + }; + let spread_bps = match (best_bid, best_ask, mid_price) { + (Some(bid), Some(ask), Some(mid)) if mid > Decimal::ZERO => { + let spread = ask.price - bid.price; + let bps = spread + .checked_mul(Decimal::from(10_000u64)) + .and_then(|scaled| scaled.checked_div(mid)) + .and_then(|res| res.to_f64()); + bps + } + _ => None, + }; + let microprice = match (best_bid, best_ask) { + (Some(bid), Some(ask)) if bid.base_remaining + ask.base_remaining > 0 => { + let bid_weight = Decimal::from(bid.base_remaining); + let ask_weight = Decimal::from(ask.base_remaining); + let numerator = ask.price * bid_weight + bid.price * ask_weight; + numerator.checked_div(bid_weight + ask_weight) + } + _ => None, + }; + + Ok(MarketSnapshot { + bids, + asks, + spread_bps, + mid_price, + microprice, + imbalance, + }) + } + /// Submits a market order for a fill produced by [`crate::taker::TakerStrategy::step`]. /// Order size is treated as base atoms for both sides. pub async fn submit_fill( From 3636dfa3878d895cec47f73085362c9e790a77b2 Mon Sep 17 00:00:00 2001 From: finnfujimura Date: Wed, 29 Apr 2026 13:36:52 -0700 Subject: [PATCH 12/24] funding per agent takers during init --- .../shared/examples/initialization_helper.rs | 126 +++++++++++++++++- 1 file changed, 121 insertions(+), 5 deletions(-) diff --git a/services/shared/examples/initialization_helper.rs b/services/shared/examples/initialization_helper.rs index 18152f738..83baa444a 100644 --- a/services/shared/examples/initialization_helper.rs +++ b/services/shared/examples/initialization_helper.rs @@ -1,6 +1,7 @@ use std::{ collections::HashSet, io::ErrorKind, + path::PathBuf, }; use anyhow::Context; @@ -22,6 +23,7 @@ use dropset_services_shared::config::{ load_raw_service_config, ServiceConfig, }; +use serde::Deserialize; use solana_keypair::{ read_keypair_file, Keypair, @@ -42,6 +44,9 @@ const MAKER_INITIAL_QUOTE: u64 = 10_000_000_000; const TAKER_INITIAL_BASE: u64 = 1_000_000_000_000; const TAKER_INITIAL_QUOTE: u64 = 1_000_000_000_000; +const AGENT_INITIAL_BASE: u64 = 1_000_000_000_000; +const AGENT_INITIAL_QUOTE: u64 = 1_000_000_000_000; + /// A helper example to bootstrap a market and a market maker on a localnet validator. /// /// It does the following: @@ -73,16 +78,30 @@ async fn main() -> anyhow::Result<()> { airdrop(&rpc.client, &maker.pubkey()).await?; airdrop(&rpc.client, &taker.pubkey()).await?; + // Per-agent taker keypairs (one per `[[agent]]` in `taker-bot/config.toml`). + // Each one signs its own market orders, so each one needs SOL for fees plus + // base/quote tokens to trade with. They are created on demand if missing. + let agents = load_or_create_agent_keypairs()?; + for agent in &agents { + airdrop(&rpc.client, &agent.keypair.pubkey()).await?; + } + // Mint the initial amounts to each account. // Pass the faucet keypair as the mint authority so the faucet service // can mint tokens on demand. + let mut users = vec![ + User::new(faucet, FAUCET_INITIAL_BASE, FAUCET_INITIAL_QUOTE), + User::new(maker, MAKER_INITIAL_BASE, MAKER_INITIAL_QUOTE), + User::new(taker, TAKER_INITIAL_BASE, TAKER_INITIAL_QUOTE), + ]; + users.extend( + agents + .iter() + .map(|agent| User::new(&agent.keypair, AGENT_INITIAL_BASE, AGENT_INITIAL_QUOTE)), + ); let e2e = E2e::new_users_and_market_with_options( Some(rpc), - [ - User::new(faucet, FAUCET_INITIAL_BASE, FAUCET_INITIAL_QUOTE), - User::new(maker, MAKER_INITIAL_BASE, MAKER_INITIAL_QUOTE), - User::new(taker, TAKER_INITIAL_BASE, TAKER_INITIAL_QUOTE), - ], + users, Some(6), Some(6), Some(faucet), @@ -101,9 +120,16 @@ async fn main() -> anyhow::Result<()> { // Patch base_mint and quote_mint into the shared config file in-place. update_base_and_quote_mints(&e2e)?; + // Write the agent registry that the frontend reads to label each fill with + // the trader personality that submitted it. + write_agent_registry(maker, &agents)?; + println!("Faucet address : {}", faucet.pubkey()); println!("Maker address : {}", maker.pubkey()); println!("Taker address : {}", taker.pubkey()); + for agent in &agents { + println!("Agent {:<8}: {}", agent.name, agent.keypair.pubkey()); + } println!("Base mint : {}", e2e.market.base.mint_address); println!("Quote mint : {}", e2e.market.quote.mint_address); println!("Market : {}", e2e.market.market); @@ -111,6 +137,96 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +#[derive(Deserialize)] +struct TakerConfigAgents { + #[serde(default, rename = "agent")] + agents: Vec, +} + +#[derive(Deserialize)] +struct TakerAgentEntry { + name: String, + keypair_path: PathBuf, +} + +pub struct AgentEntry { + pub name: String, + pub keypair: Keypair, +} + +/// Reads `services/taker-bot/config.toml`, loads each agent's keypair file, and +/// generates a fresh keypair (writing it to disk) for any path that doesn't +/// exist yet. Relative `keypair_path` entries are resolved against the +/// taker-bot config directory, mirroring `taker-bot/src/config.rs`. +fn load_or_create_agent_keypairs() -> anyhow::Result> { + let raw = load_raw_service_config(ServiceConfig::Taker)?; + let parsed: TakerConfigAgents = toml::from_str(&raw) + .context("Failed to parse taker-bot config.toml while loading agent keypairs")?; + + let taker_dir = ServiceConfig::Taker.config_dir(); + parsed + .agents + .into_iter() + .map(|entry| { + let path = if entry.keypair_path.is_absolute() { + entry.keypair_path + } else { + taker_dir.join(&entry.keypair_path) + }; + let keypair = load_or_create_keypair_file(&path)?; + Ok(AgentEntry { + name: entry.name, + keypair, + }) + }) + .collect() +} + +/// Writes `services/taker-bot/agents.json` with one entry per known trader +/// (the maker plus every taker agent), so the frontend can label each fill +/// with the personality that submitted it. +fn write_agent_registry(maker: &Keypair, agents: &[AgentEntry]) -> anyhow::Result<()> { + let mut entries: Vec = + Vec::with_capacity(agents.len() + 1); + entries.push(serde_json::json!({ + "name": "maker", + "kind": "maker", + "pubkey": maker.pubkey().to_string(), + })); + for agent in agents { + entries.push(serde_json::json!({ + "name": agent.name, + "kind": "taker", + "pubkey": agent.keypair.pubkey().to_string(), + })); + } + + let path = ServiceConfig::Taker.config_dir().join("agents.json"); + std::fs::write(&path, serde_json::to_string_pretty(&entries)?) + .with_context(|| format!("Failed to write agent registry to {path:#?}"))?; + Ok(()) +} + +/// Loads a keypair from `path` if it exists, otherwise generates a new one and +/// writes it to disk in the same JSON-array format that `solana-keygen` uses. +fn load_or_create_keypair_file(path: &std::path::Path) -> anyhow::Result { + if path.exists() { + return read_keypair_file(path).map_err(|e| { + anyhow::anyhow!("Couldn't open agent keypair file: {path:#?}, err: ({e})") + }); + } + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!("Failed to create agent keypair directory: {parent:#?}") + })?; + } + let kp = Keypair::new(); + std::fs::write(path, serde_json::to_string(&kp.to_bytes().to_vec())?) + .with_context(|| format!("Failed to write new agent keypair to {path:#?}"))?; + Ok(kp) +} + fn write_keypair_to_file(service: ServiceConfig, kp: &Keypair) -> anyhow::Result<()> { let kp_path = service.keypair_path(); if std::fs::exists(kp_path.clone())? { From 495becdb97177dc4a0dd57b215ec9a171ec0811e Mon Sep 17 00:00:00 2001 From: finnfujimura Date: Wed, 29 Apr 2026 13:39:26 -0700 Subject: [PATCH 13/24] frontend: label transaction log fills with trader personality --- frontend/src/app/api/agents/route.ts | 35 ++++++++++ frontend/src/components/TransactionLog.tsx | 68 +++++++++++++++++-- frontend/src/lib/hooks/use-agent-registry.ts | 29 ++++++++ .../src/lib/queries/fetch-agent-registry.ts | 13 ++++ 4 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 frontend/src/app/api/agents/route.ts create mode 100644 frontend/src/lib/hooks/use-agent-registry.ts create mode 100644 frontend/src/lib/queries/fetch-agent-registry.ts diff --git a/frontend/src/app/api/agents/route.ts b/frontend/src/app/api/agents/route.ts new file mode 100644 index 000000000..cb93da638 --- /dev/null +++ b/frontend/src/app/api/agents/route.ts @@ -0,0 +1,35 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { NextResponse } from "next/server"; + +export type AgentRegistryEntry = { + name: string; + kind: "maker" | "taker"; + pubkey: string; +}; + +/** + * Returns the trader registry written by `services/shared/examples/initialization_helper.rs`. + * + * The frontend uses this to label each fill in the transaction log with the + * personality (maker, retail-1, whale-1, etc.) that submitted the trade. + * + * Returns an empty array when the file is missing — e.g. when the frontend is + * run against devnet/testnet/mainnet rather than the local helper script. + */ +export async function GET() { + const filePath = path.join( + process.cwd(), + "..", + "services", + "taker-bot", + "agents.json", + ); + try { + const raw = await fs.readFile(filePath, "utf8"); + const parsed = JSON.parse(raw) as AgentRegistryEntry[]; + return NextResponse.json(parsed); + } catch { + return NextResponse.json([]); + } +} diff --git a/frontend/src/components/TransactionLog.tsx b/frontend/src/components/TransactionLog.tsx index 48a994700..fbe713684 100644 --- a/frontend/src/components/TransactionLog.tsx +++ b/frontend/src/components/TransactionLog.tsx @@ -4,6 +4,8 @@ import type { Address } from "@solana/addresses"; import { Decimal } from "decimal.js"; import { Activity } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; +import { useAgentRegistry } from "@/lib/hooks/use-agent-registry"; +import type { AgentRegistryEntry } from "@/lib/queries/fetch-agent-registry"; import { solscanTxUrl } from "@/lib/solana/explorer"; import { truncateAddress } from "@/lib/solana/format"; import { useMarketStore } from "@/lib/stores/market-store"; @@ -45,6 +47,8 @@ type FillData = { relativeTime: string; signature: string; accounts: ResolvedAccount[]; + trader: AgentRegistryEntry | null; + signerAddress: Address | null; lastFillPrice: Decimal; discriminator: number; isBuy: boolean; @@ -56,6 +60,35 @@ type FillData = { encodedPrice: number; }; +const TAKER_PERSONALITY_COLORS: Record = { + retail: "bg-sky-500/15 text-sky-400 border-sky-500/30", + whale: "bg-violet-500/15 text-violet-400 border-violet-500/30", + sniper: "bg-amber-500/15 text-amber-400 border-amber-500/30", + noise: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30", + passive: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30", + aggressive: "bg-rose-500/15 text-rose-400 border-rose-500/30", +}; + +const MAKER_BADGE = "bg-indigo-500/15 text-indigo-400 border-indigo-500/30"; +const UNKNOWN_BADGE = "bg-muted/40 text-muted-fg border-border"; + +function traderBadgeClass(trader: AgentRegistryEntry | null): string { + if (!trader) return UNKNOWN_BADGE; + if (trader.kind === "maker") return MAKER_BADGE; + // Agent names look like `retail-1`, `whale-1`, etc. Color by the archetype prefix. + const archetype = trader.name.split("-")[0]; + return TAKER_PERSONALITY_COLORS[archetype] ?? UNKNOWN_BADGE; +} + +function traderLabel( + trader: AgentRegistryEntry | null, + signerAddress: Address | null, +): string { + if (trader) return trader.name; + if (signerAddress) return truncateAddress(signerAddress); + return "unknown"; +} + function FillEntry({ fill, barPercent, @@ -89,6 +122,13 @@ function FillEntry({ {fill.relativeTime} + + {traderLabel(fill.trader, fill.signerAddress)} + + {fill.lastFillPrice.toDecimalPlaces(6).toString()} @@ -146,12 +186,25 @@ export function TransactionLog({ marketAddress }: { marketAddress: Address }) { const baseDecimals = market?.base.decimals ?? 0; const quoteDecimals = market?.quote.decimals ?? 0; + const { byPubkey: agentByPubkey } = useAgentRegistry(); + const fills: (FillData & { key: string })[] = useMemo( () => transactions .filter((tx) => !tx.err) - .flatMap((tx) => - tx.parsed.dropsetEvents + .flatMap((tx) => { + // The fill-emitting transaction is a taker market order, so the + // first writable signer (i.e. the fee payer) is the trader we want + // to label. `parsed.accounts` from `resolveAccounts` is ordered with + // writable signers first. + const signer = + tx.parsed.accounts.find((a) => a.signer && a.writable) ?? null; + const signerAddress = signer?.address ?? null; + const trader = signerAddress + ? (agentByPubkey.get(signerAddress) ?? null) + : null; + + return tx.parsed.dropsetEvents .filter((e) => e.kind === "fill") .map((fill, i) => ({ ...fill.data, @@ -159,6 +212,8 @@ export function TransactionLog({ marketAddress }: { marketAddress: Address }) { relativeTime: tx.blockTime ? relativeTime(tx.blockTime) : "—", signature: tx.signature, accounts: tx.parsed.accounts, + trader, + signerAddress, lastFillPrice: encodedU32ToDecimal(fill.data.encodedPrice), baseFilledUi: new Decimal(fill.data.baseFilled.toString()) .div(new Decimal(10).pow(baseDecimals)) @@ -168,9 +223,9 @@ export function TransactionLog({ marketAddress }: { marketAddress: Address }) { .div(new Decimal(10).pow(quoteDecimals)) .toDecimalPlaces(quoteDecimals) .toString(), - })), - ), - [transactions, baseDecimals, quoteDecimals], + })); + }), + [transactions, baseDecimals, quoteDecimals, agentByPubkey], ); return ( @@ -207,6 +262,9 @@ export function TransactionLog({ marketAddress }: { marketAddress: Address }) { {fills.length > 0 && (
Time + + Trader + Price diff --git a/frontend/src/lib/hooks/use-agent-registry.ts b/frontend/src/lib/hooks/use-agent-registry.ts new file mode 100644 index 000000000..2d2d4a38a --- /dev/null +++ b/frontend/src/lib/hooks/use-agent-registry.ts @@ -0,0 +1,29 @@ +"use client"; + +import type { Address } from "@solana/addresses"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { + type AgentRegistryEntry, + fetchAgentRegistry, +} from "@/lib/queries/fetch-agent-registry"; + +export function useAgentRegistry() { + const query = useQuery({ + queryKey: ["agent-registry"], + queryFn: fetchAgentRegistry, + // The registry is rewritten on every `run-services-on-localnet.sh --force`, + // so a long stale time is fine — refetch on remount is enough. + staleTime: Number.POSITIVE_INFINITY, + }); + + const byPubkey = useMemo(() => { + const map = new Map(); + for (const entry of query.data ?? []) { + map.set(entry.pubkey, entry); + } + return map; + }, [query.data]); + + return { entries: query.data ?? [], byPubkey }; +} diff --git a/frontend/src/lib/queries/fetch-agent-registry.ts b/frontend/src/lib/queries/fetch-agent-registry.ts new file mode 100644 index 000000000..be2e5ba09 --- /dev/null +++ b/frontend/src/lib/queries/fetch-agent-registry.ts @@ -0,0 +1,13 @@ +import type { Address } from "@solana/addresses"; + +export type AgentRegistryEntry = { + name: string; + kind: "maker" | "taker"; + pubkey: Address; +}; + +export async function fetchAgentRegistry(): Promise { + const res = await fetch("/api/agents"); + if (!res.ok) return []; + return (await res.json()) as AgentRegistryEntry[]; +} From 985d43591497fe1624096b9a496643261205f719 Mon Sep 17 00:00:00 2001 From: finnfujimura Date: Wed, 29 Apr 2026 13:48:29 -0700 Subject: [PATCH 14/24] new taker compose with mounted keypairs --- services/run-services-on-localnet.sh | 0 services/taker-bot/compose.yaml | 1 + 2 files changed, 1 insertion(+) mode change 100644 => 100755 services/run-services-on-localnet.sh diff --git a/services/run-services-on-localnet.sh b/services/run-services-on-localnet.sh old mode 100644 new mode 100755 diff --git a/services/taker-bot/compose.yaml b/services/taker-bot/compose.yaml index 3604bf47e..2a4bd7951 100644 --- a/services/taker-bot/compose.yaml +++ b/services/taker-bot/compose.yaml @@ -13,6 +13,7 @@ services: - ../shared/config.toml:/app/services/shared/config.toml:ro - ./config.toml:/app/services/taker-bot/config.toml:ro - ./keypair.json:/app/services/taker-bot/keypair.json:ro + - ./keypairs:/app/services/taker-bot/keypairs:ro healthcheck: test: ["CMD", "/app/service", "--health-check"] interval: 30s From be06414a25e5363a2fee2128f26e0e95b40788c4 Mon Sep 17 00:00:00 2001 From: finnfujimura Date: Wed, 29 Apr 2026 13:49:56 -0700 Subject: [PATCH 15/24] gitignore: re-ignore keypairs/ and ignore taker-bot/agents.json --- services/.gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/services/.gitignore b/services/.gitignore index 0ad8aee8b..c4f0d156a 100644 --- a/services/.gitignore +++ b/services/.gitignore @@ -1,2 +1,4 @@ config.toml keypair.json +keypairs/ +services/taker-bot/agents.json From 7a8f1e866028c573951d1e6ad88192933fbc643b Mon Sep 17 00:00:00 2001 From: Finley Fujimura <147115573+finnfujimura@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:37:42 -0700 Subject: [PATCH 16/24] Update services/.gitignore Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- services/.gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/.gitignore b/services/.gitignore index c4f0d156a..c708a353e 100644 --- a/services/.gitignore +++ b/services/.gitignore @@ -1,4 +1,4 @@ config.toml keypair.json keypairs/ -services/taker-bot/agents.json +taker-bot/agents.json From 7eab7f0f4fb255aef1589b053f123389dfaa852c Mon Sep 17 00:00:00 2001 From: finnfujimura Date: Wed, 29 Apr 2026 14:40:23 -0700 Subject: [PATCH 17/24] maker-bot: rename price_jitter_bps -> price_jitter_pct to match its semantics --- services/maker-bot/src/config.rs | 23 +++++++++++++---------- services/maker-bot/src/maker_context.rs | 8 ++++---- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/services/maker-bot/src/config.rs b/services/maker-bot/src/config.rs index b7e1626ef..319c366bd 100644 --- a/services/maker-bot/src/config.rs +++ b/services/maker-bot/src/config.rs @@ -31,7 +31,7 @@ pub struct MakerStyleDefaults { pub max_refill_delay_ms: u64, pub replenish_ratio_bps: u16, pub size_jitter_bps: u16, - pub price_jitter_bps: u16, + pub price_jitter_pct: u16, pub hit_widening_bps: u16, pub local_book_weight_bps: u16, pub max_quote_levels: usize, @@ -47,7 +47,7 @@ impl MakerStyle { max_refill_delay_ms: 900, replenish_ratio_bps: 9_000, size_jitter_bps: 1_000, - price_jitter_bps: 6, + price_jitter_pct: 6, hit_widening_bps: 10, local_book_weight_bps: 2_500, max_quote_levels: 10, @@ -59,7 +59,7 @@ impl MakerStyle { max_refill_delay_ms: 2_000, replenish_ratio_bps: 7_000, size_jitter_bps: 1_800, - price_jitter_bps: 12, + price_jitter_pct: 12, hit_widening_bps: 18, local_book_weight_bps: 4_000, max_quote_levels: 8, @@ -71,7 +71,7 @@ impl MakerStyle { max_refill_delay_ms: 4_000, replenish_ratio_bps: 5_500, size_jitter_bps: 2_500, - price_jitter_bps: 18, + price_jitter_pct: 18, hit_widening_bps: 28, local_book_weight_bps: 5_500, max_quote_levels: 6, @@ -97,7 +97,7 @@ pub struct ValidMakerConfig { pub max_refill_delay_ms: u64, pub replenish_ratio_bps: u16, pub size_jitter_bps: u16, - pub price_jitter_bps: u16, + pub price_jitter_pct: u16, pub hit_widening_bps: u16, pub local_book_weight_bps: u16, pub max_quote_levels: usize, @@ -125,7 +125,7 @@ pub struct MakerConfigInput { pub max_refill_delay_ms: Option, pub replenish_ratio_bps: Option, pub size_jitter_bps: Option, - pub price_jitter_bps: Option, + pub price_jitter_pct: Option, pub hit_widening_bps: Option, pub local_book_weight_bps: Option, pub max_quote_levels: Option, @@ -155,7 +155,7 @@ pub async fn validate_config_and_endpoint( max_refill_delay_ms, replenish_ratio_bps, size_jitter_bps, - price_jitter_bps, + price_jitter_pct, hit_widening_bps, local_book_weight_bps, max_quote_levels, @@ -170,7 +170,7 @@ pub async fn validate_config_and_endpoint( let max_refill_delay_ms = max_refill_delay_ms.unwrap_or(defaults.max_refill_delay_ms); let replenish_ratio_bps = replenish_ratio_bps.unwrap_or(defaults.replenish_ratio_bps); let size_jitter_bps = size_jitter_bps.unwrap_or(defaults.size_jitter_bps); - let price_jitter_bps = price_jitter_bps.unwrap_or(defaults.price_jitter_bps); + let price_jitter_pct = price_jitter_pct.unwrap_or(defaults.price_jitter_pct); let hit_widening_bps = hit_widening_bps.unwrap_or(defaults.hit_widening_bps); let local_book_weight_bps = local_book_weight_bps.unwrap_or(defaults.local_book_weight_bps); let max_quote_levels = max_quote_levels.unwrap_or(defaults.max_quote_levels); @@ -197,13 +197,16 @@ pub async fn validate_config_and_endpoint( for (name, value) in [ ("replenish_ratio_bps", replenish_ratio_bps), ("size_jitter_bps", size_jitter_bps), - ("price_jitter_bps", price_jitter_bps), ("hit_widening_bps", hit_widening_bps), ("local_book_weight_bps", local_book_weight_bps), ("spread_multiplier_bps", spread_multiplier_bps), ] { anyhow::ensure!(value <= 20_000, "{name} must be <= 20000 bps"); } + anyhow::ensure!( + price_jitter_pct <= 100, + "price_jitter_pct must be <= 100 (percent of step)" + ); let oanda_args = OandaArgs { auth_token: oanda_auth_token, @@ -237,7 +240,7 @@ pub async fn validate_config_and_endpoint( max_refill_delay_ms, replenish_ratio_bps, size_jitter_bps, - price_jitter_bps, + price_jitter_pct, hit_widening_bps, local_book_weight_bps, max_quote_levels, diff --git a/services/maker-bot/src/maker_context.rs b/services/maker-bot/src/maker_context.rs index 54d9665b0..a69009611 100644 --- a/services/maker-bot/src/maker_context.rs +++ b/services/maker-bot/src/maker_context.rs @@ -97,7 +97,7 @@ pub struct MakerContext { max_refill_delay: Duration, replenish_ratio: Decimal, size_jitter_bps: u16, - price_jitter_bps: u16, + price_jitter_pct: u16, hit_widening_bps: u16, local_book_weight: Decimal, max_quote_levels: usize, @@ -127,7 +127,7 @@ impl MakerContext { max_refill_delay_ms, replenish_ratio_bps, size_jitter_bps, - price_jitter_bps, + price_jitter_pct, hit_widening_bps, local_book_weight_bps, max_quote_levels, @@ -190,7 +190,7 @@ impl MakerContext { max_refill_delay: Duration::from_millis(max_refill_delay_ms), replenish_ratio: Decimal::from(replenish_ratio_bps) / Decimal::from(10_000u64), size_jitter_bps, - price_jitter_bps, + price_jitter_pct, hit_widening_bps, local_book_weight: Decimal::from(local_book_weight_bps) / Decimal::from(10_000u64), max_quote_levels, @@ -634,7 +634,7 @@ impl MakerContext { }; let price_jitter = step * Decimal::from(self.rng.random_range( - -(self.price_jitter_bps as i32)..=(self.price_jitter_bps as i32), + -(self.price_jitter_pct as i32)..=(self.price_jitter_pct as i32), )) / Decimal::from(100u64); price += price_jitter; if price <= Decimal::ZERO { From 2c7537030fa7edd9dbfabd4caac0e965ea9e7e55 Mon Sep 17 00:00:00 2001 From: finnfujimura Date: Wed, 29 Apr 2026 14:52:06 -0700 Subject: [PATCH 18/24] maker-bot: fix jitter unit naming and tighten validation bounds --- services/maker-bot/src/config.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/maker-bot/src/config.rs b/services/maker-bot/src/config.rs index 319c366bd..0143214d8 100644 --- a/services/maker-bot/src/config.rs +++ b/services/maker-bot/src/config.rs @@ -198,11 +198,14 @@ pub async fn validate_config_and_endpoint( ("replenish_ratio_bps", replenish_ratio_bps), ("size_jitter_bps", size_jitter_bps), ("hit_widening_bps", hit_widening_bps), - ("local_book_weight_bps", local_book_weight_bps), ("spread_multiplier_bps", spread_multiplier_bps), ] { anyhow::ensure!(value <= 20_000, "{name} must be <= 20000 bps"); } + anyhow::ensure!( + local_book_weight_bps <= 10_000, + "local_book_weight_bps must be <= 10000 bps (convex weight in [0, 1])" + ); anyhow::ensure!( price_jitter_pct <= 100, "price_jitter_pct must be <= 100 (percent of step)" From 2468e5e0268ccb72a3a2d12e8a1b2ff08084f883 Mon Sep 17 00:00:00 2001 From: Finley Fujimura <147115573+finnfujimura@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:58:57 -0700 Subject: [PATCH 19/24] Update frontend/src/components/TransactionLog.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- frontend/src/components/TransactionLog.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/TransactionLog.tsx b/frontend/src/components/TransactionLog.tsx index fbe713684..a11347cb5 100644 --- a/frontend/src/components/TransactionLog.tsx +++ b/frontend/src/components/TransactionLog.tsx @@ -61,16 +61,16 @@ type FillData = { }; const TAKER_PERSONALITY_COLORS: Record = { - retail: "bg-sky-500/15 text-sky-400 border-sky-500/30", - whale: "bg-violet-500/15 text-violet-400 border-violet-500/30", - sniper: "bg-amber-500/15 text-amber-400 border-amber-500/30", - noise: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30", - passive: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30", - aggressive: "bg-rose-500/15 text-rose-400 border-rose-500/30", + retail: "border-sky-500/30 bg-sky-500/15 text-sky-400", + whale: "border-violet-500/30 bg-violet-500/15 text-violet-400", + sniper: "border-amber-500/30 bg-amber-500/15 text-amber-400", + noise: "border-zinc-500/30 bg-zinc-500/15 text-zinc-400", + passive: "border-emerald-500/30 bg-emerald-500/15 text-emerald-400", + aggressive: "border-rose-500/30 bg-rose-500/15 text-rose-400", }; -const MAKER_BADGE = "bg-indigo-500/15 text-indigo-400 border-indigo-500/30"; -const UNKNOWN_BADGE = "bg-muted/40 text-muted-fg border-border"; +const MAKER_BADGE = "border-indigo-500/30 bg-indigo-500/15 text-indigo-400"; +const UNKNOWN_BADGE = "border-border bg-muted/40 text-muted-fg"; function traderBadgeClass(trader: AgentRegistryEntry | null): string { if (!trader) return UNKNOWN_BADGE; From c25d3e49fcf47446e8f600949f1e283e16a300a8 Mon Sep 17 00:00:00 2001 From: finnfujimura Date: Fri, 8 May 2026 14:27:05 -0700 Subject: [PATCH 20/24] taker-bot: treat unknown spread as wide so the spread gate doesn't silently disable --- services/taker-bot/src/taker.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/services/taker-bot/src/taker.rs b/services/taker-bot/src/taker.rs index 393423a77..aaabda02e 100644 --- a/services/taker-bot/src/taker.rs +++ b/services/taker-bot/src/taker.rs @@ -538,8 +538,14 @@ impl TakerStrategy { return step; } - let spread_bps = snapshot.spread_bps.unwrap_or_default(); - let too_wide = spread_bps > self.execution_profile.max_spread_bps && parent.urgency < 0.85; + let too_wide = match snapshot.spread_bps { + Some(spread_bps) => { + spread_bps > self.execution_profile.max_spread_bps && parent.urgency < 0.85 + } + // Unknown spread (e.g. one side empty) — treat as too wide unless we're already urgent, + // so the gate doesn't silently disable itself in exactly the cases it's meant to catch. + None => parent.urgency < 0.85, + }; if too_wide { if parent.patience_ticks_remaining > 0 { parent.patience_ticks_remaining -= 1; From 220247c224043c730f038f9eac6e80cc9fdb6c47 Mon Sep 17 00:00:00 2001 From: finnfujimura Date: Fri, 8 May 2026 14:27:52 -0700 Subject: [PATCH 21/24] maker-bot: name the rolling-window length and pressure/volatility denominator constants --- services/maker-bot/src/maker_context.rs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/services/maker-bot/src/maker_context.rs b/services/maker-bot/src/maker_context.rs index a69009611..36865b5c0 100644 --- a/services/maker-bot/src/maker_context.rs +++ b/services/maker-bot/src/maker_context.rs @@ -35,6 +35,17 @@ use crate::{ MakerState, }; +/// Rolling window length (in ticks) used to estimate recent fair-price volatility. +const RECENT_FAIR_PRICE_WINDOW: usize = 24; + +/// Bps denominator (1.0 == 10_000 bps). +const BPS_DENOM: u64 = 10_000; + +/// Max value `buy_pressure` / `sell_pressure` are clamped to when computing +/// the dynamic half-spread. Used to normalize the pressure component to [0, 1] +/// of `hit_widening_bps`. +const MAX_PRESSURE_UNITS: u16 = 4; + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] struct HitSignals { ask_was_lifted: bool, @@ -549,7 +560,7 @@ impl MakerContext { fn record_fair_price(&mut self, price: Decimal) { self.recent_fair_prices.push_back(price); - while self.recent_fair_prices.len() > 24 { + while self.recent_fair_prices.len() > RECENT_FAIR_PRICE_WINDOW { self.recent_fair_prices.pop_front(); } } @@ -690,15 +701,20 @@ impl MakerContext { } fn dynamic_half_spread(&self) -> Decimal { + // Normalize pressure/volatility components to a fraction of `mid`: + // component = mid * widening_bps * units / (BPS_DENOM * MAX_PRESSURE_UNITS) + // i.e. pressure of `MAX_PRESSURE_UNITS` adds the full `widening_bps` worth of widening. + let denom = Decimal::from(BPS_DENOM * u64::from(MAX_PRESSURE_UNITS)); let base = half_spread() * self.spread_multiplier; let mid = self.effective_mid_price(); - let pressure_units = u16::from(self.buy_pressure.max(self.sell_pressure)).min(4); + let pressure_units = + u16::from(self.buy_pressure.max(self.sell_pressure)).min(MAX_PRESSURE_UNITS); let pressure_component = mid * Decimal::from(u64::from(self.hit_widening_bps) * u64::from(pressure_units)) - / Decimal::from(40_000u64); + / denom; let volatility_component = mid * Decimal::from(self.recent_volatility_bps().round().clamp(0.0, 100.0) as u64) - / Decimal::from(40_000u64); + / denom; base + pressure_component + volatility_component } From 60dff1e906996053b68dcfc82a2f35765ecba007 Mon Sep 17 00:00:00 2001 From: finnfujimura Date: Fri, 8 May 2026 14:28:05 -0700 Subject: [PATCH 22/24] initialization_helper: honor --force when (re)generating per-agent keypairs --- services/shared/examples/initialization_helper.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/shared/examples/initialization_helper.rs b/services/shared/examples/initialization_helper.rs index 83baa444a..98dbcaf2b 100644 --- a/services/shared/examples/initialization_helper.rs +++ b/services/shared/examples/initialization_helper.rs @@ -209,8 +209,11 @@ fn write_agent_registry(maker: &Keypair, agents: &[AgentEntry]) -> anyhow::Resul /// Loads a keypair from `path` if it exists, otherwise generates a new one and /// writes it to disk in the same JSON-array format that `solana-keygen` uses. +/// +/// When `--force` is passed, an existing file is overwritten with a freshly +/// generated keypair so localnet identities can be reset. fn load_or_create_keypair_file(path: &std::path::Path) -> anyhow::Result { - if path.exists() { + if path.exists() && !should_force_overwrite() { return read_keypair_file(path).map_err(|e| { anyhow::anyhow!("Couldn't open agent keypair file: {path:#?}, err: ({e})") }); From 456269e15848d68f217c8d6cc35e936106fbfa7a Mon Sep 17 00:00:00 2001 From: finnfujimura Date: Fri, 8 May 2026 14:28:13 -0700 Subject: [PATCH 23/24] services/.gitignore: document keypairs/ and taker-bot/agents.json as helper-generated --- services/.gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/services/.gitignore b/services/.gitignore index c708a353e..04ce43b24 100644 --- a/services/.gitignore +++ b/services/.gitignore @@ -1,4 +1,9 @@ config.toml keypair.json +# Per-agent keypair directory written by `services/shared/examples/initialization_helper.rs` +# when bootstrapping localnet (one file per taker agent). Regenerate by re-running the +# helper; pass `--force` to overwrite existing files. keypairs/ +# Generated agent registry (`{name, kind, pubkey}` array) consumed by the frontend +# for fill labeling. Written by the same initialization_helper run that funds the agents. taker-bot/agents.json From 23f62126b60f62ea248bc04d7ebca3e170620a44 Mon Sep 17 00:00:00 2001 From: finnfujimura Date: Fri, 8 May 2026 14:29:30 -0700 Subject: [PATCH 24/24] docs: describe taker archetypes, maker styles, and the agent registry flow --- services/maker-bot/README.md | 31 +++++++++++++++++++++++++++++++ services/taker-bot/README.md | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/services/maker-bot/README.md b/services/maker-bot/README.md index cdc4bcb2f..f74988353 100644 --- a/services/maker-bot/README.md +++ b/services/maker-bot/README.md @@ -4,6 +4,37 @@ A prototype market-making bot implementing a naive version of the [Avellaneda-Stoikov model] for a `dropset` market. Intended for experimentation and local testing, not production use. +## Quoting model + +In addition to the Avellaneda-Stoikov reservation price, the bot adapts its +quotes to local-book conditions: + +- **Effective mid** blends an external fair-value anchor (OANDA `S5`) with a + local microprice from the on-chain book, weighted by `local_book_weight_bps`. +- **Dynamic half-spread** widens with recent local-fair-price volatility and + with one-sided "hit pressure" (counted by comparing maker-owned depth across + market updates). +- **Hit detection** flags which side was most recently lifted/hit, applies a + per-side refill delay, and skips re-posting at the touch on that side until + the delay elapses. +- **Quote TTL** refreshes resting liquidity even without a websocket-triggered + state change, so quotes age out instead of going stale. +- **Per-level jitter** (size and price) is applied across the ladder so the + ladder looks less robotic. + +### Style presets + +Each maker run picks a `style` preset (overridable per field in `config.toml`): + +| Style | Quote TTL | Refill delay (min–max) | `replenish_ratio_bps` | `hit_widening_bps` | Max quote levels | Spread multiplier | +|-------------|-----------|------------------------|-----------------------|--------------------|------------------|-------------------| +| `tight` | 1 500 ms | 250–900 ms | 9 000 | 10 | 10 | 0.90× | +| `balanced` | 2 500 ms | 600–2 000 ms | 7 000 | 18 | 8 | 1.20× | +| `defensive` | 3 500 ms | 1 200–4 000 ms | 5 500 | 28 | 6 | 1.75× | + +See `src/config.rs` (`MakerStyleDefaults`) for the full preset values and +`config.toml.example` for descriptions of the override knobs. + ## Running 1. If you're using Docker Desktop, make sure `Enable host networking` is checked diff --git a/services/taker-bot/README.md b/services/taker-bot/README.md index 8eb4d37eb..10a8d2447 100644 --- a/services/taker-bot/README.md +++ b/services/taker-bot/README.md @@ -5,6 +5,38 @@ market. Order arrival is modeled as a Poisson process with two states (quiet / burst), and order sizes are drawn from a LogNormal distribution. Intended for experimentation and local testing, not production use. +## Multiple agents and archetypes + +The taker service runs **one or more agents** in the same process — each +configured under a `[[agent]]` block in `config.toml` with its own keypair and +behavior. Each agent picks a named **archetype** preset, and may override +individual fields on top of the preset's defaults. + +| Archetype | Activity profile | Execution profile | Median order size | Notes | +|---------------|---------------------------------|-------------------|-------------------|-------| +| `passive` | Slow Poisson arrivals | `patient` | 2 000 | Light continuous flow; tolerates wider spreads. | +| `retail` | Moderate, occasional bursts | `balanced` | 3 000 | Generic background retail-style trader. | +| `aggressive` | Fast / bursty arrivals | `aggressive` | 5 000 | Sweeps multiple levels, low spread tolerance. | +| `whale` | Fast / bursty arrivals | `aggressive` | 15 000 | Large parent orders, willing to pay up. | +| `sniper` | Slow base, opportunistic spikes | `sniper` | 3 000 | Sits idle, then fires when conditions align. | +| `noise` | High-frequency, low size | `noise` | 1 000 | Steady CLOB chatter; no directional bias. | + +Each archetype is a *style* preset: it pairs an `ActivityProfile` (Poisson +rates, burst entry/exit probabilities) with an `ExecutionProfile` (max spread +tolerated, sweep depth, child-sizing fractions, etc.). See +`src/archetype.rs` and `src/taker.rs` for the full preset values, and +`config.toml.example` for the override knobs you can tune per agent. + +### Agent registry (`agents.json`) + +When you bootstrap localnet via `services/run-services-on-localnet.sh`, the +`initialization_helper` writes a `services/taker-bot/agents.json` registry of +the form `[{ name, kind, pubkey }, ...]`. The frontend reads this registry +through `/api/agents` to label fills in the transaction log by trader +personality. The file is gitignored — re-running the helper regenerates it, +and `--force` will overwrite the underlying agent keypairs in `keypairs/` as +well. + ## Running 1. If you're using Docker Desktop, make sure `Enable host networking` is checked