1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
//! TradingState: in-flight order inputs, wallet connection, and status display.
use std::collections::VecDeque;
use std::sync::{Arc, RwLock};
use solana_keypair::Keypair;
use super::super::config::{UserConfig, current_user_config};
use super::super::trading::{InputMode, OrderKind, PositionInfo, TradingSide};
use super::super::tx::{TraderMirror, TxContext};
use super::make_status_timestamp;
use super::markers::LedgerEntry;
/// Maximum number of past actions retained in the Ledger modal. Entries are
/// stored newest-first and older entries are evicted beyond this cap.
const LEDGER_CAPACITY: usize = 50;
pub struct TradingState {
pub side: TradingSide,
pub size_index: usize,
pub wallet_loaded: bool,
pub wallet_label: String,
pub position: Option<PositionInfo>,
/// Timestamp string for the status body row, e.g. `[12:34:56 UTC]`.
pub status_timestamp: String,
/// Shown in the status frame body (e.g. "USDC Withdrawal Confirmed!").
pub status_title: String,
/// Single body row: tx signature or error text; empty when there is nothing
/// to show.
pub status_detail: String,
pub keypair: Option<Arc<Keypair>>,
pub tx_context: Option<Arc<TxContext>>,
/// Shared mirror of the wallet's on-chain `Trader` state. Owned by
/// `TradingState` so connection-change paths (RPC swap, market switch)
/// can rebuild a fresh `TxContext` against the same live trader without
/// re-subscribing the WS stream from scratch. Set on wallet connect,
/// cleared on disconnect.
pub shared_trader: Option<Arc<RwLock<TraderMirror>>>,
pub input_mode: InputMode,
pub input_buffer: String,
pub deposit_buffer: String,
pub withdraw_buffer: String,
/// Editable path string for the "Load Wallet" modal. Seeded from
/// `default_wallet_path()` when the modal opens.
pub wallet_path_buffer: String,
/// Last load error from the "Load Wallet" modal, rendered below the
/// input until the user types again.
pub wallet_path_error: Option<String>,
/// Editable code for the "Custom referral code" modal. Empty string
/// means the user wants to skip and self-register.
pub referral_code_buffer: String,
/// Last activation error from the referral modal, rendered below the
/// input until the user types again.
pub referral_code_error: Option<String>,
/// Selected row in the first-run referral choice modal:
/// 0 = use COSMIC, 1 = enter custom code, 2 = skip.
pub referral_choice_index: usize,
pub custom_size: Option<f64>,
/// Currently selected order kind. `[t]` cycles Market → Limit → StopMarket;
/// `[e]` edits the attached price for Limit/StopMarket. The next `[Enter]`
/// submits using this kind.
pub order_kind: OrderKind,
pub usdc_balance: Option<f64>,
pub phoenix_balance: Option<f64>,
pub sol_balance: Option<f64>,
/// Persisted user settings (RPC URL, language). Modified in-memory while
/// the config modal is open and flushed to disk on save.
pub config: UserConfig,
/// Selected row in the config modal (0 = RPC URL, 1 = language,
/// 2 = CLOB orders, 3 = public-RPC fan-out).
pub config_selected_field: usize,
/// Whether the first-run referral choice modal has already been shown
/// (and dismissed) for the currently-loaded wallet. Reset on connect
/// so a fresh wallet load re-evaluates from scratch.
pub referral_choice_shown: bool,
/// Recent user actions and their txids, newest-first.
pub ledger: VecDeque<LedgerEntry>,
/// Selected row in the ledger modal.
pub ledger_selected: usize,
/// In-progress draft for the "New TWAP" modal. Reset to defaults each
/// time the modal opens via [`reset_twap_draft`](Self::reset_twap_draft).
pub twap_draft: TwapDraft,
}
/// Editable form state backing the "New TWAP" modal. Field layout mirrors
/// the Binance TWAP modal: market, side, total size, and total time
/// (hours + minutes + seconds). Side is selectable from any field via [Tab].
/// Slice cadence is one market slice per minute, EXCEPT when the seconds
/// field is non-zero — then cadence drops to one slice per second so
/// sub-minute schedules are addressable (and a 2-minute / 30-second total
/// fires 150 slices, one each second).
#[derive(Debug, Clone)]
pub struct TwapDraft {
/// Cursor row: 0 = market, 1 = side, 2 = total size, 3 = total time
/// hours, 4 = total time minutes, 5 = total time seconds.
pub selected_field: usize,
/// Symbol of the market the TWAP will run against. Seeded from the
/// active market when the modal opens; cycled with ←/→ when the market
/// field is focused.
pub market: String,
pub side: super::super::trading::TradingSide,
pub size_buffer: String,
pub duration_hour_buffer: String,
pub duration_min_buffer: String,
pub duration_sec_buffer: String,
/// Set when the user presses [Enter] but a field fails validation —
/// rendered below the form until the next keystroke.
pub error: Option<String>,
/// True after the user passed validation on the final row — the modal
/// renders a "Start TWAP? [Y/N]" prompt instead of immediately spawning
/// the bot. Y/Enter submits, N/Esc cancels back to editing.
/// Bypassed when `skip_order_confirmation` is enabled.
pub pending_confirm: bool,
}
impl TwapDraft {
/// Index of the Total Size row in the form. Cursor starts here on open
/// — Market and Side both default to sensible values, so the user almost
/// always wants to type a size first.
pub const DEFAULT_FIELD: usize = 2;
pub const FIELD_COUNT: usize = 6;
pub fn new(market: String, side: super::super::trading::TradingSide) -> Self {
Self {
selected_field: Self::DEFAULT_FIELD,
market,
side,
size_buffer: String::new(),
duration_hour_buffer: String::new(),
duration_min_buffer: String::new(),
duration_sec_buffer: String::new(),
error: None,
pending_confirm: false,
}
}
pub fn move_field_up(&mut self) {
if self.selected_field > 0 {
self.selected_field -= 1;
}
}
pub fn move_field_down(&mut self) {
if self.selected_field + 1 < Self::FIELD_COUNT {
self.selected_field += 1;
}
}
/// Step to the previous or next market in `markets` (wrapping). No-op if
/// the current market isn't in the list (shouldn't happen — the draft is
/// always seeded from the active market) or the list is empty.
pub fn cycle_market(&mut self, markets: &[String], step: i32) {
if markets.is_empty() {
return;
}
let idx = markets.iter().position(|s| *s == self.market).unwrap_or(0);
let len = markets.len() as i32;
let new_idx = ((idx as i32 + step).rem_euclid(len)) as usize;
self.market = markets[new_idx].clone();
self.error = None;
}
}
impl TradingState {
pub fn new() -> Self {
use super::super::constants::DEFAULT_SIZE_INDEX;
Self {
side: TradingSide::Long,
size_index: DEFAULT_SIZE_INDEX,
wallet_loaded: false,
wallet_label: String::new(),
position: None,
status_timestamp: make_status_timestamp(),
status_title: "Ready — Wallet not connected".to_string(),
status_detail: String::new(),
keypair: None,
tx_context: None,
shared_trader: None,
input_mode: InputMode::Normal,
input_buffer: String::new(),
deposit_buffer: String::new(),
withdraw_buffer: String::new(),
wallet_path_buffer: String::new(),
wallet_path_error: None,
referral_code_buffer: String::new(),
referral_code_error: None,
// Default to "Use COSMIC" (index 0). The prompt handler resets
// this each time the modal opens, but we keep the initial value
// aligned so a fast reader of the state struct sees the same
// default both places.
referral_choice_index: 0,
custom_size: None,
order_kind: OrderKind::Market,
usdc_balance: None,
phoenix_balance: None,
sol_balance: None,
config: current_user_config(),
config_selected_field: 0,
referral_choice_shown: false,
ledger: VecDeque::with_capacity(LEDGER_CAPACITY),
ledger_selected: 0,
twap_draft: TwapDraft::new(String::new(), TradingSide::Long),
}
}
/// Reset the TWAP draft form to default values, seeding the market and
/// side from the active context. Called when the "New TWAP" modal opens
/// so each entry starts from a known-good baseline.
pub fn reset_twap_draft(&mut self, market: String) {
self.twap_draft = TwapDraft::new(market, self.side);
}
/// Record a user action in the ledger. If the same txid already appears as
/// the most recent entry (e.g. an "awaiting confirm" status followed by a
/// "confirmed" status for the same signature), update that entry in place
/// instead of pushing a duplicate.
pub fn record_ledger(&mut self, title: impl Into<String>, txid: impl Into<String>) {
let title = title.into();
let txid = txid.into();
if let Some(front) = self.ledger.front_mut()
&& front.txid == txid
{
front.timestamp = make_status_timestamp();
front.title = title;
return;
}
self.ledger.push_front(LedgerEntry {
timestamp: make_status_timestamp(),
title,
txid,
});
while self.ledger.len() > LEDGER_CAPACITY {
self.ledger.pop_back();
}
}
pub fn set_status_title(&mut self, title: impl Into<String>) {
self.status_timestamp = make_status_timestamp();
self.status_title = title.into();
self.status_detail.clear();
}
pub fn order_size(&self) -> f64 {
use super::super::constants::ORDER_SIZE_PRESETS;
if let Some(custom) = self.custom_size {
custom
} else {
ORDER_SIZE_PRESETS[self.size_index]
}
}
pub fn increase_size(&mut self) {
use super::super::constants::ORDER_SIZE_PRESETS;
if self.size_index + 1 < ORDER_SIZE_PRESETS.len() {
self.size_index += 1;
}
}
pub fn decrease_size(&mut self) {
if self.size_index > 0 {
self.size_index -= 1;
}
}
}
#[cfg(test)]
mod tests {
use super::super::super::constants::{DEFAULT_SIZE_INDEX, ORDER_SIZE_PRESETS};
use super::*;
#[test]
fn new_starts_at_default_size_preset() {
let s = TradingState::new();
assert_eq!(s.size_index, DEFAULT_SIZE_INDEX);
assert_eq!(s.order_size(), ORDER_SIZE_PRESETS[DEFAULT_SIZE_INDEX]);
assert!(matches!(s.order_kind, OrderKind::Market));
assert_eq!(s.side, TradingSide::Long);
}
#[test]
fn custom_size_overrides_preset() {
let mut s = TradingState::new();
s.custom_size = Some(1.5);
assert_eq!(s.order_size(), 1.5);
}
#[test]
fn increase_size_stops_at_last_preset() {
let mut s = TradingState::new();
s.size_index = ORDER_SIZE_PRESETS.len() - 1;
s.increase_size();
assert_eq!(s.size_index, ORDER_SIZE_PRESETS.len() - 1);
}
#[test]
fn decrease_size_stops_at_zero() {
let mut s = TradingState::new();
s.size_index = 0;
s.decrease_size();
assert_eq!(s.size_index, 0);
}
#[test]
fn set_status_title_clears_detail() {
let mut s = TradingState::new();
s.status_detail = "old".to_string();
s.set_status_title("hello");
assert_eq!(s.status_title, "hello");
assert!(s.status_detail.is_empty());
}
#[test]
fn record_ledger_pushes_new_entries_newest_first() {
let mut s = TradingState::new();
s.record_ledger("first", "sig1");
s.record_ledger("second", "sig2");
assert_eq!(s.ledger.len(), 2);
assert_eq!(s.ledger.front().unwrap().txid, "sig2");
assert_eq!(s.ledger.back().unwrap().txid, "sig1");
}
#[test]
fn twap_draft_field_nav_clamps_at_bounds() {
let mut d = TwapDraft::new("SOL".into(), TradingSide::Long);
assert_eq!(d.selected_field, TwapDraft::DEFAULT_FIELD);
for _ in 0..10 {
d.move_field_up();
}
assert_eq!(d.selected_field, 0);
for _ in 0..10 {
d.move_field_down();
}
assert_eq!(d.selected_field, TwapDraft::FIELD_COUNT - 1);
}
#[test]
fn reset_twap_draft_seeds_market_and_side() {
let mut s = TradingState::new();
s.side = TradingSide::Short;
s.twap_draft.size_buffer = "leftover".to_string();
s.reset_twap_draft("BTC".to_string());
assert_eq!(s.twap_draft.market, "BTC");
assert_eq!(s.twap_draft.side, TradingSide::Short);
assert!(s.twap_draft.size_buffer.is_empty());
assert_eq!(s.twap_draft.selected_field, TwapDraft::DEFAULT_FIELD);
}
#[test]
fn cycle_market_wraps_at_ends() {
let mut d = TwapDraft::new("SOL".into(), TradingSide::Long);
let markets = vec!["SOL".to_string(), "BTC".to_string(), "ETH".to_string()];
d.cycle_market(&markets, 1);
assert_eq!(d.market, "BTC");
d.cycle_market(&markets, 1);
assert_eq!(d.market, "ETH");
d.cycle_market(&markets, 1);
assert_eq!(d.market, "SOL");
d.cycle_market(&markets, -1);
assert_eq!(d.market, "ETH");
}
#[test]
fn record_ledger_updates_in_place_for_same_txid() {
let mut s = TradingState::new();
s.record_ledger("awaiting", "sig");
s.record_ledger("confirmed", "sig");
assert_eq!(s.ledger.len(), 1);
assert_eq!(s.ledger.front().unwrap().title, "confirmed");
}
#[test]
fn record_ledger_evicts_beyond_capacity() {
let mut s = TradingState::new();
for i in 0..(LEDGER_CAPACITY + 5) {
s.record_ledger(format!("title-{i}"), format!("sig-{i}"));
}
assert_eq!(s.ledger.len(), LEDGER_CAPACITY);
// Newest still at front.
assert_eq!(
s.ledger.front().unwrap().txid,
format!("sig-{}", LEDGER_CAPACITY + 4)
);
}
}