Skip to content

Commit eaf4378

Browse files
committed
feat: expose experimental TWAP and lean settings with signer conflict detection
- Add TWAP window and max-deviation fields to experimental settings card, with same validation as bot startup - Add inventory-lean settings (enabled, shadow mode, floor/base/wide bps) to experimental section - Implement `/api/signer/check` endpoint to detect when a wallet is already in use by another fleet bot - Surface which sibling bots would conflict with a signer change, and whether they're live-transacting (blocks switch) - Sort fleet list alphabetically by bot name in both HTTP and discovery layers - Align bot detail action buttons on mobile with 2-column grid for lifecycle actions, full-width Remove - Refactor settings patch application to preserve untouched experimental fields when spreads-only changes occur - Improve error messages for failed settings writes by exposing full validation chain - Add tests for fleet alphabetization, experimental field round-trips, and signer conflict detection
1 parent dd884cc commit eaf4378

18 files changed

Lines changed: 972 additions & 75 deletions

.textile-monorepo-source

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
e1a1f43c78c7c42fa16002408fedc3f69096b442
1+
02fbe15ff9105ecaddf1999485d69be64e384dfd

.textile-stitch-release-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.112
1+
0.1.113

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.112"
3+
version = "0.1.113"
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"

src/panel/http/bots.rs

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -142,12 +142,16 @@ struct FleetBody {
142142

143143
pub async fn list(State(state): State<AppState>) -> Result<Response, ApiError> {
144144
let fleet = state.fleet().await?;
145+
// Discovery already yields name order (BTreeMap), but sort here too so the
146+
// fleet page stays alphabetical even if that ever changes.
147+
let mut bots: Vec<BotBody> = fleet
148+
.bots()
149+
.iter()
150+
.map(|b| to_body(b, &state, &fleet))
151+
.collect();
152+
bots.sort_by(|a, b| a.name.cmp(&b.name));
145153
Ok(Json(FleetBody {
146-
bots: fleet
147-
.bots()
148-
.iter()
149-
.map(|b| to_body(b, &state, &fleet))
150-
.collect(),
154+
bots,
151155
bot_image: state.cfg.bot_image.clone(),
152156
bots_dir: state.cfg.bots_dir.display().to_string(),
153157
})
@@ -1207,6 +1211,24 @@ mod tests {
12071211
assert!(v["botImage"].as_str().unwrap().contains("textile-stitch"));
12081212
}
12091213

1214+
#[tokio::test]
1215+
async fn the_fleet_list_is_alphabetical_by_bot_name() {
1216+
let h = harness("fleet-alpha");
1217+
write_bot(&h.root, "zeta");
1218+
write_bot(&h.root, "alpha");
1219+
write_bot(&h.root, "mid");
1220+
let (status, body) = h.get("/api/bots").await;
1221+
assert_eq!(status, StatusCode::OK, "{body}");
1222+
let parsed = Harness::parse(&body);
1223+
let names: Vec<_> = parsed["bots"]
1224+
.as_array()
1225+
.unwrap()
1226+
.iter()
1227+
.map(|b| b["name"].as_str().unwrap())
1228+
.collect();
1229+
assert_eq!(names, vec!["alpha", "mid", "zeta"]);
1230+
}
1231+
12101232
#[tokio::test]
12111233
async fn a_panel_bot_is_listed_with_its_config_and_no_secrets() {
12121234
let h = harness("list");

src/panel/http/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ fn public_routes() -> Router<AppState> {
253253
fn protected_routes(state: &AppState) -> Router<AppState> {
254254
Router::new()
255255
.route("/api/corridors", get(wizard::corridors))
256+
.route("/api/signer/check", post(wizard::check_signer))
256257
.route("/api/bots", get(bots::list).post(wizard::create))
257258
.route("/api/bots/{name}", get(bots::show).delete(bots::remove))
258259
.route("/api/bots/{name}/start", post(bots::start))

src/panel/http/settings.rs

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,15 @@ pub struct SettingsBody {
136136
pub sell_sizing: SizingBody,
137137
pub ttl_secs: u64,
138138
pub tick_interval_secs: u64,
139+
/// Rolling TWAP window in seconds. Empty = quote off the instantaneous feed.
140+
pub twap_window_secs: String,
141+
/// Spot-deviation guard in bps. Empty = bot default when TWAP is on.
142+
pub twap_max_deviation_bps: String,
143+
pub lean_enabled: bool,
144+
pub lean_shadow: bool,
145+
pub lean_floor_bps: String,
146+
pub lean_base_bps: String,
147+
pub lean_wide_bps: String,
139148
/// Whether saving will be accepted. False for a bot whose config the panel can
140149
/// see but not write.
141150
pub editable: bool,
@@ -156,6 +165,13 @@ impl SettingsBody {
156165
sell_sizing: SizingBody::from(&v.sell_sizing),
157166
ttl_secs: v.ttl_secs,
158167
tick_interval_secs: v.tick_interval_secs,
168+
twap_window_secs: v.twap_window_secs.clone(),
169+
twap_max_deviation_bps: v.twap_max_deviation_bps.clone(),
170+
lean_enabled: v.lean_enabled,
171+
lean_shadow: v.lean_shadow,
172+
lean_floor_bps: v.lean_floor_bps.clone(),
173+
lean_base_bps: v.lean_base_bps.clone(),
174+
lean_wide_bps: v.lean_wide_bps.clone(),
159175
editable,
160176
}
161177
}
@@ -261,6 +277,20 @@ pub struct SettingsUpdate {
261277
pub ttl_secs: Option<u64>,
262278
#[serde(default)]
263279
pub tick_interval_secs: Option<u64>,
280+
#[serde(default)]
281+
pub twap_window_secs: Option<String>,
282+
#[serde(default)]
283+
pub twap_max_deviation_bps: Option<String>,
284+
#[serde(default)]
285+
pub lean_enabled: Option<bool>,
286+
#[serde(default)]
287+
pub lean_shadow: Option<bool>,
288+
#[serde(default)]
289+
pub lean_floor_bps: Option<String>,
290+
#[serde(default)]
291+
pub lean_base_bps: Option<String>,
292+
#[serde(default)]
293+
pub lean_wide_bps: Option<String>,
264294
}
265295

266296
impl SettingsUpdate {
@@ -297,6 +327,38 @@ impl SettingsUpdate {
297327
if let Some(v) = self.tick_interval_secs {
298328
patch.tick_interval_secs = Some(v);
299329
}
330+
// A partial UI patch must leave untouched experimental fields alone —
331+
// otherwise a spread-only save would rewrite them from a stale form.
332+
// So start from a "don't touch" patch for those and only fold in what
333+
// the request named.
334+
patch.twap_window_secs = None;
335+
patch.twap_max_deviation_bps = None;
336+
patch.lean_enabled = None;
337+
patch.lean_shadow = None;
338+
patch.lean_floor_bps = None;
339+
patch.lean_base_bps = None;
340+
patch.lean_wide_bps = None;
341+
if let Some(v) = &self.twap_window_secs {
342+
patch.twap_window_secs = Some(v.trim().to_string());
343+
}
344+
if let Some(v) = &self.twap_max_deviation_bps {
345+
patch.twap_max_deviation_bps = Some(v.trim().to_string());
346+
}
347+
if let Some(v) = self.lean_enabled {
348+
patch.lean_enabled = Some(v);
349+
}
350+
if let Some(v) = self.lean_shadow {
351+
patch.lean_shadow = Some(v);
352+
}
353+
if let Some(v) = &self.lean_floor_bps {
354+
patch.lean_floor_bps = Some(v.trim().to_string());
355+
}
356+
if let Some(v) = &self.lean_base_bps {
357+
patch.lean_base_bps = Some(v.trim().to_string());
358+
}
359+
if let Some(v) = &self.lean_wide_bps {
360+
patch.lean_wide_bps = Some(v.trim().to_string());
361+
}
300362
Ok(patch)
301363
}
302364
}
@@ -322,8 +384,10 @@ pub async fn update(
322384
let current = setup::read_settings_at(&current_toml, pool).map_err(ApiError::bad_request)?;
323385
let patch = body.onto(&current)?;
324386
// `apply_settings` re-validates through the real loader, so an invalid value
325-
// fails here and nothing is written.
326-
let edited = setup::apply_settings(&current_toml, &patch).map_err(ApiError::bad_request)?;
387+
// fails here and nothing is written. Use the full anyhow chain — the outer
388+
// "edited config is not valid" alone doesn't name the field that failed.
389+
let edited = setup::apply_settings(&current_toml, &patch)
390+
.map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
327391

328392
save_and_restart(&state, &bot, &path, &edited, pool).await
329393
}
@@ -930,6 +994,40 @@ mod tests {
930994
assert_eq!(v["settings"]["sell"], before["sell"]);
931995
assert_eq!(v["settings"]["ttlSecs"], before["ttlSecs"]);
932996
assert_eq!(v["settings"]["buySizing"], before["buySizing"]);
997+
assert_eq!(v["settings"]["twapWindowSecs"], before["twapWindowSecs"]);
998+
}
999+
1000+
#[tokio::test]
1001+
async fn experimental_twap_and_lean_round_trip_through_settings() {
1002+
let h = harness("settings-experimental");
1003+
seed(&h, "bot-a");
1004+
let (status, body) = h
1005+
.patch_json(
1006+
"/api/bots/bot-a/settings",
1007+
json!({
1008+
"twapWindowSecs": "60",
1009+
"twapMaxDeviationBps": "50",
1010+
"leanShadow": true,
1011+
"leanFloorBps": "3.0",
1012+
}),
1013+
)
1014+
.await;
1015+
assert_eq!(status, StatusCode::OK, "first patch: {body}");
1016+
let v = Harness::parse(&body);
1017+
assert_eq!(v["settings"]["twapWindowSecs"], "60");
1018+
assert_eq!(v["settings"]["twapMaxDeviationBps"], "50");
1019+
assert_eq!(v["settings"]["leanShadow"], true);
1020+
assert_eq!(v["settings"]["leanEnabled"], false);
1021+
1022+
// Lean on without a floor is refused; nothing written for that field set.
1023+
let (status, body) = h
1024+
.patch_json(
1025+
"/api/bots/bot-a/settings",
1026+
json!({ "leanEnabled": true, "leanFloorBps": "" }),
1027+
)
1028+
.await;
1029+
assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
1030+
assert!(body.contains("lean_floor"), "{body}");
9331031
}
9341032

9351033
#[tokio::test]

0 commit comments

Comments
 (0)