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 current_effective_stop(&self) -> Option<crate::types::EffectiveStop> {
816 self.current_stoploss()
817 .map(|price| crate::types::EffectiveStop {
818 price,
819 origin: self
820 .data
821 .stop_origin
822 .unwrap_or(crate::types::StopOrigin::Initial),
823 })
824 }
825
826 pub fn current_stoploss(&self) -> Option<f64> {
827 for rule in &self.rules {
828 if let Rule::FixedStoploss { price } = rule {
829 return Some(*price);
830 }
831 }
832 None
833 }
834
835 pub fn set_stoploss(&mut self, new_price: f64) -> Option<f64> {
837 self.set_stoploss_with_origin(new_price, crate::types::StopOrigin::Modified)
838 }
839
840 pub fn set_stoploss_with_origin(
841 &mut self,
842 new_price: f64,
843 origin: crate::types::StopOrigin,
844 ) -> Option<f64> {
845 self.data.stop_origin = Some(origin);
846 for rule in &mut self.rules {
847 if let Rule::FixedStoploss { price } = rule {
848 let old = *price;
849 *price = new_price;
850 return Some(old);
851 }
852 }
853 self.rules.push(Rule::fixed_stoploss(new_price));
855 None
856 }
857
858 pub fn remove_rule(&mut self, name: &str) -> bool {
860 let before = self.rules.len();
861 self.rules.retain(|r| r.name() != name);
862 self.rules.len() < before
863 }
864
865 pub fn evaluate_stateful_rules(&mut self, quote: &PriceQuote, model: FillModel) -> Vec<Effect> {
868 if self.data.status != PositionStatus::Open {
869 return vec![];
870 }
871 let view = self.data.view();
872 let mut effects = Vec::new();
873 for rule in &mut self.rules {
874 if rule.is_stateful() {
875 effects.extend(rule.evaluate(&view, quote, model));
876 }
877 }
878 effects
879 }
880
881 pub fn has_stateful_rules(&self) -> bool {
883 self.rules.iter().any(|r| r.is_stateful())
884 }
885}
886
887fn stop_transition_effect(
890 position_id: &str,
891 current: Option<(f64, StopOrigin)>,
892 next: Option<(f64, StopOrigin)>,
893) -> Option<FutureIntent> {
894 let (new_price, origin) = next?;
895 if current == next {
896 return None;
897 }
898 Some(FutureIntent {
899 effect: Effect::StoplossModified {
900 id: position_id.to_owned(),
901 old_price: current.map_or(0.0, |stop| stop.0),
902 new_price,
903 },
904 requested_price: Some(new_price),
905 stop_origin: Some(origin),
906 })
907}
908
909fn more_protective_stop(
910 side: Side,
911 current: Option<(f64, StopOrigin)>,
912 candidate: (f64, StopOrigin),
913) -> Option<(f64, StopOrigin)> {
914 match current {
915 None => Some(candidate),
916 Some(existing) => match side {
917 Side::Buy if candidate.0 > existing.0 => Some(candidate),
918 Side::Sell if candidate.0 < existing.0 => Some(candidate),
919 _ => Some(existing),
920 },
921 }
922}
923
924#[cfg(test)]
925mod tests {
926 use super::*;
927 use chrono::NaiveDate;
928
929 fn ts(h: u32, m: u32, s: u32) -> NaiveDateTime {
930 NaiveDate::from_ymd_opt(2026, 1, 1)
931 .unwrap()
932 .and_hms_opt(h, m, s)
933 .unwrap()
934 }
935
936 fn make_fill(price: f64, size: f64) -> Fill {
937 Fill {
938 price,
939 size,
940 ts: ts(10, 0, 0),
941 }
942 }
943
944 #[test]
945 fn average_entry_single_fill() {
946 let pos = Position::new_market(
947 "p1".into(),
948 "EURUSD".into(),
949 Side::Buy,
950 make_fill(1.0850, 1.0),
951 vec![],
952 );
953 assert!((pos.data.average_entry() - 1.0850).abs() < f64::EPSILON);
954 }
955
956 #[test]
957 fn average_entry_multiple_fills() {
958 let mut pos = Position::new_market(
959 "p1".into(),
960 "EURUSD".into(),
961 Side::Buy,
962 make_fill(1.0800, 1.0),
963 vec![],
964 );
965 pos.data.add_fill(Fill {
966 price: 1.0900,
967 size: 1.0,
968 ts: ts(10, 5, 0),
969 });
970 assert!((pos.data.average_entry() - 1.0850).abs() < f64::EPSILON);
972 }
973
974 #[test]
975 fn average_entry_weighted() {
976 let mut pos = Position::new_market(
977 "p1".into(),
978 "EURUSD".into(),
979 Side::Buy,
980 make_fill(1.0800, 2.0),
981 vec![],
982 );
983 pos.data.add_fill(Fill {
984 price: 1.0900,
985 size: 1.0,
986 ts: ts(10, 5, 0),
987 });
988 let expected = (1.0800 * 2.0 + 1.0900 * 1.0) / 3.0;
990 assert!((pos.data.average_entry() - expected).abs() < 1e-10);
991 }
992
993 #[test]
994 fn remaining_size_after_partial_close() {
995 let mut pos = Position::new_market(
996 "p1".into(),
997 "EURUSD".into(),
998 Side::Buy,
999 make_fill(1.0850, 2.0),
1000 vec![],
1001 );
1002 assert!((pos.data.remaining_size() - 2.0).abs() < f64::EPSILON);
1003
1004 pos.data
1005 .apply_partial_close(0.5, 1.0900, CloseReason::Target, ts(10, 30, 0));
1006 assert!((pos.data.remaining_size() - 1.0).abs() < f64::EPSILON);
1008 assert_eq!(pos.data.status, PositionStatus::Open);
1009 assert_eq!(pos.data.target_hits, 1);
1010 }
1011
1012 #[test]
1013 fn partial_close_then_scale_in_conserves_absolute_size() {
1014 let mut pos = Position::new_market(
1015 "p1".into(),
1016 "EURUSD".into(),
1017 Side::Buy,
1018 make_fill(1.0850, 2.0),
1019 vec![],
1020 );
1021
1022 pos.data
1023 .apply_partial_close(0.5, 1.0900, CloseReason::Manual, ts(10, 30, 0));
1024 pos.data.add_fill(Fill {
1025 price: 1.0950,
1026 size: 1.0,
1027 ts: ts(10, 35, 0),
1028 });
1029
1030 assert!((pos.data.total_filled_size() - 3.0).abs() < f64::EPSILON);
1031 assert!((pos.data.closed_size - 1.0).abs() < f64::EPSILON);
1032 assert!((pos.data.remaining_size() - 2.0).abs() < f64::EPSILON);
1033 assert!((pos.data.remaining_ratio - (2.0 / 3.0)).abs() < f64::EPSILON);
1034 }
1035
1036 #[test]
1037 fn partial_close_then_scale_in_preserves_average_cost_cash_flow() {
1038 let mut pos = Position::new_market(
1039 "p1".into(),
1040 "EURUSD".into(),
1041 Side::Buy,
1042 make_fill(100.0, 2.0),
1043 vec![],
1044 );
1045
1046 let first_basis = pos.data.average_entry();
1047 pos.data
1048 .apply_partial_close(0.5, 110.0, CloseReason::Manual, ts(10, 30, 0));
1049 let first_pnl = (110.0 - first_basis) * 1.0;
1050 assert_eq!(pos.data.open_entry_value, 100.0);
1051 assert_eq!(pos.data.average_entry(), 100.0);
1052
1053 pos.data.add_fill(Fill {
1054 price: 120.0,
1055 size: 1.0,
1056 ts: ts(10, 35, 0),
1057 });
1058 assert_eq!(pos.data.average_entry(), 110.0);
1059 assert_eq!(pos.data.open_entry_value, 220.0);
1060
1061 let final_basis = pos.data.average_entry();
1062 let final_pnl = (130.0 - final_basis) * pos.data.remaining_size();
1063 pos.data
1064 .apply_full_close(CloseReason::Manual, ts(10, 40, 0));
1065
1066 assert_eq!(first_pnl + final_pnl, 50.0);
1067 assert_eq!(pos.data.entries.len(), 2);
1068 assert_eq!(pos.data.historical_average_entry(), 320.0 / 3.0);
1069 assert_eq!(pos.data.open_entry_value, 0.0);
1070 }
1071
1072 #[test]
1073 fn scale_in_then_partial_close_uses_all_entered_size() {
1074 let mut pos = Position::new_market(
1075 "p1".into(),
1076 "EURUSD".into(),
1077 Side::Buy,
1078 make_fill(1.0850, 2.0),
1079 vec![],
1080 );
1081 pos.data.add_fill(Fill {
1082 price: 1.0950,
1083 size: 1.0,
1084 ts: ts(10, 5, 0),
1085 });
1086 pos.data
1087 .apply_partial_close(0.5, 1.1000, CloseReason::Manual, ts(10, 30, 0));
1088
1089 assert!((pos.data.closed_size - 1.5).abs() < f64::EPSILON);
1090 assert!((pos.data.remaining_size() - 1.5).abs() < f64::EPSILON);
1091 assert!((pos.data.remaining_ratio - 0.5).abs() < f64::EPSILON);
1092 }
1093
1094 #[test]
1095 fn repeated_partial_closes_cap_and_reach_exact_zero() {
1096 let mut pos = Position::new_market(
1097 "p1".into(),
1098 "EURUSD".into(),
1099 Side::Buy,
1100 make_fill(1.0850, 1.0),
1101 vec![],
1102 );
1103
1104 for minute in [10, 20, 30] {
1105 pos.data
1106 .apply_partial_close(0.4, 1.0900, CloseReason::Manual, ts(10, minute, 0));
1107 }
1108
1109 assert_eq!(pos.data.closed_size, 1.0);
1110 assert_eq!(pos.data.remaining_size(), 0.0);
1111 assert_eq!(pos.data.remaining_ratio, 0.0);
1112 assert_eq!(pos.data.status, PositionStatus::Closed);
1113 let last_ratio = pos
1114 .data
1115 .records
1116 .iter()
1117 .rev()
1118 .find_map(|(record, _)| match record {
1119 PositionRecord::PartialClose { ratio, .. } => Some(*ratio),
1120 _ => None,
1121 })
1122 .unwrap();
1123 assert!((last_ratio - 0.2).abs() < 1e-12);
1124 }
1125
1126 #[test]
1127 fn serde_migrates_legacy_ratio_to_absolute_closed_size() {
1128 let mut pos = Position::new_market(
1129 "p1".into(),
1130 "EURUSD".into(),
1131 Side::Buy,
1132 make_fill(1.0850, 2.0),
1133 vec![],
1134 );
1135 pos.data
1136 .apply_partial_close(0.25, 1.0900, CloseReason::Manual, ts(10, 30, 0));
1137
1138 let mut legacy = serde_json::to_value(&pos.data).unwrap();
1139 legacy
1140 .as_object_mut()
1141 .unwrap()
1142 .remove("closed_size")
1143 .unwrap();
1144 legacy
1145 .as_object_mut()
1146 .unwrap()
1147 .remove("open_entry_value")
1148 .unwrap();
1149 let migrated: PositionData = serde_json::from_value(legacy).unwrap();
1150 assert!((migrated.closed_size - 0.5).abs() < f64::EPSILON);
1151 assert!((migrated.remaining_size() - 1.5).abs() < f64::EPSILON);
1152 assert!((migrated.remaining_ratio - 0.75).abs() < f64::EPSILON);
1153 assert!((migrated.open_entry_value - 1.6275).abs() < f64::EPSILON);
1154 assert!((migrated.average_entry() - 1.0850).abs() < f64::EPSILON);
1155
1156 let mut current = serde_json::to_value(&migrated).unwrap();
1157 current["remaining_ratio"] = serde_json::json!(0.99);
1158 let round_trip: PositionData = serde_json::from_value(current).unwrap();
1159 assert!((round_trip.closed_size - 0.5).abs() < f64::EPSILON);
1160 assert!((round_trip.remaining_ratio - 0.75).abs() < f64::EPSILON);
1161 }
1162
1163 #[test]
1164 fn serde_defaults_legacy_unclosed_position_to_zero_closed_size() {
1165 let pos = Position::new_market(
1166 "p1".into(),
1167 "EURUSD".into(),
1168 Side::Buy,
1169 make_fill(1.0850, 2.0),
1170 vec![],
1171 );
1172 let mut legacy = serde_json::to_value(&pos.data).unwrap();
1173 legacy.as_object_mut().unwrap().remove("closed_size");
1174 legacy.as_object_mut().unwrap().remove("open_entry_value");
1175
1176 let migrated: PositionData = serde_json::from_value(legacy).unwrap();
1177 assert_eq!(migrated.closed_size, 0.0);
1178 assert_eq!(migrated.remaining_size(), 2.0);
1179 assert_eq!(migrated.remaining_ratio, 1.0);
1180 }
1181
1182 #[test]
1183 fn full_close_via_partial() {
1184 let mut pos = Position::new_market(
1185 "p1".into(),
1186 "EURUSD".into(),
1187 Side::Buy,
1188 make_fill(1.0850, 1.0),
1189 vec![],
1190 );
1191 pos.data
1192 .apply_partial_close(1.0, 1.0900, CloseReason::Target, ts(10, 30, 0));
1193 assert_eq!(pos.data.status, PositionStatus::Closed);
1194 assert!(pos.data.close_ts.is_some());
1195 assert_eq!(pos.data.closed_size, 1.0);
1196 assert_eq!(pos.data.remaining_size(), 0.0);
1197 assert_eq!(pos.data.remaining_ratio, 0.0);
1198 }
1199
1200 #[test]
1201 fn full_close() {
1202 let mut pos = Position::new_market(
1203 "p1".into(),
1204 "EURUSD".into(),
1205 Side::Sell,
1206 make_fill(1.0850, 1.0),
1207 vec![],
1208 );
1209 pos.data
1210 .apply_full_close(CloseReason::Stoploss, ts(10, 30, 0));
1211 assert_eq!(pos.data.status, PositionStatus::Closed);
1212 assert_eq!(pos.data.closed_size, 1.0);
1213 assert_eq!(pos.data.remaining_size(), 0.0);
1214 assert_eq!(pos.data.remaining_ratio, 0.0);
1215 }
1216
1217 #[test]
1218 fn unrealized_pnl_buy() {
1219 let pos = Position::new_market(
1220 "p1".into(),
1221 "EURUSD".into(),
1222 Side::Buy,
1223 make_fill(1.0850, 1.0),
1224 vec![],
1225 );
1226 let pnl = pos.data.unrealized_pnl(1.0900);
1227 assert!((pnl - 0.0050).abs() < 1e-10);
1228 }
1229
1230 #[test]
1231 fn unrealized_pnl_sell() {
1232 let pos = Position::new_market(
1233 "p1".into(),
1234 "EURUSD".into(),
1235 Side::Sell,
1236 make_fill(1.0850, 1.0),
1237 vec![],
1238 );
1239 let pnl = pos.data.unrealized_pnl(1.0800);
1240 assert!((pnl - 0.0050).abs() < 1e-10);
1241 }
1242
1243 #[test]
1244 fn try_fill_limit_buy() {
1245 let mut pos = Position::new_pending(
1246 "p1".into(),
1247 "EURUSD".into(),
1248 Side::Buy,
1249 OrderType::Limit,
1250 1.0800,
1251 1.0,
1252 ts(9, 0, 0),
1253 vec![],
1254 );
1255 assert_eq!(pos.data.status, PositionStatus::Pending);
1256
1257 let q1 = PriceQuote {
1259 symbol: "EURUSD".into(),
1260 ts: ts(10, 0, 0),
1261 bid: 1.0808,
1262 ask: 1.0810,
1263 };
1264 assert!(!pos.try_fill(&q1, FillModel::BidAsk));
1265 assert_eq!(pos.data.status, PositionStatus::Pending);
1266
1267 let q2 = PriceQuote {
1269 symbol: "EURUSD".into(),
1270 ts: ts(10, 5, 0),
1271 bid: 1.0798,
1272 ask: 1.0800,
1273 };
1274 assert!(pos.try_fill(&q2, FillModel::BidAsk));
1275 assert_eq!(pos.data.status, PositionStatus::Open);
1276 assert_eq!(pos.data.entries.len(), 1);
1277 assert!((pos.data.entries[0].price - 1.0800).abs() < f64::EPSILON);
1278 }
1279
1280 #[test]
1281 fn try_fill_stop_sell() {
1282 let mut pos = Position::new_pending(
1283 "p1".into(),
1284 "EURUSD".into(),
1285 Side::Sell,
1286 OrderType::Stop,
1287 1.0800,
1288 1.0,
1289 ts(9, 0, 0),
1290 vec![],
1291 );
1292
1293 let q1 = PriceQuote {
1295 symbol: "EURUSD".into(),
1296 ts: ts(10, 0, 0),
1297 bid: 1.0810,
1298 ask: 1.0812,
1299 };
1300 assert!(!pos.try_fill(&q1, FillModel::BidAsk));
1301
1302 let q2 = PriceQuote {
1304 symbol: "EURUSD".into(),
1305 ts: ts(10, 5, 0),
1306 bid: 1.0800,
1307 ask: 1.0802,
1308 };
1309 assert!(pos.try_fill(&q2, FillModel::BidAsk));
1310 assert_eq!(pos.data.status, PositionStatus::Open);
1311 }
1312
1313 #[test]
1314 fn set_stoploss_updates_existing() {
1315 let mut pos = Position::new_market(
1316 "p1".into(),
1317 "EURUSD".into(),
1318 Side::Buy,
1319 make_fill(1.0850, 1.0),
1320 vec![Rule::fixed_stoploss(1.0800)],
1321 );
1322 assert!((pos.current_stoploss().unwrap() - 1.0800).abs() < f64::EPSILON);
1323
1324 let old = pos.set_stoploss(1.0820);
1325 assert!((old.unwrap() - 1.0800).abs() < f64::EPSILON);
1326 assert!((pos.current_stoploss().unwrap() - 1.0820).abs() < f64::EPSILON);
1327 }
1328
1329 #[test]
1330 fn set_stoploss_adds_when_missing() {
1331 let mut pos = Position::new_market(
1332 "p1".into(),
1333 "EURUSD".into(),
1334 Side::Buy,
1335 make_fill(1.0850, 1.0),
1336 vec![],
1337 );
1338 assert!(pos.current_stoploss().is_none());
1339
1340 let old = pos.set_stoploss(1.0800);
1341 assert!(old.is_none());
1342 assert!((pos.current_stoploss().unwrap() - 1.0800).abs() < f64::EPSILON);
1343 }
1344
1345 #[test]
1346 fn remove_rule_by_name() {
1347 let mut pos = Position::new_market(
1348 "p1".into(),
1349 "EURUSD".into(),
1350 Side::Buy,
1351 make_fill(1.0850, 1.0),
1352 vec![Rule::fixed_stoploss(1.0800), Rule::take_profit(1.0900, 1.0)],
1353 );
1354 assert_eq!(pos.rules.len(), 2);
1355 assert!(pos.remove_rule("TakeProfit"));
1356 assert_eq!(pos.rules.len(), 1);
1357 assert_eq!(pos.rules[0].name(), "FixedStoploss");
1358 }
1359
1360 #[test]
1361 fn evaluate_rules_produces_effects() {
1362 let mut pos = Position::new_market(
1363 "p1".into(),
1364 "EURUSD".into(),
1365 Side::Buy,
1366 make_fill(1.0850, 1.0),
1367 vec![Rule::fixed_stoploss(1.0800), Rule::take_profit(1.0900, 1.0)],
1368 );
1369
1370 let q = PriceQuote {
1372 symbol: "EURUSD".into(),
1373 ts: ts(10, 5, 0),
1374 bid: 1.0860,
1375 ask: 1.0862,
1376 };
1377 let effects = pos.evaluate_rules(&q, FillModel::BidAsk);
1378 assert!(effects.is_empty());
1379
1380 let q_sl = PriceQuote {
1382 symbol: "EURUSD".into(),
1383 ts: ts(10, 10, 0),
1384 bid: 1.0799,
1385 ask: 1.0801,
1386 };
1387 let effects = pos.evaluate_rules(&q_sl, FillModel::BidAsk);
1388 assert!(!effects.is_empty());
1389 assert!(matches!(
1390 &effects[0],
1391 Effect::PositionClosed {
1392 reason: CloseReason::Stoploss,
1393 ..
1394 }
1395 ));
1396 }
1397
1398 #[test]
1399 fn pending_position_skips_rule_evaluation() {
1400 let mut pos = Position::new_pending(
1401 "p1".into(),
1402 "EURUSD".into(),
1403 Side::Buy,
1404 OrderType::Limit,
1405 1.0800,
1406 1.0,
1407 ts(9, 0, 0),
1408 vec![Rule::fixed_stoploss(1.0750)],
1409 );
1410
1411 let q = PriceQuote {
1413 symbol: "EURUSD".into(),
1414 ts: ts(10, 0, 0),
1415 bid: 1.0740,
1416 ask: 1.0742,
1417 };
1418 let effects = pos.evaluate_rules(&q, FillModel::BidAsk);
1419 assert!(effects.is_empty());
1420 }
1421}