1use chrono::NaiveDateTime;
9use serde::{Deserialize, Serialize};
10
11use crate::rules::{PositionView, Rule};
12use crate::types::{
13 CloseReason, Effect, Fill, FillModel, FillPurpose, FutureIntent, GroupId, OrderType,
14 PositionId, PositionRecord, PositionStatus, PriceQuote, Side, StopOrigin, TradeId,
15 position_size_tolerance,
16};
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(from = "PositionDataSerde")]
21pub struct PositionData {
22 pub id: PositionId,
24
25 pub symbol: String,
27
28 pub side: Side,
30
31 pub order_type: OrderType,
33
34 pub status: PositionStatus,
36
37 pub pending_price: Option<f64>,
39
40 pub size: f64,
42
43 pub entries: Vec<Fill>,
45
46 pub remaining_ratio: f64,
51
52 pub closed_size: f64,
58
59 pub open_entry_value: f64,
67
68 pub target_hits: u32,
71
72 pub open_ts: Option<NaiveDateTime>,
74
75 pub close_ts: Option<NaiveDateTime>,
77
78 #[serde(default)]
80 pub group: Option<GroupId>,
81
82 #[serde(default)]
89 pub trade_id: Option<TradeId>,
90
91 #[serde(default)]
93 pub stop_origin: Option<crate::types::StopOrigin>,
94
95 pub records: Vec<(PositionRecord, NaiveDateTime)>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct Position {
102 pub data: PositionData,
103 pub rules: Vec<Rule>,
104}
105
106#[derive(Deserialize)]
107struct PositionDataSerde {
108 id: PositionId,
109 symbol: String,
110 side: Side,
111 order_type: OrderType,
112 status: PositionStatus,
113 pending_price: Option<f64>,
114 size: f64,
115 entries: Vec<Fill>,
116 remaining_ratio: f64,
117 #[serde(default)]
118 closed_size: Option<f64>,
119 #[serde(default)]
120 open_entry_value: Option<f64>,
121 target_hits: u32,
122 open_ts: Option<NaiveDateTime>,
123 close_ts: Option<NaiveDateTime>,
124 #[serde(default)]
125 group: Option<GroupId>,
126 #[serde(default)]
127 trade_id: Option<TradeId>,
128 #[serde(default)]
129 stop_origin: Option<StopOrigin>,
130 records: Vec<(PositionRecord, NaiveDateTime)>,
131}
132
133impl From<PositionDataSerde> for PositionData {
134 fn from(value: PositionDataSerde) -> Self {
135 let entered_size: f64 = value.entries.iter().map(|fill| fill.size).sum();
136 let inferred_closed_size = entered_size * (1.0 - value.remaining_ratio.clamp(0.0, 1.0));
137 let closed_size = value
138 .closed_size
139 .unwrap_or(inferred_closed_size)
140 .max(0.0)
141 .min(entered_size.max(0.0));
142 let remaining_size = (entered_size - closed_size).max(0.0);
143 let remaining_ratio = if entered_size > 0.0 {
144 remaining_size / entered_size
145 } else {
146 value.remaining_ratio
147 };
148 let historical_entry_value: f64 = value
149 .entries
150 .iter()
151 .map(|fill| fill.price * fill.size)
152 .sum();
153 let inferred_open_entry_value = if entered_size > 0.0 {
154 historical_entry_value * (remaining_size / entered_size)
155 } else {
156 0.0
157 };
158 let open_entry_value = if remaining_size <= position_size_tolerance(entered_size) {
159 0.0
160 } else {
161 value
162 .open_entry_value
163 .filter(|basis| basis.is_finite() && *basis >= 0.0)
164 .unwrap_or(inferred_open_entry_value)
165 };
166
167 Self {
168 id: value.id,
169 symbol: value.symbol,
170 side: value.side,
171 order_type: value.order_type,
172 status: value.status,
173 pending_price: value.pending_price,
174 size: value.size,
175 entries: value.entries,
176 remaining_ratio,
177 closed_size,
178 open_entry_value,
179 target_hits: value.target_hits,
180 open_ts: value.open_ts,
181 close_ts: value.close_ts,
182 group: value.group,
183 trade_id: value.trade_id,
184 stop_origin: value.stop_origin,
185 records: value.records,
186 }
187 }
188}
189
190impl PositionData {
193 pub fn average_entry(&self) -> f64 {
199 let remaining_size = self.remaining_size();
200 if remaining_size == 0.0 {
201 0.0
202 } else {
203 self.open_entry_value / remaining_size
204 }
205 }
206
207 pub fn historical_average_entry(&self) -> f64 {
209 let total_size = self.total_filled_size();
210 if total_size == 0.0 {
211 0.0
212 } else {
213 self.entries
214 .iter()
215 .map(|fill| fill.price * fill.size)
216 .sum::<f64>()
217 / total_size
218 }
219 }
220
221 pub fn total_filled_size(&self) -> f64 {
223 self.entries.iter().map(|f| f.size).sum()
224 }
225
226 pub fn remaining_size(&self) -> f64 {
228 let entered_size = self.total_filled_size();
229 let remaining = (entered_size - self.closed_size).max(0.0);
230 if remaining <= position_size_tolerance(entered_size) {
231 0.0
232 } else {
233 remaining
234 }
235 }
236
237 pub fn open_ratio(&self) -> f64 {
239 let entered_size = self.total_filled_size();
240 if entered_size <= 0.0 {
241 return 0.0;
242 }
243 self.remaining_size() / entered_size
244 }
245
246 pub fn capped_close_ratio(&self, ratio: f64) -> f64 {
248 if !ratio.is_finite() || ratio <= 0.0 {
249 return 0.0;
250 }
251 ratio.min(self.open_ratio())
252 }
253
254 pub fn close_size_for_ratio(&self, ratio: f64) -> f64 {
256 let actual_ratio = self.capped_close_ratio(ratio);
257 (self.total_filled_size() * actual_ratio).min(self.remaining_size())
258 }
259
260 fn sync_remaining_ratio(&mut self) {
261 let entered_size = self.total_filled_size().max(0.0);
262 self.closed_size = self.closed_size.max(0.0).min(entered_size);
263 self.remaining_ratio = if entered_size > 0.0 {
264 self.remaining_size() / entered_size
265 } else if self.status == PositionStatus::Pending {
266 1.0
267 } else {
268 0.0
269 };
270 if self.remaining_size() == 0.0 {
271 self.open_entry_value = 0.0;
272 }
273 }
274
275 pub fn unrealized_pnl(&self, current_price: f64) -> f64 {
277 let entry = self.average_entry();
278 let size = self.remaining_size();
279 match self.side {
280 Side::Buy => (current_price - entry) * size,
281 Side::Sell => (entry - current_price) * size,
282 }
283 }
284
285 pub fn is_active(&self) -> bool {
287 self.status == PositionStatus::Open && self.remaining_size() > 0.0
288 }
289
290 pub fn add_fill(&mut self, fill: Fill) {
293 self.open_entry_value += fill.price * fill.size;
294 self.entries.push(fill);
295 self.sync_remaining_ratio();
296 }
297
298 pub fn replace_latest_fill_execution(&mut self, price: f64, ts: NaiveDateTime) -> bool {
305 if !price.is_finite() || price <= 0.0 {
306 return false;
307 }
308 let Some(fill) = self.entries.last_mut() else {
309 return false;
310 };
311 self.open_entry_value += (price - fill.price) * fill.size;
312 fill.price = price;
313 fill.ts = ts;
314 true
315 }
316
317 pub fn synchronize_latest_fill(&mut self, fill: Fill) -> bool {
323 let Some(latest) = self.entries.last_mut() else {
324 return false;
325 };
326 self.open_entry_value += fill.price * fill.size - latest.price * latest.size;
327 *latest = fill.clone();
328 self.open_ts = Some(fill.ts);
329 self.sync_remaining_ratio();
330 if let Some((PositionRecord::Filled { fill: recorded }, _)) = self
331 .records
332 .iter_mut()
333 .rev()
334 .find(|(record, _)| matches!(record, PositionRecord::Filled { .. }))
335 {
336 *recorded = fill;
337 }
338 true
339 }
340
341 pub fn apply_partial_close(
347 &mut self,
348 ratio: f64,
349 price: f64,
350 reason: CloseReason,
351 ts: NaiveDateTime,
352 ) {
353 let actual_ratio = self.capped_close_ratio(ratio);
354 let entered_size = self.total_filled_size();
355 let open_size = self.remaining_size();
356 let close_size = self.close_size_for_ratio(actual_ratio);
357 let released_entry_value = self.average_entry() * close_size;
358 if open_size - close_size <= position_size_tolerance(entered_size) {
359 self.closed_size = entered_size;
360 self.open_entry_value = 0.0;
361 } else {
362 self.closed_size = (self.closed_size + close_size).min(entered_size);
363 self.open_entry_value = (self.open_entry_value - released_entry_value).max(0.0);
364 }
365 self.sync_remaining_ratio();
366 if reason == CloseReason::Target {
367 self.target_hits += 1;
368 }
369 self.records.push((
370 PositionRecord::PartialClose {
371 ratio: actual_ratio,
372 price,
373 reason,
374 },
375 ts,
376 ));
377 if self.remaining_size() == 0.0 {
378 self.closed_size = entered_size;
379 self.open_entry_value = 0.0;
380 self.remaining_ratio = 0.0;
381 self.status = PositionStatus::Closed;
382 self.close_ts = Some(ts);
383 self.records.push((PositionRecord::Closed { reason }, ts));
384 }
385 }
386
387 pub fn apply_full_close(&mut self, reason: CloseReason, ts: NaiveDateTime) {
389 self.closed_size = self.total_filled_size();
390 self.open_entry_value = 0.0;
391 self.remaining_ratio = 0.0;
392 self.status = PositionStatus::Closed;
393 self.close_ts = Some(ts);
394 if reason == CloseReason::Target {
395 self.target_hits += 1;
396 }
397 self.records.push((PositionRecord::Closed { reason }, ts));
398 }
399
400 pub fn view(&self) -> PositionView<'_> {
402 PositionView {
403 id: &self.id,
404 symbol: &self.symbol,
405 side: self.side,
406 status: self.status,
407 average_entry: self.average_entry(),
408 remaining_ratio: self.open_ratio(),
409 target_hits: self.target_hits,
410 open_ts: self.open_ts,
411 }
412 }
413}
414
415impl Position {
418 pub fn new_market(
420 id: PositionId,
421 symbol: String,
422 side: Side,
423 fill: Fill,
424 rules: Vec<Rule>,
425 ) -> Self {
426 let open_ts = fill.ts;
427 let size = fill.size;
428 let open_entry_value = fill.price * fill.size;
429 Self {
430 data: PositionData {
431 id,
432 symbol: symbol.clone(),
433 side,
434 order_type: OrderType::Market,
435 status: PositionStatus::Open,
436 pending_price: None,
437 size,
438 entries: vec![fill],
439 remaining_ratio: 1.0,
440 closed_size: 0.0,
441 open_entry_value,
442 target_hits: 0,
443 open_ts: Some(open_ts),
444 close_ts: None,
445 group: None,
446 trade_id: None,
447 stop_origin: None,
448 records: vec![(
449 PositionRecord::Created {
450 symbol,
451 side,
452 order_type: OrderType::Market,
453 },
454 open_ts,
455 )],
456 },
457 rules,
458 }
459 }
460
461 #[allow(clippy::too_many_arguments)]
464 pub fn new_pending(
465 id: PositionId,
466 symbol: String,
467 side: Side,
468 order_type: OrderType,
469 pending_price: f64,
470 size: f64,
471 ts: NaiveDateTime,
472 rules: Vec<Rule>,
473 ) -> Self {
474 debug_assert!(
475 order_type == OrderType::Limit || order_type == OrderType::Stop,
476 "new_pending requires Limit or Stop order type"
477 );
478 Self {
479 data: PositionData {
480 id,
481 symbol: symbol.clone(),
482 side,
483 order_type,
484 status: PositionStatus::Pending,
485 pending_price: Some(pending_price),
486 size,
487 entries: Vec::new(),
488 remaining_ratio: 1.0,
489 closed_size: 0.0,
490 open_entry_value: 0.0,
491 target_hits: 0,
492 open_ts: None,
493 close_ts: None,
494 group: None,
495 trade_id: None,
496 stop_origin: None,
497 records: vec![(
498 PositionRecord::Created {
499 symbol,
500 side,
501 order_type,
502 },
503 ts,
504 )],
505 },
506 rules,
507 }
508 }
509
510 pub fn set_trade_id(&mut self, trade_id: Option<TradeId>) {
512 self.data.trade_id = trade_id;
513 }
514
515 pub fn pending_fill_purpose(
518 &self,
519 quote: &PriceQuote,
520 model: FillModel,
521 ) -> Option<FillPurpose> {
522 if self.data.status != PositionStatus::Pending {
523 return None;
524 }
525 let pending_price = self.data.pending_price?;
526 let check = quote.fill_price(self.data.side, model);
527 let triggered = match (self.data.order_type, self.data.side) {
528 (OrderType::Limit, Side::Buy) => check <= pending_price,
529 (OrderType::Limit, Side::Sell) => check >= pending_price,
530 (OrderType::Stop, Side::Buy) => check >= pending_price,
531 (OrderType::Stop, Side::Sell) => check <= pending_price,
532 (OrderType::Market, _) => false,
533 };
534 if !triggered {
535 return None;
536 }
537 match self.data.order_type {
538 OrderType::Limit => Some(FillPurpose::LimitEntry),
539 OrderType::Stop => Some(FillPurpose::StopEntry),
540 OrderType::Market => None,
541 }
542 }
543
544 pub(crate) fn apply_pending_fill(&mut self, fill: Fill) -> bool {
546 if self.data.status != PositionStatus::Pending {
547 return false;
548 }
549 let ts = fill.ts;
550 self.data.status = PositionStatus::Open;
551 self.data.add_fill(fill.clone());
552 self.data.open_ts = Some(ts);
553 self.data
554 .records
555 .push((PositionRecord::Filled { fill }, ts));
556 true
557 }
558
559 pub fn try_fill(&mut self, quote: &PriceQuote, model: FillModel) -> bool {
564 if self.pending_fill_purpose(quote, model).is_none() {
565 return false;
566 }
567 let Some(pending_price) = self.data.pending_price else {
568 return false;
569 };
570 self.apply_pending_fill(Fill {
571 price: pending_price,
572 size: self.data.size,
573 ts: quote.ts,
574 })
575 }
576
577 pub fn evaluate_rules(&mut self, quote: &PriceQuote, model: FillModel) -> Vec<Effect> {
583 if self.data.status != PositionStatus::Open {
584 return vec![];
585 }
586
587 let view = self.data.view();
588 let mut effects = Vec::new();
589
590 for rule in &mut self.rules {
591 let rule_effects = rule.evaluate(&view, quote, model);
592 effects.extend(rule_effects);
593 }
594
595 effects
596 }
597
598 pub(crate) fn evaluate_rules_future(
604 &mut self,
605 quote: &PriceQuote,
606 model: FillModel,
607 ) -> Vec<FutureIntent> {
608 if self.data.status != PositionStatus::Open {
609 return Vec::new();
610 }
611 let side = self.data.side;
612 let check = quote.eval_price(side, model);
613 let average_entry = self.data.average_entry();
614 let current_stop = self
615 .current_effective_stop()
616 .map(|stop| (stop.price, stop.origin));
617 let mut effective_stop = None;
618
619 for rule in &mut self.rules {
620 match rule {
621 Rule::FixedStoploss { price } => {
622 let origin = self.data.stop_origin.unwrap_or(StopOrigin::Initial);
623 effective_stop = more_protective_stop(side, effective_stop, (*price, origin));
624 }
625 Rule::TrailingStop {
626 distance,
627 peak_price,
628 initialized,
629 } => {
630 if !*initialized {
631 *peak_price = average_entry;
632 *initialized = true;
633 }
634 match side {
635 Side::Buy => *peak_price = peak_price.max(check),
636 Side::Sell => {
637 *peak_price = if *peak_price == 0.0 {
638 check
639 } else {
640 peak_price.min(check)
641 }
642 }
643 }
644 let candidate = match side {
645 Side::Buy => *peak_price - *distance,
646 Side::Sell => *peak_price + *distance,
647 };
648 effective_stop = more_protective_stop(
649 side,
650 effective_stop,
651 (candidate, StopOrigin::Trailing),
652 );
653 }
654 _ => {}
655 }
656 }
657
658 if let Some((price, origin)) = effective_stop {
659 let hit = match side {
660 Side::Buy => check <= price,
661 Side::Sell => check >= price,
662 };
663 if hit {
664 let mut effects = Vec::new();
665 if let Some(effect) =
666 stop_transition_effect(&self.data.id, current_stop, effective_stop)
667 {
668 effects.push(effect);
669 }
670 let reason = match origin {
671 StopOrigin::Breakeven => CloseReason::BreakevenStop,
672 StopOrigin::Trailing => CloseReason::TrailingStop,
673 _ => CloseReason::Stoploss,
674 };
675 effects.push(FutureIntent {
676 effect: Effect::PositionClosed {
677 id: self.data.id.clone(),
678 reason,
679 },
680 requested_price: Some(price),
681 stop_origin: Some(origin),
682 });
683 return effects;
684 }
685 }
686
687 let mut target_indices: Vec<(usize, f64, f64)> = self
688 .rules
689 .iter()
690 .enumerate()
691 .filter_map(|(index, rule)| match rule {
692 Rule::TakeProfit {
693 price,
694 close_ratio,
695 triggered: false,
696 } if match side {
697 Side::Buy => check >= *price,
698 Side::Sell => check <= *price,
699 } =>
700 {
701 Some((index, *price, *close_ratio))
702 }
703 _ => None,
704 })
705 .collect();
706 target_indices.sort_by(|left, right| match side {
707 Side::Buy => left.1.total_cmp(&right.1),
708 Side::Sell => right.1.total_cmp(&left.1),
709 });
710
711 let mut effects = Vec::new();
712 let mut remaining = self.data.open_ratio();
713 let mut target_hits = self.data.target_hits;
714 for (index, price, ratio) in target_indices {
715 if remaining <= position_size_tolerance(1.0) {
716 break;
717 }
718 if let Rule::TakeProfit { triggered, .. } = &mut self.rules[index] {
719 *triggered = true;
720 }
721 let actual = ratio.min(remaining).max(0.0);
722 if actual <= position_size_tolerance(1.0) {
723 continue;
724 }
725 target_hits += 1;
726 remaining = (remaining - actual).max(0.0);
727 let effect = if remaining <= position_size_tolerance(1.0) {
728 Effect::PositionClosed {
729 id: self.data.id.clone(),
730 reason: CloseReason::Target,
731 }
732 } else {
733 Effect::PartialClose {
734 id: self.data.id.clone(),
735 ratio: actual,
736 reason: CloseReason::Target,
737 }
738 };
739 effects.push(FutureIntent {
740 effect,
741 requested_price: Some(price),
742 stop_origin: None,
743 });
744 if remaining <= position_size_tolerance(1.0) {
745 if let Some(effect) =
746 stop_transition_effect(&self.data.id, current_stop, effective_stop)
747 {
748 effects.insert(effects.len() - 1, effect);
749 }
750 return effects;
751 }
752 }
753
754 for rule in &self.rules {
755 if let Rule::TimeExit { max_seconds } = rule
756 && self
757 .data
758 .open_ts
759 .is_some_and(|open| (quote.ts - open).num_seconds() >= *max_seconds as i64)
760 {
761 if let Some(effect) =
762 stop_transition_effect(&self.data.id, current_stop, effective_stop)
763 {
764 effects.push(effect);
765 }
766 effects.push(FutureIntent::plain(Effect::PositionClosed {
767 id: self.data.id.clone(),
768 reason: CloseReason::TimeExit,
769 }));
770 return effects;
771 }
772 }
773
774 let mut breakeven_triggered = false;
775 for rule in &mut self.rules {
776 let trigger = match rule {
777 Rule::BreakevenWhen {
778 trigger_price,
779 triggered,
780 } if !*triggered => {
781 let hit = match side {
782 Side::Buy => check >= *trigger_price,
783 Side::Sell => check <= *trigger_price,
784 };
785 if hit {
786 *triggered = true;
787 }
788 hit
789 }
790 Rule::BreakevenAfterTargets { after_n, triggered } if !*triggered => {
791 let hit = target_hits >= *after_n;
792 if hit {
793 *triggered = true;
794 }
795 hit
796 }
797 _ => false,
798 };
799 if trigger {
800 breakeven_triggered = true;
801 break;
802 }
803 }
804 if breakeven_triggered {
805 effective_stop =
806 more_protective_stop(side, effective_stop, (average_entry, StopOrigin::Breakeven));
807 }
808 if let Some(effect) = stop_transition_effect(&self.data.id, current_stop, effective_stop) {
809 effects.push(effect);
810 }
811 effects
812 }
813
814 pub fn future_trigger_levels(&self) -> Vec<f64> {
818 match self.data.status {
819 PositionStatus::Pending => self.data.pending_price.into_iter().collect(),
820 PositionStatus::Open => {
821 let average_entry = self.data.average_entry();
822 self.rules
823 .iter()
824 .filter_map(|rule| match rule {
825 Rule::FixedStoploss { price } => Some(*price),
826 Rule::TrailingStop {
827 distance,
828 peak_price,
829 initialized,
830 } => {
831 let peak = if *initialized {
832 *peak_price
833 } else {
834 average_entry
835 };
836 Some(match self.data.side {
837 Side::Buy => peak - *distance,
838 Side::Sell => peak + *distance,
839 })
840 }
841 Rule::TakeProfit {
842 price,
843 triggered: false,
844 ..
845 } => Some(*price),
846 Rule::BreakevenWhen {
847 trigger_price,
848 triggered: false,
849 } => Some(*trigger_price),
850 _ => None,
851 })
852 .filter(|level| level.is_finite() && *level > 0.0)
853 .collect()
854 }
855 PositionStatus::Closed | PositionStatus::Cancelled => Vec::new(),
856 }
857 }
858
859 pub fn current_effective_stop(&self) -> Option<crate::types::EffectiveStop> {
861 self.current_stoploss()
862 .map(|price| crate::types::EffectiveStop {
863 price,
864 origin: self
865 .data
866 .stop_origin
867 .unwrap_or(crate::types::StopOrigin::Initial),
868 })
869 }
870
871 pub fn current_stoploss(&self) -> Option<f64> {
872 for rule in &self.rules {
873 if let Rule::FixedStoploss { price } = rule {
874 return Some(*price);
875 }
876 }
877 None
878 }
879
880 pub fn set_stoploss(&mut self, new_price: f64) -> Option<f64> {
882 self.set_stoploss_with_origin(new_price, crate::types::StopOrigin::Modified)
883 }
884
885 pub fn set_stoploss_with_origin(
886 &mut self,
887 new_price: f64,
888 origin: crate::types::StopOrigin,
889 ) -> Option<f64> {
890 self.data.stop_origin = Some(origin);
891 for rule in &mut self.rules {
892 if let Rule::FixedStoploss { price } = rule {
893 let old = *price;
894 *price = new_price;
895 return Some(old);
896 }
897 }
898 self.rules.push(Rule::fixed_stoploss(new_price));
900 None
901 }
902
903 pub fn remove_rule(&mut self, name: &str) -> bool {
905 let before = self.rules.len();
906 self.rules.retain(|r| r.name() != name);
907 self.rules.len() < before
908 }
909
910 pub fn evaluate_stateful_rules(&mut self, quote: &PriceQuote, model: FillModel) -> Vec<Effect> {
913 if self.data.status != PositionStatus::Open {
914 return vec![];
915 }
916 let view = self.data.view();
917 let mut effects = Vec::new();
918 for rule in &mut self.rules {
919 if rule.is_stateful() {
920 effects.extend(rule.evaluate(&view, quote, model));
921 }
922 }
923 effects
924 }
925
926 pub fn has_stateful_rules(&self) -> bool {
928 self.rules.iter().any(|r| r.is_stateful())
929 }
930}
931
932fn stop_transition_effect(
935 position_id: &str,
936 current: Option<(f64, StopOrigin)>,
937 next: Option<(f64, StopOrigin)>,
938) -> Option<FutureIntent> {
939 let (new_price, origin) = next?;
940 if current == next {
941 return None;
942 }
943 Some(FutureIntent {
944 effect: Effect::StoplossModified {
945 id: position_id.to_owned(),
946 old_price: current.map_or(0.0, |stop| stop.0),
947 new_price,
948 },
949 requested_price: Some(new_price),
950 stop_origin: Some(origin),
951 })
952}
953
954fn more_protective_stop(
955 side: Side,
956 current: Option<(f64, StopOrigin)>,
957 candidate: (f64, StopOrigin),
958) -> Option<(f64, StopOrigin)> {
959 match current {
960 None => Some(candidate),
961 Some(existing) => match side {
962 Side::Buy if candidate.0 > existing.0 => Some(candidate),
963 Side::Sell if candidate.0 < existing.0 => Some(candidate),
964 _ => Some(existing),
965 },
966 }
967}
968
969#[cfg(test)]
970mod tests {
971 use super::*;
972 use chrono::NaiveDate;
973
974 fn ts(h: u32, m: u32, s: u32) -> NaiveDateTime {
975 NaiveDate::from_ymd_opt(2026, 1, 1)
976 .unwrap()
977 .and_hms_opt(h, m, s)
978 .unwrap()
979 }
980
981 fn make_fill(price: f64, size: f64) -> Fill {
982 Fill {
983 price,
984 size,
985 ts: ts(10, 0, 0),
986 }
987 }
988
989 #[test]
990 fn average_entry_single_fill() {
991 let pos = Position::new_market(
992 "p1".into(),
993 "EURUSD".into(),
994 Side::Buy,
995 make_fill(1.0850, 1.0),
996 vec![],
997 );
998 assert!((pos.data.average_entry() - 1.0850).abs() < f64::EPSILON);
999 }
1000
1001 #[test]
1002 fn average_entry_multiple_fills() {
1003 let mut pos = Position::new_market(
1004 "p1".into(),
1005 "EURUSD".into(),
1006 Side::Buy,
1007 make_fill(1.0800, 1.0),
1008 vec![],
1009 );
1010 pos.data.add_fill(Fill {
1011 price: 1.0900,
1012 size: 1.0,
1013 ts: ts(10, 5, 0),
1014 });
1015 assert!((pos.data.average_entry() - 1.0850).abs() < f64::EPSILON);
1017 }
1018
1019 #[test]
1020 fn average_entry_weighted() {
1021 let mut pos = Position::new_market(
1022 "p1".into(),
1023 "EURUSD".into(),
1024 Side::Buy,
1025 make_fill(1.0800, 2.0),
1026 vec![],
1027 );
1028 pos.data.add_fill(Fill {
1029 price: 1.0900,
1030 size: 1.0,
1031 ts: ts(10, 5, 0),
1032 });
1033 let expected = (1.0800 * 2.0 + 1.0900 * 1.0) / 3.0;
1035 assert!((pos.data.average_entry() - expected).abs() < 1e-10);
1036 }
1037
1038 #[test]
1039 fn remaining_size_after_partial_close() {
1040 let mut pos = Position::new_market(
1041 "p1".into(),
1042 "EURUSD".into(),
1043 Side::Buy,
1044 make_fill(1.0850, 2.0),
1045 vec![],
1046 );
1047 assert!((pos.data.remaining_size() - 2.0).abs() < f64::EPSILON);
1048
1049 pos.data
1050 .apply_partial_close(0.5, 1.0900, CloseReason::Target, ts(10, 30, 0));
1051 assert!((pos.data.remaining_size() - 1.0).abs() < f64::EPSILON);
1053 assert_eq!(pos.data.status, PositionStatus::Open);
1054 assert_eq!(pos.data.target_hits, 1);
1055 }
1056
1057 #[test]
1058 fn partial_close_then_scale_in_conserves_absolute_size() {
1059 let mut pos = Position::new_market(
1060 "p1".into(),
1061 "EURUSD".into(),
1062 Side::Buy,
1063 make_fill(1.0850, 2.0),
1064 vec![],
1065 );
1066
1067 pos.data
1068 .apply_partial_close(0.5, 1.0900, CloseReason::Manual, ts(10, 30, 0));
1069 pos.data.add_fill(Fill {
1070 price: 1.0950,
1071 size: 1.0,
1072 ts: ts(10, 35, 0),
1073 });
1074
1075 assert!((pos.data.total_filled_size() - 3.0).abs() < f64::EPSILON);
1076 assert!((pos.data.closed_size - 1.0).abs() < f64::EPSILON);
1077 assert!((pos.data.remaining_size() - 2.0).abs() < f64::EPSILON);
1078 assert!((pos.data.remaining_ratio - (2.0 / 3.0)).abs() < f64::EPSILON);
1079 }
1080
1081 #[test]
1082 fn partial_close_then_scale_in_preserves_average_cost_cash_flow() {
1083 let mut pos = Position::new_market(
1084 "p1".into(),
1085 "EURUSD".into(),
1086 Side::Buy,
1087 make_fill(100.0, 2.0),
1088 vec![],
1089 );
1090
1091 let first_basis = pos.data.average_entry();
1092 pos.data
1093 .apply_partial_close(0.5, 110.0, CloseReason::Manual, ts(10, 30, 0));
1094 let first_pnl = (110.0 - first_basis) * 1.0;
1095 assert_eq!(pos.data.open_entry_value, 100.0);
1096 assert_eq!(pos.data.average_entry(), 100.0);
1097
1098 pos.data.add_fill(Fill {
1099 price: 120.0,
1100 size: 1.0,
1101 ts: ts(10, 35, 0),
1102 });
1103 assert_eq!(pos.data.average_entry(), 110.0);
1104 assert_eq!(pos.data.open_entry_value, 220.0);
1105
1106 let final_basis = pos.data.average_entry();
1107 let final_pnl = (130.0 - final_basis) * pos.data.remaining_size();
1108 pos.data
1109 .apply_full_close(CloseReason::Manual, ts(10, 40, 0));
1110
1111 assert_eq!(first_pnl + final_pnl, 50.0);
1112 assert_eq!(pos.data.entries.len(), 2);
1113 assert_eq!(pos.data.historical_average_entry(), 320.0 / 3.0);
1114 assert_eq!(pos.data.open_entry_value, 0.0);
1115 }
1116
1117 #[test]
1118 fn scale_in_then_partial_close_uses_all_entered_size() {
1119 let mut pos = Position::new_market(
1120 "p1".into(),
1121 "EURUSD".into(),
1122 Side::Buy,
1123 make_fill(1.0850, 2.0),
1124 vec![],
1125 );
1126 pos.data.add_fill(Fill {
1127 price: 1.0950,
1128 size: 1.0,
1129 ts: ts(10, 5, 0),
1130 });
1131 pos.data
1132 .apply_partial_close(0.5, 1.1000, CloseReason::Manual, ts(10, 30, 0));
1133
1134 assert!((pos.data.closed_size - 1.5).abs() < f64::EPSILON);
1135 assert!((pos.data.remaining_size() - 1.5).abs() < f64::EPSILON);
1136 assert!((pos.data.remaining_ratio - 0.5).abs() < f64::EPSILON);
1137 }
1138
1139 #[test]
1140 fn repeated_partial_closes_cap_and_reach_exact_zero() {
1141 let mut pos = Position::new_market(
1142 "p1".into(),
1143 "EURUSD".into(),
1144 Side::Buy,
1145 make_fill(1.0850, 1.0),
1146 vec![],
1147 );
1148
1149 for minute in [10, 20, 30] {
1150 pos.data
1151 .apply_partial_close(0.4, 1.0900, CloseReason::Manual, ts(10, minute, 0));
1152 }
1153
1154 assert_eq!(pos.data.closed_size, 1.0);
1155 assert_eq!(pos.data.remaining_size(), 0.0);
1156 assert_eq!(pos.data.remaining_ratio, 0.0);
1157 assert_eq!(pos.data.status, PositionStatus::Closed);
1158 let last_ratio = pos
1159 .data
1160 .records
1161 .iter()
1162 .rev()
1163 .find_map(|(record, _)| match record {
1164 PositionRecord::PartialClose { ratio, .. } => Some(*ratio),
1165 _ => None,
1166 })
1167 .unwrap();
1168 assert!((last_ratio - 0.2).abs() < 1e-12);
1169 }
1170
1171 #[test]
1172 fn serde_migrates_legacy_ratio_to_absolute_closed_size() {
1173 let mut pos = Position::new_market(
1174 "p1".into(),
1175 "EURUSD".into(),
1176 Side::Buy,
1177 make_fill(1.0850, 2.0),
1178 vec![],
1179 );
1180 pos.data
1181 .apply_partial_close(0.25, 1.0900, CloseReason::Manual, ts(10, 30, 0));
1182
1183 let mut legacy = serde_json::to_value(&pos.data).unwrap();
1184 legacy
1185 .as_object_mut()
1186 .unwrap()
1187 .remove("closed_size")
1188 .unwrap();
1189 legacy
1190 .as_object_mut()
1191 .unwrap()
1192 .remove("open_entry_value")
1193 .unwrap();
1194 let migrated: PositionData = serde_json::from_value(legacy).unwrap();
1195 assert!((migrated.closed_size - 0.5).abs() < f64::EPSILON);
1196 assert!((migrated.remaining_size() - 1.5).abs() < f64::EPSILON);
1197 assert!((migrated.remaining_ratio - 0.75).abs() < f64::EPSILON);
1198 assert!((migrated.open_entry_value - 1.6275).abs() < f64::EPSILON);
1199 assert!((migrated.average_entry() - 1.0850).abs() < f64::EPSILON);
1200
1201 let mut current = serde_json::to_value(&migrated).unwrap();
1202 current["remaining_ratio"] = serde_json::json!(0.99);
1203 let round_trip: PositionData = serde_json::from_value(current).unwrap();
1204 assert!((round_trip.closed_size - 0.5).abs() < f64::EPSILON);
1205 assert!((round_trip.remaining_ratio - 0.75).abs() < f64::EPSILON);
1206 }
1207
1208 #[test]
1209 fn serde_defaults_legacy_unclosed_position_to_zero_closed_size() {
1210 let pos = Position::new_market(
1211 "p1".into(),
1212 "EURUSD".into(),
1213 Side::Buy,
1214 make_fill(1.0850, 2.0),
1215 vec![],
1216 );
1217 let mut legacy = serde_json::to_value(&pos.data).unwrap();
1218 legacy.as_object_mut().unwrap().remove("closed_size");
1219 legacy.as_object_mut().unwrap().remove("open_entry_value");
1220
1221 let migrated: PositionData = serde_json::from_value(legacy).unwrap();
1222 assert_eq!(migrated.closed_size, 0.0);
1223 assert_eq!(migrated.remaining_size(), 2.0);
1224 assert_eq!(migrated.remaining_ratio, 1.0);
1225 }
1226
1227 #[test]
1228 fn full_close_via_partial() {
1229 let mut pos = Position::new_market(
1230 "p1".into(),
1231 "EURUSD".into(),
1232 Side::Buy,
1233 make_fill(1.0850, 1.0),
1234 vec![],
1235 );
1236 pos.data
1237 .apply_partial_close(1.0, 1.0900, CloseReason::Target, ts(10, 30, 0));
1238 assert_eq!(pos.data.status, PositionStatus::Closed);
1239 assert!(pos.data.close_ts.is_some());
1240 assert_eq!(pos.data.closed_size, 1.0);
1241 assert_eq!(pos.data.remaining_size(), 0.0);
1242 assert_eq!(pos.data.remaining_ratio, 0.0);
1243 }
1244
1245 #[test]
1246 fn full_close() {
1247 let mut pos = Position::new_market(
1248 "p1".into(),
1249 "EURUSD".into(),
1250 Side::Sell,
1251 make_fill(1.0850, 1.0),
1252 vec![],
1253 );
1254 pos.data
1255 .apply_full_close(CloseReason::Stoploss, ts(10, 30, 0));
1256 assert_eq!(pos.data.status, PositionStatus::Closed);
1257 assert_eq!(pos.data.closed_size, 1.0);
1258 assert_eq!(pos.data.remaining_size(), 0.0);
1259 assert_eq!(pos.data.remaining_ratio, 0.0);
1260 }
1261
1262 #[test]
1263 fn unrealized_pnl_buy() {
1264 let pos = Position::new_market(
1265 "p1".into(),
1266 "EURUSD".into(),
1267 Side::Buy,
1268 make_fill(1.0850, 1.0),
1269 vec![],
1270 );
1271 let pnl = pos.data.unrealized_pnl(1.0900);
1272 assert!((pnl - 0.0050).abs() < 1e-10);
1273 }
1274
1275 #[test]
1276 fn unrealized_pnl_sell() {
1277 let pos = Position::new_market(
1278 "p1".into(),
1279 "EURUSD".into(),
1280 Side::Sell,
1281 make_fill(1.0850, 1.0),
1282 vec![],
1283 );
1284 let pnl = pos.data.unrealized_pnl(1.0800);
1285 assert!((pnl - 0.0050).abs() < 1e-10);
1286 }
1287
1288 #[test]
1289 fn try_fill_limit_buy() {
1290 let mut pos = Position::new_pending(
1291 "p1".into(),
1292 "EURUSD".into(),
1293 Side::Buy,
1294 OrderType::Limit,
1295 1.0800,
1296 1.0,
1297 ts(9, 0, 0),
1298 vec![],
1299 );
1300 assert_eq!(pos.data.status, PositionStatus::Pending);
1301
1302 let q1 = PriceQuote {
1304 symbol: "EURUSD".into(),
1305 ts: ts(10, 0, 0),
1306 bid: 1.0808,
1307 ask: 1.0810,
1308 };
1309 assert!(!pos.try_fill(&q1, FillModel::BidAsk));
1310 assert_eq!(pos.data.status, PositionStatus::Pending);
1311
1312 let q2 = PriceQuote {
1314 symbol: "EURUSD".into(),
1315 ts: ts(10, 5, 0),
1316 bid: 1.0798,
1317 ask: 1.0800,
1318 };
1319 assert!(pos.try_fill(&q2, FillModel::BidAsk));
1320 assert_eq!(pos.data.status, PositionStatus::Open);
1321 assert_eq!(pos.data.entries.len(), 1);
1322 assert!((pos.data.entries[0].price - 1.0800).abs() < f64::EPSILON);
1323 }
1324
1325 #[test]
1326 fn try_fill_stop_sell() {
1327 let mut pos = Position::new_pending(
1328 "p1".into(),
1329 "EURUSD".into(),
1330 Side::Sell,
1331 OrderType::Stop,
1332 1.0800,
1333 1.0,
1334 ts(9, 0, 0),
1335 vec![],
1336 );
1337
1338 let q1 = PriceQuote {
1340 symbol: "EURUSD".into(),
1341 ts: ts(10, 0, 0),
1342 bid: 1.0810,
1343 ask: 1.0812,
1344 };
1345 assert!(!pos.try_fill(&q1, FillModel::BidAsk));
1346
1347 let q2 = PriceQuote {
1349 symbol: "EURUSD".into(),
1350 ts: ts(10, 5, 0),
1351 bid: 1.0800,
1352 ask: 1.0802,
1353 };
1354 assert!(pos.try_fill(&q2, FillModel::BidAsk));
1355 assert_eq!(pos.data.status, PositionStatus::Open);
1356 }
1357
1358 #[test]
1359 fn set_stoploss_updates_existing() {
1360 let mut pos = Position::new_market(
1361 "p1".into(),
1362 "EURUSD".into(),
1363 Side::Buy,
1364 make_fill(1.0850, 1.0),
1365 vec![Rule::fixed_stoploss(1.0800)],
1366 );
1367 assert!((pos.current_stoploss().unwrap() - 1.0800).abs() < f64::EPSILON);
1368
1369 let old = pos.set_stoploss(1.0820);
1370 assert!((old.unwrap() - 1.0800).abs() < f64::EPSILON);
1371 assert!((pos.current_stoploss().unwrap() - 1.0820).abs() < f64::EPSILON);
1372 }
1373
1374 #[test]
1375 fn set_stoploss_adds_when_missing() {
1376 let mut pos = Position::new_market(
1377 "p1".into(),
1378 "EURUSD".into(),
1379 Side::Buy,
1380 make_fill(1.0850, 1.0),
1381 vec![],
1382 );
1383 assert!(pos.current_stoploss().is_none());
1384
1385 let old = pos.set_stoploss(1.0800);
1386 assert!(old.is_none());
1387 assert!((pos.current_stoploss().unwrap() - 1.0800).abs() < f64::EPSILON);
1388 }
1389
1390 #[test]
1391 fn remove_rule_by_name() {
1392 let mut pos = Position::new_market(
1393 "p1".into(),
1394 "EURUSD".into(),
1395 Side::Buy,
1396 make_fill(1.0850, 1.0),
1397 vec![Rule::fixed_stoploss(1.0800), Rule::take_profit(1.0900, 1.0)],
1398 );
1399 assert_eq!(pos.rules.len(), 2);
1400 assert!(pos.remove_rule("TakeProfit"));
1401 assert_eq!(pos.rules.len(), 1);
1402 assert_eq!(pos.rules[0].name(), "FixedStoploss");
1403 }
1404
1405 #[test]
1406 fn evaluate_rules_produces_effects() {
1407 let mut pos = Position::new_market(
1408 "p1".into(),
1409 "EURUSD".into(),
1410 Side::Buy,
1411 make_fill(1.0850, 1.0),
1412 vec![Rule::fixed_stoploss(1.0800), Rule::take_profit(1.0900, 1.0)],
1413 );
1414
1415 let q = PriceQuote {
1417 symbol: "EURUSD".into(),
1418 ts: ts(10, 5, 0),
1419 bid: 1.0860,
1420 ask: 1.0862,
1421 };
1422 let effects = pos.evaluate_rules(&q, FillModel::BidAsk);
1423 assert!(effects.is_empty());
1424
1425 let q_sl = PriceQuote {
1427 symbol: "EURUSD".into(),
1428 ts: ts(10, 10, 0),
1429 bid: 1.0799,
1430 ask: 1.0801,
1431 };
1432 let effects = pos.evaluate_rules(&q_sl, FillModel::BidAsk);
1433 assert!(!effects.is_empty());
1434 assert!(matches!(
1435 &effects[0],
1436 Effect::PositionClosed {
1437 reason: CloseReason::Stoploss,
1438 ..
1439 }
1440 ));
1441 }
1442
1443 #[test]
1444 fn pending_position_skips_rule_evaluation() {
1445 let mut pos = Position::new_pending(
1446 "p1".into(),
1447 "EURUSD".into(),
1448 Side::Buy,
1449 OrderType::Limit,
1450 1.0800,
1451 1.0,
1452 ts(9, 0, 0),
1453 vec![Rule::fixed_stoploss(1.0750)],
1454 );
1455
1456 let q = PriceQuote {
1458 symbol: "EURUSD".into(),
1459 ts: ts(10, 0, 0),
1460 bid: 1.0740,
1461 ask: 1.0742,
1462 };
1463 let effects = pos.evaluate_rules(&q, FillModel::BidAsk);
1464 assert!(effects.is_empty());
1465 }
1466}