Skip to main content

melin_server/
app_factory.rs

1//! Trading-side [`AppFactory`] implementation.
2//!
3//! Owns the trading-domain construction recipe: empty / pre-sized
4//! exchange, SEC-03/SEC-04 operator policy, and the bulk-seed
5//! `AddInstrument` / `ProvisionAccount` events. Moves all four out
6//! of `runtime/server.rs` so the runtime never references trading
7//! event variants by name.
8
9use melin_app::app_factory::AppFactory;
10use melin_trading::trading_event::TradingEvent;
11use melin_types::types::{AccountId, CurrencyId, InstrumentSpec, Symbol};
12
13use crate::exchange_app::ServerApp;
14
15/// Construction config for [`Factory`]. Mirrors the
16/// trading-shaped fields of `ServerConfig`; kept as its own struct
17/// so the binary can build one independently of the larger runtime
18/// config when the eventual `ServerConfig` split happens.
19#[derive(Debug, Clone, Copy)]
20pub struct FactoryConfig {
21    /// Number of accounts to provision at startup.
22    pub accounts: u32,
23    /// Number of instruments to register at startup.
24    pub instruments: u32,
25    /// SEC-03: maximum simultaneously open orders per account.
26    pub max_orders_per_account: u32,
27    /// SEC-04: token-bucket refill rate, orders per second. `0`
28    /// disables the limiter.
29    pub max_orders_per_second: u32,
30    /// SEC-04: token-bucket capacity (max burst). `0` disables
31    /// the limiter.
32    pub max_orders_burst: u32,
33}
34
35/// Trading-side [`AppFactory`] producing `ServerApp` instances.
36#[derive(Debug, Clone, Copy)]
37pub struct Factory {
38    config: FactoryConfig,
39}
40
41impl Factory {
42    pub fn new(config: FactoryConfig) -> Self {
43        Self { config }
44    }
45}
46
47impl AppFactory for Factory {
48    type App = ServerApp;
49
50    fn empty(&self) -> ServerApp {
51        ServerApp(melin_exchange_core::exchange::Exchange::with_capacity())
52    }
53
54    fn prefault(&self, app: &mut ServerApp) {
55        app.0.prefault_seed(
56            self.config.accounts as usize,
57            self.config.instruments as usize,
58        );
59    }
60
61    fn apply_operator_policy(&self, app: &mut ServerApp) {
62        // SEC-04 mismatch detection (must run BEFORE we apply the
63        // new config). Non-empty bucket map paired with a disabled
64        // limiter means we just restored a snapshot whose primary
65        // had the limiter active, but the local operator forgot to
66        // wire matching `--max-orders-per-second` / `--max-orders-burst`
67        // flags. The engine will continue accepting all orders
68        // unthrottled — silent until the replica is promoted and
69        // starts diverging from the primary's accept/reject
70        // decisions. Surface the misconfig loudly so operators catch
71        // it at startup, not on incident.
72        let restored_buckets = app.order_bucket_count();
73        let limiter_disabled =
74            self.config.max_orders_per_second == 0 || self.config.max_orders_burst == 0;
75        if limiter_disabled && restored_buckets > 0 {
76            tracing::warn!(
77                restored_buckets,
78                max_orders_per_second = self.config.max_orders_per_second,
79                max_orders_burst = self.config.max_orders_burst,
80                "config mismatch: snapshot carries rate-limit buckets but local limiter \
81                 is disabled — primary and replica must run with matching values"
82            );
83        }
84
85        app.set_max_open_orders_per_account(self.config.max_orders_per_account);
86        app.set_max_orders_per_second(
87            self.config.max_orders_per_second,
88            self.config.max_orders_burst,
89        );
90
91        // Visibility for operators verifying primary↔replica parity
92        // at a glance. SEC-03 cap and SEC-04 rate-limit knobs are
93        // operator policy (not journaled), so logging the applied
94        // values is the only way to confirm both processes started
95        // with the same config.
96        tracing::info!(
97            max_orders_per_account = self.config.max_orders_per_account,
98            max_orders_per_second = self.config.max_orders_per_second,
99            max_orders_burst = self.config.max_orders_burst,
100            "applied per-account order limits (SEC-03 cap, SEC-04 rate)"
101        );
102    }
103
104    fn seed_events(&self) -> Vec<TradingEvent> {
105        let mut events =
106            Vec::with_capacity(self.config.instruments as usize + self.config.accounts as usize);
107        for i in 0..self.config.instruments {
108            events.push(TradingEvent::AddInstrument {
109                spec: InstrumentSpec {
110                    symbol: Symbol(i),
111                    base: CurrencyId(i * 2),
112                    quote: CurrencyId(i * 2 + 1),
113                },
114            });
115        }
116        for acct in 1..=self.config.accounts {
117            events.push(TradingEvent::ProvisionAccount {
118                account: AccountId(acct),
119                amount: u64::MAX / 4,
120            });
121        }
122        events
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    fn cfg(accounts: u32, instruments: u32) -> FactoryConfig {
131        FactoryConfig {
132            accounts,
133            instruments,
134            max_orders_per_account: 100,
135            max_orders_per_second: 1_000,
136            max_orders_burst: 100,
137        }
138    }
139
140    #[test]
141    fn seed_events_count_matches_config() {
142        let factory = Factory::new(cfg(5, 3));
143        let events = factory.seed_events();
144        // 3 instruments + 5 accounts.
145        assert_eq!(events.len(), 8);
146    }
147
148    #[test]
149    fn seed_events_order_is_instruments_then_accounts() {
150        let factory = Factory::new(cfg(2, 2));
151        let events = factory.seed_events();
152        assert!(matches!(events[0], TradingEvent::AddInstrument { .. }));
153        assert!(matches!(events[1], TradingEvent::AddInstrument { .. }));
154        assert!(matches!(events[2], TradingEvent::ProvisionAccount { .. }));
155        assert!(matches!(events[3], TradingEvent::ProvisionAccount { .. }));
156    }
157
158    #[test]
159    fn seed_events_empty_when_no_accounts_or_instruments() {
160        let factory = Factory::new(cfg(0, 0));
161        assert!(factory.seed_events().is_empty());
162    }
163
164    #[test]
165    fn empty_does_not_apply_policy() {
166        let factory = Factory::new(cfg(2, 2));
167        let app = factory.empty();
168        assert_ne!(app.max_open_orders_per_account(), 100);
169    }
170
171    #[test]
172    fn apply_operator_policy_overrides_default() {
173        let factory = Factory::new(cfg(2, 2));
174        let mut app = factory.empty();
175        factory.apply_operator_policy(&mut app);
176        assert_eq!(app.max_open_orders_per_account(), 100);
177    }
178}