use serde::{Deserialize, Serialize};
use crate::types::{
CloseReason, Effect, FillModel, PositionStatus, PriceQuote, RuleConfig, Side,
position_size_tolerance,
};
pub struct PositionView<'a> {
pub id: &'a str,
pub symbol: &'a str,
pub side: Side,
pub status: PositionStatus,
pub average_entry: f64,
pub remaining_ratio: f64,
pub target_hits: u32,
pub open_ts: Option<chrono::NaiveDateTime>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Rule {
FixedStoploss { price: f64 },
TrailingStop {
distance: f64,
#[serde(default)]
peak_price: f64,
#[serde(default)]
initialized: bool,
},
TakeProfit {
price: f64,
close_ratio: f64,
#[serde(default)]
triggered: bool,
},
BreakevenWhen {
trigger_price: f64,
#[serde(default)]
triggered: bool,
},
BreakevenAfterTargets {
after_n: u32,
#[serde(default)]
triggered: bool,
},
TimeExit { max_seconds: u64 },
}
impl Rule {
pub fn fixed_stoploss(price: f64) -> Self {
Rule::FixedStoploss { price }
}
pub fn trailing_stop(distance: f64) -> Self {
Rule::TrailingStop {
distance,
peak_price: 0.0,
initialized: false,
}
}
pub fn take_profit(price: f64, close_ratio: f64) -> Self {
Rule::TakeProfit {
price,
close_ratio,
triggered: false,
}
}
pub fn breakeven_when(trigger_price: f64) -> Self {
Rule::BreakevenWhen {
trigger_price,
triggered: false,
}
}
pub fn breakeven_after_targets(after_n: u32) -> Self {
Rule::BreakevenAfterTargets {
after_n,
triggered: false,
}
}
pub fn time_exit(max_seconds: u64) -> Self {
Rule::TimeExit { max_seconds }
}
pub fn from_config(config: RuleConfig) -> Self {
match config {
RuleConfig::FixedStoploss { price } => Self::fixed_stoploss(price),
RuleConfig::TrailingStop { distance } => Self::trailing_stop(distance),
RuleConfig::TakeProfit { price, close_ratio } => Self::take_profit(price, close_ratio),
RuleConfig::BreakevenWhen { trigger_price } => Self::breakeven_when(trigger_price),
RuleConfig::BreakevenAfterTargets { after_n } => Self::breakeven_after_targets(after_n),
RuleConfig::TimeExit { max_seconds } => Self::time_exit(max_seconds),
}
}
}
impl Rule {
pub fn is_stateful(&self) -> bool {
matches!(
self,
Rule::TrailingStop { .. } | Rule::TimeExit { .. } | Rule::BreakevenAfterTargets { .. }
)
}
pub fn name(&self) -> &'static str {
match self {
Rule::FixedStoploss { .. } => "FixedStoploss",
Rule::TrailingStop { .. } => "TrailingStop",
Rule::TakeProfit { .. } => "TakeProfit",
Rule::BreakevenWhen { .. } => "BreakevenWhen",
Rule::BreakevenAfterTargets { .. } => "BreakevenAfterTargets",
Rule::TimeExit { .. } => "TimeExit",
}
}
pub fn evaluate(
&mut self,
data: &PositionView<'_>,
quote: &PriceQuote,
model: FillModel,
) -> Vec<Effect> {
if data.status != PositionStatus::Open {
return vec![];
}
match self {
Rule::FixedStoploss { price } => {
let check = quote.eval_price(data.side, model);
let hit = match data.side {
Side::Buy => check <= *price,
Side::Sell => check >= *price,
};
if hit {
vec![Effect::PositionClosed {
id: data.id.to_owned(),
reason: CloseReason::Stoploss,
}]
} else {
vec![]
}
}
Rule::TrailingStop {
distance,
peak_price,
initialized,
} => {
if !*initialized {
*peak_price = data.average_entry;
*initialized = true;
}
let check = quote.eval_price(data.side, model);
match data.side {
Side::Buy => {
if check > *peak_price {
*peak_price = check;
}
let trailing_sl = *peak_price - *distance;
if check <= trailing_sl {
vec![Effect::PositionClosed {
id: data.id.to_owned(),
reason: CloseReason::TrailingStop,
}]
} else {
vec![]
}
}
Side::Sell => {
if check < *peak_price {
*peak_price = check;
}
let trailing_sl = *peak_price + *distance;
if check >= trailing_sl {
vec![Effect::PositionClosed {
id: data.id.to_owned(),
reason: CloseReason::TrailingStop,
}]
} else {
vec![]
}
}
}
}
Rule::TakeProfit {
price,
close_ratio,
triggered,
} => {
if *triggered {
return vec![];
}
if data.remaining_ratio <= 0.0 {
return vec![];
}
let check = quote.eval_price(data.side, model);
let hit = match data.side {
Side::Buy => check >= *price,
Side::Sell => check <= *price,
};
if hit {
*triggered = true;
let actual_ratio = close_ratio.min(data.remaining_ratio);
if data.remaining_ratio - actual_ratio <= position_size_tolerance(1.0) {
vec![Effect::PositionClosed {
id: data.id.to_owned(),
reason: CloseReason::Target,
}]
} else {
vec![Effect::PartialClose {
id: data.id.to_owned(),
ratio: actual_ratio,
reason: CloseReason::Target,
}]
}
} else {
vec![]
}
}
Rule::BreakevenWhen {
trigger_price,
triggered,
} => {
if *triggered {
return vec![];
}
let check = quote.eval_price(data.side, model);
let hit = match data.side {
Side::Buy => check >= *trigger_price,
Side::Sell => check <= *trigger_price,
};
if hit {
*triggered = true;
vec![Effect::StoplossModified {
id: data.id.to_owned(),
old_price: 0.0, new_price: data.average_entry,
}]
} else {
vec![]
}
}
Rule::BreakevenAfterTargets { after_n, triggered } => {
if *triggered {
return vec![];
}
if data.target_hits >= *after_n {
*triggered = true;
vec![Effect::StoplossModified {
id: data.id.to_owned(),
old_price: 0.0, new_price: data.average_entry,
}]
} else {
vec![]
}
}
Rule::TimeExit { max_seconds } => {
if let Some(open_ts) = data.open_ts {
let elapsed = (quote.ts - open_ts).num_seconds();
if elapsed >= *max_seconds as i64 {
vec![Effect::PositionClosed {
id: data.id.to_owned(),
reason: CloseReason::TimeExit,
}]
} else {
vec![]
}
} else {
vec![]
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
fn ts(h: u32, m: u32, s: u32) -> chrono::NaiveDateTime {
NaiveDate::from_ymd_opt(2026, 1, 1)
.unwrap()
.and_hms_opt(h, m, s)
.unwrap()
}
fn quote(bid: f64, ask: f64) -> PriceQuote {
PriceQuote {
symbol: "EURUSD".into(),
ts: ts(12, 0, 0),
bid,
ask,
}
}
fn view_buy(id: &str, entry: f64) -> PositionView<'_> {
PositionView {
id,
symbol: "EURUSD",
side: Side::Buy,
status: PositionStatus::Open,
average_entry: entry,
remaining_ratio: 1.0,
target_hits: 0,
open_ts: Some(ts(10, 0, 0)),
}
}
fn view_sell(id: &str, entry: f64) -> PositionView<'_> {
PositionView {
id,
symbol: "EURUSD",
side: Side::Sell,
status: PositionStatus::Open,
average_entry: entry,
remaining_ratio: 1.0,
target_hits: 0,
open_ts: Some(ts(10, 0, 0)),
}
}
#[test]
fn fixed_stoploss_buy_triggers() {
let mut rule = Rule::fixed_stoploss(1.0800);
let v = view_buy("p1", 1.0850);
let effects = rule.evaluate(&v, "e(1.0800, 1.0802), FillModel::BidAsk);
assert_eq!(effects.len(), 1);
assert!(matches!(
&effects[0],
Effect::PositionClosed {
reason: CloseReason::Stoploss,
..
}
));
}
#[test]
fn fixed_stoploss_buy_no_trigger() {
let mut rule = Rule::fixed_stoploss(1.0800);
let v = view_buy("p1", 1.0850);
let effects = rule.evaluate(&v, "e(1.0810, 1.0812), FillModel::BidAsk);
assert!(effects.is_empty());
}
#[test]
fn fixed_stoploss_sell_triggers() {
let mut rule = Rule::fixed_stoploss(1.0900);
let v = view_sell("p1", 1.0850);
let effects = rule.evaluate(&v, "e(1.0898, 1.0900), FillModel::BidAsk);
assert_eq!(effects.len(), 1);
assert!(matches!(
&effects[0],
Effect::PositionClosed {
reason: CloseReason::Stoploss,
..
}
));
}
#[test]
fn trailing_stop_buy() {
let mut rule = Rule::trailing_stop(0.0020);
let v = view_buy("p1", 1.0850);
let effects = rule.evaluate(&v, "e(1.0870, 1.0872), FillModel::BidAsk);
assert!(effects.is_empty());
let effects = rule.evaluate(&v, "e(1.0900, 1.0902), FillModel::BidAsk);
assert!(effects.is_empty());
let effects = rule.evaluate(&v, "e(1.0882, 1.0884), FillModel::BidAsk);
assert!(effects.is_empty());
let effects = rule.evaluate(&v, "e(1.0879, 1.0881), FillModel::BidAsk);
assert_eq!(effects.len(), 1);
assert!(matches!(
&effects[0],
Effect::PositionClosed {
reason: CloseReason::TrailingStop,
..
}
));
}
#[test]
fn take_profit_partial() {
let mut rule = Rule::take_profit(1.0900, 0.5);
let v = view_buy("p1", 1.0850);
let effects = rule.evaluate(&v, "e(1.0890, 1.0892), FillModel::BidAsk);
assert!(effects.is_empty());
let effects = rule.evaluate(&v, "e(1.0900, 1.0902), FillModel::BidAsk);
assert_eq!(effects.len(), 1);
assert!(
matches!(&effects[0], Effect::PartialClose { ratio, .. } if (*ratio - 0.5).abs() < f64::EPSILON)
);
let effects = rule.evaluate(&v, "e(1.0910, 1.0912), FillModel::BidAsk);
assert!(effects.is_empty());
}
#[test]
fn take_profit_closes_remaining() {
let mut rule = Rule::take_profit(1.0900, 1.0);
let v = view_buy("p1", 1.0850);
let effects = rule.evaluate(&v, "e(1.0900, 1.0902), FillModel::BidAsk);
assert_eq!(effects.len(), 1);
assert!(matches!(
&effects[0],
Effect::PositionClosed {
reason: CloseReason::Target,
..
}
));
}
#[test]
fn breakeven_when_triggers() {
let mut rule = Rule::breakeven_when(1.0900);
let v = view_buy("p1", 1.0850);
let effects = rule.evaluate(&v, "e(1.0890, 1.0892), FillModel::BidAsk);
assert!(effects.is_empty());
let effects = rule.evaluate(&v, "e(1.0900, 1.0902), FillModel::BidAsk);
assert_eq!(effects.len(), 1);
assert!(
matches!(&effects[0], Effect::StoplossModified { new_price, .. } if (*new_price - 1.0850).abs() < f64::EPSILON)
);
let effects = rule.evaluate(&v, "e(1.0910, 1.0912), FillModel::BidAsk);
assert!(effects.is_empty());
}
#[test]
fn breakeven_after_targets() {
let mut rule = Rule::breakeven_after_targets(2);
let mut v = view_buy("p1", 1.0850);
v.target_hits = 1;
let effects = rule.evaluate(&v, "e(1.0900, 1.0902), FillModel::BidAsk);
assert!(effects.is_empty());
v.target_hits = 2;
let effects = rule.evaluate(&v, "e(1.0900, 1.0902), FillModel::BidAsk);
assert_eq!(effects.len(), 1);
assert!(matches!(&effects[0], Effect::StoplossModified { .. }));
v.target_hits = 3;
let effects = rule.evaluate(&v, "e(1.0900, 1.0902), FillModel::BidAsk);
assert!(effects.is_empty());
}
#[test]
fn time_exit_triggers() {
let mut rule = Rule::time_exit(3600); let v = view_buy("p1", 1.0850);
let mut q = quote(1.0860, 1.0862);
q.ts = ts(10, 30, 0);
let effects = rule.evaluate(&v, &q, FillModel::BidAsk);
assert!(effects.is_empty());
q.ts = ts(11, 0, 0);
let effects = rule.evaluate(&v, &q, FillModel::BidAsk);
assert_eq!(effects.len(), 1);
assert!(matches!(
&effects[0],
Effect::PositionClosed {
reason: CloseReason::TimeExit,
..
}
));
}
#[test]
fn rule_skips_closed_position() {
let mut rule = Rule::fixed_stoploss(1.0800);
let mut v = view_buy("p1", 1.0850);
v.status = PositionStatus::Closed;
let effects = rule.evaluate(&v, "e(1.0790, 1.0792), FillModel::BidAsk);
assert!(effects.is_empty());
}
#[test]
fn from_config_roundtrip() {
let configs = vec![
RuleConfig::FixedStoploss { price: 1.08 },
RuleConfig::TrailingStop { distance: 0.002 },
RuleConfig::TakeProfit {
price: 1.09,
close_ratio: 0.5,
},
RuleConfig::BreakevenWhen {
trigger_price: 1.09,
},
RuleConfig::BreakevenAfterTargets { after_n: 2 },
RuleConfig::TimeExit { max_seconds: 3600 },
];
let names: Vec<&str> = configs
.into_iter()
.map(|c| Rule::from_config(c).name())
.collect();
assert_eq!(
names,
vec![
"FixedStoploss",
"TrailingStop",
"TakeProfit",
"BreakevenWhen",
"BreakevenAfterTargets",
"TimeExit",
]
);
}
}