Skip to main content

sandbox_quant/app/
runtime.rs

1use crate::app::bootstrap::AppBootstrap;
2use crate::app::commands::AppCommand;
3use crate::domain::instrument::Instrument;
4use crate::domain::market::Market;
5use crate::execution::command::ExecutionCommand;
6use crate::execution::price_source::PriceSource;
7use crate::storage::event_log::log;
8use crate::strategy::command::StrategyCommand;
9use serde_json::json;
10
11#[derive(Debug, Default)]
12pub struct AppRuntime {
13    pub last_command: Option<AppCommand>,
14}
15
16impl AppRuntime {
17    pub fn record_command(&mut self, command: AppCommand) {
18        self.last_command = Some(command);
19    }
20
21    pub fn run<
22        E: crate::exchange::facade::ExchangeFacade<
23            Error = crate::error::exchange_error::ExchangeError,
24        >,
25    >(
26        &mut self,
27        app: &mut AppBootstrap<E>,
28        command: AppCommand,
29    ) -> Result<(), crate::error::app_error::AppError> {
30        self.record_command(command.clone());
31
32        match command {
33            AppCommand::Portfolio(_) => {
34                let report = app
35                    .portfolio_sync
36                    .refresh_authoritative(&app.exchange, &mut app.portfolio_store)?;
37                refresh_position_prices(app)?;
38                let today_realized_pnl_usdt = app.exchange.load_today_realized_pnl_usdt().ok();
39                let today_funding_pnl_usdt = app.exchange.load_today_funding_pnl_usdt().ok();
40                let margin_ratio = app.exchange.load_margin_ratio().ok().flatten();
41                log(
42                    &mut app.event_log,
43                    "app.portfolio.refreshed",
44                    json!({
45                        "positions": report.positions,
46                        "open_order_groups": report.open_order_groups,
47                        "balances": report.balances,
48                        "today_realized_pnl_usdt": today_realized_pnl_usdt,
49                        "today_funding_pnl_usdt": today_funding_pnl_usdt,
50                        "margin_ratio": margin_ratio,
51                    }),
52                );
53            }
54            AppCommand::Execution(command) => {
55                let report = app
56                    .portfolio_sync
57                    .refresh_authoritative(&app.exchange, &mut app.portfolio_store)?;
58                log(
59                    &mut app.event_log,
60                    "app.portfolio.refreshed",
61                    json!({
62                        "positions": report.positions,
63                        "open_order_groups": report.open_order_groups,
64                        "balances": report.balances,
65                    }),
66                );
67
68                if let ExecutionCommand::SetTargetExposure { instrument, .. } = &command {
69                    let market = app
70                        .portfolio_store
71                        .snapshot
72                        .positions
73                        .get(instrument)
74                        .map(|position| position.market)
75                        .or_else(|| {
76                            if app
77                                .exchange
78                                .load_symbol_rules(
79                                    instrument,
80                                    crate::domain::market::Market::Futures,
81                                )
82                                .is_ok()
83                            {
84                                Some(crate::domain::market::Market::Futures)
85                            } else if app
86                                .exchange
87                                .load_symbol_rules(instrument, crate::domain::market::Market::Spot)
88                                .is_ok()
89                            {
90                                Some(crate::domain::market::Market::Spot)
91                            } else {
92                                None
93                            }
94                        });
95
96                    if let Some(market) = market {
97                        let price = app.market_data.refresh_price(
98                            &app.exchange,
99                            &mut app.price_store,
100                            instrument.clone(),
101                            market,
102                        )?;
103                        log(
104                            &mut app.event_log,
105                            "app.market_data.price_refreshed",
106                            json!({
107                                "instrument": instrument.0,
108                                "market": format!("{market:?}"),
109                                "price": price,
110                            }),
111                        );
112                    }
113                }
114                let outcome = app.execution.execute(
115                    &app.exchange,
116                    &app.portfolio_store,
117                    &app.price_store,
118                    command.clone(),
119                )?;
120
121                let post_report = app
122                    .portfolio_sync
123                    .refresh_authoritative(&app.exchange, &mut app.portfolio_store)?;
124                refresh_position_prices(app)?;
125                log(
126                    &mut app.event_log,
127                    "app.portfolio.refreshed",
128                    json!({
129                        "positions": post_report.positions,
130                        "open_order_groups": post_report.open_order_groups,
131                        "balances": post_report.balances,
132                        "phase": "post_execution",
133                    }),
134                );
135                log(
136                    &mut app.event_log,
137                    "app.execution.completed",
138                    execution_payload(
139                        &command,
140                        &outcome,
141                        post_report.positions,
142                        remaining_gross_exposure_usdt(&app.portfolio_store, &app.price_store),
143                    ),
144                );
145            }
146            AppCommand::Strategy(command) => match command {
147                StrategyCommand::Templates | StrategyCommand::List | StrategyCommand::History => {}
148                StrategyCommand::Show { watch_id } => {
149                    app.strategy_store.get(app.mode, watch_id).ok_or(
150                        crate::error::strategy_error::StrategyError::WatchNotFound(watch_id),
151                    )?;
152                }
153                StrategyCommand::Start {
154                    template,
155                    instrument,
156                    config,
157                } => {
158                    app.exchange
159                        .load_symbol_rules(&instrument, Market::Futures)?;
160                    let watch = app.strategy_store.create_watch(
161                        app.mode,
162                        template,
163                        instrument.clone(),
164                        config.clone(),
165                    )?;
166                    app.recorder_coordination.sync_strategy_symbols(
167                        app.mode,
168                        active_strategy_symbols(&app.strategy_store, app.mode),
169                    )?;
170                    log(
171                        &mut app.event_log,
172                        "app.strategy.watch_started",
173                        json!({
174                            "watch_id": watch.id,
175                            "mode": format!("{:?}", watch.mode).to_ascii_lowercase(),
176                            "template": watch.template.slug(),
177                            "instrument": watch.instrument.0,
178                            "state": watch.state.as_str(),
179                            "risk_pct": watch.config.risk_pct,
180                            "win_rate": watch.config.win_rate,
181                            "r_multiple": watch.config.r_multiple,
182                            "max_entry_slippage_pct": watch.config.max_entry_slippage_pct,
183                            "current_step": watch.current_step,
184                        }),
185                    );
186                }
187                StrategyCommand::Stop { watch_id } => {
188                    let watch = app.strategy_store.stop_watch(app.mode, watch_id)?;
189                    app.recorder_coordination.sync_strategy_symbols(
190                        app.mode,
191                        active_strategy_symbols(&app.strategy_store, app.mode),
192                    )?;
193                    log(
194                        &mut app.event_log,
195                        "app.strategy.watch_stopped",
196                        json!({
197                            "watch_id": watch.id,
198                            "mode": format!("{:?}", watch.mode).to_ascii_lowercase(),
199                            "template": watch.template.slug(),
200                            "instrument": watch.instrument.0,
201                            "state": watch.state.as_str(),
202                        }),
203                    );
204                }
205            },
206            AppCommand::RefreshAuthoritativeState => {
207                let report = app
208                    .portfolio_sync
209                    .refresh_authoritative(&app.exchange, &mut app.portfolio_store)?;
210                refresh_position_prices(app)?;
211                let today_realized_pnl_usdt = app.exchange.load_today_realized_pnl_usdt().ok();
212                let today_funding_pnl_usdt = app.exchange.load_today_funding_pnl_usdt().ok();
213                let margin_ratio = app.exchange.load_margin_ratio().ok().flatten();
214                log(
215                    &mut app.event_log,
216                    "app.portfolio.refreshed",
217                    json!({
218                        "positions": report.positions,
219                        "open_order_groups": report.open_order_groups,
220                        "balances": report.balances,
221                        "today_realized_pnl_usdt": today_realized_pnl_usdt,
222                        "today_funding_pnl_usdt": today_funding_pnl_usdt,
223                        "margin_ratio": margin_ratio,
224                    }),
225                );
226            }
227        }
228
229        Ok(())
230    }
231}
232
233fn active_strategy_symbols(
234    store: &crate::strategy::store::StrategyStore,
235    mode: crate::app::bootstrap::BinanceMode,
236) -> Vec<String> {
237    store
238        .active_watches(mode)
239        .into_iter()
240        .map(|watch| watch.instrument.0.clone())
241        .collect()
242}
243
244fn refresh_position_prices<
245    E: crate::exchange::facade::ExchangeFacade<Error = crate::error::exchange_error::ExchangeError>,
246>(
247    app: &mut AppBootstrap<E>,
248) -> Result<(), crate::error::exchange_error::ExchangeError> {
249    let instruments = app
250        .portfolio_store
251        .snapshot
252        .positions
253        .values()
254        .map(|position| (position.instrument.clone(), position.market))
255        .collect::<Vec<(Instrument, crate::domain::market::Market)>>();
256
257    for (instrument, market) in instruments {
258        app.market_data
259            .refresh_price(&app.exchange, &mut app.price_store, instrument, market)?;
260    }
261
262    Ok(())
263}
264
265fn execution_payload(
266    command: &ExecutionCommand,
267    outcome: &crate::execution::service::ExecutionOutcome,
268    remaining_positions: usize,
269    remaining_gross_exposure_usdt: f64,
270) -> serde_json::Value {
271    match (command, outcome) {
272        (
273            ExecutionCommand::SetTargetExposure {
274                instrument,
275                target,
276                order_type,
277                ..
278            },
279            crate::execution::service::ExecutionOutcome::TargetExposureSubmitted { .. },
280        ) => json!({
281            "command_kind": "set_target_exposure",
282            "instrument": instrument.0,
283            "target": target.value(),
284            "order_type": format_order_type(*order_type),
285            "outcome_kind": "submitted",
286            "remaining_positions": remaining_positions,
287            "flat_confirmed": remaining_positions == 0,
288            "remaining_gross_exposure_usdt": remaining_gross_exposure_usdt,
289        }),
290        (
291            ExecutionCommand::SetTargetExposure {
292                instrument,
293                target,
294                order_type,
295                ..
296            },
297            crate::execution::service::ExecutionOutcome::TargetExposureAlreadyAtTarget { .. },
298        ) => json!({
299            "command_kind": "set_target_exposure",
300            "instrument": instrument.0,
301            "target": target.value(),
302            "order_type": format_order_type(*order_type),
303            "outcome_kind": "already-at-target",
304            "remaining_positions": remaining_positions,
305            "flat_confirmed": remaining_positions == 0,
306            "remaining_gross_exposure_usdt": remaining_gross_exposure_usdt,
307        }),
308        (
309            ExecutionCommand::SubmitOptionOrder {
310                instrument,
311                side,
312                qty,
313                order_type,
314                ..
315            },
316            crate::execution::service::ExecutionOutcome::OptionOrderSubmitted { .. },
317        ) => json!({
318            "command_kind": "submit_option_order",
319            "instrument": instrument.0,
320            "side": format!("{side:?}"),
321            "qty": qty,
322            "order_type": format_order_type(*order_type),
323            "remaining_positions": remaining_positions,
324            "flat_confirmed": remaining_positions == 0,
325            "remaining_gross_exposure_usdt": remaining_gross_exposure_usdt,
326            "outcome_kind": "submitted",
327        }),
328        (
329            ExecutionCommand::CloseSymbol { instrument, .. },
330            crate::execution::service::ExecutionOutcome::CloseSymbol(result),
331        ) => json!({
332            "command_kind": "close_symbol",
333            "instrument": instrument.0,
334            "outcome_kind": format!("{:?}", result.result),
335            "remaining_positions": remaining_positions,
336            "flat_confirmed": remaining_positions == 0,
337            "remaining_gross_exposure_usdt": remaining_gross_exposure_usdt,
338        }),
339        (
340            ExecutionCommand::CloseAll { .. },
341            crate::execution::service::ExecutionOutcome::CloseAll(result),
342        ) => {
343            let submitted = result
344                .results
345                .iter()
346                .filter(|item| {
347                    matches!(
348                        item.result,
349                        crate::execution::close_symbol::CloseSubmitResult::Submitted
350                    )
351                })
352                .count();
353            let skipped = result
354                .results
355                .iter()
356                .filter(|item| {
357                    matches!(
358                        item.result,
359                        crate::execution::close_symbol::CloseSubmitResult::SkippedNoPosition
360                    )
361                })
362                .count();
363            let rejected = result
364                .results
365                .iter()
366                .filter(|item| {
367                    matches!(
368                        item.result,
369                        crate::execution::close_symbol::CloseSubmitResult::Rejected
370                    )
371                })
372                .count();
373            json!({
374                "command_kind": "close_all",
375                "batch_id": result.batch_id.0,
376                "submitted": submitted,
377                "skipped": skipped,
378                "rejected": rejected,
379                "remaining_positions": remaining_positions,
380                "flat_confirmed": remaining_positions == 0,
381                "remaining_gross_exposure_usdt": remaining_gross_exposure_usdt,
382                "outcome_kind": "batch_completed",
383            })
384        }
385        _ => json!({
386            "command_kind": "unknown",
387            "outcome_kind": "unknown",
388        }),
389    }
390}
391
392fn format_order_type(order_type: crate::domain::order_type::OrderType) -> String {
393    match order_type {
394        crate::domain::order_type::OrderType::Market => "market".to_string(),
395        crate::domain::order_type::OrderType::Limit { price } => format!("limit@{price:.2}"),
396    }
397}
398
399fn remaining_gross_exposure_usdt(
400    store: &crate::portfolio::store::PortfolioStateStore,
401    prices: &crate::market_data::price_store::PriceStore,
402) -> f64 {
403    store
404        .snapshot
405        .positions
406        .values()
407        .filter(|position| !position.is_flat())
408        .filter(|position| position.market != crate::domain::market::Market::Options)
409        .filter_map(|position| {
410            let price = prices
411                .current_price(&position.instrument)
412                .or(position.entry_price)?;
413            Some(position.abs_qty() * price)
414        })
415        .sum::<f64>()
416}