use chrono::NaiveDate;
use pretty_simple_display::{DebugPretty, DisplaySimple};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
pub const BACKTESTER_BASE_URL: &str = "https://backtester.vast.tastyworks.com";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum BacktestInstrument {
Equity,
EquityOption,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BacktestDirection {
Long,
Short,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BacktestSide {
Call,
Put,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum StrikeSelection {
#[serde(rename = "delta")]
Delta,
#[serde(rename = "percentageOTM")]
PercentageOtm,
#[serde(rename = "percentageOTMRelative")]
PercentageOtmRelative,
#[serde(rename = "currentPriceOffset")]
CurrentPriceOffset,
#[serde(rename = "currentPriceOffsetRelative")]
CurrentPriceOffsetRelative,
#[serde(rename = "currentPriceExactOffsetRelative")]
CurrentPriceExactOffsetRelative,
#[serde(rename = "premium")]
Premium,
}
pub const MAX_BACKTEST_QUANTITY: u32 = 100;
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
pub struct BacktestLeg {
#[serde(rename = "type")]
pub leg_type: BacktestInstrument,
pub direction: BacktestDirection,
#[serde(with = "crate::types::wire::decimal")]
pub quantity: Decimal,
#[serde(rename = "strikeSelection")]
pub strike_selection: StrikeSelection,
#[serde(rename = "daysUntilExpiration")]
pub days_until_expiration: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub side: Option<BacktestSide>,
#[serde(
rename = "strikeRelativeLeg",
default,
skip_serializing_if = "Option::is_none"
)]
pub strike_relative_leg: Option<i64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::types::wire::decimal_option"
)]
pub delta: Option<Decimal>,
#[serde(
rename = "percentageOTM",
default,
skip_serializing_if = "Option::is_none",
with = "crate::types::wire::decimal_option"
)]
pub percentage_otm: Option<Decimal>,
#[serde(
rename = "currentPriceOffset",
default,
skip_serializing_if = "Option::is_none",
with = "crate::types::wire::decimal_option"
)]
pub current_price_offset: Option<Decimal>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::types::wire::decimal_option"
)]
pub premium: Option<Decimal>,
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone, Default)]
pub struct EntryConditions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub frequency: Option<String>,
#[serde(
rename = "specificDays",
default,
skip_serializing_if = "Vec::is_empty"
)]
pub specific_days: Vec<i64>,
#[serde(
rename = "maximumActiveTrials",
default,
skip_serializing_if = "Option::is_none"
)]
pub maximum_active_trials: Option<i64>,
#[serde(
rename = "maximumActiveTrialsBehavior",
default,
skip_serializing_if = "Option::is_none"
)]
pub maximum_active_trials_behavior: Option<String>,
#[serde(
rename = "minimumVIX",
default,
skip_serializing_if = "Option::is_none"
)]
pub minimum_vix: Option<i64>,
#[serde(
rename = "maximumVIX",
default,
skip_serializing_if = "Option::is_none"
)]
pub maximum_vix: Option<i64>,
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone, Default)]
pub struct ExitConditions {
#[serde(
rename = "takeProfitPercentage",
default,
skip_serializing_if = "Option::is_none"
)]
pub take_profit_percentage: Option<i64>,
#[serde(
rename = "stopLossPercentage",
default,
skip_serializing_if = "Option::is_none"
)]
pub stop_loss_percentage: Option<i64>,
#[serde(
rename = "afterDaysInTrade",
default,
skip_serializing_if = "Option::is_none"
)]
pub after_days_in_trade: Option<i64>,
#[serde(
rename = "atDaysToExpiration",
default,
skip_serializing_if = "Option::is_none"
)]
pub at_days_to_expiration: Option<i64>,
#[serde(
rename = "minimumVIX",
default,
skip_serializing_if = "Option::is_none"
)]
pub minimum_vix: Option<i64>,
}
#[derive(DebugPretty, DisplaySimple, Serialize, Clone)]
pub struct NewBacktest {
pub symbol: String,
#[serde(rename = "startDate", with = "crate::types::wire::date")]
pub start_date: NaiveDate,
#[serde(rename = "endDate", with = "crate::types::wire::date")]
pub end_date: NaiveDate,
pub legs: Vec<BacktestLeg>,
#[serde(rename = "entryConditions", skip_serializing_if = "Option::is_none")]
pub entry_conditions: Option<EntryConditions>,
#[serde(rename = "exitConditions", skip_serializing_if = "Option::is_none")]
pub exit_conditions: Option<ExitConditions>,
}
impl NewBacktest {
pub fn new(
symbol: impl Into<String>,
start_date: NaiveDate,
end_date: NaiveDate,
legs: Vec<BacktestLeg>,
) -> Self {
Self {
symbol: symbol.into(),
start_date,
end_date,
legs,
entry_conditions: None,
exit_conditions: None,
}
}
#[must_use]
pub fn with_entry_conditions(mut self, conditions: EntryConditions) -> Self {
self.entry_conditions = Some(conditions);
self
}
#[must_use]
pub fn with_exit_conditions(mut self, conditions: ExitConditions) -> Self {
self.exit_conditions = Some(conditions);
self
}
pub(crate) fn validate(&self) -> crate::TastyResult<()> {
if self.symbol.trim().is_empty() {
return Err(crate::TastyTradeError::Precondition(
"a backtest needs an underlying symbol".to_string(),
));
}
if self.legs.is_empty() {
return Err(crate::TastyTradeError::Precondition(
"a backtest needs at least one leg; there is no strategy without one".to_string(),
));
}
if self.start_date > self.end_date {
return Err(crate::TastyTradeError::Precondition(format!(
"the backtest starts on {} and ends on {}, which is before it",
self.start_date, self.end_date
)));
}
for (index, leg) in self.legs.iter().enumerate() {
if leg.quantity <= Decimal::ZERO
|| leg.quantity > Decimal::from(MAX_BACKTEST_QUANTITY)
|| leg.quantity.fract() != Decimal::ZERO
{
return Err(crate::TastyTradeError::Precondition(format!(
"leg {index} asks for {} contracts; the backtester takes a whole \
number from 1 to {MAX_BACKTEST_QUANTITY}",
leg.quantity
)));
}
match (leg.leg_type, leg.side) {
(BacktestInstrument::EquityOption, None) => {
return Err(crate::TastyTradeError::Precondition(format!(
"leg {index} is an option leg with no side; it has to say call \
or put"
)));
}
(BacktestInstrument::Equity, Some(side)) => {
return Err(crate::TastyTradeError::Precondition(format!(
"leg {index} is an equity leg carrying a {side:?} side; call and \
put describe an option"
)));
}
_ => {}
}
if leg.days_until_expiration < 0 {
return Err(crate::TastyTradeError::Precondition(format!(
"leg {index} expires {} days from entry, which is in the past",
leg.days_until_expiration
)));
}
}
Ok(())
}
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
pub struct Trial {
#[serde(rename = "openDateTime", default)]
pub open_date_time: Option<String>,
#[serde(rename = "closeDateTime", default)]
pub close_date_time: Option<String>,
#[serde(
rename = "profitLoss",
default,
with = "crate::types::wire::decimal_option"
)]
pub profit_loss: Option<Decimal>,
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
pub struct Snapshot {
#[serde(rename = "dateTime", default)]
pub date_time: Option<String>,
#[serde(
rename = "profitLoss",
default,
with = "crate::types::wire::decimal_option"
)]
pub profit_loss: Option<Decimal>,
#[serde(
rename = "underlyingPrice",
default,
with = "crate::types::wire::decimal_option"
)]
pub underlying_price: Option<Decimal>,
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
pub struct Backtest {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub symbol: Option<String>,
#[serde(
rename = "startDate",
default,
with = "crate::types::wire::date_option"
)]
pub start_date: Option<NaiveDate>,
#[serde(rename = "endDate", default, with = "crate::types::wire::date_option")]
pub end_date: Option<NaiveDate>,
#[serde(default)]
pub status: Option<String>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub progress: Option<Decimal>,
#[serde(rename = "ETA", default, with = "crate::types::wire::decimal_option")]
pub eta: Option<Decimal>,
#[serde(default)]
pub legs: Vec<BacktestLeg>,
#[serde(rename = "entryConditions", default)]
pub entry_conditions: Option<EntryConditions>,
#[serde(rename = "exitConditions", default)]
pub exit_conditions: Option<ExitConditions>,
#[serde(default)]
pub statistics: Vec<serde_json::Value>,
#[serde(default)]
pub trials: Vec<Trial>,
#[serde(default)]
pub snapshots: Vec<Snapshot>,
#[serde(default)]
pub notices: Vec<String>,
}
impl Backtest {
pub fn is_finished(&self) -> bool {
self.status.as_deref().is_some_and(|status| {
matches!(
status.trim().to_ascii_lowercase().as_str(),
"completed" | "complete" | "finished" | "failed" | "cancelled" | "canceled"
)
})
}
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
pub struct AvailableDates {
#[serde(default)]
pub symbol: Option<String>,
#[serde(rename = "startDate", default)]
pub start_date: Option<String>,
#[serde(rename = "endDate", default)]
pub end_date: Option<String>,
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
pub struct SimulatedLeg {
pub symbol: String,
pub direction: BacktestDirection,
#[serde(with = "crate::types::wire::decimal")]
pub quantity: Decimal,
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
pub struct SimulateTrade {
pub underlying: String,
#[serde(
rename = "startTime",
default,
skip_serializing_if = "Option::is_none",
with = "crate::types::wire::datetime_option"
)]
pub start_time: Option<chrono::DateTime<chrono::FixedOffset>>,
#[serde(
rename = "endTime",
default,
skip_serializing_if = "Option::is_none",
with = "crate::types::wire::datetime_option"
)]
pub end_time: Option<chrono::DateTime<chrono::FixedOffset>>,
pub legs: Vec<SimulatedLeg>,
}
impl SimulateTrade {
pub fn new(underlying: impl Into<String>, legs: Vec<SimulatedLeg>) -> Self {
Self {
underlying: underlying.into(),
start_time: None,
end_time: None,
legs,
}
}
#[must_use]
pub fn between(
mut self,
start: chrono::DateTime<chrono::FixedOffset>,
end: chrono::DateTime<chrono::FixedOffset>,
) -> Self {
self.start_time = Some(start);
self.end_time = Some(end);
self
}
pub(crate) fn validate(&self) -> crate::TastyResult<()> {
if self.underlying.trim().is_empty() {
return Err(crate::TastyTradeError::Precondition(
"a simulated trade needs an underlying symbol".to_string(),
));
}
if self.legs.is_empty() {
return Err(crate::TastyTradeError::Precondition(
"a simulated trade needs at least one leg".to_string(),
));
}
for (index, leg) in self.legs.iter().enumerate() {
if leg.symbol.trim().is_empty() {
return Err(crate::TastyTradeError::Precondition(format!(
"simulated leg {index} has a blank symbol"
)));
}
if leg.quantity <= Decimal::ZERO || leg.quantity.fract() != Decimal::ZERO {
return Err(crate::TastyTradeError::Precondition(format!(
"simulated leg {index} asks for {} contracts; the backtester takes \
a whole number above zero",
leg.quantity
)));
}
}
if let (Some(start), Some(end)) = (self.start_time, self.end_time)
&& start > end
{
return Err(crate::TastyTradeError::Precondition(
"the simulation starts after it ends".to_string(),
));
}
Ok(())
}
}
#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
pub struct SimulatedTradePoint {
#[serde(rename = "dateTime", default)]
pub date_time: Option<String>,
#[serde(default, with = "crate::types::wire::decimal_option")]
pub price: Option<Decimal>,
#[serde(default)]
pub effect: Option<String>,
#[serde(
rename = "underlyingPrice",
default,
with = "crate::types::wire::decimal_option"
)]
pub underlying_price: Option<Decimal>,
}
#[cfg(test)]
mod tests {
use super::*;
fn day(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("a real date")
}
fn leg() -> BacktestLeg {
BacktestLeg {
leg_type: BacktestInstrument::EquityOption,
direction: BacktestDirection::Short,
quantity: Decimal::ONE,
strike_selection: StrikeSelection::Delta,
days_until_expiration: 45,
side: Some(BacktestSide::Put),
strike_relative_leg: None,
delta: Some(Decimal::new(16, 2)),
percentage_otm: None,
current_price_offset: None,
premium: None,
}
}
fn backtest() -> NewBacktest {
NewBacktest::new("SPY", day(2024, 1, 1), day(2024, 12, 31), vec![leg()])
}
#[test]
fn the_request_serialises_in_the_services_own_casing() {
let body = serde_json::to_value(backtest()).expect("serialises");
assert_eq!(body["symbol"], "SPY");
assert_eq!(body["startDate"], "2024-01-01");
assert_eq!(body["endDate"], "2024-12-31");
assert_eq!(body["legs"][0]["strikeSelection"], "delta");
assert_eq!(body["legs"][0]["daysUntilExpiration"], 45);
assert_eq!(body["legs"][0]["type"], "equity-option");
assert_eq!(body["legs"][0]["direction"], "short");
assert_eq!(body["legs"][0]["side"], "put");
assert!(body["legs"][0].get("premium").is_none(), "{body}");
assert!(body.get("entryConditions").is_none(), "{body}");
}
#[test]
fn conditions_serialise_in_the_same_casing() {
let body = serde_json::to_value(
backtest()
.with_entry_conditions(EntryConditions {
frequency: Some("Daily".to_string()),
maximum_active_trials: Some(3),
minimum_vix: Some(12),
..EntryConditions::default()
})
.with_exit_conditions(ExitConditions {
take_profit_percentage: Some(50),
at_days_to_expiration: Some(21),
..ExitConditions::default()
}),
)
.expect("serialises");
assert_eq!(body["entryConditions"]["maximumActiveTrials"], 3);
assert_eq!(body["entryConditions"]["minimumVIX"], 12);
assert_eq!(body["exitConditions"]["takeProfitPercentage"], 50);
assert_eq!(body["exitConditions"]["atDaysToExpiration"], 21);
assert!(
body["entryConditions"].get("specificDays").is_none(),
"{body}"
);
}
#[test]
fn an_impossible_backtest_is_refused_locally() {
for (what, bad) in [
(
"no legs",
NewBacktest::new("SPY", day(2024, 1, 1), day(2024, 12, 31), vec![]),
),
(
"an inverted range",
NewBacktest::new("SPY", day(2024, 12, 31), day(2024, 1, 1), vec![leg()]),
),
(
"a blank symbol",
NewBacktest::new(" ", day(2024, 1, 1), day(2024, 12, 31), vec![leg()]),
),
] {
let error = bad.validate().expect_err(what);
assert!(matches!(error, crate::TastyTradeError::Precondition(_)));
assert!(!error.is_retryable(), "{what}");
}
assert!(backtest().validate().is_ok());
}
#[test]
fn a_result_decodes_with_its_trials_and_snapshots() {
let result: Backtest = serde_json::from_str(
r#"{"id": "bt-1", "symbol": "SPY", "status": "completed",
"startDate": "2024-01-01", "endDate": "2024-12-31",
"progress": 100, "ETA": 0,
"trials": [{"openDateTime": "2024-01-02T09:30",
"closeDateTime": "2024-02-16T16:00",
"profitLoss": 125.5}],
"snapshots": [{"dateTime": "2024-01-02", "profitLoss": 0,
"underlyingPrice": 470.1}],
"notices": ["partial data for one week"]}"#,
)
.expect("the backtest must decode");
assert_eq!(result.id.as_deref(), Some("bt-1"));
assert_eq!(result.start_date, Some(day(2024, 1, 1)));
assert_eq!(result.trials.len(), 1);
assert_eq!(
result.trials[0].profit_loss.expect("a P&L").to_string(),
"125.5"
);
assert_eq!(
result.snapshots[0].underlying_price.map(|p| p.to_string()),
Some("470.1".to_string())
);
assert_eq!(result.notices.len(), 1);
assert!(result.is_finished());
}
#[test]
fn an_unrecognised_status_is_not_treated_as_finished() {
let running: Backtest =
serde_json::from_str(r#"{"id": "bt-2", "status": "queued"}"#).expect("decodes");
assert!(!running.is_finished());
let unknown: Backtest =
serde_json::from_str(r#"{"id": "bt-3", "status": "reticulating splines"}"#)
.expect("decodes");
assert!(!unknown.is_finished());
let absent: Backtest = serde_json::from_str(r#"{"id": "bt-4"}"#).expect("decodes");
assert!(!absent.is_finished());
}
#[test]
fn the_backtester_host_is_the_one_the_document_declares() {
assert_eq!(
BACKTESTER_BASE_URL,
"https://backtester.vast.tastyworks.com"
);
}
}