Skip to content

Commit 3c26625

Browse files
committed
feat(stitch): add configurable repost lead time for order overlap
- Add `repost_lead_secs` configuration option to control when sides repost before expiry, ensuring replacement orders overlap with live orders instead of leaving gaps - Introduce `DEFAULT_REPOST_LEAD_SECS` constant set to 60 seconds, sized to clear the indexer's deadline margin plus web poll time - Update `requote_age_secs()` to accept repost lead parameter and cap it at half the TTL to prevent misconfiguration from causing excessive re-signing - Refactor `should_requote_now()` to use the new repost lead logic instead of fixed two-thirds TTL calculation - Add comprehensive test coverage for repost timing with various lead and TTL configurations - Update example configuration with documentation on repost lead semantics and the relationship between TTL and lead time - Bump version to 0.1.27
1 parent e6965ed commit 3c26625

8 files changed

Lines changed: 79 additions & 20 deletions

File tree

.textile-monorepo-source

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
70c55cb34fd82fcc9137fb6a693141001ba5309b
1+
33e93aff140e6a7964f4532446bb31871c98231f

.textile-stitch-release-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.26
1+
0.1.27

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "stitch-bot"
3-
version = "0.1.26"
3+
version = "0.1.27"
44
edition = "2021"
55
description = "Stitch — Textile filler-network operator bot; signs UniswapX limit orders and closes settlement auctions."
66
license = "AGPL-3.0-or-later"

src/config.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ use crate::quote::Spread;
1212
pub const MAX_SUPPORTED_LADDER_ORDERS: u32 = 40;
1313
pub const DEFAULT_MAX_LADDER_ORDERS: u32 = MAX_SUPPORTED_LADDER_ORDERS;
1414

15+
/// Default seconds before a live order's deadline at which the bot reposts its
16+
/// side, so the replacement overlaps the old order instead of leaving a gap.
17+
/// Sized to clear the indexer's order-deadline margin (30s) plus the ~15s web
18+
/// poll with headroom, so the live order book never blanks between reposts.
19+
/// Effective overlap is capped at half the TTL (see `requote_age_secs`), so
20+
/// keep `ttl_secs ≥ 2 × repost_lead_secs` to get the full lead.
21+
pub const DEFAULT_REPOST_LEAD_SECS: u64 = 60;
22+
1523
#[derive(Debug, Clone, Deserialize)]
1624
pub struct Config {
1725
pub chain_id: u64,
@@ -109,6 +117,11 @@ pub struct PoolConfig {
109117

110118
/// Order lifetime.
111119
pub ttl_secs: u64,
120+
/// Repost a side this many seconds before its live order expires, so the
121+
/// replacement overlaps the old order rather than leaving a book gap.
122+
/// Capped at half the TTL. Defaults to `DEFAULT_REPOST_LEAD_SECS`.
123+
#[serde(default)]
124+
pub repost_lead_secs: Option<u64>,
112125
/// Re-sign a side when its price moves more than this since its last order.
113126
pub refresh_threshold_bps: u32,
114127

@@ -151,6 +164,10 @@ fn spread_from(bps: Option<u32>, abs: Option<f64>) -> Option<Spread> {
151164
}
152165

153166
impl PoolConfig {
167+
/// Seconds before expiry at which this side reposts, with the default applied.
168+
pub fn repost_lead_secs(&self) -> u64 {
169+
self.repost_lead_secs.unwrap_or(DEFAULT_REPOST_LEAD_SECS)
170+
}
154171
/// The bid spread for this pool, however the operator expressed it.
155172
pub fn buy_spread(&self) -> Option<Spread> {
156173
spread_from(self.buy_offset_bps, self.buy_offset_abs)

src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,7 @@ async fn run(config_path: String, dry_run: bool) -> anyhow::Result<()> {
537537
pool.refresh_threshold_bps,
538538
now,
539539
pool.ttl_secs,
540+
pool.repost_lead_secs(),
540541
) {
541542
let sizes = if pool.buy_ladder_enabled() {
542543
match (
@@ -605,6 +606,7 @@ async fn run(config_path: String, dry_run: bool) -> anyhow::Result<()> {
605606
pool.refresh_threshold_bps,
606607
now,
607608
pool.ttl_secs,
609+
pool.repost_lead_secs(),
608610
) {
609611
let sizes = if pool.sell_ladder_enabled() {
610612
match (

src/tick.rs

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,30 +22,43 @@ pub fn should_requote(last_bid: Option<f64>, new_bid: f64, threshold_bps: u32) -
2222
}
2323

2424
/// True when a side's order should be re-signed: no prior order, the price
25-
/// moved at least `threshold_bps`, or the last post is late enough in its TTL so a
26-
/// replacement lands before the live order expires. The age gate keeps a stable
27-
/// market from going dark when the price-move gate alone would never fire before
28-
/// the TTL lapses, but waits until two-thirds of the TTL to limit how long old
29-
/// signed orders overlap with their replacements. `last` is
25+
/// moved at least `threshold_bps`, or the last post is old enough that a
26+
/// replacement should land while the live order still has `repost_lead_secs` of
27+
/// life. The age gate keeps a stable market from going dark when the price-move
28+
/// gate alone would never fire before the TTL lapses. `last` is
3029
/// `(price, posted_at_unix)`.
3130
pub fn should_requote_now(
3231
last: Option<(f64, u64)>,
3332
new_bid: f64,
3433
threshold_bps: u32,
3534
now: u64,
3635
ttl_secs: u64,
36+
repost_lead_secs: u64,
3737
) -> bool {
3838
match last {
3939
None => true,
4040
Some((prev, posted_at)) => {
41-
let aged = now.saturating_sub(posted_at) >= requote_age_secs(ttl_secs);
41+
let aged =
42+
now.saturating_sub(posted_at) >= requote_age_secs(ttl_secs, repost_lead_secs);
4243
aged || should_requote(Some(prev), new_bid, threshold_bps)
4344
}
4445
}
4546
}
4647

47-
fn requote_age_secs(ttl_secs: u64) -> u64 {
48-
ttl_secs.saturating_mul(2) / 3
48+
/// The order age at which a side reposts: `ttl_secs - repost_lead_secs`, so the
49+
/// replacement is signed while the live order still has `repost_lead_secs` of
50+
/// life and the two overlap instead of leaving a gap.
51+
///
52+
/// The lead is capped at half the TTL. That cap is a safety floor against a
53+
/// large or misconfigured lead driving the repost age to zero (which would
54+
/// re-sign the whole ladder every tick), but it has a cost: below `ttl ≈ 2 ×
55+
/// lead` the effective overlap is `ttl/2`, not the configured lead. So as the
56+
/// TTL drops the gap-free margin shrinks back toward the indexer's deadline
57+
/// margin. Keep `ttl_secs ≥ 2 × repost_lead_secs` to actually get the lead you
58+
/// asked for.
59+
fn requote_age_secs(ttl_secs: u64, repost_lead_secs: u64) -> u64 {
60+
let lead = repost_lead_secs.min(ttl_secs / 2);
61+
ttl_secs.saturating_sub(lead)
4962
}
5063

5164
#[cfg(test)]
@@ -70,15 +83,38 @@ mod tests {
7083

7184
#[test]
7285
fn requotes_near_expiry_even_when_price_is_flat() {
86+
// 30s TTL, 10s lead → repost at age 20 (30 - 10).
7387
// First quote always.
74-
assert!(should_requote_now(None, 1.0, 10, 0, 30));
75-
// 5s into a 30s TTL, flat price → not yet.
76-
assert!(!should_requote_now(Some((1.0, 0)), 1.0, 10, 5, 30));
77-
// 15s in, flat price → wait to reduce old/new signed-order overlap.
78-
assert!(!should_requote_now(Some((1.0, 0)), 1.0, 10, 15, 30));
79-
// 20s in (two-thirds of TTL), flat price → re-quote to avoid a gap.
80-
assert!(should_requote_now(Some((1.0, 0)), 1.0, 10, 20, 30));
88+
assert!(should_requote_now(None, 1.0, 10, 0, 30, 10));
89+
// 5s into the TTL, flat price → not yet.
90+
assert!(!should_requote_now(Some((1.0, 0)), 1.0, 10, 5, 30, 10));
91+
// 15s in, flat price → still before the repost age.
92+
assert!(!should_requote_now(Some((1.0, 0)), 1.0, 10, 15, 30, 10));
93+
// 20s in (lead before expiry), flat price → re-quote to avoid a gap.
94+
assert!(should_requote_now(Some((1.0, 0)), 1.0, 10, 20, 30, 10));
8195
// A big move still re-quotes before the age gate.
82-
assert!(should_requote_now(Some((1.0, 0)), 1.002, 10, 1, 30));
96+
assert!(should_requote_now(Some((1.0, 0)), 1.002, 10, 1, 30, 10));
97+
}
98+
99+
#[test]
100+
fn lead_time_sets_repost_age_and_clamps_at_half_ttl() {
101+
// ttl 240, lead 60 → repost at age 180 (240 - 60), 60s overlap.
102+
assert!(!should_requote_now(Some((1.0, 0)), 1.0, 10, 179, 240, 60));
103+
assert!(should_requote_now(Some((1.0, 0)), 1.0, 10, 180, 240, 60));
104+
105+
// The clamp: an oversized lead is capped at ttl/2, so the repost age
106+
// never collapses toward zero. ttl 120, lead 1000 → age 60, not 0.
107+
assert!(!should_requote_now(Some((1.0, 0)), 1.0, 10, 59, 120, 1000));
108+
assert!(should_requote_now(Some((1.0, 0)), 1.0, 10, 60, 120, 1000));
109+
110+
// The cost of the clamp: at ttl = 2 × lead the overlap is exactly the
111+
// lead; below that it silently shrinks to ttl/2. ttl 90, lead 60 → the
112+
// lead is capped to 45, so the real overlap is 45s, not 60s.
113+
assert!(!should_requote_now(Some((1.0, 0)), 1.0, 10, 44, 90, 60));
114+
assert!(should_requote_now(Some((1.0, 0)), 1.0, 10, 45, 90, 60));
115+
116+
// Zero lead → repost only at the deadline (age == ttl).
117+
assert!(!should_requote_now(Some((1.0, 0)), 1.0, 10, 239, 240, 0));
118+
assert!(should_requote_now(Some((1.0, 0)), 1.0, 10, 240, 240, 0));
83119
}
84120
}

stitch.example.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ sell_min_slice_debt = "10000000" # 10 USDC-equivalent smallest sl
4646
sell_max_orders = 40
4747

4848
ttl_secs = 30
49+
# repost_lead_secs = 60 # repost each side this many secs before its order
50+
# expires, so the new ladder overlaps the old one and
51+
# the book never blanks between reposts. Capped at
52+
# ttl/2, so keep ttl_secs >= 2 x this. Defaults to 60.
4953
refresh_threshold_bps = 10 # re-quote a side when its price moves >0.10%
5054

5155
# ----- Blue leg (auction closer). Set these to also close this pool's

0 commit comments

Comments
 (0)