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}