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 daily_currency = ctx.daily_currency();
210 let today = now.date_naive();
211 let counter = self
212 .counters
213 .entry(key_id.to_string())
214 .or_insert_with(|| DailyCounter::new(today));
215 match counter.peek_add(
216 daily_currency.as_deref(),
217 value,
218 limits.max_daily_value,
219 today,
220 ) {
221 Ok(_) => Some((value, today, daily_currency)),
222 Err(DailyAddError::OverCap(msg)) => {
223 return Err(LimitOutcome::ThroughputReject(msg));
224 }
225 Err(DailyAddError::Invalid(reason)) => {
226 return Err(LimitOutcome::ValueReject(format!(
227 "order value invalid ({reason:?}): {value}"
228 )));
229 }
230 }
231 } else {
232 None
233 };
234
235 Ok(LimitGuard {
236 counters: self,
237 key_id: key_id.to_string(),
238 pending_daily,
239 daily_cap: limits.max_daily_value,
240 })
241 }
242
243 #[must_use]
253 pub fn check_and_commit(
254 &self,
255 key_id: &str,
256 limits: &Limits,
257 ctx: &CheckCtx,
258 now: DateTime<Utc>,
259 ) -> LimitOutcome {
260 if let (Some(allowed), Some(id)) = (&limits.allowed_acc_ids, ctx.acc_id)
265 && !allowed.is_empty()
266 && !allowed.contains(&id)
267 {
268 return LimitOutcome::WhitelistReject(format!(
269 "acc_id {id} not in allowed list {allowed:?}"
270 ));
271 }
272
273 if let Some(markets) = &limits.allowed_markets
275 && !markets.is_empty()
276 && !ctx.market.is_empty()
277 && !markets.contains(&ctx.market)
278 {
279 return LimitOutcome::WhitelistReject(format!(
280 "market {:?} not in allowed list {:?}",
281 ctx.market, markets
282 ));
283 }
284
285 if let Some(symbols) = &limits.allowed_symbols
287 && !symbols.is_empty()
288 && !ctx.symbol.is_empty()
289 && !symbols.contains(&ctx.symbol)
290 {
291 return LimitOutcome::WhitelistReject(format!(
292 "symbol {:?} not in allowed list",
293 ctx.symbol
294 ));
295 }
296
297 if let (Some(allowed), Some(side)) = (&limits.allowed_trd_sides, &ctx.trd_side)
299 && !allowed.is_empty()
300 && !allowed.contains(side)
301 {
302 return LimitOutcome::WhitelistReject(format!(
303 "trd_side {side:?} not in allowed list {allowed:?}"
304 ));
305 }
306
307 if let Some(spec) = &limits.hours_window {
309 match parse_window(spec) {
310 Ok((start, end)) => {
311 let now_local = now.with_timezone(&Local).time();
312 if !in_window(now_local, start, end) {
313 return LimitOutcome::ThroughputReject(format!(
314 "outside hours window {spec} (now={})",
315 now_local.format("%H:%M")
316 ));
317 }
318 }
319 Err(e) => {
320 return LimitOutcome::ThroughputReject(format!(
321 "invalid hours_window {spec:?}: {e}"
322 ));
323 }
324 }
325 }
326
327 if let Some(value) = ctx.order_value {
333 if let Err(reason) = validate_order_value(value) {
334 return LimitOutcome::ValueReject(format!(
335 "order value invalid ({reason:?}): {value}"
336 ));
337 }
338 if let Some(cap) = limits.max_order_value
339 && value > cap + f64::EPSILON
340 {
341 return LimitOutcome::ValueReject(format!(
342 "order value {value:.2} exceeds per-order cap {cap:.2}"
343 ));
344 }
345 }
346
347 if let Some(max) = limits.max_orders_per_minute {
349 let window = self.rates.entry(key_id.to_string()).or_default();
350 if let Err(e) = window.try_record(now, max) {
351 return LimitOutcome::ThroughputReject(e);
352 }
353 }
354
355 if !ctx.mutation_no_exposure
361 && let (Some(value), Some(_)) = (ctx.order_value, limits.max_daily_value)
362 {
363 let daily_currency = ctx.daily_currency();
364 let today = now.date_naive();
365 let counter = self
366 .counters
367 .entry(key_id.to_string())
368 .or_insert_with(|| DailyCounter::new(today));
369 match counter.try_add(
370 daily_currency.as_deref(),
371 value,
372 limits.max_daily_value,
373 today,
374 ) {
375 Ok(_) => {}
376 Err(DailyAddError::OverCap(msg)) => return LimitOutcome::ThroughputReject(msg),
377 Err(DailyAddError::Invalid(reason)) => {
378 return LimitOutcome::ValueReject(format!(
381 "order value invalid ({reason:?}): {value}"
382 ));
383 }
384 }
385 }
386
387 LimitOutcome::Allow
388 }
389
390 #[must_use]
402 pub fn check_full_skip_rate(
403 &self,
404 key_id: &str,
405 limits: &Limits,
406 ctx: &CheckCtx,
407 now: DateTime<Utc>,
408 ) -> LimitOutcome {
409 if let (Some(allowed), Some(id)) = (&limits.allowed_acc_ids, ctx.acc_id)
411 && !allowed.is_empty()
412 && !allowed.contains(&id)
413 {
414 return LimitOutcome::WhitelistReject(format!(
415 "acc_id {id} not in allowed list {allowed:?}"
416 ));
417 }
418
419 if let Some(markets) = &limits.allowed_markets
421 && !markets.is_empty()
422 && !ctx.market.is_empty()
423 && !markets.contains(&ctx.market)
424 {
425 return LimitOutcome::WhitelistReject(format!(
426 "market {:?} not in allowed list {:?}",
427 ctx.market, markets
428 ));
429 }
430
431 if let Some(symbols) = &limits.allowed_symbols
433 && !symbols.is_empty()
434 && !ctx.symbol.is_empty()
435 && !symbols.contains(&ctx.symbol)
436 {
437 return LimitOutcome::WhitelistReject(format!(
438 "symbol {:?} not in allowed list",
439 ctx.symbol
440 ));
441 }
442
443 if let (Some(allowed), Some(side)) = (&limits.allowed_trd_sides, &ctx.trd_side)
445 && !allowed.is_empty()
446 && !allowed.contains(side)
447 {
448 return LimitOutcome::WhitelistReject(format!(
449 "trd_side {side:?} not in allowed list {allowed:?}"
450 ));
451 }
452
453 if let Some(spec) = &limits.hours_window {
455 match parse_window(spec) {
456 Ok((start, end)) => {
457 let now_local = now.with_timezone(&Local).time();
458 if !in_window(now_local, start, end) {
459 return LimitOutcome::ThroughputReject(format!(
460 "outside hours window {spec} (now={})",
461 now_local.format("%H:%M")
462 ));
463 }
464 }
465 Err(e) => {
466 return LimitOutcome::ThroughputReject(format!(
467 "invalid hours_window {spec:?}: {e}"
468 ));
469 }
470 }
471 }
472
473 if let Some(value) = ctx.order_value {
477 if let Err(reason) = validate_order_value(value) {
478 return LimitOutcome::ValueReject(format!(
479 "order value invalid ({reason:?}): {value}"
480 ));
481 }
482 if let Some(cap) = limits.max_order_value
483 && value > cap + f64::EPSILON
484 {
485 return LimitOutcome::ValueReject(format!(
486 "order value {value:.2} exceeds per-order cap {cap:.2}"
487 ));
488 }
489 }
490
491 if !ctx.mutation_no_exposure
498 && let (Some(value), Some(_)) = (ctx.order_value, limits.max_daily_value)
499 {
500 let daily_currency = ctx.daily_currency();
501 let today = now.date_naive();
502 let counter = self
503 .counters
504 .entry(key_id.to_string())
505 .or_insert_with(|| DailyCounter::new(today));
506 match counter.try_add(
507 daily_currency.as_deref(),
508 value,
509 limits.max_daily_value,
510 today,
511 ) {
512 Ok(_) => {}
513 Err(DailyAddError::OverCap(msg)) => return LimitOutcome::ThroughputReject(msg),
514 Err(DailyAddError::Invalid(reason)) => {
515 return LimitOutcome::ValueReject(format!(
516 "order value invalid ({reason:?}): {value}"
517 ));
518 }
519 }
520 }
521
522 LimitOutcome::Allow
523 }
524
525 #[cfg(test)]
526 pub(super) fn peek_total(&self, key_id: &str) -> f64 {
527 self.counters
528 .get(key_id)
529 .map(|c| c.peek_total())
530 .unwrap_or(0.0)
531 }
532
533 #[cfg(test)]
534 pub(super) fn peek_total_for_currency(&self, key_id: &str, currency: &str) -> f64 {
535 self.counters
536 .get(key_id)
537 .map(|c| c.peek_total_for_currency(currency))
538 .unwrap_or(0.0)
539 }
540}