1use serde::{Deserialize, Serialize};
9
10use crate::types::{
11 CloseReason, Effect, FillModel, PositionStatus, PriceQuote, RuleConfig, Side,
12 position_size_tolerance,
13};
14
15pub struct PositionView<'a> {
22 pub id: &'a str,
23 pub symbol: &'a str,
24 pub side: Side,
25 pub status: PositionStatus,
26 pub average_entry: f64,
27 pub remaining_ratio: f64,
28 pub target_hits: u32,
29 pub open_ts: Option<chrono::NaiveDateTime>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
39pub enum Rule {
40 FixedStoploss { price: f64 },
42
43 TrailingStop {
48 distance: f64,
49 #[serde(default)]
50 peak_price: f64,
51 #[serde(default)]
52 initialized: bool,
53 },
54
55 TakeProfit {
58 price: f64,
59 close_ratio: f64,
60 #[serde(default)]
61 triggered: bool,
62 },
63
64 BreakevenWhen {
67 trigger_price: f64,
68 #[serde(default)]
69 triggered: bool,
70 },
71
72 BreakevenAfterTargets {
75 after_n: u32,
76 #[serde(default)]
77 triggered: bool,
78 },
79
80 TimeExit { max_seconds: u64 },
82}
83
84impl Rule {
87 pub fn fixed_stoploss(price: f64) -> Self {
88 Rule::FixedStoploss { price }
89 }
90
91 pub fn trailing_stop(distance: f64) -> Self {
92 Rule::TrailingStop {
93 distance,
94 peak_price: 0.0,
95 initialized: false,
96 }
97 }
98
99 pub fn take_profit(price: f64, close_ratio: f64) -> Self {
100 Rule::TakeProfit {
101 price,
102 close_ratio,
103 triggered: false,
104 }
105 }
106
107 pub fn breakeven_when(trigger_price: f64) -> Self {
108 Rule::BreakevenWhen {
109 trigger_price,
110 triggered: false,
111 }
112 }
113
114 pub fn breakeven_after_targets(after_n: u32) -> Self {
115 Rule::BreakevenAfterTargets {
116 after_n,
117 triggered: false,
118 }
119 }
120
121 pub fn time_exit(max_seconds: u64) -> Self {
122 Rule::TimeExit { max_seconds }
123 }
124
125 pub fn from_config(config: RuleConfig) -> Self {
127 match config {
128 RuleConfig::FixedStoploss { price } => Self::fixed_stoploss(price),
129 RuleConfig::TrailingStop { distance } => Self::trailing_stop(distance),
130 RuleConfig::TakeProfit { price, close_ratio } => Self::take_profit(price, close_ratio),
131 RuleConfig::BreakevenWhen { trigger_price } => Self::breakeven_when(trigger_price),
132 RuleConfig::BreakevenAfterTargets { after_n } => Self::breakeven_after_targets(after_n),
133 RuleConfig::TimeExit { max_seconds } => Self::time_exit(max_seconds),
134 }
135 }
136}
137
138impl Rule {
141 pub fn is_stateful(&self) -> bool {
143 matches!(
144 self,
145 Rule::TrailingStop { .. } | Rule::TimeExit { .. } | Rule::BreakevenAfterTargets { .. }
146 )
147 }
148
149 pub fn name(&self) -> &'static str {
151 match self {
152 Rule::FixedStoploss { .. } => "FixedStoploss",
153 Rule::TrailingStop { .. } => "TrailingStop",
154 Rule::TakeProfit { .. } => "TakeProfit",
155 Rule::BreakevenWhen { .. } => "BreakevenWhen",
156 Rule::BreakevenAfterTargets { .. } => "BreakevenAfterTargets",
157 Rule::TimeExit { .. } => "TimeExit",
158 }
159 }
160
161 pub fn evaluate(
166 &mut self,
167 data: &PositionView<'_>,
168 quote: &PriceQuote,
169 model: FillModel,
170 ) -> Vec<Effect> {
171 if data.status != PositionStatus::Open {
173 return vec![];
174 }
175
176 match self {
177 Rule::FixedStoploss { price } => {
179 let check = quote.eval_price(data.side, model);
180 let hit = match data.side {
181 Side::Buy => check <= *price,
182 Side::Sell => check >= *price,
183 };
184 if hit {
185 vec![Effect::PositionClosed {
186 id: data.id.to_owned(),
187 reason: CloseReason::Stoploss,
188 }]
189 } else {
190 vec![]
191 }
192 }
193
194 Rule::TrailingStop {
196 distance,
197 peak_price,
198 initialized,
199 } => {
200 if !*initialized {
201 *peak_price = data.average_entry;
202 *initialized = true;
203 }
204
205 let check = quote.eval_price(data.side, model);
206
207 match data.side {
208 Side::Buy => {
209 if check > *peak_price {
210 *peak_price = check;
211 }
212 let trailing_sl = *peak_price - *distance;
213 if check <= trailing_sl {
214 vec![Effect::PositionClosed {
215 id: data.id.to_owned(),
216 reason: CloseReason::TrailingStop,
217 }]
218 } else {
219 vec![]
220 }
221 }
222 Side::Sell => {
223 if check < *peak_price {
224 *peak_price = check;
225 }
226 let trailing_sl = *peak_price + *distance;
227 if check >= trailing_sl {
228 vec![Effect::PositionClosed {
229 id: data.id.to_owned(),
230 reason: CloseReason::TrailingStop,
231 }]
232 } else {
233 vec![]
234 }
235 }
236 }
237 }
238
239 Rule::TakeProfit {
241 price,
242 close_ratio,
243 triggered,
244 } => {
245 if *triggered {
246 return vec![];
247 }
248 if data.remaining_ratio <= 0.0 {
249 return vec![];
250 }
251
252 let check = quote.eval_price(data.side, model);
253 let hit = match data.side {
254 Side::Buy => check >= *price,
255 Side::Sell => check <= *price,
256 };
257
258 if hit {
259 *triggered = true;
260 let actual_ratio = close_ratio.min(data.remaining_ratio);
261
262 if data.remaining_ratio - actual_ratio <= position_size_tolerance(1.0) {
264 vec![Effect::PositionClosed {
265 id: data.id.to_owned(),
266 reason: CloseReason::Target,
267 }]
268 } else {
269 vec![Effect::PartialClose {
270 id: data.id.to_owned(),
271 ratio: actual_ratio,
272 reason: CloseReason::Target,
273 }]
274 }
275 } else {
276 vec![]
277 }
278 }
279
280 Rule::BreakevenWhen {
282 trigger_price,
283 triggered,
284 } => {
285 if *triggered {
286 return vec![];
287 }
288
289 let check = quote.eval_price(data.side, model);
290 let hit = match data.side {
291 Side::Buy => check >= *trigger_price,
292 Side::Sell => check <= *trigger_price,
293 };
294
295 if hit {
296 *triggered = true;
297 vec![Effect::StoplossModified {
298 id: data.id.to_owned(),
299 old_price: 0.0, new_price: data.average_entry,
301 }]
302 } else {
303 vec![]
304 }
305 }
306
307 Rule::BreakevenAfterTargets { after_n, triggered } => {
309 if *triggered {
310 return vec![];
311 }
312
313 if data.target_hits >= *after_n {
314 *triggered = true;
315 vec![Effect::StoplossModified {
316 id: data.id.to_owned(),
317 old_price: 0.0, new_price: data.average_entry,
319 }]
320 } else {
321 vec![]
322 }
323 }
324
325 Rule::TimeExit { max_seconds } => {
327 if let Some(open_ts) = data.open_ts {
328 let elapsed = (quote.ts - open_ts).num_seconds();
329 if elapsed >= *max_seconds as i64 {
330 vec![Effect::PositionClosed {
331 id: data.id.to_owned(),
332 reason: CloseReason::TimeExit,
333 }]
334 } else {
335 vec![]
336 }
337 } else {
338 vec![]
339 }
340 }
341 }
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use chrono::NaiveDate;
349
350 fn ts(h: u32, m: u32, s: u32) -> chrono::NaiveDateTime {
351 NaiveDate::from_ymd_opt(2026, 1, 1)
352 .unwrap()
353 .and_hms_opt(h, m, s)
354 .unwrap()
355 }
356
357 fn quote(bid: f64, ask: f64) -> PriceQuote {
358 PriceQuote {
359 symbol: "EURUSD".into(),
360 ts: ts(12, 0, 0),
361 bid,
362 ask,
363 }
364 }
365
366 fn view_buy(id: &str, entry: f64) -> PositionView<'_> {
367 PositionView {
368 id,
369 symbol: "EURUSD",
370 side: Side::Buy,
371 status: PositionStatus::Open,
372 average_entry: entry,
373 remaining_ratio: 1.0,
374 target_hits: 0,
375 open_ts: Some(ts(10, 0, 0)),
376 }
377 }
378
379 fn view_sell(id: &str, entry: f64) -> PositionView<'_> {
380 PositionView {
381 id,
382 symbol: "EURUSD",
383 side: Side::Sell,
384 status: PositionStatus::Open,
385 average_entry: entry,
386 remaining_ratio: 1.0,
387 target_hits: 0,
388 open_ts: Some(ts(10, 0, 0)),
389 }
390 }
391
392 #[test]
393 fn fixed_stoploss_buy_triggers() {
394 let mut rule = Rule::fixed_stoploss(1.0800);
395 let v = view_buy("p1", 1.0850);
396 let effects = rule.evaluate(&v, "e(1.0800, 1.0802), FillModel::BidAsk);
398 assert_eq!(effects.len(), 1);
399 assert!(matches!(
400 &effects[0],
401 Effect::PositionClosed {
402 reason: CloseReason::Stoploss,
403 ..
404 }
405 ));
406 }
407
408 #[test]
409 fn fixed_stoploss_buy_no_trigger() {
410 let mut rule = Rule::fixed_stoploss(1.0800);
411 let v = view_buy("p1", 1.0850);
412 let effects = rule.evaluate(&v, "e(1.0810, 1.0812), FillModel::BidAsk);
413 assert!(effects.is_empty());
414 }
415
416 #[test]
417 fn fixed_stoploss_sell_triggers() {
418 let mut rule = Rule::fixed_stoploss(1.0900);
419 let v = view_sell("p1", 1.0850);
420 let effects = rule.evaluate(&v, "e(1.0898, 1.0900), FillModel::BidAsk);
422 assert_eq!(effects.len(), 1);
423 assert!(matches!(
424 &effects[0],
425 Effect::PositionClosed {
426 reason: CloseReason::Stoploss,
427 ..
428 }
429 ));
430 }
431
432 #[test]
433 fn trailing_stop_buy() {
434 let mut rule = Rule::trailing_stop(0.0020);
435 let v = view_buy("p1", 1.0850);
436
437 let effects = rule.evaluate(&v, "e(1.0870, 1.0872), FillModel::BidAsk);
439 assert!(effects.is_empty());
440
441 let effects = rule.evaluate(&v, "e(1.0900, 1.0902), FillModel::BidAsk);
443 assert!(effects.is_empty());
444
445 let effects = rule.evaluate(&v, "e(1.0882, 1.0884), FillModel::BidAsk);
447 assert!(effects.is_empty());
448
449 let effects = rule.evaluate(&v, "e(1.0879, 1.0881), FillModel::BidAsk);
451 assert_eq!(effects.len(), 1);
452 assert!(matches!(
453 &effects[0],
454 Effect::PositionClosed {
455 reason: CloseReason::TrailingStop,
456 ..
457 }
458 ));
459 }
460
461 #[test]
462 fn take_profit_partial() {
463 let mut rule = Rule::take_profit(1.0900, 0.5);
464 let v = view_buy("p1", 1.0850);
465
466 let effects = rule.evaluate(&v, "e(1.0890, 1.0892), FillModel::BidAsk);
468 assert!(effects.is_empty());
469
470 let effects = rule.evaluate(&v, "e(1.0900, 1.0902), FillModel::BidAsk);
472 assert_eq!(effects.len(), 1);
473 assert!(
474 matches!(&effects[0], Effect::PartialClose { ratio, .. } if (*ratio - 0.5).abs() < f64::EPSILON)
475 );
476
477 let effects = rule.evaluate(&v, "e(1.0910, 1.0912), FillModel::BidAsk);
479 assert!(effects.is_empty());
480 }
481
482 #[test]
483 fn take_profit_closes_remaining() {
484 let mut rule = Rule::take_profit(1.0900, 1.0);
485 let v = view_buy("p1", 1.0850);
486
487 let effects = rule.evaluate(&v, "e(1.0900, 1.0902), FillModel::BidAsk);
488 assert_eq!(effects.len(), 1);
489 assert!(matches!(
490 &effects[0],
491 Effect::PositionClosed {
492 reason: CloseReason::Target,
493 ..
494 }
495 ));
496 }
497
498 #[test]
499 fn breakeven_when_triggers() {
500 let mut rule = Rule::breakeven_when(1.0900);
501 let v = view_buy("p1", 1.0850);
502
503 let effects = rule.evaluate(&v, "e(1.0890, 1.0892), FillModel::BidAsk);
505 assert!(effects.is_empty());
506
507 let effects = rule.evaluate(&v, "e(1.0900, 1.0902), FillModel::BidAsk);
509 assert_eq!(effects.len(), 1);
510 assert!(
511 matches!(&effects[0], Effect::StoplossModified { new_price, .. } if (*new_price - 1.0850).abs() < f64::EPSILON)
512 );
513
514 let effects = rule.evaluate(&v, "e(1.0910, 1.0912), FillModel::BidAsk);
516 assert!(effects.is_empty());
517 }
518
519 #[test]
520 fn breakeven_after_targets() {
521 let mut rule = Rule::breakeven_after_targets(2);
522
523 let mut v = view_buy("p1", 1.0850);
525 v.target_hits = 1;
526 let effects = rule.evaluate(&v, "e(1.0900, 1.0902), FillModel::BidAsk);
527 assert!(effects.is_empty());
528
529 v.target_hits = 2;
531 let effects = rule.evaluate(&v, "e(1.0900, 1.0902), FillModel::BidAsk);
532 assert_eq!(effects.len(), 1);
533 assert!(matches!(&effects[0], Effect::StoplossModified { .. }));
534
535 v.target_hits = 3;
537 let effects = rule.evaluate(&v, "e(1.0900, 1.0902), FillModel::BidAsk);
538 assert!(effects.is_empty());
539 }
540
541 #[test]
542 fn time_exit_triggers() {
543 let mut rule = Rule::time_exit(3600); let v = view_buy("p1", 1.0850); let mut q = quote(1.0860, 1.0862);
548 q.ts = ts(10, 30, 0);
549 let effects = rule.evaluate(&v, &q, FillModel::BidAsk);
550 assert!(effects.is_empty());
551
552 q.ts = ts(11, 0, 0);
554 let effects = rule.evaluate(&v, &q, FillModel::BidAsk);
555 assert_eq!(effects.len(), 1);
556 assert!(matches!(
557 &effects[0],
558 Effect::PositionClosed {
559 reason: CloseReason::TimeExit,
560 ..
561 }
562 ));
563 }
564
565 #[test]
566 fn rule_skips_closed_position() {
567 let mut rule = Rule::fixed_stoploss(1.0800);
568 let mut v = view_buy("p1", 1.0850);
569 v.status = PositionStatus::Closed;
570
571 let effects = rule.evaluate(&v, "e(1.0790, 1.0792), FillModel::BidAsk);
572 assert!(effects.is_empty());
573 }
574
575 #[test]
576 fn from_config_roundtrip() {
577 let configs = vec![
578 RuleConfig::FixedStoploss { price: 1.08 },
579 RuleConfig::TrailingStop { distance: 0.002 },
580 RuleConfig::TakeProfit {
581 price: 1.09,
582 close_ratio: 0.5,
583 },
584 RuleConfig::BreakevenWhen {
585 trigger_price: 1.09,
586 },
587 RuleConfig::BreakevenAfterTargets { after_n: 2 },
588 RuleConfig::TimeExit { max_seconds: 3600 },
589 ];
590 let names: Vec<&str> = configs
591 .into_iter()
592 .map(|c| Rule::from_config(c).name())
593 .collect();
594 assert_eq!(
595 names,
596 vec![
597 "FixedStoploss",
598 "TrailingStop",
599 "TakeProfit",
600 "BreakevenWhen",
601 "BreakevenAfterTargets",
602 "TimeExit",
603 ]
604 );
605 }
606}