Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/testnet11-asset-bootstrap-helper.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
8 changes: 3 additions & 5 deletions config/markets.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions config/testnet-markets.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
35 changes: 35 additions & 0 deletions docs/decisions/0025-two-sided-target-spread.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions docs/progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions greenfloor-engine/src/config/markets/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,32 @@ pub struct MarketConfig {
pub ladders: HashMap<String, Vec<LadderEntry>>,
}

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<f64> {
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<MarketConfig>,
Expand Down Expand Up @@ -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);
}
}
52 changes: 51 additions & 1 deletion greenfloor-engine/src/config/markets/pricing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
///
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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<i64>, 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<String, Value>, market_id: &str) -> SignerResult<()> {
for legacy_field in ["reference_source", "reference_pair"] {
if obj.contains_key(legacy_field) {
Expand Down Expand Up @@ -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"));
}
}
4 changes: 0 additions & 4 deletions greenfloor-engine/src/cycle/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@ pub struct PlannedActionInput {
pub cancel_after_create: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_spread_bps: Option<i64>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
Expand Down Expand Up @@ -177,7 +175,6 @@ mod tests {
expiry_value: None,
cancel_after_create: None,
reason: None,
target_spread_bps: None,
},
PlannedActionInput {
size: 10,
Expand All @@ -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);
Expand Down
Loading
Loading