Skip to content

Commit 630d701

Browse files
committed
feat: add TWAP quoting with spot-deviation guard
- Add rolling time-weighted average price (TWAP) module that smooths feed observations over a configurable window, handling gaps, duplicates, backward clocks, and resets across staleness boundaries - Implement spot-deviation guard that bounds how far TWAP-centered quotes may post through instantaneous feed, preventing unguarded asks/bids from drifting through the market on persistent moves - Add per-pool TWAP configuration: `twap_window_secs` enables TWAP centering (~60–300s recommended); `twap_max_deviation_bps` tunes the guard (default 50) - Make `refresh_threshold_bps` default to 0 (re-quote every tick) since TWAP centers move slowly and deadbands cause staleness; validate that `ttl_secs > 30` to match indexer deadline margin - Update tick loop to read clock per pool after feed fetch to correctly measure bot-observation gaps and TWAP window; feed both maker/taker and inventory lean off the smoothed center; keep auction closer on instantaneous feed - Warn at startup when re-quote deadband is combined with TWAP; log both spot and smoothed mid every tick for tuning - Update WETH template to use 60s TWAP window with 60s TTL (up from 15s to stay above indexer deadline margin) and no refresh deadband - Add validation: reject zero TWAP window, reject deviation without window, reject zero/excessive deviation (≥10000 bps), key TWAP map by pool index not token pair to isolate separate pool histories - Update test configs to use safe TTL values and document TWAP quoting in ADVANCED.md with noise-vs-trend tradeoff, tuning guidance, composition with inventory lean, and signing/RPC costs
1 parent 6e6deea commit 630d701

17 files changed

Lines changed: 912 additions & 37 deletions

.textile-monorepo-source

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
d37827d5be9a6384638e51797c5375d1a0851c34
1+
1fa5615dd2f9d1d26d8a9a1ea5eb7609e2fd5269

.textile-stitch-release-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.105
1+
0.1.106

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.105"
3+
version = "0.1.106"
44
edition = "2021"
55
description = "Stitch — Textile filler-network operator bot; market-makes the filler order book with signed UniswapX limit orders."
66
license = "AGPL-3.0-or-later"

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,8 +238,10 @@ When several corridors spend the same token (for example two pools that both
238238
buy with USDC) and more than one of them is set to `"max"`, Stitch splits the
239239
token's funded balance into even target shares on every tick, so an existing
240240
corridor can't keep the whole wallet after another max side is added. For
241-
the price-feed orientation, spread options, ladder sizing, and the
242-
limit-order taker, see the
241+
the price-feed orientation, spread options, TWAP quoting (centering the
242+
spread on a rolling average of the feed instead of the instantaneous value —
243+
recommended for volatile pairs like WETH), ladder sizing, and the limit-order
244+
taker, see the
243245
[configuration reference in ADVANCED.md](docs/ADVANCED.md#configuration-reference).
244246

245247
## Security Notes

docs/ADVANCED.md

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,72 @@ sell_offset_abs = 5.0
5858

5959
If both are set for a side, basis points win.
6060

61+
### TWAP Quoting (Smoothed Center)
62+
63+
By default each quote centers on the feed's instantaneous value. For a
64+
volatile pair like WETH that means the book chases every tick: when the price
65+
spikes for a few seconds and reverts, the quote either chases the spike (and
66+
buys the top) or sits stale (and the resting side gets picked off). Setting
67+
`twap_window_secs` centers the spread on a short rolling time-weighted average
68+
of the feed instead:
69+
70+
```toml
71+
twap_window_secs = 60 # ~1 min rolling TWAP; omit to quote off spot
72+
# twap_max_deviation_bps = 50 # optional guard, default 50 — see below
73+
refresh_threshold_bps = 0 # no deadband: stay pinned to the smoothed center
74+
```
75+
76+
The average moves slowly, so a transient spike leaves the quote near the
77+
settled mean: the ask sells *into* the spike above the reverting average and
78+
keeps the spread when it snaps back. A move that persists flows into the
79+
average, and the center fully converges within one window. The trade is
80+
explicit — TWAP wins on noise (spikes that revert) and pays a lag on trend
81+
(moves that persist) — so it's worth it exactly when short-term price action
82+
is more noise than trend. Two knobs to tune:
83+
84+
- **The window** (`twap_window_secs`, ~60–300s is sensible): longer filters
85+
more noise but lags real moves more.
86+
- **The spread** (`buy_offset_bps` / `sell_offset_bps`): what each fill earns
87+
around the center.
88+
89+
The TWAP weights each feed observation by how long it was actually live
90+
(last observation carried forward), so it is correct for both a fast feed
91+
(fresh sample every tick) and a slow one (the cNGN center re-samples every
92+
~3 min — though for a feed that smoothed already, a TWAP on top adds little).
93+
A feed gap longer than `staleness_secs` resets the average rather than
94+
carrying a price across an outage the bot refused to quote through.
95+
96+
**The deviation guard.** The lag on trend needs a bound: with the book
97+
re-posting every tick, an unguarded ask would keep selling further and further
98+
below a market that keeps rising until the average catches up.
99+
`twap_max_deviation_bps` (default 50) trails each side at most that far
100+
through the instantaneous feed — the ask never posts more than the deviation
101+
below spot, the bid never more than it above. The clamp only ever moves a
102+
quote *away* from being picked off (ask up, bid down), which also makes it
103+
glitch-safe: a single bad feed print lifts the ask out of reach (unfillable,
104+
harmless) instead of dragging the bid toward the glitch. Inside the deviation
105+
budget nothing is clamped, so ordinary spikes still get sold into.
106+
107+
**Drop the deadband with TWAP.** `refresh_threshold_bps` exists to skip
108+
re-signing when the price barely moved, but a TWAP center *always* barely
109+
moves — a deadband on top of it holds the book stale between threshold
110+
crossings, which is the exact failure TWAP is meant to fix. Set it to 0 (or
111+
remove it; 0 is the default) so the book re-posts every `tick_interval_secs`,
112+
pinned to the current center. Posting is off-chain and free. The costs of
113+
every-tick reposting are local: one balance/allowance read per side per tick
114+
against your RPC, and one signature per live order per tick — a full 40-slice
115+
ladder on both sides is 80 signatures per tick, instant for the local
116+
hotwallet but a round trip each for an MPC signer (raise
117+
`max_concurrent_signs`, or lengthen `tick_interval_secs`, if your signing
118+
backend rate-limits).
119+
120+
TWAP composes with the inventory lean: the lean's fair price becomes the
121+
smoothed center, and the deviation guard clamps the lean's quotes the same as
122+
the configured spreads. The auction closer deliberately keeps pricing off the
123+
instantaneous feed — it values a one-shot on-chain fill at execution time,
124+
where the current price is the right mark; the TWAP smooths standing quotes
125+
that rest in the book waiting to be picked off.
126+
61127
### Liquidity And Order Sizing
62128

63129
Stitch can post one order per side or a ladder of smaller orders:
@@ -417,7 +483,8 @@ Check these in order:
417483
6. The spread is not so wide that your orders are outside the expected fill
418484
range.
419485
7. `refresh_threshold_bps` has not prevented an unchanged quote from being
420-
reposted.
486+
reposted (0 — the default — re-posts every tick; see
487+
[TWAP Quoting](#twap-quoting-smoothed-center)).
421488

422489
For the buy side, the wallet spends the pool's `debt` token. For the sell side,
423490
the wallet spends the pool's `collateral` token.

src/approve.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ mod tests {
301301
collateral_decimals = 6
302302
debt = "{DEBT}"
303303
debt_decimals = 6
304-
ttl_secs = 30
304+
ttl_secs = 60
305305
refresh_threshold_bps = 10
306306
{pool_body}
307307
"#
@@ -369,7 +369,7 @@ mod tests {
369369
collateral_decimals = 6
370370
debt = "{DEBT}"
371371
debt_decimals = 6
372-
ttl_secs = 30
372+
ttl_secs = 60
373373
refresh_threshold_bps = 10
374374
buy_offset_bps = 150
375375
buy_order_size_debt = "1000000000"
@@ -378,7 +378,7 @@ mod tests {
378378
collateral_decimals = 6
379379
debt = "{DEBT}"
380380
debt_decimals = 6
381-
ttl_secs = 30
381+
ttl_secs = 60
382382
refresh_threshold_bps = 10
383383
buy_offset_bps = 150
384384
buy_order_size_debt = "2000000000"
@@ -466,7 +466,7 @@ mod tests {
466466
collateral_decimals = 6
467467
debt = "{DEBT}"
468468
debt_decimals = 6
469-
ttl_secs = 30
469+
ttl_secs = 60
470470
refresh_threshold_bps = 10
471471
buy_offset_bps = 150
472472
buy_total_liquidity_debt = "max"
@@ -476,7 +476,7 @@ mod tests {
476476
collateral_decimals = 6
477477
debt = "{DEBT}"
478478
debt_decimals = 6
479-
ttl_secs = 30
479+
ttl_secs = 60
480480
refresh_threshold_bps = 10
481481
buy_offset_bps = 150
482482
buy_order_size_debt = "2000000000"

src/config.rs

Lines changed: 160 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,24 @@ pub const MAX_LIQUIDITY_SENTINEL: &str = "max";
2424
/// keep `ttl_secs ≥ 2 × repost_lead_secs` to get the full lead.
2525
pub const DEFAULT_REPOST_LEAD_SECS: u64 = 60;
2626

27+
/// Seconds the indexer (and stitch's own slot-reuse credit) subtract from an
28+
/// order's deadline before serving it as live. Matches
29+
/// `FILLER_ORDER_DEADLINE_MARGIN_SECS` / `REUSABLE_DEADLINE_MARGIN_SECS`.
30+
/// `ttl_secs` must be strictly greater than this, or posted orders are accepted
31+
/// but immediately excluded from the live book and never fillable.
32+
pub const LIVE_ORDER_DEADLINE_MARGIN_SECS: u64 = 30;
33+
34+
/// Default cap on how far a TWAP-centered quote may post through the
35+
/// instantaneous feed, in bps (see `quote::SpotDeviationGuard`). Wide enough
36+
/// to keep selling into ordinary transient spikes (the strategy's win), tight
37+
/// enough that a persistent trend can't pick the lagging side off by more
38+
/// than this per fill while the average converges.
39+
pub const DEFAULT_TWAP_MAX_DEVIATION_BPS: u32 = 50;
40+
41+
/// Exclusive upper bound on `twap_max_deviation_bps`. At 10_000 bps the ask
42+
/// floor collapses to ≤ 0 and the guard silently disables itself.
43+
pub const MAX_TWAP_MAX_DEVIATION_BPS: u32 = 10_000;
44+
2745
#[derive(Debug, Clone, Deserialize)]
2846
pub struct Config {
2947
pub chain_id: u64,
@@ -163,9 +181,32 @@ pub struct PoolConfig {
163181
/// Capped at half the TTL. Defaults to `DEFAULT_REPOST_LEAD_SECS`.
164182
#[serde(default)]
165183
pub repost_lead_secs: Option<u64>,
166-
/// Re-sign a side when its price moves more than this since its last order.
184+
/// Re-sign a side only when its price moves more than this since its last
185+
/// order (plus the TTL-driven age repost either way). 0 — the default —
186+
/// re-quotes every tick, keeping the book pinned to the current price:
187+
/// posting is off-chain and free, and a deadband lets quotes drift stale
188+
/// on slow moves. Set it above 0 only to cut signing/RPC churn (e.g. a
189+
/// rate-limited MPC signer).
190+
#[serde(default)]
167191
pub refresh_threshold_bps: u32,
168192

193+
// ----- TWAP quoting. Center the spread on a short rolling time-weighted
194+
// average of the feed instead of the instantaneous value, so the book
195+
// stops chasing every tick: transient spikes get sold into above the
196+
// reverting mean instead of picking off a chased quote. See
197+
// [`crate::twap`]. -----
198+
/// Rolling TWAP window in seconds (~60-300 is sensible). Omit to quote
199+
/// off the instantaneous feed (the historical behavior). Longer filters
200+
/// more noise but lags real moves more.
201+
#[serde(default)]
202+
pub twap_window_secs: Option<u64>,
203+
/// With TWAP on: never post a side more than this many bps through the
204+
/// instantaneous feed (ask below spot / bid above spot), bounding what a
205+
/// persistent trend can extract from the lagging center. Defaults to
206+
/// `DEFAULT_TWAP_MAX_DEVIATION_BPS`.
207+
#[serde(default)]
208+
pub twap_max_deviation_bps: Option<u32>,
209+
169210
// ----- Taker leg (user limit orders). Users rest signed limit orders in
170211
// the same book the bot quotes into; when one's price reaches the bot's
171212
// own bid/ask it can be filled on-chain via `reactor.executeBatch`. The
@@ -290,6 +331,15 @@ impl PoolConfig {
290331
self.limit_taker_enabled.unwrap_or(false)
291332
&& (self.buy_spread().is_some() || self.sell_spread().is_some())
292333
}
334+
/// The TWAP window when TWAP quoting is on for this pool.
335+
pub fn twap_window(&self) -> Option<u64> {
336+
self.twap_window_secs.filter(|w| *w > 0)
337+
}
338+
/// The spot-deviation cap for TWAP-centered quotes, with the default.
339+
pub fn twap_deviation_bps(&self) -> u32 {
340+
self.twap_max_deviation_bps
341+
.unwrap_or(DEFAULT_TWAP_MAX_DEVIATION_BPS)
342+
}
293343
/// The pool's inventory-lean rollout mode. Live wins over shadow.
294344
pub fn lean_mode(&self) -> LeanMode {
295345
if self.lean_enabled.unwrap_or(false) {
@@ -320,6 +370,14 @@ impl Config {
320370

321371
fn validate(&self) -> anyhow::Result<()> {
322372
for (idx, pool) in self.pools.iter().enumerate() {
373+
anyhow::ensure!(
374+
pool.ttl_secs > LIVE_ORDER_DEADLINE_MARGIN_SECS,
375+
"pools[{idx}].ttl_secs ({}) must be greater than the live-order deadline \
376+
margin ({LIVE_ORDER_DEADLINE_MARGIN_SECS}s) — the indexer and stitch's own \
377+
slot reuse only serve orders whose deadline is later than chain time plus \
378+
that margin, so a shorter TTL posts orders that never appear as fillable depth",
379+
pool.ttl_secs
380+
);
323381
if let Some(min_slice) = pool.buy_min_slice_debt.as_deref() {
324382
parse_min_slice_debt(min_slice, &format!("pools[{idx}].buy_min_slice_debt"))?;
325383
}
@@ -338,6 +396,30 @@ impl Config {
338396
"pools[{idx}].sell_max_orders {max_orders} exceeds supported limit {MAX_SUPPORTED_LADDER_ORDERS}"
339397
);
340398
}
399+
if let Some(window) = pool.twap_window_secs {
400+
anyhow::ensure!(
401+
window > 0,
402+
"pools[{idx}].twap_window_secs must be positive; omit it to quote off the \
403+
instantaneous feed"
404+
);
405+
}
406+
if let Some(dev) = pool.twap_max_deviation_bps {
407+
anyhow::ensure!(
408+
pool.twap_window_secs.is_some(),
409+
"pools[{idx}].twap_max_deviation_bps only applies with twap_window_secs set"
410+
);
411+
anyhow::ensure!(
412+
dev > 0,
413+
"pools[{idx}].twap_max_deviation_bps must be positive — 0 would pin every \
414+
quote to the instantaneous feed and disable the TWAP center entirely"
415+
);
416+
anyhow::ensure!(
417+
dev < MAX_TWAP_MAX_DEVIATION_BPS,
418+
"pools[{idx}].twap_max_deviation_bps ({dev}) must be < {MAX_TWAP_MAX_DEVIATION_BPS} \
419+
— at 10000 bps the ask floor collapses to ≤ 0 and the spot-deviation guard \
420+
silently disables itself"
421+
);
422+
}
341423
if pool.lean_mode() != LeanMode::Off {
342424
let floor = pool.lean_floor_bps.ok_or_else(|| {
343425
anyhow::anyhow!(
@@ -413,7 +495,7 @@ mod tests {
413495
sell_total_liquidity_collateral = "30000000000000000000000"
414496
sell_min_slice_debt = "10000000"
415497
sell_max_orders = 40
416-
ttl_secs = 30
498+
ttl_secs = 60
417499
refresh_threshold_bps = 10
418500
"#;
419501
let cfg = Config::from_toml(toml).expect("config parses");
@@ -493,7 +575,7 @@ mod tests {
493575
sell_total_liquidity_collateral = "30000000000000000000000"
494576
sell_min_slice_debt = "10000000"
495577
sell_max_orders = 40
496-
ttl_secs = 30
578+
ttl_secs = 60
497579
refresh_threshold_bps = 10
498580
"#;
499581
let err = Config::from_toml(toml).expect_err("oversized buy cap is rejected");
@@ -533,6 +615,80 @@ mod tests {
533615
refresh_threshold_bps = 10
534616
"#;
535617

618+
#[test]
619+
fn refresh_threshold_defaults_to_requoting_every_tick() {
620+
let toml = LEAN_POOL_BASE.replace("refresh_threshold_bps = 10", "");
621+
let cfg = Config::from_toml(&toml).expect("threshold is optional");
622+
assert_eq!(cfg.pools[0].refresh_threshold_bps, 0);
623+
}
624+
625+
#[test]
626+
fn twap_defaults_to_off_with_a_default_deviation_cap() {
627+
let cfg = Config::from_toml(LEAN_POOL_BASE).unwrap();
628+
assert_eq!(cfg.pools[0].twap_window(), None);
629+
assert_eq!(
630+
cfg.pools[0].twap_deviation_bps(),
631+
DEFAULT_TWAP_MAX_DEVIATION_BPS
632+
);
633+
}
634+
635+
#[test]
636+
fn twap_window_and_deviation_parse_together() {
637+
let toml =
638+
format!("{LEAN_POOL_BASE}\ntwap_window_secs = 180\ntwap_max_deviation_bps = 80\n");
639+
let cfg = Config::from_toml(&toml).unwrap();
640+
assert_eq!(cfg.pools[0].twap_window(), Some(180));
641+
assert_eq!(cfg.pools[0].twap_deviation_bps(), 80);
642+
}
643+
644+
#[test]
645+
fn a_zero_twap_window_is_rejected() {
646+
let toml = format!("{LEAN_POOL_BASE}\ntwap_window_secs = 0\n");
647+
let err = Config::from_toml(&toml).expect_err("zero window is rejected");
648+
assert!(err.to_string().contains("twap_window_secs"));
649+
}
650+
651+
#[test]
652+
fn a_twap_deviation_without_a_window_is_rejected() {
653+
let toml = format!("{LEAN_POOL_BASE}\ntwap_max_deviation_bps = 50\n");
654+
let err = Config::from_toml(&toml).expect_err("deviation needs the window");
655+
assert!(err.to_string().contains("twap_window_secs"));
656+
}
657+
658+
#[test]
659+
fn a_zero_twap_deviation_is_rejected() {
660+
let toml =
661+
format!("{LEAN_POOL_BASE}\ntwap_window_secs = 180\ntwap_max_deviation_bps = 0\n");
662+
let err = Config::from_toml(&toml).expect_err("zero deviation is rejected");
663+
assert!(err.to_string().contains("twap_max_deviation_bps"));
664+
}
665+
666+
#[test]
667+
fn a_twap_deviation_at_or_above_10000_bps_is_rejected() {
668+
// 10000 bps collapses ask_floor to ≤ 0 and silently disables the guard.
669+
let toml =
670+
format!("{LEAN_POOL_BASE}\ntwap_window_secs = 60\ntwap_max_deviation_bps = 10000\n");
671+
let err = Config::from_toml(&toml).expect_err("10000 bps deviation is rejected");
672+
assert!(err.to_string().contains("twap_max_deviation_bps"));
673+
}
674+
675+
#[test]
676+
fn a_ttl_at_or_below_the_live_deadline_margin_is_rejected() {
677+
// The indexer only serves orders with deadline > chain_time + 30s.
678+
// ttl == 30 posts orders that are immediately invisible; ttl == 15
679+
// (the short-ETH temptation) is the same footgun.
680+
for ttl in [0_u64, 15, LIVE_ORDER_DEADLINE_MARGIN_SECS] {
681+
let toml = LEAN_POOL_BASE.replace("ttl_secs = 120", &format!("ttl_secs = {ttl}"));
682+
let err =
683+
Config::from_toml(&toml).expect_err(&format!("ttl_secs = {ttl} must be rejected"));
684+
let msg = err.to_string();
685+
assert!(
686+
msg.contains("ttl_secs") && msg.contains("deadline margin"),
687+
"unexpected error for ttl={ttl}: {msg}"
688+
);
689+
}
690+
}
691+
536692
#[test]
537693
fn lean_defaults_to_off_and_live_wins_over_shadow() {
538694
let cfg = Config::from_toml(LEAN_POOL_BASE).unwrap();
@@ -598,7 +754,7 @@ mod tests {
598754
debt_decimals = 6
599755
buy_offset_bps = 150
600756
buy_order_size_debt = "1000000000"
601-
ttl_secs = 30
757+
ttl_secs = 60
602758
refresh_threshold_bps = 10
603759
"#;
604760
let cfg = Config::from_toml(toml).expect("buy-only config parses");

0 commit comments

Comments
 (0)