1use super::*;
6use chrono::{DateTime, Local, NaiveDate, Utc};
7use dashmap::DashMap;
8
9#[derive(Debug, Default)]
10pub struct RuntimeCounters {
11 pub(super) counters: DashMap<String, DailyCounter>,
12 rates: DashMap<String, RateWindow>,
13}
14
15#[must_use = "LimitGuard 必须显式 commit_daily() 或 drop(); drop 时 daily counter 不写入 (accepted-quota 语义)"]
38#[derive(Debug)]
39pub struct LimitGuard<'a> {
40 counters: &'a RuntimeCounters,
41 key_id: String,
42 pending_daily: Option<(f64, NaiveDate, Option<String>)>,
47 daily_cap: Option<f64>,
49}
50
51impl<'a> LimitGuard<'a> {
52 pub fn commit_daily(mut self) -> Result<(), LimitOutcome> {
64 let Some((value, today, currency)) = self.pending_daily.take() else {
65 return Ok(());
66 };
67 let counter = self
68 .counters
69 .counters
70 .entry(self.key_id.clone())
71 .or_insert_with(|| DailyCounter::new(today));
72 match counter.try_add(currency.as_deref(), value, self.daily_cap, today) {
73 Ok(_) => Ok(()),
74 Err(DailyAddError::OverCap(msg)) => Err(LimitOutcome::ThroughputReject(msg)),
75 Err(DailyAddError::Invalid(reason)) => Err(LimitOutcome::ValueReject(format!(
76 "order value invalid ({reason:?}): {value}"
77 ))),
78 }
79 }
80
81 #[must_use]
83 pub fn has_pending_daily(&self) -> bool {
84 self.pending_daily.is_some()
85 }
86}
87
88impl RuntimeCounters {
89 pub fn new() -> Self {
90 Self::default()
91 }
92
93 pub fn check_limits<'a>(
108 &'a self,
109 key_id: &str,
110 limits: &Limits,
111 ctx: &CheckCtx,
112 now: DateTime<Utc>,
113 commit_rate: bool,
114 ) -> Result<LimitGuard<'a>, LimitOutcome> {
115 if let (Some(allowed), Some(id)) = (&limits.allowed_acc_ids, ctx.acc_id)
117 && !allowed.is_empty()
118 && !allowed.contains(&id)
119 {
120 return Err(LimitOutcome::WhitelistReject(format!(
121 "acc_id {id} not in allowed list {allowed:?}"
122 )));
123 }
124
125 if let Some(markets) = &limits.allowed_markets
127 && !markets.is_empty()
128 && !ctx.market.is_empty()
129 && !markets.contains(&ctx.market)
130 {
131 return Err(LimitOutcome::WhitelistReject(format!(
132 "market {:?} not in allowed list {:?}",
133 ctx.market, markets
134 )));
135 }
136
137 if let Some(symbols) = &limits.allowed_symbols
139 && !symbols.is_empty()
140 && !ctx.symbol.is_empty()
141 && !symbols.contains(&ctx.symbol)
142 {
143 return Err(LimitOutcome::WhitelistReject(format!(
144 "symbol {:?} not in allowed list",
145 ctx.symbol
146 )));
147 }
148
149 if let (Some(allowed), Some(side)) = (&limits.allowed_trd_sides, &ctx.trd_side)
151 && !allowed.is_empty()
152 && !allowed.contains(side)
153 {
154 return Err(LimitOutcome::WhitelistReject(format!(
155 "trd_side {side:?} not in allowed list {allowed:?}"
156 )));
157 }
158
159 if let Some(spec) = &limits.hours_window {
161 match parse_window(spec) {
162 Ok((start, end)) => {
163 let now_local = now.with_timezone(&Local).time();
164 if !in_window(now_local, start, end) {
165 return Err(LimitOutcome::ThroughputReject(format!(
166 "outside hours window {spec} (now={})",
167 now_local.format("%H:%M")
168 )));
169 }
170 }
171 Err(e) => {
172 return Err(LimitOutcome::ThroughputReject(format!(
173 "invalid hours_window {spec:?}: {e}"
174 )));
175 }
176 }
177 }
178
179 if let Some(value) = ctx.order_value {
181 if let Err(reason) = validate_order_value(value) {
182 return Err(LimitOutcome::ValueReject(format!(
183 "order value invalid ({reason:?}): {value}"
184 )));
185 }
186 if let Some(cap) = limits.max_order_value
187 && value > cap + f64::EPSILON
188 {
189 return Err(LimitOutcome::ValueReject(format!(
190 "order value {value:.2} exceeds per-order cap {cap:.2}"
191 )));
192 }
193 }
194
195 if commit_rate && let Some(max) = limits.max_orders_per_minute {
197 let window = self.rates.entry(key_id.to_string()).or_default();
198 if let Err(e) = window.try_record(now, max) {
199 return Err(LimitOutcome::ThroughputReject(e));
200 }
201 }
202
203 let pending_daily = if !ctx.mutation_no_exposure
207 && let (Some(value), Some(_)) = (ctx.order_value, limits.max_daily_value)
208 {
209 let today = now.date_naive();
210 let counter = self
211 .counters
212 .entry(key_id.to_string())
213 .or_insert_with(|| DailyCounter::new(today));
214 match counter.peek_add(
215 ctx.currency.as_deref(),
216 value,
217 limits.max_daily_value,
218 today,
219 ) {
220 Ok(_) => Some((value, today, ctx.currency.clone())),
221 Err(DailyAddError::OverCap(msg)) => {
222 return Err(LimitOutcome::ThroughputReject(msg));
223 }
224 Err(DailyAddError::Invalid(reason)) => {
225 return Err(LimitOutcome::ValueReject(format!(
226 "order value invalid ({reason:?}): {value}"
227 )));
228 }
229 }
230 } else {
231 None
232 };
233
234 Ok(LimitGuard {
235 counters: self,
236 key_id: key_id.to_string(),
237 pending_daily,
238 daily_cap: limits.max_daily_value,
239 })
240 }
241
242 #[must_use]
252 pub fn check_and_commit(
253 &self,
254 key_id: &str,
255 limits: &Limits,
256 ctx: &CheckCtx,
257 now: DateTime<Utc>,
258 ) -> LimitOutcome {
259 if let (Some(allowed), Some(id)) = (&limits.allowed_acc_ids, ctx.acc_id)
264 && !allowed.is_empty()
265 && !allowed.contains(&id)
266 {
267 return LimitOutcome::WhitelistReject(format!(
268 "acc_id {id} not in allowed list {allowed:?}"
269 ));
270 }
271
272 if let Some(markets) = &limits.allowed_markets
274 && !markets.is_empty()
275 && !ctx.market.is_empty()
276 && !markets.contains(&ctx.market)
277 {
278 return LimitOutcome::WhitelistReject(format!(
279 "market {:?} not in allowed list {:?}",
280 ctx.market, markets
281 ));
282 }
283
284 if let Some(symbols) = &limits.allowed_symbols
286 && !symbols.is_empty()
287 && !ctx.symbol.is_empty()
288 && !symbols.contains(&ctx.symbol)
289 {
290 return LimitOutcome::WhitelistReject(format!(
291 "symbol {:?} not in allowed list",
292 ctx.symbol
293 ));
294 }
295
296 if let (Some(allowed), Some(side)) = (&limits.allowed_trd_sides, &ctx.trd_side)
298 && !allowed.is_empty()
299 && !allowed.contains(side)
300 {
301 return LimitOutcome::WhitelistReject(format!(
302 "trd_side {side:?} not in allowed list {allowed:?}"
303 ));
304 }
305
306 if let Some(spec) = &limits.hours_window {
308 match parse_window(spec) {
309 Ok((start, end)) => {
310 let now_local = now.with_timezone(&Local).time();
311 if !in_window(now_local, start, end) {
312 return LimitOutcome::ThroughputReject(format!(
313 "outside hours window {spec} (now={})",
314 now_local.format("%H:%M")
315 ));
316 }
317 }
318 Err(e) => {
319 return LimitOutcome::ThroughputReject(format!(
320 "invalid hours_window {spec:?}: {e}"
321 ));
322 }
323 }
324 }
325
326 if let Some(value) = ctx.order_value {
332 if let Err(reason) = validate_order_value(value) {
333 return LimitOutcome::ValueReject(format!(
334 "order value invalid ({reason:?}): {value}"
335 ));
336 }
337 if let Some(cap) = limits.max_order_value
338 && value > cap + f64::EPSILON
339 {
340 return LimitOutcome::ValueReject(format!(
341 "order value {value:.2} exceeds per-order cap {cap:.2}"
342 ));
343 }
344 }
345
346 if let Some(max) = limits.max_orders_per_minute {
348 let window = self.rates.entry(key_id.to_string()).or_default();
349 if let Err(e) = window.try_record(now, max) {
350 return LimitOutcome::ThroughputReject(e);
351 }
352 }
353
354 if !ctx.mutation_no_exposure
360 && let (Some(value), Some(_)) = (ctx.order_value, limits.max_daily_value)
361 {
362 let today = now.date_naive();
363 let counter = self
364 .counters
365 .entry(key_id.to_string())
366 .or_insert_with(|| DailyCounter::new(today));
367 match counter.try_add(
368 ctx.currency.as_deref(),
369 value,
370 limits.max_daily_value,
371 today,
372 ) {
373 Ok(_) => {}
374 Err(DailyAddError::OverCap(msg)) => return LimitOutcome::ThroughputReject(msg),
375 Err(DailyAddError::Invalid(reason)) => {
376 return LimitOutcome::ValueReject(format!(
379 "order value invalid ({reason:?}): {value}"
380 ));
381 }
382 }
383 }
384
385 LimitOutcome::Allow
386 }
387
388 #[must_use]
400 pub fn check_full_skip_rate(
401 &self,
402 key_id: &str,
403 limits: &Limits,
404 ctx: &CheckCtx,
405 now: DateTime<Utc>,
406 ) -> LimitOutcome {
407 if let (Some(allowed), Some(id)) = (&limits.allowed_acc_ids, ctx.acc_id)
409 && !allowed.is_empty()
410 && !allowed.contains(&id)
411 {
412 return LimitOutcome::WhitelistReject(format!(
413 "acc_id {id} not in allowed list {allowed:?}"
414 ));
415 }
416
417 if let Some(markets) = &limits.allowed_markets
419 && !markets.is_empty()
420 && !ctx.market.is_empty()
421 && !markets.contains(&ctx.market)
422 {
423 return LimitOutcome::WhitelistReject(format!(
424 "market {:?} not in allowed list {:?}",
425 ctx.market, markets
426 ));
427 }
428
429 if let Some(symbols) = &limits.allowed_symbols
431 && !symbols.is_empty()
432 && !ctx.symbol.is_empty()
433 && !symbols.contains(&ctx.symbol)
434 {
435 return LimitOutcome::WhitelistReject(format!(
436 "symbol {:?} not in allowed list",
437 ctx.symbol
438 ));
439 }
440
441 if let (Some(allowed), Some(side)) = (&limits.allowed_trd_sides, &ctx.trd_side)
443 && !allowed.is_empty()
444 && !allowed.contains(side)
445 {
446 return LimitOutcome::WhitelistReject(format!(
447 "trd_side {side:?} not in allowed list {allowed:?}"
448 ));
449 }
450
451 if let Some(spec) = &limits.hours_window {
453 match parse_window(spec) {
454 Ok((start, end)) => {
455 let now_local = now.with_timezone(&Local).time();
456 if !in_window(now_local, start, end) {
457 return LimitOutcome::ThroughputReject(format!(
458 "outside hours window {spec} (now={})",
459 now_local.format("%H:%M")
460 ));
461 }
462 }
463 Err(e) => {
464 return LimitOutcome::ThroughputReject(format!(
465 "invalid hours_window {spec:?}: {e}"
466 ));
467 }
468 }
469 }
470
471 if let Some(value) = ctx.order_value {
475 if let Err(reason) = validate_order_value(value) {
476 return LimitOutcome::ValueReject(format!(
477 "order value invalid ({reason:?}): {value}"
478 ));
479 }
480 if let Some(cap) = limits.max_order_value
481 && value > cap + f64::EPSILON
482 {
483 return LimitOutcome::ValueReject(format!(
484 "order value {value:.2} exceeds per-order cap {cap:.2}"
485 ));
486 }
487 }
488
489 if !ctx.mutation_no_exposure
496 && let (Some(value), Some(_)) = (ctx.order_value, limits.max_daily_value)
497 {
498 let today = now.date_naive();
499 let counter = self
500 .counters
501 .entry(key_id.to_string())
502 .or_insert_with(|| DailyCounter::new(today));
503 match counter.try_add(
504 ctx.currency.as_deref(),
505 value,
506 limits.max_daily_value,
507 today,
508 ) {
509 Ok(_) => {}
510 Err(DailyAddError::OverCap(msg)) => return LimitOutcome::ThroughputReject(msg),
511 Err(DailyAddError::Invalid(reason)) => {
512 return LimitOutcome::ValueReject(format!(
513 "order value invalid ({reason:?}): {value}"
514 ));
515 }
516 }
517 }
518
519 LimitOutcome::Allow
520 }
521
522 #[cfg(test)]
523 pub(super) fn peek_total(&self, key_id: &str) -> f64 {
524 self.counters
525 .get(key_id)
526 .map(|c| c.peek_total())
527 .unwrap_or(0.0)
528 }
529}