- Reduce CI GPU test datasets 16x for walltime reduction - Reduce early-stop epochs 50→10, add --test-threads=1 - Serialize all GPU lib tests to prevent cuBLAS init race - Align state_dim to 16 for BF16 tensor core HMMA dispatch - BF16 precision tolerance in ml-dqn tests - Enable branching DQN + tracing subscriber in smoke tests - Prevent min_replay_size > buffer_size deadlock in early-stop tests - Prevent AutoReplaySizer from breaking gradient collapse warmup - Replace racy tokio::spawn checkpoint counter with AtomicUsize - Set warmup_steps=0 and max_training_steps_per_epoch=300 in early-stop tests - RealDataLoader respects TEST_DATA_DIR for CI PVC layout - Add collapse_warmup_capacity to gpu_smoketest DQNConfig - Drain CUDA context between test binaries - Detached HEAD checkout prevents local branch corruption - GPU pipeline tests: fix BF16 dtype and rank-1 squeeze assertions - OOD input handling tests use use_gpu: true Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
965 lines
33 KiB
Rust
965 lines
33 KiB
Rust
#![allow(
|
|
clippy::assertions_on_constants,
|
|
clippy::assertions_on_result_states,
|
|
clippy::clone_on_copy,
|
|
clippy::decimal_literal_representation,
|
|
clippy::doc_markdown,
|
|
clippy::empty_line_after_doc_comments,
|
|
clippy::field_reassign_with_default,
|
|
clippy::get_unwrap,
|
|
clippy::identity_op,
|
|
clippy::inconsistent_digit_grouping,
|
|
clippy::indexing_slicing,
|
|
clippy::integer_division,
|
|
clippy::len_zero,
|
|
clippy::let_underscore_must_use,
|
|
clippy::manual_div_ceil,
|
|
clippy::manual_let_else,
|
|
clippy::manual_range_contains,
|
|
clippy::modulo_arithmetic,
|
|
clippy::needless_range_loop,
|
|
clippy::non_ascii_literal,
|
|
clippy::redundant_clone,
|
|
clippy::shadow_reuse,
|
|
clippy::shadow_same,
|
|
clippy::shadow_unrelated,
|
|
clippy::single_match_else,
|
|
clippy::str_to_string,
|
|
clippy::string_slice,
|
|
clippy::tests_outside_test_module,
|
|
clippy::too_many_lines,
|
|
clippy::unnecessary_wraps,
|
|
clippy::unseparated_literal_suffix,
|
|
clippy::use_debug,
|
|
clippy::useless_vec,
|
|
clippy::wildcard_enum_match_arm,
|
|
clippy::else_if_without_else,
|
|
clippy::expect_used,
|
|
clippy::missing_const_for_fn,
|
|
clippy::similar_names,
|
|
clippy::type_complexity,
|
|
clippy::collapsible_else_if,
|
|
clippy::doc_lazy_continuation,
|
|
clippy::items_after_test_module,
|
|
clippy::map_clone,
|
|
clippy::multiple_unsafe_ops_per_block,
|
|
clippy::unwrap_or_default,
|
|
clippy::assign_op_pattern,
|
|
clippy::needless_borrow,
|
|
clippy::println_empty_string,
|
|
clippy::unnecessary_cast,
|
|
clippy::used_underscore_binding,
|
|
clippy::create_dir,
|
|
clippy::implicit_saturating_sub,
|
|
clippy::exit,
|
|
clippy::expect_fun_call,
|
|
clippy::too_many_arguments,
|
|
clippy::unnecessary_map_or,
|
|
clippy::unwrap_used,
|
|
dead_code,
|
|
unused_imports,
|
|
unused_variables,
|
|
clippy::cloned_ref_to_slice_refs,
|
|
clippy::neg_multiply,
|
|
clippy::while_let_loop,
|
|
clippy::bool_assert_comparison,
|
|
clippy::excessive_precision,
|
|
clippy::trivially_copy_pass_by_ref,
|
|
clippy::op_ref,
|
|
clippy::redundant_closure,
|
|
clippy::unnecessary_lazy_evaluations,
|
|
clippy::if_then_some_else_none,
|
|
clippy::unnecessary_to_owned,
|
|
clippy::single_component_path_imports,
|
|
)]
|
|
//! Comprehensive TDD Test Suite for Triple Barrier Labeling
|
|
//!
|
|
//! This test suite validates the triple barrier method implementation following
|
|
//! TDD methodology and MLFinLab research principles.
|
|
//!
|
|
//! ## Test Coverage
|
|
//! 1. **Profit Target Tests**: Upper barrier hit first
|
|
//! 2. **Stop Loss Tests**: Lower barrier hit first
|
|
//! 3. **Time Horizon Tests**: Expiry without barrier touch
|
|
//! 4. **Volatility-Based Barriers**: Dynamic barrier calculation
|
|
//! 5. **Edge Cases**: Gaps, extreme moves, simultaneous touches
|
|
//! 6. **Label Balance Tests**: Symmetric vs asymmetric barriers
|
|
//! 7. **Quality Score Tests**: Label quality metrics
|
|
//! 8. **Performance Tests**: <80μs latency target
|
|
|
|
use ml::labeling::{
|
|
triple_barrier::{BarrierTracker, PricePoint, TripleBarrierEngine},
|
|
types::{BarrierConfig, BarrierResult, BarrierTouchedFirst},
|
|
utils,
|
|
};
|
|
use tracing::info;
|
|
|
|
// ============================================================================
|
|
// TEST 1: Profit Target Hit First (Upper Barrier)
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_profit_target_hit_first() {
|
|
// GIVEN: A tracker with 1% profit target, 0.5% stop loss
|
|
let config = BarrierConfig::conservative(); // 100bps profit, 50bps stop
|
|
let entry_price = 100.00; // $100.00
|
|
let entry_price_cents = utils::price_to_cents(entry_price);
|
|
let entry_timestamp_ns = 1692000000_000_000_000;
|
|
|
|
let mut tracker = BarrierTracker::new(entry_price_cents, entry_timestamp_ns, config);
|
|
|
|
// WHEN: Price moves to $101.05 (above 1% profit target)
|
|
let profit_price = utils::price_to_cents(101.05);
|
|
let profit_timestamp = entry_timestamp_ns + 1_000_000_000; // +1 second
|
|
let price_point = PricePoint::new(profit_price, profit_timestamp);
|
|
|
|
// THEN: Label should be BUY (+1) with profit target result
|
|
let result = tracker.update(price_point);
|
|
assert!(result.is_some(), "Should return a label");
|
|
|
|
let label = result.unwrap();
|
|
assert_eq!(label.label_value, 1, "Should be BUY label");
|
|
assert!(matches!(label.barrier_result, BarrierResult::ProfitTarget));
|
|
assert!(label.return_bps > 0, "Return should be positive");
|
|
assert!(label.is_profitable());
|
|
assert_eq!(tracker.touched_first, Some(BarrierTouchedFirst::Upper));
|
|
}
|
|
|
|
#[test]
|
|
fn test_profit_target_exact_touch() {
|
|
// GIVEN: A tracker with 1% profit target
|
|
let config = BarrierConfig::conservative();
|
|
let entry_price_cents = 10000; // $100.00
|
|
let entry_timestamp_ns = 1692000000_000_000_000;
|
|
|
|
let mut tracker = BarrierTracker::new(entry_price_cents, entry_timestamp_ns, config);
|
|
|
|
// WHEN: Price touches exactly the upper barrier ($101.00)
|
|
let upper_barrier = tracker.upper_barrier_cents;
|
|
let price_point = PricePoint::new(upper_barrier, entry_timestamp_ns + 500_000_000);
|
|
|
|
// THEN: Should trigger profit target
|
|
let result = tracker.update(price_point);
|
|
assert!(result.is_some());
|
|
|
|
let label = result.unwrap();
|
|
assert_eq!(label.label_value, 1);
|
|
assert!(matches!(label.barrier_result, BarrierResult::ProfitTarget));
|
|
}
|
|
|
|
#[test]
|
|
fn test_profit_target_gap_up() {
|
|
// GIVEN: A tracker with 1% profit target
|
|
let config = BarrierConfig::conservative();
|
|
let entry_price_cents = 10000;
|
|
let entry_timestamp_ns = 1692000000_000_000_000;
|
|
|
|
let mut tracker = BarrierTracker::new(entry_price_cents, entry_timestamp_ns, config);
|
|
|
|
// WHEN: Price gaps up to $102.50 (far above profit target)
|
|
let gap_price = utils::price_to_cents(102.50);
|
|
let price_point = PricePoint::new(gap_price, entry_timestamp_ns + 100_000_000);
|
|
|
|
// THEN: Should still trigger profit target (not miss due to gap)
|
|
let result = tracker.update(price_point);
|
|
assert!(result.is_some());
|
|
|
|
let label = result.unwrap();
|
|
assert_eq!(label.label_value, 1);
|
|
assert!(label.return_bps > 100, "Return should be > 1%");
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEST 2: Stop Loss Hit First (Lower Barrier)
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_stop_loss_hit_first() {
|
|
// GIVEN: A tracker with 0.5% stop loss
|
|
let config = BarrierConfig::conservative();
|
|
let entry_price = 100.00;
|
|
let entry_price_cents = utils::price_to_cents(entry_price);
|
|
let entry_timestamp_ns = 1692000000_000_000_000;
|
|
|
|
let mut tracker = BarrierTracker::new(entry_price_cents, entry_timestamp_ns, config);
|
|
|
|
// WHEN: Price drops to $99.40 (below 0.5% stop loss)
|
|
let stop_price = utils::price_to_cents(99.40);
|
|
let stop_timestamp = entry_timestamp_ns + 2_000_000_000; // +2 seconds
|
|
let price_point = PricePoint::new(stop_price, stop_timestamp);
|
|
|
|
// THEN: Label should be SELL (-1) with stop loss result
|
|
let result = tracker.update(price_point);
|
|
assert!(result.is_some());
|
|
|
|
let label = result.unwrap();
|
|
assert_eq!(label.label_value, -1, "Should be SELL label");
|
|
assert!(matches!(label.barrier_result, BarrierResult::StopLoss));
|
|
assert!(label.return_bps < 0, "Return should be negative");
|
|
assert!(!label.is_profitable());
|
|
assert_eq!(tracker.touched_first, Some(BarrierTouchedFirst::Lower));
|
|
}
|
|
|
|
#[test]
|
|
fn test_stop_loss_exact_touch() {
|
|
// GIVEN: A tracker with 0.5% stop loss
|
|
let config = BarrierConfig::conservative();
|
|
let entry_price_cents = 10000;
|
|
let entry_timestamp_ns = 1692000000_000_000_000;
|
|
|
|
let mut tracker = BarrierTracker::new(entry_price_cents, entry_timestamp_ns, config);
|
|
|
|
// WHEN: Price touches exactly the lower barrier ($99.50)
|
|
let lower_barrier = tracker.lower_barrier_cents;
|
|
let price_point = PricePoint::new(lower_barrier, entry_timestamp_ns + 1_000_000_000);
|
|
|
|
// THEN: Should trigger stop loss
|
|
let result = tracker.update(price_point);
|
|
assert!(result.is_some());
|
|
|
|
let label = result.unwrap();
|
|
assert_eq!(label.label_value, -1);
|
|
assert!(matches!(label.barrier_result, BarrierResult::StopLoss));
|
|
}
|
|
|
|
#[test]
|
|
fn test_stop_loss_gap_down() {
|
|
// GIVEN: A tracker
|
|
let config = BarrierConfig::conservative();
|
|
let entry_price_cents = 10000;
|
|
let entry_timestamp_ns = 1692000000_000_000_000;
|
|
|
|
let mut tracker = BarrierTracker::new(entry_price_cents, entry_timestamp_ns, config);
|
|
|
|
// WHEN: Price gaps down to $97.00 (far below stop loss)
|
|
let gap_price = utils::price_to_cents(97.00);
|
|
let price_point = PricePoint::new(gap_price, entry_timestamp_ns + 50_000_000);
|
|
|
|
// THEN: Should still trigger stop loss
|
|
let result = tracker.update(price_point);
|
|
assert!(result.is_some());
|
|
|
|
let label = result.unwrap();
|
|
assert_eq!(label.label_value, -1);
|
|
assert!(label.return_bps < -50, "Return should be < -0.5%");
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEST 3: Time Horizon Expiry (No Barrier Touch)
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_time_expiry_no_barrier_touch() {
|
|
// GIVEN: A tracker with 1-hour time horizon
|
|
let config = BarrierConfig::conservative(); // 3600s = 1 hour
|
|
let entry_price_cents = 10000;
|
|
let entry_timestamp_ns = 1692000000_000_000_000;
|
|
|
|
let mut tracker = BarrierTracker::new(entry_price_cents, entry_timestamp_ns, config);
|
|
|
|
// WHEN: Price stays at $100.30 (within barriers) until expiry
|
|
let neutral_price = utils::price_to_cents(100.30);
|
|
let expiry_timestamp = entry_timestamp_ns + 3700_000_000_000; // 1 hour + 100s
|
|
let price_point = PricePoint::new(neutral_price, expiry_timestamp);
|
|
|
|
// THEN: Label should be HOLD (0) or BUY (1) depending on return sign
|
|
let result = tracker.update(price_point);
|
|
assert!(result.is_some());
|
|
|
|
let label = result.unwrap();
|
|
assert!(matches!(label.barrier_result, BarrierResult::TimeExpiry));
|
|
// Since price is above entry (100.30 > 100.00), label should be BUY (1)
|
|
assert_eq!(label.label_value, 1, "Positive return at expiry → BUY");
|
|
assert!(label.return_bps > 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_time_expiry_negative_return() {
|
|
// GIVEN: A tracker
|
|
let config = BarrierConfig::conservative();
|
|
let entry_price_cents = 10000;
|
|
let entry_timestamp_ns = 1692000000_000_000_000;
|
|
|
|
let mut tracker = BarrierTracker::new(entry_price_cents, entry_timestamp_ns, config);
|
|
|
|
// WHEN: Price is at $99.70 (negative but within stop loss) at expiry
|
|
let negative_price = utils::price_to_cents(99.70);
|
|
let expiry_timestamp = entry_timestamp_ns + 3700_000_000_000;
|
|
let price_point = PricePoint::new(negative_price, expiry_timestamp);
|
|
|
|
// THEN: Label should be SELL (-1) due to negative return
|
|
let result = tracker.update(price_point);
|
|
assert!(result.is_some());
|
|
|
|
let label = result.unwrap();
|
|
assert!(matches!(label.barrier_result, BarrierResult::TimeExpiry));
|
|
assert_eq!(label.label_value, -1, "Negative return at expiry → SELL");
|
|
assert!(label.return_bps < 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_time_expiry_exactly_zero_return() {
|
|
// GIVEN: A tracker
|
|
let config = BarrierConfig::conservative();
|
|
let entry_price_cents = 10000;
|
|
let entry_timestamp_ns = 1692000000_000_000_000;
|
|
|
|
let mut tracker = BarrierTracker::new(entry_price_cents, entry_timestamp_ns, config);
|
|
|
|
// WHEN: Price returns exactly to entry price at expiry
|
|
let same_price = 10000;
|
|
let expiry_timestamp = entry_timestamp_ns + 3700_000_000_000;
|
|
let price_point = PricePoint::new(same_price, expiry_timestamp);
|
|
|
|
// THEN: Label should be HOLD (0) due to zero return
|
|
let result = tracker.update(price_point);
|
|
assert!(result.is_some());
|
|
|
|
let label = result.unwrap();
|
|
assert!(matches!(label.barrier_result, BarrierResult::TimeExpiry));
|
|
assert_eq!(label.label_value, 0, "Zero return at expiry → HOLD");
|
|
assert_eq!(label.return_bps, 0);
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEST 4: Barrier Calculation (Volatility-Based)
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_barrier_calculation_conservative() {
|
|
// GIVEN: Conservative config (1% profit, 0.5% stop)
|
|
let config = BarrierConfig::conservative();
|
|
let entry_price_cents = 10000; // $100.00
|
|
let entry_timestamp_ns = 1692000000_000_000_000;
|
|
|
|
// WHEN: Creating tracker
|
|
let tracker = BarrierTracker::new(entry_price_cents, entry_timestamp_ns, config);
|
|
|
|
// THEN: Barriers should match expected values
|
|
assert_eq!(
|
|
tracker.upper_barrier_cents, 10100,
|
|
"Upper barrier should be +1% = $101.00"
|
|
);
|
|
assert_eq!(
|
|
tracker.lower_barrier_cents, 9950,
|
|
"Lower barrier should be -0.5% = $99.50"
|
|
);
|
|
assert_eq!(
|
|
tracker.expiry_timestamp_ns,
|
|
entry_timestamp_ns + 3600_000_000_000,
|
|
"Expiry should be 1 hour later"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_barrier_calculation_asymmetric() {
|
|
// GIVEN: Asymmetric config (2% profit, 1% stop)
|
|
let config = BarrierConfig {
|
|
profit_target_bps: 200,
|
|
stop_loss_bps: 100,
|
|
max_holding_period_ns: 1800_000_000_000, // 30 minutes
|
|
min_return_threshold_bps: 10,
|
|
use_sample_weights: true,
|
|
volatility_lookback_periods: Some(20),
|
|
};
|
|
let entry_price_cents = 50000; // $500.00
|
|
let entry_timestamp_ns = 1692000000_000_000_000;
|
|
|
|
// WHEN: Creating tracker
|
|
let tracker = BarrierTracker::new(entry_price_cents, entry_timestamp_ns, config);
|
|
|
|
// THEN: Barriers should match expected asymmetric values
|
|
assert_eq!(
|
|
tracker.upper_barrier_cents, 51000,
|
|
"Upper barrier should be +2% = $510.00"
|
|
);
|
|
assert_eq!(
|
|
tracker.lower_barrier_cents, 49500,
|
|
"Lower barrier should be -1% = $495.00"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_barrier_calculation_edge_case_low_price() {
|
|
// GIVEN: Low price stock ($0.50)
|
|
let config = BarrierConfig::conservative();
|
|
let entry_price_cents = 50; // $0.50
|
|
let entry_timestamp_ns = 1692000000_000_000_000;
|
|
|
|
// WHEN: Creating tracker
|
|
let tracker = BarrierTracker::new(entry_price_cents, entry_timestamp_ns, config);
|
|
|
|
// THEN: Barriers should still be calculated correctly
|
|
// Upper: $0.50 * 1.01 = $0.505 (rounded to 50 cents due to integer math)
|
|
// Lower: $0.50 * 0.995 = $0.4975 (rounded to 49 cents)
|
|
assert!(
|
|
tracker.upper_barrier_cents >= entry_price_cents,
|
|
"Upper barrier should be >= entry"
|
|
);
|
|
assert!(
|
|
tracker.lower_barrier_cents <= entry_price_cents,
|
|
"Lower barrier should be <= entry"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEST 5: Edge Cases
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_multiple_updates_same_tracker() {
|
|
// GIVEN: A tracker
|
|
let config = BarrierConfig::conservative();
|
|
let mut tracker = BarrierTracker::new(10000, 1692000000_000_000_000, config);
|
|
|
|
// WHEN: Multiple price updates within barriers
|
|
let price1 = PricePoint::new(10020, 1692000000_000_000_000 + 100_000_000);
|
|
let price2 = PricePoint::new(10040, 1692000000_000_000_000 + 200_000_000);
|
|
let price3 = PricePoint::new(10060, 1692000000_000_000_000 + 300_000_000);
|
|
|
|
// THEN: Should return None until barrier is touched
|
|
assert!(tracker.update(price1).is_none());
|
|
assert!(tracker.update(price2).is_none());
|
|
assert!(tracker.update(price3).is_none());
|
|
assert!(!tracker.is_closed());
|
|
|
|
// AND WHEN: Final price hits profit target
|
|
let price_final = PricePoint::new(10150, 1692000000_000_000_000 + 400_000_000);
|
|
let result = tracker.update(price_final);
|
|
|
|
// THEN: Should return label
|
|
assert!(result.is_some());
|
|
assert!(tracker.is_closed());
|
|
}
|
|
|
|
#[test]
|
|
fn test_tracker_closed_after_barrier_touch() {
|
|
// GIVEN: A tracker that hit profit target
|
|
let config = BarrierConfig::conservative();
|
|
let mut tracker = BarrierTracker::new(10000, 1692000000_000_000_000, config);
|
|
|
|
let profit_price = PricePoint::new(10150, 1692000000_000_000_000 + 1_000_000_000);
|
|
let _label = tracker.update(profit_price);
|
|
|
|
assert!(tracker.is_closed());
|
|
|
|
// WHEN: Trying to update again
|
|
let new_price = PricePoint::new(10200, 1692000000_000_000_000 + 2_000_000_000);
|
|
let result = tracker.update(new_price);
|
|
|
|
// THEN: Should return None (tracker is closed)
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_extreme_volatility_scenario() {
|
|
// GIVEN: A tracker with tight barriers
|
|
let config = BarrierConfig {
|
|
profit_target_bps: 10, // 0.1%
|
|
stop_loss_bps: 10, // 0.1%
|
|
max_holding_period_ns: 60_000_000_000, // 1 minute
|
|
min_return_threshold_bps: 1,
|
|
use_sample_weights: true,
|
|
volatility_lookback_periods: Some(5),
|
|
};
|
|
let entry_price_cents = 10000;
|
|
let entry_timestamp_ns = 1692000000_000_000_000;
|
|
|
|
let mut tracker = BarrierTracker::new(entry_price_cents, entry_timestamp_ns, config);
|
|
|
|
// WHEN: Price moves very quickly to profit target
|
|
let fast_profit = PricePoint::new(10011, entry_timestamp_ns + 1_000_000); // 1ms later
|
|
let result = tracker.update(fast_profit);
|
|
|
|
// THEN: Should still capture the profit
|
|
assert!(result.is_some());
|
|
let label = result.unwrap();
|
|
assert_eq!(label.label_value, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_price_oscillation_around_entry() {
|
|
// GIVEN: A tracker
|
|
let config = BarrierConfig::conservative();
|
|
let mut tracker = BarrierTracker::new(10000, 1692000000_000_000_000, config);
|
|
|
|
// WHEN: Price oscillates but stays within barriers
|
|
let prices = vec![
|
|
PricePoint::new(10030, 1692000000_000_000_000 + 100_000_000),
|
|
PricePoint::new(9970, 1692000000_000_000_000 + 200_000_000),
|
|
PricePoint::new(10020, 1692000000_000_000_000 + 300_000_000),
|
|
PricePoint::new(9980, 1692000000_000_000_000 + 400_000_000),
|
|
];
|
|
|
|
// THEN: No labels should be generated
|
|
for price in prices {
|
|
let result = tracker.update(price);
|
|
assert!(result.is_none());
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEST 6: Label Balance (Symmetric vs Asymmetric Barriers)
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_symmetric_barriers_balance() {
|
|
// GIVEN: Symmetric barriers (equal profit and stop)
|
|
let config = BarrierConfig {
|
|
profit_target_bps: 100,
|
|
stop_loss_bps: 100,
|
|
max_holding_period_ns: 3600_000_000_000,
|
|
min_return_threshold_bps: 10,
|
|
use_sample_weights: true,
|
|
volatility_lookback_periods: Some(20),
|
|
};
|
|
|
|
// WHEN: Creating tracker
|
|
let entry_price_cents = 10000;
|
|
let entry_timestamp_ns = 1692000000_000_000_000;
|
|
let tracker = BarrierTracker::new(entry_price_cents, entry_timestamp_ns, config);
|
|
|
|
// THEN: Upper and lower barriers should be equidistant from entry
|
|
let upper_distance = tracker.upper_barrier_cents - entry_price_cents;
|
|
let lower_distance = entry_price_cents - tracker.lower_barrier_cents;
|
|
|
|
assert_eq!(
|
|
upper_distance, lower_distance,
|
|
"Symmetric barriers should have equal distance"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_asymmetric_barriers_reduce_false_positives() {
|
|
// GIVEN: Asymmetric barriers (profit > stop)
|
|
let config = BarrierConfig {
|
|
profit_target_bps: 200, // 2x the stop loss
|
|
stop_loss_bps: 100,
|
|
max_holding_period_ns: 3600_000_000_000,
|
|
min_return_threshold_bps: 10,
|
|
use_sample_weights: true,
|
|
volatility_lookback_periods: Some(20),
|
|
};
|
|
|
|
let entry_price_cents = 10000;
|
|
let entry_timestamp_ns = 1692000000_000_000_000;
|
|
let tracker = BarrierTracker::new(entry_price_cents, entry_timestamp_ns, config);
|
|
|
|
// THEN: Profit barrier should be farther from entry than stop loss
|
|
let upper_distance = tracker.upper_barrier_cents - entry_price_cents;
|
|
let lower_distance = entry_price_cents - tracker.lower_barrier_cents;
|
|
|
|
assert!(
|
|
upper_distance > lower_distance,
|
|
"Asymmetric barriers: profit target further than stop loss"
|
|
);
|
|
assert_eq!(upper_distance, lower_distance * 2);
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEST 7: Quality Score Validation
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_quality_score_profit_target() {
|
|
// GIVEN: A tracker that hits profit target
|
|
let config = BarrierConfig::conservative();
|
|
let mut tracker = BarrierTracker::new(10000, 1692000000_000_000_000, config);
|
|
|
|
// WHEN: Hitting profit target
|
|
let profit_price = PricePoint::new(10150, 1692000000_000_000_000 + 1_000_000_000);
|
|
let result = tracker.update(profit_price);
|
|
|
|
// THEN: Quality score should be high (0.9)
|
|
assert!(result.is_some());
|
|
let label = result.unwrap();
|
|
assert!(
|
|
label.quality_score >= 0.85,
|
|
"Profit targets should have high quality score"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_quality_score_stop_loss() {
|
|
// GIVEN: A tracker that hits stop loss
|
|
let config = BarrierConfig::conservative();
|
|
let mut tracker = BarrierTracker::new(10000, 1692000000_000_000_000, config);
|
|
|
|
// WHEN: Hitting stop loss
|
|
let stop_price = PricePoint::new(9940, 1692000000_000_000_000 + 1_000_000_000);
|
|
let result = tracker.update(stop_price);
|
|
|
|
// THEN: Quality score should be moderate (0.8)
|
|
assert!(result.is_some());
|
|
let label = result.unwrap();
|
|
assert!(
|
|
label.quality_score >= 0.75,
|
|
"Stop losses should have moderate quality score"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_quality_score_time_expiry() {
|
|
// GIVEN: A tracker that expires
|
|
let config = BarrierConfig::conservative();
|
|
let mut tracker = BarrierTracker::new(10000, 1692000000_000_000_000, config);
|
|
|
|
// WHEN: Time expiry with neutral price
|
|
let neutral_price = PricePoint::new(10020, 1692000000_000_000_000 + 3700_000_000_000);
|
|
let result = tracker.update(neutral_price);
|
|
|
|
// THEN: Quality score should be low (0.5)
|
|
assert!(result.is_some());
|
|
let label = result.unwrap();
|
|
assert!(
|
|
label.quality_score <= 0.6,
|
|
"Time expiry should have lower quality score"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEST 8: Engine Multi-Tracker Tests
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_engine_start_tracking() {
|
|
// GIVEN: An empty engine
|
|
let mut engine = TripleBarrierEngine::new(1000);
|
|
assert_eq!(engine.active_count(), 0);
|
|
|
|
// WHEN: Starting new tracker
|
|
let config = BarrierConfig::conservative();
|
|
let result = engine.start_tracking(config, 10000, 1692000000_000_000_000);
|
|
|
|
// THEN: Should return tracker ID and increment count
|
|
assert!(result.is_ok());
|
|
assert_eq!(engine.active_count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_engine_max_active_trackers() {
|
|
// GIVEN: An engine with max 2 trackers
|
|
let mut engine = TripleBarrierEngine::new(2);
|
|
|
|
// WHEN: Starting 2 trackers (should succeed)
|
|
let config = BarrierConfig::conservative();
|
|
let r1 = engine.start_tracking(config.clone(), 10000, 1692000000_000_000_000);
|
|
let r2 = engine.start_tracking(config.clone(), 10100, 1692000000_000_000_000);
|
|
|
|
assert!(r1.is_ok());
|
|
assert!(r2.is_ok());
|
|
assert_eq!(engine.active_count(), 2);
|
|
|
|
// WHEN: Starting 3rd tracker (should fail)
|
|
let r3 = engine.start_tracking(config, 10200, 1692000000_000_000_000);
|
|
|
|
// THEN: Should return error
|
|
assert!(r3.is_err());
|
|
assert_eq!(engine.active_count(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_engine_update_all() {
|
|
// GIVEN: Engine with multiple trackers at different entry prices
|
|
let mut engine = TripleBarrierEngine::new(100);
|
|
let config = BarrierConfig::conservative();
|
|
|
|
// Start 5 trackers with varying entry prices
|
|
for i in 0..5 {
|
|
let entry_price = 10000 + i * 100;
|
|
let _ = engine.start_tracking(config.clone(), entry_price, 1692000000_000_000_000);
|
|
}
|
|
|
|
assert_eq!(engine.active_count(), 5);
|
|
|
|
// WHEN: Price moves to a level that hits some profit targets
|
|
let price_point = PricePoint::new(10200, 1692000000_000_000_000 + 1_000_000_000);
|
|
let labels = engine.update_all(price_point);
|
|
|
|
// THEN: Some trackers should close (those with profit targets hit)
|
|
assert!(labels.len() > 0, "Should generate some labels");
|
|
assert!(engine.active_count() < 5, "Some trackers should be closed");
|
|
assert_eq!(engine.completed_count() as usize, labels.len());
|
|
}
|
|
|
|
#[test]
|
|
fn test_engine_expire_old_trackers() {
|
|
// GIVEN: Engine with trackers at entry time T0
|
|
let mut engine = TripleBarrierEngine::new(100);
|
|
let config = BarrierConfig::conservative(); // 1 hour max holding
|
|
let entry_timestamp = 1692000000_000_000_000;
|
|
|
|
// Start 3 trackers
|
|
for i in 0..3 {
|
|
let entry_price = 10000 + i * 100;
|
|
let _ = engine.start_tracking(config.clone(), entry_price, entry_timestamp);
|
|
}
|
|
|
|
// WHEN: Forcing expiry at T0 + 2 hours (past 1-hour limit)
|
|
let expiry_timestamp = entry_timestamp + 7200_000_000_000;
|
|
let expired_labels = engine.expire_old_trackers(expiry_timestamp);
|
|
|
|
// THEN: All 3 trackers should be expired
|
|
assert_eq!(expired_labels.len(), 3, "All trackers should expire");
|
|
assert_eq!(engine.active_count(), 0, "No active trackers left");
|
|
|
|
// All labels should be time expiry
|
|
for label in &expired_labels {
|
|
assert!(matches!(label.barrier_result, BarrierResult::TimeExpiry));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_engine_drain_completed_labels() {
|
|
// GIVEN: Engine with completed labels
|
|
let mut engine = TripleBarrierEngine::new(100);
|
|
let config = BarrierConfig::conservative();
|
|
|
|
// Start and complete a tracker
|
|
let _ = engine.start_tracking(config, 10000, 1692000000_000_000_000);
|
|
let price_point = PricePoint::new(10150, 1692000000_000_000_000 + 1_000_000_000);
|
|
let _labels = engine.update_all(price_point);
|
|
|
|
// WHEN: Draining completed labels
|
|
let drained = engine.drain_completed_labels();
|
|
|
|
// THEN: Should return labels and clear internal buffer
|
|
assert_eq!(drained.len(), 1);
|
|
|
|
// Draining again should return empty
|
|
let drained_again = engine.drain_completed_labels();
|
|
assert_eq!(drained_again.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_engine_get_tracker() {
|
|
// GIVEN: Engine with a tracker
|
|
let mut engine = TripleBarrierEngine::new(100);
|
|
let config = BarrierConfig::conservative();
|
|
let tracker_id = engine
|
|
.start_tracking(config, 10000, 1692000000_000_000_000)
|
|
.unwrap();
|
|
|
|
// WHEN: Getting tracker by ID
|
|
let tracker = engine.get_tracker(&tracker_id);
|
|
|
|
// THEN: Should return the tracker
|
|
assert!(tracker.is_some());
|
|
let tracker = tracker.unwrap();
|
|
assert_eq!(tracker.entry_price_cents, 10000);
|
|
assert!(!tracker.is_closed());
|
|
}
|
|
|
|
#[test]
|
|
fn test_engine_clear() {
|
|
// GIVEN: Engine with multiple active trackers
|
|
let mut engine = TripleBarrierEngine::new(100);
|
|
let config = BarrierConfig::conservative();
|
|
|
|
for i in 0..5 {
|
|
let _ = engine.start_tracking(config.clone(), 10000 + i * 100, 1692000000_000_000_000);
|
|
}
|
|
|
|
// WHEN: Clearing the engine
|
|
engine.clear();
|
|
|
|
// THEN: All trackers and stats should be reset
|
|
assert_eq!(engine.active_count(), 0);
|
|
assert_eq!(engine.completed_count(), 0);
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEST 9: Performance Tests (<80μs latency target)
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_latency_single_update() {
|
|
use std::time::Instant;
|
|
|
|
// GIVEN: A tracker
|
|
let config = BarrierConfig::conservative();
|
|
let mut tracker = BarrierTracker::new(10000, 1692000000_000_000_000, config);
|
|
|
|
// WHEN: Updating with a price point
|
|
let price_point = PricePoint::new(10150, 1692000000_000_000_000 + 1_000_000_000);
|
|
let start = Instant::now();
|
|
let result = tracker.update(price_point);
|
|
let elapsed_us = start.elapsed().as_micros();
|
|
|
|
// THEN: Should complete in <80μs
|
|
assert!(result.is_some());
|
|
assert!(
|
|
elapsed_us < 80,
|
|
"Single update should be <80μs, got {}μs",
|
|
elapsed_us
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_latency_engine_update_all() {
|
|
use std::time::Instant;
|
|
|
|
// GIVEN: Engine with 100 active trackers
|
|
let mut engine = TripleBarrierEngine::new(1000);
|
|
let config = BarrierConfig::conservative();
|
|
|
|
for i in 0..100 {
|
|
let _ = engine.start_tracking(
|
|
config.clone(),
|
|
10000 + (i % 20) * 10,
|
|
1692000000_000_000_000,
|
|
);
|
|
}
|
|
|
|
// WHEN: Updating all trackers
|
|
let price_point = PricePoint::new(10150, 1692000000_000_000_000 + 1_000_000_000);
|
|
let start = Instant::now();
|
|
let _labels = engine.update_all(price_point);
|
|
let elapsed_us = start.elapsed().as_micros();
|
|
|
|
// THEN: Should complete in reasonable time (<10ms for 100 trackers)
|
|
assert!(
|
|
elapsed_us < 10_000,
|
|
"Update 100 trackers should be <10ms, got {}μs",
|
|
elapsed_us
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_throughput_batch_processing() {
|
|
use std::time::Instant;
|
|
|
|
// GIVEN: Engine with many trackers
|
|
let mut engine = TripleBarrierEngine::new(10_000);
|
|
let config = BarrierConfig::conservative();
|
|
|
|
// Start 1000 trackers
|
|
for i in 0..1000 {
|
|
let _ = engine.start_tracking(
|
|
config.clone(),
|
|
10000 + (i % 50) * 10,
|
|
1692000000_000_000_000 + i * 1_000_000,
|
|
);
|
|
}
|
|
|
|
// WHEN: Processing 100 price updates
|
|
let start = Instant::now();
|
|
let mut total_labels = 0;
|
|
|
|
for i in 0..100 {
|
|
let price = 10000 + (i % 300);
|
|
let timestamp = 1692000000_000_000_000 + i * 10_000_000;
|
|
let price_point = PricePoint::new(price, timestamp);
|
|
let labels = engine.update_all(price_point);
|
|
total_labels += labels.len();
|
|
}
|
|
|
|
let elapsed_ms = start.elapsed().as_millis();
|
|
let throughput = (total_labels as f64 / elapsed_ms as f64) * 1000.0;
|
|
|
|
// THEN: Should achieve >10K labels/second
|
|
info!(
|
|
total_labels,
|
|
elapsed_ms,
|
|
labels_per_sec = format_args!("{:.0}", throughput),
|
|
"Processed labels"
|
|
);
|
|
assert!(
|
|
throughput > 10_000.0,
|
|
"Should achieve >10K labels/sec, got {:.0}",
|
|
throughput
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEST 10: Integration Tests (Real-World Scenarios)
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_realistic_trading_scenario() {
|
|
// GIVEN: A realistic trading scenario with ES futures
|
|
let config = BarrierConfig {
|
|
profit_target_bps: 50, // 0.5% profit target (realistic for ES)
|
|
stop_loss_bps: 25, // 0.25% stop loss (2:1 risk-reward)
|
|
max_holding_period_ns: 900_000_000_000, // 15 minutes
|
|
min_return_threshold_bps: 5,
|
|
use_sample_weights: true,
|
|
volatility_lookback_periods: Some(20),
|
|
};
|
|
|
|
let mut engine = TripleBarrierEngine::new(1000);
|
|
let entry_price = 475000; // ES at $4,750.00
|
|
let entry_timestamp = 1692000000_000_000_000;
|
|
|
|
// WHEN: Starting position
|
|
let tracker_id = engine
|
|
.start_tracking(config, entry_price, entry_timestamp)
|
|
.unwrap();
|
|
|
|
// Simulate price movement over 5 minutes (profit scenario)
|
|
let price_updates = vec![
|
|
(475100, entry_timestamp + 60_000_000_000), // +1 min: $4,751
|
|
(475200, entry_timestamp + 120_000_000_000), // +2 min: $4,752
|
|
(475300, entry_timestamp + 180_000_000_000), // +3 min: $4,753
|
|
(475400, entry_timestamp + 240_000_000_000), // +4 min: $4,754
|
|
(477500, entry_timestamp + 300_000_000_000), // +5 min: $4,775 (hit profit)
|
|
];
|
|
|
|
let mut final_label = None;
|
|
for (price, timestamp) in price_updates {
|
|
let price_point = PricePoint::new(price, timestamp);
|
|
if let Some(label) = engine.update_tracker(tracker_id, price_point) {
|
|
final_label = Some(label);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// THEN: Should hit profit target
|
|
assert!(final_label.is_some());
|
|
let label = final_label.unwrap();
|
|
assert_eq!(label.label_value, 1);
|
|
assert!(matches!(label.barrier_result, BarrierResult::ProfitTarget));
|
|
assert!(label.return_bps >= 50); // At least 0.5% return
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_validation() {
|
|
// GIVEN: Invalid config (stop loss >= profit target)
|
|
let invalid_config = BarrierConfig {
|
|
profit_target_bps: 50,
|
|
stop_loss_bps: 100, // Greater than profit target
|
|
max_holding_period_ns: 3600_000_000_000,
|
|
min_return_threshold_bps: 10,
|
|
use_sample_weights: true,
|
|
volatility_lookback_periods: Some(20),
|
|
};
|
|
|
|
// WHEN: Validating config
|
|
let result = invalid_config.validate();
|
|
|
|
// THEN: Should return error
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_return_calculation_accuracy() {
|
|
// GIVEN: A tracker
|
|
let config = BarrierConfig::conservative();
|
|
let entry_price_cents = 10000; // $100.00
|
|
let mut tracker = BarrierTracker::new(entry_price_cents, 1692000000_000_000_000, config);
|
|
|
|
// WHEN: Price moves to $102.50 (exactly +2.5%)
|
|
let profit_price = 10250;
|
|
let price_point = PricePoint::new(profit_price, 1692000000_000_000_000 + 1_000_000_000);
|
|
let result = tracker.update(price_point);
|
|
|
|
// THEN: Return should be exactly 250 bps (2.5%)
|
|
assert!(result.is_some());
|
|
let label = result.unwrap();
|
|
assert_eq!(label.return_bps, 250, "Return should be exactly 2.5%");
|
|
assert!(
|
|
(label.return_as_ratio() - 0.025).abs() < 1e-10,
|
|
"Return ratio should be 0.025"
|
|
);
|
|
}
|