Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6160a74d2a | |||
| d847cb5c28 | |||
| fa0b316a8b | |||
| 21786187c9 |
@@ -0,0 +1,52 @@
|
||||
//! Indicator metadata is versioned independently from the numerical kernel.
|
||||
use crate::factor_events::{CONTRACT, OPERATORS, TA_REV};
|
||||
use serde_json::{Value, json};
|
||||
use ta_lib::abstract_api::{self, OptInputType};
|
||||
|
||||
pub fn catalog() -> Value {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut implementation = Sha256::new();
|
||||
for file in [include_bytes!("factor_events.rs").as_slice(), include_bytes!("factor_cross_section.rs").as_slice(),
|
||||
include_bytes!("daily_patterns.rs").as_slice(),include_bytes!("market_event_context.rs").as_slice(),
|
||||
include_bytes!("session_events.rs").as_slice(),include_bytes!("pattern_context.rs").as_slice(),TA_REV.as_bytes()] {implementation.update(file);}
|
||||
let implementation_sha256=format!("{:x}",implementation.finalize());
|
||||
let indicators: Vec<Value> = abstract_api::funcs().map(|f| json!({
|
||||
"name":f.name, "group":format!("{:?}",f.group), "description":f.hint,
|
||||
"inputs":f.inputs.iter().map(|p|json!({"name":p.param_name,"kind":format!("{:?}",p.kind),"flags":p.flags.0})).collect::<Vec<_>>(),
|
||||
"parameters":f.opt_inputs.iter().map(|p|json!({"name":p.param_name,"label":p.display_name,"description":p.hint,"domain":parameter_domain(p.kind)})).collect::<Vec<_>>(),
|
||||
"outputs":f.outputs.iter().enumerate().map(|(i,p)|json!({"index":i,"name":p.param_name,"kind":format!("{:?}",p.kind)})).collect::<Vec<_>>(),
|
||||
"unstable_period":format!("{:?}",f.unst_id), "production_eligible":false,
|
||||
})).collect();
|
||||
json!({"contract":CONTRACT,"parameter_domain_contract":"fidc.indicator-parameter-domain/v1","expression_kernel_sha256":implementation_sha256,"library":{"name":"TA-Lib native Rust","revision":TA_REV,"license":"BSD-3-Clause"},
|
||||
"execution_context_contract":crate::pattern_context::CONTRACT,
|
||||
"execution_context_fields":crate::pattern_context::CONTEXT_FIELDS,
|
||||
"market_event_context_contract":crate::market_event_context::CONTRACT,
|
||||
"market_event_kernel_sha256":crate::market_event_context::implementation_sha256(),
|
||||
"market_event_common_fields":crate::market_event_context::COMMON_FIELDS,
|
||||
"market_event_industry_fields":crate::market_event_context::INDUSTRY_FIELDS,
|
||||
"session_events":crate::session_events::EVENTS,"session_event_contract":crate::session_events::CONTRACT,
|
||||
"indicators":indicators,"operators":OPERATORS,"cross_section_operators":crate::factor_cross_section::OPERATORS,"read_only":true,"live_routing":false,
|
||||
"policies":{"null":"unknown_not_false","warmup":"null_until_full_history","recursive_seed":"frozen_input_start",
|
||||
"breakout":"previous_window_excludes_current","boolean":"three_valued_logic","daily_execution":"next_completed_session",
|
||||
"minute_execution":"strictly_after_completed_bar","cross_section":"requires_separate_complete_universe_contract"}})
|
||||
}
|
||||
|
||||
pub(crate) fn parameter_domain(kind: OptInputType) -> Value {
|
||||
match kind {
|
||||
OptInputType::IntegerRange { min, max, default, .. } => json!({
|
||||
"value_type":"integer", "minimum":min, "maximum":max, "default":default,
|
||||
}),
|
||||
OptInputType::RealRange { min, max, default, precision, .. } => json!({
|
||||
"value_type":"number", "minimum":min, "maximum":max,
|
||||
"default":default, "display_precision":precision,
|
||||
}),
|
||||
OptInputType::IntegerList { values, default } => json!({
|
||||
"value_type":"integer", "default":default,
|
||||
"choices":values.iter().map(|(value,label)|json!({"value":value,"label":label})).collect::<Vec<_>>(),
|
||||
}),
|
||||
OptInputType::RealList { values, default } => json!({
|
||||
"value_type":"number", "default":default,
|
||||
"choices":values.iter().map(|(value,label)|json!({"value":value,"label":label})).collect::<Vec<_>>(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Causal, typed indicator/event expressions shared by research and trading.
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use ta_lib::{
|
||||
Core,
|
||||
@@ -82,7 +82,7 @@ pub struct Series {
|
||||
pub values: Vec<Option<f64>>,
|
||||
}
|
||||
|
||||
const OPERATORS: &[&str] = &[
|
||||
pub(crate) const OPERATORS: &[&str] = &[
|
||||
"GT",
|
||||
"GTE",
|
||||
"LT",
|
||||
@@ -168,33 +168,7 @@ const OPERATORS: &[&str] = &[
|
||||
"IF",
|
||||
];
|
||||
|
||||
pub fn catalog() -> Value {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut implementation = Sha256::new();
|
||||
for file in [include_bytes!("factor_events.rs").as_slice(), include_bytes!("factor_cross_section.rs").as_slice(),
|
||||
include_bytes!("daily_patterns.rs").as_slice(),include_bytes!("market_event_context.rs").as_slice(),
|
||||
include_bytes!("session_events.rs").as_slice(),include_bytes!("pattern_context.rs").as_slice(),TA_REV.as_bytes()] {implementation.update(file);}
|
||||
let implementation_sha256=format!("{:x}",implementation.finalize());
|
||||
let indicators: Vec<Value> = abstract_api::funcs().map(|f| json!({
|
||||
"name":f.name, "group":format!("{:?}",f.group), "description":f.hint,
|
||||
"inputs":f.inputs.iter().map(|p|json!({"name":p.param_name,"kind":format!("{:?}",p.kind),"flags":p.flags.0})).collect::<Vec<_>>(),
|
||||
"parameters":f.opt_inputs.iter().map(|p|json!({"name":p.param_name,"label":p.display_name,"description":p.hint,"domain":format!("{:?}",p.kind)})).collect::<Vec<_>>(),
|
||||
"outputs":f.outputs.iter().enumerate().map(|(i,p)|json!({"index":i,"name":p.param_name,"kind":format!("{:?}",p.kind)})).collect::<Vec<_>>(),
|
||||
"unstable_period":format!("{:?}",f.unst_id), "production_eligible":false,
|
||||
})).collect();
|
||||
json!({"contract":CONTRACT,"expression_kernel_sha256":implementation_sha256,"library":{"name":"TA-Lib native Rust","revision":TA_REV,"license":"BSD-3-Clause"},
|
||||
"execution_context_contract":crate::pattern_context::CONTRACT,
|
||||
"execution_context_fields":crate::pattern_context::CONTEXT_FIELDS,
|
||||
"market_event_context_contract":crate::market_event_context::CONTRACT,
|
||||
"market_event_kernel_sha256":crate::market_event_context::implementation_sha256(),
|
||||
"market_event_common_fields":crate::market_event_context::COMMON_FIELDS,
|
||||
"market_event_industry_fields":crate::market_event_context::INDUSTRY_FIELDS,
|
||||
"session_events":crate::session_events::EVENTS,"session_event_contract":crate::session_events::CONTRACT,
|
||||
"indicators":indicators,"operators":OPERATORS,"cross_section_operators":crate::factor_cross_section::OPERATORS,"read_only":true,"live_routing":false,
|
||||
"policies":{"null":"unknown_not_false","warmup":"null_until_full_history","recursive_seed":"frozen_input_start",
|
||||
"breakout":"previous_window_excludes_current","boolean":"three_valued_logic","daily_execution":"next_completed_session",
|
||||
"minute_execution":"strictly_after_completed_bar","cross_section":"requires_separate_complete_universe_contract"}})
|
||||
}
|
||||
pub use crate::factor_event_catalog::catalog;
|
||||
|
||||
impl Frame {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
@@ -958,124 +932,5 @@ fn operator(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn frame(values: Vec<Option<f64>>) -> Frame {
|
||||
let start = DateTime::parse_from_rfc3339("2026-09-01T15:30:00+08:00").unwrap();
|
||||
let times = (0..values.len())
|
||||
.map(|i| start + chrono::Duration::days(i as i64))
|
||||
.collect::<Vec<_>>();
|
||||
Frame {
|
||||
symbol: "TEST".into(),
|
||||
frequency: "1d".into(),
|
||||
decision_at: *times.last().unwrap(),
|
||||
available_at: times.clone(),
|
||||
timestamps: times,
|
||||
fields: BTreeMap::from([("close".into(), values)]),
|
||||
}
|
||||
}
|
||||
fn expr(v: Value) -> Expr {
|
||||
serde_json::from_value(v).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn ta_sma_real_values_and_parameter_validation() {
|
||||
let frame = frame(vec![Some(1.0), Some(2.0), Some(3.0), Some(4.0)]);
|
||||
let e = expr(
|
||||
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":3}}),
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate(&e, &frame).unwrap().values,
|
||||
vec![None, None, Some(2.0), Some(3.0)]
|
||||
);
|
||||
let bad = expr(
|
||||
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"period":3}}),
|
||||
);
|
||||
assert!(
|
||||
evaluate(&bad, &frame)
|
||||
.unwrap_err()
|
||||
.contains("parameter_unknown")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn cross_is_event_not_state_and_never_uses_future() {
|
||||
let f = frame(vec![
|
||||
Some(9.0),
|
||||
Some(10.0),
|
||||
Some(11.0),
|
||||
Some(12.0),
|
||||
Some(8.0),
|
||||
]);
|
||||
let e = expr(
|
||||
json!({"kind":"operator","name":"CROSS_ABOVE","args":[{"kind":"field","name":"close"},{"kind":"number","value":10.0}]}),
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate(&e, &f).unwrap().values,
|
||||
vec![None, Some(0.0), Some(1.0), Some(0.0), Some(0.0)]
|
||||
);
|
||||
let mut invalid = f.clone();
|
||||
invalid.available_at[4] = invalid.decision_at + chrono::Duration::seconds(1);
|
||||
assert!(evaluate(&e, &invalid).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn missing_is_not_zero_and_breakout_excludes_current() {
|
||||
let f = frame(vec![Some(1.0), Some(2.0), Some(3.0), None, Some(5.0)]);
|
||||
let e = expr(
|
||||
json!({"kind":"operator","name":"BREAK_HIGH","window":2,"args":[{"kind":"field","name":"close"}]}),
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate(&e, &f).unwrap().values,
|
||||
vec![None, None, Some(1.0), None, None]
|
||||
);
|
||||
let zero = expr(
|
||||
json!({"kind":"operator","name":"DIV","args":[{"kind":"field","name":"close"},{"kind":"number","value":0}]}),
|
||||
);
|
||||
assert!(
|
||||
evaluate(&zero, &f)
|
||||
.unwrap()
|
||||
.values
|
||||
.iter()
|
||||
.all(Option::is_none)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn ta_rewarms_after_gap_and_const_zscore_is_unknown() {
|
||||
let f = frame(vec![Some(1.0), Some(1.0), None, Some(2.0), Some(2.0)]);
|
||||
let e = expr(
|
||||
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":2}}),
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate(&e, &f).unwrap().values,
|
||||
vec![None, Some(1.0), None, None, Some(2.0)]
|
||||
);
|
||||
let e = expr(
|
||||
json!({"kind":"operator","name":"ZSCORE","window":2,"args":[{"kind":"field","name":"close"}]}),
|
||||
);
|
||||
assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none));
|
||||
}
|
||||
#[test]
|
||||
fn no_event_has_no_bars_since_and_type_errors_reject() {
|
||||
let f = frame(vec![Some(1.0), Some(1.0), Some(1.0)]);
|
||||
let state = json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":5}]});
|
||||
let e = expr(json!({"kind":"operator","name":"BARS_SINCE","args":[state]}));
|
||||
assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none));
|
||||
assert!(
|
||||
evaluate(
|
||||
&expr(
|
||||
json!({"kind":"operator","name":"NOT","args":[{"kind":"field","name":"close"}]})
|
||||
),
|
||||
&f
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn literal_unknown_fields_reject_and_catalog_is_not_trading_permission() {
|
||||
assert!(
|
||||
serde_json::from_value::<Expr>(json!({"kind":"number","value":1,"account_id":2}))
|
||||
.is_err()
|
||||
);
|
||||
let c = catalog();
|
||||
assert!(c["indicators"].as_array().unwrap().len() > 190);
|
||||
assert_eq!(c["live_routing"], false);
|
||||
}
|
||||
}
|
||||
#[path = "factor_events_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use crate::factor_event_catalog::parameter_domain;
|
||||
|
||||
#[test]
|
||||
fn every_parameter_domain_is_structured_and_matches_native_defaults() {
|
||||
for function in abstract_api::funcs() {
|
||||
let handle = abstract_api::get_func_handle(function.name).unwrap();
|
||||
let core = Core::new();
|
||||
let mut call = handle.new_call(&core);
|
||||
for (index, parameter) in function.opt_inputs.iter().enumerate() {
|
||||
let domain = parameter_domain(parameter.kind);
|
||||
let default = domain["default"].as_f64().unwrap();
|
||||
assert!(default.is_finite(), "{} {}", function.name, parameter.param_name);
|
||||
if let Some(choices) = domain.get("choices") {
|
||||
assert!(choices.as_array().unwrap().iter().any(|v| v["value"].as_f64() == Some(default)));
|
||||
} else {
|
||||
assert!(default >= domain["minimum"].as_f64().unwrap());
|
||||
assert!(default <= domain["maximum"].as_f64().unwrap());
|
||||
}
|
||||
if domain["value_type"] == "integer" {
|
||||
assert_eq!(default.fract(), 0.0);
|
||||
call.set_opt(index, default as i32).unwrap();
|
||||
} else {
|
||||
call.set_opt(index, default).unwrap();
|
||||
}
|
||||
}
|
||||
assert!(call.lookback().is_ok(), "{}", function.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameter_domains_keep_enumeration_labels_without_debug_string_parsing() {
|
||||
let catalog = catalog();
|
||||
assert_eq!(catalog["parameter_domain_contract"], "fidc.indicator-parameter-domain/v1");
|
||||
let indicators = catalog["indicators"].as_array().unwrap();
|
||||
let rsi = indicators.iter().find(|v| v["name"] == "RSI").unwrap();
|
||||
assert_eq!(rsi["parameters"][0]["domain"]["minimum"], 2);
|
||||
let stoch = indicators.iter().find(|v| v["name"] == "STOCH").unwrap();
|
||||
let ma_type = stoch["parameters"].as_array().unwrap().iter().find(|p| p["name"] == "optInSlowK_MAType").unwrap();
|
||||
assert!(ma_type["domain"]["choices"].as_array().unwrap().iter().any(|v| v["label"] == "EMA" && v["value"] == 1));
|
||||
}
|
||||
|
||||
fn frame(values: Vec<Option<f64>>) -> Frame {
|
||||
let start = DateTime::parse_from_rfc3339("2026-09-01T15:30:00+08:00").unwrap();
|
||||
let times = (0..values.len())
|
||||
.map(|i| start + chrono::Duration::days(i as i64))
|
||||
.collect::<Vec<_>>();
|
||||
Frame {
|
||||
symbol: "TEST".into(),
|
||||
frequency: "1d".into(),
|
||||
decision_at: *times.last().unwrap(),
|
||||
available_at: times.clone(),
|
||||
timestamps: times,
|
||||
fields: BTreeMap::from([("close".into(), values)]),
|
||||
}
|
||||
}
|
||||
fn expr(v: Value) -> Expr {
|
||||
serde_json::from_value(v).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn ta_sma_real_values_and_parameter_validation() {
|
||||
let frame = frame(vec![Some(1.0), Some(2.0), Some(3.0), Some(4.0)]);
|
||||
let e = expr(
|
||||
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":3}}),
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate(&e, &frame).unwrap().values,
|
||||
vec![None, None, Some(2.0), Some(3.0)]
|
||||
);
|
||||
let bad = expr(
|
||||
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"period":3}}),
|
||||
);
|
||||
assert!(
|
||||
evaluate(&bad, &frame)
|
||||
.unwrap_err()
|
||||
.contains("parameter_unknown")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn cross_is_event_not_state_and_never_uses_future() {
|
||||
let f = frame(vec![
|
||||
Some(9.0),
|
||||
Some(10.0),
|
||||
Some(11.0),
|
||||
Some(12.0),
|
||||
Some(8.0),
|
||||
]);
|
||||
let e = expr(
|
||||
json!({"kind":"operator","name":"CROSS_ABOVE","args":[{"kind":"field","name":"close"},{"kind":"number","value":10.0}]}),
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate(&e, &f).unwrap().values,
|
||||
vec![None, Some(0.0), Some(1.0), Some(0.0), Some(0.0)]
|
||||
);
|
||||
let mut invalid = f.clone();
|
||||
invalid.available_at[4] = invalid.decision_at + chrono::Duration::seconds(1);
|
||||
assert!(evaluate(&e, &invalid).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn missing_is_not_zero_and_breakout_excludes_current() {
|
||||
let f = frame(vec![Some(1.0), Some(2.0), Some(3.0), None, Some(5.0)]);
|
||||
let e = expr(
|
||||
json!({"kind":"operator","name":"BREAK_HIGH","window":2,"args":[{"kind":"field","name":"close"}]}),
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate(&e, &f).unwrap().values,
|
||||
vec![None, None, Some(1.0), None, None]
|
||||
);
|
||||
let zero = expr(
|
||||
json!({"kind":"operator","name":"DIV","args":[{"kind":"field","name":"close"},{"kind":"number","value":0}]}),
|
||||
);
|
||||
assert!(
|
||||
evaluate(&zero, &f)
|
||||
.unwrap()
|
||||
.values
|
||||
.iter()
|
||||
.all(Option::is_none)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn ta_rewarms_after_gap_and_const_zscore_is_unknown() {
|
||||
let f = frame(vec![Some(1.0), Some(1.0), None, Some(2.0), Some(2.0)]);
|
||||
let e = expr(
|
||||
json!({"kind":"indicator","name":"SMA","inputs":[{"kind":"field","name":"close"}],"parameters":{"optInTimePeriod":2}}),
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate(&e, &f).unwrap().values,
|
||||
vec![None, Some(1.0), None, None, Some(2.0)]
|
||||
);
|
||||
let e = expr(
|
||||
json!({"kind":"operator","name":"ZSCORE","window":2,"args":[{"kind":"field","name":"close"}]}),
|
||||
);
|
||||
assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none));
|
||||
}
|
||||
#[test]
|
||||
fn no_event_has_no_bars_since_and_type_errors_reject() {
|
||||
let f = frame(vec![Some(1.0), Some(1.0), Some(1.0)]);
|
||||
let state = json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":5}]});
|
||||
let e = expr(json!({"kind":"operator","name":"BARS_SINCE","args":[state]}));
|
||||
assert!(evaluate(&e, &f).unwrap().values.iter().all(Option::is_none));
|
||||
assert!(
|
||||
evaluate(
|
||||
&expr(
|
||||
json!({"kind":"operator","name":"NOT","args":[{"kind":"field","name":"close"}]})
|
||||
),
|
||||
&f
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn literal_unknown_fields_reject_and_catalog_is_not_trading_permission() {
|
||||
assert!(
|
||||
serde_json::from_value::<Expr>(json!({"kind":"number","value":1,"account_id":2}))
|
||||
.is_err()
|
||||
);
|
||||
let c = catalog();
|
||||
assert!(c["indicators"].as_array().unwrap().len() > 190);
|
||||
assert_eq!(c["live_routing"], false);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub mod daily_patterns;
|
||||
pub mod pattern_context;
|
||||
pub mod session_events;
|
||||
pub mod factor_events;
|
||||
mod factor_event_catalog;
|
||||
pub mod factor_cross_section;
|
||||
pub mod market_event_context;
|
||||
pub mod engine;
|
||||
|
||||
@@ -558,7 +558,9 @@ fn alpha_beta(
|
||||
}
|
||||
|
||||
fn drawdown_stats(nav: &[f64]) -> (f64, usize) {
|
||||
let mut peak = 0.0_f64;
|
||||
// NAV is measured against the pre-period capital. The first real loss
|
||||
// must not become a new zero-drawdown baseline.
|
||||
let mut peak = 1.0_f64;
|
||||
let mut max_drawdown = 0.0_f64;
|
||||
let mut duration = 0_usize;
|
||||
let mut max_duration = 0_usize;
|
||||
@@ -767,6 +769,28 @@ fn safe_div(numerator: f64, denominator: f64, fallback: f64) -> f64 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn drawdown_includes_initial_nav_without_adding_a_trading_day() {
|
||||
let (drawdown, duration) = drawdown_stats(&[0.9, 0.99]);
|
||||
assert!((drawdown + 0.1).abs() < 1e-12);
|
||||
assert_eq!(duration, 2);
|
||||
assert_eq!(drawdown_stats(&[1.0, 1.1, 1.1]), (0.0, 0));
|
||||
assert_eq!(drawdown_stats(&[0.0]), (-1.0, 1));
|
||||
assert_eq!(drawdown_stats(&[]), (0.0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_day_loss_is_preserved_in_shared_backtest_metrics() {
|
||||
let curve = vec![
|
||||
equity_point("2025-01-03", 99.16648349337, 98.81608059815, 100.0),
|
||||
equity_point("2025-01-06", 99.68551588547, 98.65392198168, 98.81608059815),
|
||||
];
|
||||
let metrics = compute_backtest_metrics(&curve, &[], &[], &[], 100.0, None).unwrap();
|
||||
assert!((metrics.max_drawdown + 0.0083351650663).abs() < 1e-12);
|
||||
assert_eq!(metrics.total_trade_days, 2);
|
||||
assert_eq!(metrics.max_drawdown_duration_days, 2);
|
||||
}
|
||||
|
||||
fn equity_point(
|
||||
date: &str,
|
||||
total_equity: f64,
|
||||
|
||||
Reference in New Issue
Block a user