diff --git a/.github/workflows/testnet11-asset-bootstrap-helper.yml b/.github/workflows/testnet11-asset-bootstrap-helper.yml index a9d724dd..28bd091c 100644 --- a/.github/workflows/testnet11-asset-bootstrap-helper.yml +++ b/.github/workflows/testnet11-asset-bootstrap-helper.yml @@ -282,7 +282,6 @@ jobs: snippet_lines.append(f" fixed_quote_per_base: {fixed_quote_per_base}") snippet_lines.append(" quote_unit_mojo_multiplier: 1000000000000") snippet_lines.append(" slippage_bps: 100") - snippet_lines.append(" strategy_target_spread_bps: 140") snippet_lines.append(" strategy_min_xch_price_usd: 20.0") snippet_lines.append(" strategy_max_xch_price_usd: 60.0") snippet_lines.append(" strategy_offer_expiry_minutes: 10") diff --git a/config/markets.yaml b/config/markets.yaml index 18c96473..2689028a 100644 --- a/config/markets.yaml +++ b/config/markets.yaml @@ -16,7 +16,6 @@ markets: min_price_quote_per_base: 0.0031 max_price_quote_per_base: 0.0038 slippage_bps: 100 - strategy_target_spread_bps: 140 strategy_min_xch_price_usd: 20.0 strategy_max_xch_price_usd: 60.0 strategy_offer_expiry_minutes: 10 @@ -95,7 +94,6 @@ markets: min_price_quote_per_base: 0.0027 max_price_quote_per_base: 0.0033 slippage_bps: 100 - strategy_target_spread_bps: 140 strategy_min_xch_price_usd: 20.0 strategy_max_xch_price_usd: 60.0 strategy_offer_expiry_minutes: 10 @@ -174,7 +172,6 @@ markets: min_price_quote_per_base: 0.0030 max_price_quote_per_base: 0.0037 slippage_bps: 100 - strategy_target_spread_bps: 140 strategy_offer_expiry_minutes: 10 cancel_move_threshold_bps: 300 cancel_policy_stable_vs_unstable: true @@ -252,7 +249,6 @@ markets: min_price_quote_per_base: 0.0027 max_price_quote_per_base: 0.0033 slippage_bps: 100 - strategy_target_spread_bps: 140 strategy_min_xch_price_usd: 20.0 strategy_max_xch_price_usd: 60.0 strategy_offer_expiry_minutes: 10 @@ -364,7 +360,9 @@ markets: signer_key_id: "key-main-2" receive_address: "xch1u3tytpv45sj0h4lpwmtkyzh2ggvw4x7jccyxzu995p2aj40wzcxqvymyn3" pricing: - fixed_quote_per_base: 0.999 + # Mid at par; 20 bps full spread → bid 0.999 / ask 1.001 so 1.0 is inside. + fixed_quote_per_base: 1.0 + strategy_target_spread_bps: 20 strategy_offer_expiry_minutes: 30 inventory: low_watermark_base_units: 200 diff --git a/config/testnet-markets.yaml b/config/testnet-markets.yaml index 9ae1df49..33bc9f63 100644 --- a/config/testnet-markets.yaml +++ b/config/testnet-markets.yaml @@ -14,7 +14,6 @@ markets: fixed_quote_per_base: 0.004714285714285714 quote_unit_mojo_multiplier: 1000000000000 slippage_bps: 100 - strategy_target_spread_bps: 140 strategy_min_xch_price_usd: 20.0 strategy_max_xch_price_usd: 60.0 strategy_offer_expiry_minutes: 10 @@ -56,7 +55,6 @@ markets: fixed_quote_per_base: 0.002420604183 quote_unit_mojo_multiplier: 1000000000000 slippage_bps: 100 - strategy_target_spread_bps: 140 strategy_min_xch_price_usd: 20.0 strategy_max_xch_price_usd: 60.0 strategy_offer_expiry_minutes: 10 diff --git a/docs/README.md b/docs/README.md index 4a087bd4..8ee99495 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,7 @@ accepted decision** when onboarding. | ADR | Topic | | ---------------------------------------------------------------- | ----------------------------------------------------------------------- | +| [0025](decisions/0025-two-sided-target-spread.md) | **Two-sided spread** — bid/ask around mid; sell-only omits the field | | [0023](decisions/0023-canonical-cat-outer-puzzle-hash.md) | **CAT outer hash** — one `coinset/cats/outer` primitive + Coinset hex | | [0021](decisions/0021-three-ownership-simplifications.md) | **Ownership spines** — expired maker, reconcile prep, `coin_ops::shape` | | [0020](decisions/0020-soft-expiry-stable-makers.md) | **Soft listing expiry** — stable makers, `ensure_size_n_offer` | diff --git a/docs/decisions/0025-two-sided-target-spread.md b/docs/decisions/0025-two-sided-target-spread.md new file mode 100644 index 00000000..92836296 --- /dev/null +++ b/docs/decisions/0025-two-sided-target-spread.md @@ -0,0 +1,35 @@ +# ADR 0025: Two-sided target spread around mid + +## Status + +Accepted (2026-08-12). + +## Context + +`strategy_target_spread_bps` was parsed and copied onto strategy actions, but offer +build used a single mid (`fixed_quote_per_base` or min/max midpoint) for both sides. +`byc_two_sided_wusdbc` therefore posted bid and ask at the same price (0.999), so +there was no spread and par (1.0) sat above both quotes. + +Sell-only books used to set `strategy_target_spread_bps` (historically unused). Applying +it there would move ECO ask prices, so those configs omit the field. Strategy/dispatch +also carried a dead `target_spread_bps` on `PlannedAction` that never reached quote math. + +## Decision + +1. **Full-width bps, split equally.** `strategy_target_spread_bps` is the bid-ask width. + Buy = mid × (1 − half); sell = mid × (1 + half). +2. **Two-sided only.** `MarketConfig::quote_price_for_side` is the only policy entry. + It applies the offset when `mode` is `two_sided`. Sell-only YAML omits the field. + If it is still present, `quote_price` stays mid so it cannot move posted asks. +3. **One price path.** Create, unique-maker pin, bootstrap denomination, and reservation + all take the side-adjusted quote from `MarketConfig` so clip mojos stay consistent. + Strategy actions do not carry spread; `PlannedAction` / `StrategyConfig` no longer + have `target_spread_bps`. + +## Consequences + +- `byc_two_sided_wusdbc` mid 1.0 + 20 bps posts bid 0.999 / ask 1.001 (1.0 inside). +- Buy clips change with the bid; existing 9990-mojo wUSDC.b makers still match 20 bps + at size 10. Sell makers stay 10000-mojo BYC; only the requested quote amount changes. +- Open Direct listings at the old price remain until listing expiry or take. diff --git a/docs/progress.md b/docs/progress.md index d3dd6e28..93789d3c 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -16,6 +16,12 @@ Pre-Rust migration detail lives in git history and ## Milestones +### 2026-08-12 — Two-sided target spread (ADR 0025) + +`strategy_target_spread_bps` now offsets two-sided bid/ask around mid (buy below, sell +above). Sell-only configs omit the field and keep mid. `byc_two_sided_wusdbc` is mid +1.0 + 20 bps so par sits inside the book (bid 0.999 / ask 1.001). + ### 2026-08-10 — Canonical CAT outer puzzle-hash (ADR 0023) `cat(asset_id, p2)` outer hashes curry once in `coinset/cats/outer`; vault create-assert diff --git a/greenfloor-engine/src/config/markets/mod.rs b/greenfloor-engine/src/config/markets/mod.rs index 6d5b1f32..17f152e9 100644 --- a/greenfloor-engine/src/config/markets/mod.rs +++ b/greenfloor-engine/src/config/markets/mod.rs @@ -45,6 +45,32 @@ pub struct MarketConfig { pub ladders: HashMap>, } +impl MarketConfig { + #[must_use] + pub fn is_two_sided(&self) -> bool { + self.mode.trim().eq_ignore_ascii_case("two_sided") + } + + /// Quote-per-base for `side`. Two-sided markets apply `strategy_target_spread_bps` + /// around mid (buy below, sell above). One-sided markets keep the mid price so + /// unused spread fields on sell-only books do not move posted prices. + /// + /// # Errors + /// + /// Returns an error when pricing lacks a usable quote price. + pub fn quote_price_for_side(&self, side: &str) -> SignerResult { + let mid = self.pricing.quote_price()?; + if !self.is_two_sided() { + return Ok(mid); + } + Ok(pricing::spread_adjusted_quote_price( + mid, + self.pricing.strategy_target_spread_bps, + side, + )) + } +} + #[derive(Debug, Clone)] pub struct MarketsConfig { pub markets: Vec, @@ -206,4 +232,39 @@ mod soft_expiry_tests { disabled.enabled = false; assert!(!market_wants_ladder_size(&disabled, "sell", 10)); } + + #[test] + fn two_sided_quote_price_applies_spread_sell_only_does_not() { + let pricing = MarketPricing { + fixed_quote_per_base: Some(1.0), + strategy_target_spread_bps: Some(20), + ..MarketPricing::default() + }; + let two_sided = MarketConfig { + market_id: "byc".to_string(), + enabled: true, + unique_maker_coins: true, + base_asset: "BYC".to_string(), + base_symbol: "BYC".to_string(), + quote_asset: "wUSDC.b".to_string(), + quote_asset_type: "stable".to_string(), + receive_address: "xch1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq2u30w" + .to_string(), + signer_key_id: "k".to_string(), + mode: "two_sided".to_string(), + pricing: pricing.clone(), + cancel_move_threshold_bps: None, + ladders: HashMap::new(), + }; + let buy = two_sided.quote_price_for_side("buy").expect("buy"); + let sell = two_sided.quote_price_for_side("sell").expect("sell"); + assert!(buy < 1.0 && 1.0 < sell); + + let mut sell_only = two_sided.clone(); + sell_only.mode = "sell_only".to_string(); + let sell_mid = sell_only.quote_price_for_side("sell").expect("mid"); + let buy_mid = sell_only.quote_price_for_side("buy").expect("mid"); + assert!((sell_mid - 1.0).abs() < 1e-12); + assert!((buy_mid - 1.0).abs() < 1e-12); + } } diff --git a/greenfloor-engine/src/config/markets/pricing.rs b/greenfloor-engine/src/config/markets/pricing.rs index cabd4de6..e83b8b0a 100644 --- a/greenfloor-engine/src/config/markets/pricing.rs +++ b/greenfloor-engine/src/config/markets/pricing.rs @@ -48,7 +48,7 @@ impl MarketPricing { .unwrap_or(DEFAULT_OFFER_EXPIRY_MINUTES) } - /// Quote-per-base for manual / managed offer build. + /// Mid quote-per-base for manual / managed offer build (no bid-ask offset). /// /// # Errors /// @@ -116,6 +116,12 @@ pub(super) fn parse_market_pricing( optional_positive_i64(obj, market_id, "cancel_move_threshold_bps")?; let strategy_target_spread_bps = optional_positive_i64(obj, market_id, "strategy_target_spread_bps")?; + if strategy_target_spread_bps.is_some_and(|bps| bps >= 20_000) { + return Err(market_err( + market_id, + "strategy_target_spread_bps must be < 20000", + )); + } let strategy_min_xch_price_usd = optional_positive_f64(obj, market_id, "strategy_min_xch_price_usd")?; let strategy_max_xch_price_usd = @@ -171,6 +177,21 @@ pub(super) fn parse_market_pricing( }) } +/// Full spread in bps, split equally around mid. Buy below mid; sell above. +#[must_use] +#[allow(clippy::cast_precision_loss)] +pub(super) fn spread_adjusted_quote_price(mid: f64, spread_bps: Option, side: &str) -> f64 { + let Some(bps) = spread_bps.filter(|value| *value > 0) else { + return mid; + }; + let half = (bps as f64) / 20_000.0; + if side.trim().eq_ignore_ascii_case("buy") { + mid * (1.0 - half) + } else { + mid * (1.0 + half) + } +} + fn reject_legacy_fields(obj: &Map, market_id: &str) -> SignerResult<()> { for legacy_field in ["reference_source", "reference_pair"] { if obj.contains_key(legacy_field) { @@ -347,4 +368,33 @@ mod tests { .expect("valid"); assert_eq!(parsed.cancel_move_threshold_bps, Some(250)); } + + #[test] + fn spread_adjusted_quote_price_offsets_half_spread_around_mid() { + let buy = spread_adjusted_quote_price(1.0, Some(20), "buy"); + let sell = spread_adjusted_quote_price(1.0, Some(20), "sell"); + assert!((buy - 0.999).abs() < 1e-12); + assert!((sell - 1.001).abs() < 1e-12); + assert!(buy < 1.0 && 1.0 < sell); + } + + #[test] + fn spread_adjusted_quote_price_without_spread_is_mid() { + assert!((spread_adjusted_quote_price(0.999, None, "buy") - 0.999).abs() < 1e-12); + assert!((spread_adjusted_quote_price(0.999, None, "sell") - 0.999).abs() < 1e-12); + } + + #[test] + fn parse_rejects_spread_that_would_zero_the_buy_side() { + let err = parse_market_pricing( + Some(&json!({"strategy_target_spread_bps": 20_000})), + "BYC", + "wUSDC.b", + "stable", + "m1", + ) + .err() + .expect("spread too wide"); + assert!(err.to_string().contains("strategy_target_spread_bps")); + } } diff --git a/greenfloor-engine/src/cycle/dispatch.rs b/greenfloor-engine/src/cycle/dispatch.rs index b57e8f40..5bce637e 100644 --- a/greenfloor-engine/src/cycle/dispatch.rs +++ b/greenfloor-engine/src/cycle/dispatch.rs @@ -21,8 +21,6 @@ pub struct PlannedActionInput { pub cancel_after_create: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub reason: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target_spread_bps: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -177,7 +175,6 @@ mod tests { expiry_value: None, cancel_after_create: None, reason: None, - target_spread_bps: None, }, PlannedActionInput { size: 10, @@ -188,7 +185,6 @@ mod tests { expiry_value: None, cancel_after_create: None, reason: None, - target_spread_bps: None, }, ]; let expanded = expand_inputs_by_repeat(&actions); diff --git a/greenfloor-engine/src/cycle/execution.rs b/greenfloor-engine/src/cycle/execution.rs index 17ce446d..8d475deb 100644 --- a/greenfloor-engine/src/cycle/execution.rs +++ b/greenfloor-engine/src/cycle/execution.rs @@ -25,7 +25,18 @@ pub struct ParallelReservationContext { pub fee_amount_mojos: i64, pub base_unit_mojo_multiplier: i64, pub quote_unit_mojo_multiplier: i64, - pub quote_price: f64, + pub sell_quote_price: f64, + pub buy_quote_price: f64, +} + +impl ParallelReservationContext { + fn quote_price_for_side(&self, side: &str) -> f64 { + if side.trim().eq_ignore_ascii_case("buy") { + self.buy_quote_price + } else { + self.sell_quote_price + } + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -55,7 +66,7 @@ fn build_parallel_reservation_prep( quote_asset_id: &ctx.quote_asset_id, base_unit_mojo_multiplier: ctx.base_unit_mojo_multiplier, quote_unit_mojo_multiplier: ctx.quote_unit_mojo_multiplier, - quote_price: ctx.quote_price, + quote_price: ctx.quote_price_for_side(&action.side), fee_asset_id: &ctx.fee_asset_id, fee_amount_mojos: ctx.fee_amount_mojos, })?; @@ -173,7 +184,6 @@ pub fn expand_planned_actions(actions: &[PlannedAction]) -> Vec { expiry_value: Some(action.expiry_value), cancel_after_create: Some(action.cancel_after_create), reason: Some(action.reason.clone()), - target_spread_bps: action.target_spread_bps, }) .collect(); expand_inputs_by_repeat(&inputs) @@ -187,7 +197,6 @@ pub fn expand_planned_actions(actions: &[PlannedAction]) -> Vec { expiry_value: input.expiry_value.unwrap_or(0), cancel_after_create: input.cancel_after_create.unwrap_or(false), reason: input.reason.unwrap_or_default(), - target_spread_bps: input.target_spread_bps, }) .collect() } @@ -205,7 +214,8 @@ mod tests { fee_amount_mojos: 0, base_unit_mojo_multiplier: 1000, quote_unit_mojo_multiplier: 1000, - quote_price: 1.5, + sell_quote_price: 1.5, + buy_quote_price: 1.5, } } @@ -220,7 +230,6 @@ mod tests { expiry_value: 0, cancel_after_create: false, reason: String::new(), - target_spread_bps: None, side: "sell".to_string(), }]; let plan = @@ -241,7 +250,6 @@ mod tests { expiry_value: 0, cancel_after_create: false, reason: String::new(), - target_spread_bps: None, side: "sell".to_string(), }]; let profiles = BTreeMap::from([( @@ -272,7 +280,6 @@ mod tests { expiry_value: 0, cancel_after_create: false, reason: String::new(), - target_spread_bps: None, side: "sell".to_string(), }, PlannedAction { @@ -283,7 +290,6 @@ mod tests { expiry_value: 0, cancel_after_create: false, reason: String::new(), - target_spread_bps: None, side: "sell".to_string(), }, ]; @@ -303,6 +309,38 @@ mod tests { assert_eq!(plan.queue[0].submit_index, 1); } + #[test] + fn plan_parallel_managed_dispatch_buy_reserves_spread_adjusted_quote() { + let mut ctx = sample_reservation_context(); + ctx.sell_quote_price = 1.001; + ctx.buy_quote_price = 0.999; + let actions = vec![PlannedAction { + size: 10, + repeat: 1, + pair: String::new(), + expiry_unit: String::new(), + expiry_value: 0, + cancel_after_create: false, + reason: String::new(), + side: "buy".to_string(), + }]; + let spendable_profiles = BTreeMap::from([( + "quote_asset".to_string(), + SpendableAssetProfile { + total: 50_000, + max_single: 50_000, + max_single_known: true, + }, + )]); + let plan = + plan_parallel_managed_dispatch(&actions, &ctx, &spendable_profiles).expect("plan"); + assert_eq!(plan.queue.len(), 1); + assert_eq!( + plan.queue[0].requested_amounts.get("quote_asset"), + Some(&9_990) + ); + } + #[test] fn expand_planned_actions_sets_repeat_one_per_unit() { let actions = vec![PlannedAction { @@ -313,7 +351,6 @@ mod tests { expiry_value: 10, cancel_after_create: true, reason: "below_target".to_string(), - target_spread_bps: None, side: "sell".to_string(), }]; let expanded = expand_planned_actions(&actions); diff --git a/greenfloor-engine/src/cycle/reseed.rs b/greenfloor-engine/src/cycle/reseed.rs index f8bedcf2..15f0dcf6 100644 --- a/greenfloor-engine/src/cycle/reseed.rs +++ b/greenfloor-engine/src/cycle/reseed.rs @@ -125,7 +125,6 @@ pub fn plan_reseed_actions_from_gap( expiry_value: template.expiry_value, cancel_after_create: template.cancel_after_create, reason: "offer_size_gap_reseed".to_string(), - target_spread_bps: template.target_spread_bps, side: template.side.clone(), }); } @@ -165,7 +164,6 @@ mod tests { ones_target: 5, tens_target: 2, hundreds_target: 1, - target_spread_bps: None, min_xch_price_usd: None, max_xch_price_usd: None, offer_expiry_minutes: None, @@ -192,7 +190,6 @@ mod tests { expiry_value: 10, cancel_after_create: true, reason: "below_target".to_string(), - target_spread_bps: None, side: "sell".to_string(), }]; let plan = plan_reseed_actions_from_gap( diff --git a/greenfloor-engine/src/cycle/strategy.rs b/greenfloor-engine/src/cycle/strategy.rs index 2861b9cd..a79032bc 100644 --- a/greenfloor-engine/src/cycle/strategy.rs +++ b/greenfloor-engine/src/cycle/strategy.rs @@ -23,8 +23,6 @@ pub struct StrategyConfig { #[serde(default = "default_hundreds_target")] pub hundreds_target: i64, #[serde(default, skip_serializing_if = "Option::is_none")] - pub target_spread_bps: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub min_xch_price_usd: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub max_xch_price_usd: Option, @@ -55,8 +53,6 @@ pub struct PlannedAction { pub expiry_value: i64, pub cancel_after_create: bool, pub reason: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target_spread_bps: Option, #[serde(default = "default_side")] pub side: String, } @@ -141,7 +137,6 @@ pub fn evaluate_market(state: &MarketState, config: &StrategyConfig) -> Vec String { @@ -23,7 +22,8 @@ pub async fn parallel_reservation_context( let fee_asset_id = resolver.resolve_fee_asset(&assets).await?; let base_unit_mojo_multiplier = market.pricing.base_mojo_multiplier(&assets.base_asset_id); let quote_unit_mojo_multiplier = market.pricing.quote_mojo_multiplier(&assets.quote_asset_id); - let quote_price = resolve_quote_price_for_pricing(&market.pricing)?; + let sell_quote_price = market.quote_price_for_side("sell")?; + let buy_quote_price = market.quote_price_for_side("buy")?; Ok(ParallelReservationContext { base_asset_id: assets.base_asset_id.trim().to_string(), quote_asset_id: assets.quote_asset_id.trim().to_string(), @@ -31,7 +31,8 @@ pub async fn parallel_reservation_context( fee_amount_mojos, base_unit_mojo_multiplier, quote_unit_mojo_multiplier, - quote_price, + sell_quote_price, + buy_quote_price, }) } diff --git a/greenfloor-engine/src/daemon/offer_dispatch/tests/harness.rs b/greenfloor-engine/src/daemon/offer_dispatch/tests/harness.rs index d1899c94..747e1156 100644 --- a/greenfloor-engine/src/daemon/offer_dispatch/tests/harness.rs +++ b/greenfloor-engine/src/daemon/offer_dispatch/tests/harness.rs @@ -83,7 +83,6 @@ pub(super) fn sample_action() -> PlannedAction { expiry_value: 10, cancel_after_create: false, reason: "test".to_string(), - target_spread_bps: None, side: "sell".to_string(), } } diff --git a/greenfloor-engine/src/daemon/strategy_support.rs b/greenfloor-engine/src/daemon/strategy_support.rs index 2adcbbab..e83c9866 100644 --- a/greenfloor-engine/src/daemon/strategy_support.rs +++ b/greenfloor-engine/src/daemon/strategy_support.rs @@ -156,9 +156,6 @@ fn strategy_config_for_ladder( ones_target: *normalized.get(&1).unwrap_or(&0), tens_target: *normalized.get(&10).unwrap_or(&0), hundreds_target: *normalized.get(&100).unwrap_or(&0), - target_spread_bps: include_pricing_bounds - .then_some(pricing.strategy_target_spread_bps) - .flatten(), min_xch_price_usd: include_pricing_bounds .then_some(pricing.strategy_min_xch_price_usd) .flatten(), @@ -261,10 +258,9 @@ mod tests { } #[test] - fn buy_side_config_omits_spread_and_price_bounds() { + fn buy_side_config_omits_price_bounds() { let market = sample_market(); let buy = strategy_config_for_ladder(&market, "mainnet", "buy", false); - assert!(buy.target_spread_bps.is_none()); assert!(buy.min_xch_price_usd.is_none()); assert!(buy.max_xch_price_usd.is_none()); } diff --git a/greenfloor-engine/src/offer/action.rs b/greenfloor-engine/src/offer/action.rs index c75becf7..cf479414 100644 --- a/greenfloor-engine/src/offer/action.rs +++ b/greenfloor-engine/src/offer/action.rs @@ -12,9 +12,7 @@ use crate::config::{ use crate::error::{SignerError, SignerResult}; use crate::offer::assets::OfferAssetResolver; use crate::offer::build::build_vault_cat_offer; -use crate::offer::build_context::{ - resolve_offer_expiry_for_pricing, resolve_quote_price_for_pricing, -}; +use crate::offer::build_context::resolve_offer_expiry_for_pricing; use crate::offer::request::{compute_signer_offer_leg_amounts, normalize_offer_side}; use crate::offer::types::{ effective_maker_reuse, CreateOfferRequest, CreateOfferResult, OfferTerms, PresplitMakerReuse, @@ -87,7 +85,7 @@ pub(crate) fn offer_terms_from_resolved_assets( size_base_units: u64, side: &str, ) -> SignerResult { - let quote_price = resolve_quote_price_for_pricing(&market.pricing)?; + let quote_price = market.quote_price_for_side(side)?; let size_i64 = i64::try_from(size_base_units).map_err(|_| SignerError::InvalidSizeBaseUnits)?; let leg = compute_signer_offer_leg_amounts( size_i64, @@ -132,12 +130,10 @@ pub(crate) async fn plan_offer_terms_for_market( } fn resolve_quote_price(request: &BuildOfferForActionRequest) -> SignerResult { - if let Some(price) = request.quote_price { - if price > 0.0 { - return Ok(price); - } + if let Some(price) = request.quote_price.filter(|price| *price > 0.0) { + return Ok(price); } - resolve_quote_price_for_pricing(&request.pricing) + request.pricing.quote_price() } /// Build signer offer for action. @@ -324,4 +320,34 @@ mod tests { assert_eq!(terms.request_asset_id, "xch"); assert!(!terms.bake_expiry_into_conditions); } + + #[test] + fn two_sided_spread_puts_par_inside_bid_and_ask() { + use crate::offer::assets::ResolvedMarketOfferAssets; + use crate::test_support::market_config::sample_market; + + let quote = "bb".repeat(32); + let mut market = + sample_market("xch1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq2u30w"); + market.mode = "two_sided".into(); + market.quote_asset_type = "stable".into(); + market.pricing = MarketPricing { + fixed_quote_per_base: Some(1.0), + strategy_target_spread_bps: Some(20), + base_unit_mojo_multiplier: Some(1_000), + quote_unit_mojo_multiplier: Some(1_000), + ..MarketPricing::default() + }; + let assets = ResolvedMarketOfferAssets { + base_asset_id: "aa".repeat(32), + quote_asset_id: quote.clone(), + quote_asset_for_offer: quote.clone(), + }; + let sell = offer_terms_from_resolved_assets(&market, &assets, 10, "sell").expect("sell"); + let buy = offer_terms_from_resolved_assets(&market, &assets, 10, "buy").expect("buy"); + assert_eq!(sell.offer_amount, 10_000); + assert_eq!(sell.request_amount, 10_010); + assert_eq!(buy.offer_amount, 9_990); + assert_eq!(buy.request_amount, 10_000); + } } diff --git a/greenfloor-engine/src/offer/operator/build_and_post/context.rs b/greenfloor-engine/src/offer/operator/build_and_post/context.rs index 642b74ef..37d4bbea 100644 --- a/greenfloor-engine/src/offer/operator/build_and_post/context.rs +++ b/greenfloor-engine/src/offer/operator/build_and_post/context.rs @@ -4,6 +4,7 @@ use crate::config::{ GatedOperatorMarketLoadRequest, MarketPricing, OperatorMarketCommand, }; use crate::error::SignerResult; +#[cfg(test)] use crate::offer::build_context::resolve_quote_price_for_pricing; use crate::offer::{normalize_offer_side, ResolvedMarketOfferAssets}; @@ -35,13 +36,15 @@ impl ResolvedBuildAndPostContext { ) } - /// Quote-per-base from market pricing. + /// Quote-per-base for this action side (two-sided markets apply the configured spread). /// /// # Errors /// /// Returns an error when pricing lacks a usable quote price. pub(crate) fn quote_price(&self) -> SignerResult { - resolve_quote_price_for_pricing(&self.gated.market_row.pricing) + self.gated + .market_row + .quote_price_for_side(&self.action_side()) } } @@ -222,9 +225,23 @@ pub(crate) fn sample_resolved_build_and_post_context() -> ResolvedBuildAndPostCo #[cfg(test)] mod tests { - use super::resolve_maker_offer_fee; + use super::{resolve_maker_offer_fee, sample_resolved_build_and_post_context}; use crate::test_support::signer_config::test_signer_config; + #[test] + fn quote_price_applies_two_sided_spread_per_side() { + let mut ctx = sample_resolved_build_and_post_context(); + ctx.gated.market_row.mode = "two_sided".to_string(); + ctx.gated.market_row.pricing.strategy_target_spread_bps = Some(20); + ctx.action_side_override = Some("buy".to_string()); + let buy = ctx.quote_price().expect("buy"); + ctx.action_side_override = Some("sell".to_string()); + let sell = ctx.quote_price().expect("sell"); + assert!((buy - 0.999).abs() < 1e-12); + assert!((sell - 1.001).abs() < 1e-12); + assert!(buy < 1.0 && 1.0 < sell); + } + #[tokio::test] async fn resolve_maker_offer_fee_uses_signer_coinset_endpoint() { let mut server = mockito::Server::new_async().await; diff --git a/greenfloor-engine/src/offer/operator/unique_maker.rs b/greenfloor-engine/src/offer/operator/unique_maker.rs index 7f4b724a..ccd792a5 100644 --- a/greenfloor-engine/src/offer/operator/unique_maker.rs +++ b/greenfloor-engine/src/offer/operator/unique_maker.rs @@ -7,7 +7,6 @@ use crate::config::{MarketConfig, SignerConfig}; use crate::error::{SignerError, SignerResult}; use crate::hex::normalize_hex_id; use crate::offer::assets::ResolvedMarketOfferAssets; -use crate::offer::build_context::resolve_quote_price_for_pricing; use crate::offer::request::compute_signer_offer_leg_amounts; use crate::offer::types::{effective_maker_reuse, PresplitMakerReuse}; use crate::storage::SqliteStore; @@ -186,7 +185,7 @@ fn offered_leg_for_unique_pin( size_base_units: u64, side: &str, ) -> SignerResult<(String, u64)> { - let quote_price = resolve_quote_price_for_pricing(&market.pricing)?; + let quote_price = market.quote_price_for_side(side)?; let size_i64 = i64::try_from(size_base_units).map_err(|_| SignerError::InvalidSizeBaseUnits)?; let leg = compute_signer_offer_leg_amounts( size_i64, @@ -520,6 +519,32 @@ mod tests { assert_eq!(amount, 10_000); } + #[test] + fn offered_leg_for_unique_pin_buy_uses_bid_price() { + let base = "ab".repeat(32); + let quote = "cd".repeat(32); + let mut market = + sample_market("xch1a0t57qn6uhe7tzjlxlhwy2qgmuxvvft8gnfzmg5detg0q9f3yc3s2apz0h"); + market.mode = "two_sided".to_string(); + market.base_asset = base.clone(); + market.quote_asset = quote.clone(); + market.pricing = MarketPricing { + fixed_quote_per_base: Some(1.0), + strategy_target_spread_bps: Some(20), + base_unit_mojo_multiplier: Some(1_000), + quote_unit_mojo_multiplier: Some(1_000), + ..MarketPricing::default() + }; + let assets = ResolvedMarketOfferAssets { + base_asset_id: base, + quote_asset_id: quote.clone(), + quote_asset_for_offer: quote.clone(), + }; + let (asset, amount) = offered_leg_for_unique_pin(&market, &assets, 10, "buy").expect("leg"); + assert_eq!(asset, quote); + assert_eq!(amount, 9_990); + } + #[tokio::test] async fn pin_unique_exact_maker_coin_id_lists_and_picks_exact_xch() { const RECEIVE: &str = "xch1a0t57qn6uhe7tzjlxlhwy2qgmuxvvft8gnfzmg5detg0q9f3yc3s2apz0h"; diff --git a/greenfloor-engine/src/test_support/eco181_cycle_cases.rs b/greenfloor-engine/src/test_support/eco181_cycle_cases.rs index 0634b56a..9887d4af 100644 --- a/greenfloor-engine/src/test_support/eco181_cycle_cases.rs +++ b/greenfloor-engine/src/test_support/eco181_cycle_cases.rs @@ -14,7 +14,6 @@ mod tests { ones_target: 5, tens_target: 2, hundreds_target: 1, - target_spread_bps: None, min_xch_price_usd: None, max_xch_price_usd: None, offer_expiry_minutes: Some(120),