Compare commits
6 Commits
edc655ca2c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| eda716edad | |||
| 84461319a0 | |||
| 4476c04512 | |||
| 62847846d0 | |||
|
|
0e820852fa | ||
|
|
79816b9e2e |
@@ -1,19 +1,19 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -e
|
set -e
|
||||||
if [[ ! -d "/home/work/Documents/rust/invest-bot" ]]; then
|
if [[ ! -d "/home/mrfluffy/Documents/projects/rust/vibe-invest" ]]; then
|
||||||
echo "Cannot find source directory; Did you move it?"
|
echo "Cannot find source directory; Did you move it?"
|
||||||
echo "(Looking for "/home/work/Documents/rust/invest-bot")"
|
echo "(Looking for "/home/mrfluffy/Documents/projects/rust/vibe-invest")"
|
||||||
echo 'Cannot force reload with this script - use "direnv reload" manually and then try again'
|
echo 'Cannot force reload with this script - use "direnv reload" manually and then try again'
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# rebuild the cache forcefully
|
# rebuild the cache forcefully
|
||||||
_nix_direnv_force_reload=1 direnv exec "/home/work/Documents/rust/invest-bot" true
|
_nix_direnv_force_reload=1 direnv exec "/home/mrfluffy/Documents/projects/rust/vibe-invest" true
|
||||||
|
|
||||||
# Update the mtime for .envrc.
|
# Update the mtime for .envrc.
|
||||||
# This will cause direnv to reload again - but without re-building.
|
# This will cause direnv to reload again - but without re-building.
|
||||||
touch "/home/work/Documents/rust/invest-bot/.envrc"
|
touch "/home/mrfluffy/Documents/projects/rust/vibe-invest/.envrc"
|
||||||
|
|
||||||
# Also update the timestamp of whatever profile_rc we have.
|
# Also update the timestamp of whatever profile_rc we have.
|
||||||
# This makes sure that we know we are up to date.
|
# This makes sure that we know we are up to date.
|
||||||
touch -r "/home/work/Documents/rust/invest-bot/.envrc" "/home/work/Documents/rust/invest-bot/.direnv"/*.rc
|
touch -r "/home/mrfluffy/Documents/projects/rust/vibe-invest/.envrc" "/home/mrfluffy/Documents/projects/rust/vibe-invest/.direnv"/*.rc
|
||||||
|
|||||||
1
.direnv/flake-inputs/j9250k63yp54q9r2m0xnca8lxjcfadv0-source
Symbolic link
1
.direnv/flake-inputs/j9250k63yp54q9r2m0xnca8lxjcfadv0-source
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/j9250k63yp54q9r2m0xnca8lxjcfadv0-source
|
||||||
@@ -1 +0,0 @@
|
|||||||
/nix/store/vanbyn1mbsqmff9in675grd5lqpr69zl-source
|
|
||||||
@@ -41,7 +41,7 @@ NIX_ENFORCE_NO_NATIVE='1'
|
|||||||
export NIX_ENFORCE_NO_NATIVE
|
export NIX_ENFORCE_NO_NATIVE
|
||||||
NIX_HARDENING_ENABLE='bindnow format fortify fortify3 libcxxhardeningextensive libcxxhardeningfast pic relro stackclashprotection stackprotector strictoverflow zerocallusedregs'
|
NIX_HARDENING_ENABLE='bindnow format fortify fortify3 libcxxhardeningextensive libcxxhardeningfast pic relro stackclashprotection stackprotector strictoverflow zerocallusedregs'
|
||||||
export NIX_HARDENING_ENABLE
|
export NIX_HARDENING_ENABLE
|
||||||
NIX_LDFLAGS='-rpath /home/work/Documents/rust/invest-bot/outputs/out/lib -L/nix/store/2w8pppicnsddp3n61ar96cnv3r6iyrdh-rust-mixed/lib -L/nix/store/g7lir5wb7g1a31szgw6n5wa0kbsq04zd-openssl-3.6.0/lib -L/nix/store/2w8pppicnsddp3n61ar96cnv3r6iyrdh-rust-mixed/lib -L/nix/store/g7lir5wb7g1a31szgw6n5wa0kbsq04zd-openssl-3.6.0/lib'
|
NIX_LDFLAGS='-rpath /home/mrfluffy/Documents/projects/rust/vibe-invest/outputs/out/lib -L/nix/store/2w8pppicnsddp3n61ar96cnv3r6iyrdh-rust-mixed/lib -L/nix/store/g7lir5wb7g1a31szgw6n5wa0kbsq04zd-openssl-3.6.0/lib -L/nix/store/2w8pppicnsddp3n61ar96cnv3r6iyrdh-rust-mixed/lib -L/nix/store/g7lir5wb7g1a31szgw6n5wa0kbsq04zd-openssl-3.6.0/lib'
|
||||||
export NIX_LDFLAGS
|
export NIX_LDFLAGS
|
||||||
NIX_NO_SELF_RPATH='1'
|
NIX_NO_SELF_RPATH='1'
|
||||||
NIX_PKG_CONFIG_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu='1'
|
NIX_PKG_CONFIG_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu='1'
|
||||||
@@ -142,7 +142,7 @@ name='nix-shell-env'
|
|||||||
export name
|
export name
|
||||||
nativeBuildInputs='/nix/store/2w8pppicnsddp3n61ar96cnv3r6iyrdh-rust-mixed /nix/store/fgm3pz8486ksh3f94629lpb7xjr2wjp7-openssl-3.6.0-dev /nix/store/rvp7qlpf5jqvdckjy1afjb6aha6j8dxg-pkg-config-wrapper-0.29.2 /nix/store/fl02yv3ax1qf1xkq64ik8qz5bjxyyd71-cargo-deny-0.19.0 /nix/store/7va1z8il76ycxvyvgsbpr4bjk89lzj5a-cargo-edit-0.13.8 /nix/store/zrx7kmcgzax4s6fldam9hf6nmwcw5nks-cargo-watch-8.5.3 /nix/store/b42adwrm8v2lb1889x1zb8dxzf5ljqys-rust-analyzer-2026-02-02'
|
nativeBuildInputs='/nix/store/2w8pppicnsddp3n61ar96cnv3r6iyrdh-rust-mixed /nix/store/fgm3pz8486ksh3f94629lpb7xjr2wjp7-openssl-3.6.0-dev /nix/store/rvp7qlpf5jqvdckjy1afjb6aha6j8dxg-pkg-config-wrapper-0.29.2 /nix/store/fl02yv3ax1qf1xkq64ik8qz5bjxyyd71-cargo-deny-0.19.0 /nix/store/7va1z8il76ycxvyvgsbpr4bjk89lzj5a-cargo-edit-0.13.8 /nix/store/zrx7kmcgzax4s6fldam9hf6nmwcw5nks-cargo-watch-8.5.3 /nix/store/b42adwrm8v2lb1889x1zb8dxzf5ljqys-rust-analyzer-2026-02-02'
|
||||||
export nativeBuildInputs
|
export nativeBuildInputs
|
||||||
out='/home/work/Documents/rust/invest-bot/outputs/out'
|
out='/home/mrfluffy/Documents/projects/rust/vibe-invest/outputs/out'
|
||||||
export out
|
export out
|
||||||
outputBin='out'
|
outputBin='out'
|
||||||
outputDev='out'
|
outputDev='out'
|
||||||
@@ -173,7 +173,7 @@ preConfigurePhases=' updateAutotoolsGnuConfigScriptsPhase'
|
|||||||
declare -a preFixupHooks=('_moveToShare' '_multioutDocs' '_multioutDevs' )
|
declare -a preFixupHooks=('_moveToShare' '_multioutDocs' '_multioutDevs' )
|
||||||
preferLocalBuild='1'
|
preferLocalBuild='1'
|
||||||
export preferLocalBuild
|
export preferLocalBuild
|
||||||
prefix='/home/work/Documents/rust/invest-bot/outputs/out'
|
prefix='/home/mrfluffy/Documents/projects/rust/vibe-invest/outputs/out'
|
||||||
declare -a propagatedBuildDepFiles=('propagated-build-build-deps' 'propagated-native-build-inputs' 'propagated-build-target-deps' )
|
declare -a propagatedBuildDepFiles=('propagated-build-build-deps' 'propagated-native-build-inputs' 'propagated-build-target-deps' )
|
||||||
propagatedBuildInputs=''
|
propagatedBuildInputs=''
|
||||||
export propagatedBuildInputs
|
export propagatedBuildInputs
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ cargo run --release -- --backtest --years 3
|
|||||||
cargo run --release -- --backtest --years 5 --capital 50000
|
cargo run --release -- --backtest --years 5 --capital 50000
|
||||||
cargo run --release -- --backtest --years 1 --months 6 --timeframe hourly
|
cargo run --release -- --backtest --years 1 --months 6 --timeframe hourly
|
||||||
|
|
||||||
|
# Run backtesting with custom date range
|
||||||
|
cargo run --release -- --backtest --start-date 2007-01-01 --end-date 2008-12-31
|
||||||
|
cargo run --release -- --backtest --start-date 2020-03-01 --end-date 2020-12-31 --timeframe hourly
|
||||||
|
|
||||||
# Lint and format (available via nix flake)
|
# Lint and format (available via nix flake)
|
||||||
cargo clippy
|
cargo clippy
|
||||||
cargo fmt
|
cargo fmt
|
||||||
|
|||||||
135
src/alpaca.rs
135
src/alpaca.rs
@@ -632,3 +632,138 @@ pub async fn fetch_backtest_data(
|
|||||||
|
|
||||||
Ok(all_data)
|
Ok(all_data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Helper to fetch bars for backtesting with specific date range.
|
||||||
|
/// Similar to fetch_backtest_data but accepts explicit start/end dates.
|
||||||
|
pub async fn fetch_backtest_data_with_dates(
|
||||||
|
client: &AlpacaClient,
|
||||||
|
symbols: &[&str],
|
||||||
|
start: DateTime<Utc>,
|
||||||
|
end: DateTime<Utc>,
|
||||||
|
timeframe: Timeframe,
|
||||||
|
warmup_days: i64,
|
||||||
|
) -> Result<HashMap<String, Vec<Bar>>> {
|
||||||
|
// Add warmup period to start date
|
||||||
|
let start_with_warmup = start - Duration::days(warmup_days + 30);
|
||||||
|
|
||||||
|
// Re-fetch overlap: always re-fetch the last 2 days to handle partial/corrected bars
|
||||||
|
let refetch_overlap = Duration::days(2);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Fetching data from {} to {}...",
|
||||||
|
start_with_warmup.format("%Y-%m-%d"),
|
||||||
|
end.format("%Y-%m-%d")
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut all_data = HashMap::new();
|
||||||
|
let mut cache_hits = 0u32;
|
||||||
|
let mut cache_misses = 0u32;
|
||||||
|
|
||||||
|
for symbol in symbols {
|
||||||
|
let cached = load_cached_bars(symbol, timeframe);
|
||||||
|
|
||||||
|
if cached.is_empty() {
|
||||||
|
// Full fetch — no cache
|
||||||
|
cache_misses += 1;
|
||||||
|
tracing::info!(" Fetching {} (no cache)...", symbol);
|
||||||
|
|
||||||
|
match client
|
||||||
|
.get_historical_bars(symbol, timeframe, start_with_warmup, end)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(bars) => {
|
||||||
|
if !bars.is_empty() {
|
||||||
|
tracing::info!(" {}: {} bars fetched", symbol, bars.len());
|
||||||
|
save_cached_bars(symbol, timeframe, &bars);
|
||||||
|
all_data.insert(symbol.to_string(), bars);
|
||||||
|
} else {
|
||||||
|
tracing::warn!(" {}: No data", symbol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(" Failed to fetch {}: {}", symbol, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let first_cached_ts = cached.first().unwrap().timestamp;
|
||||||
|
let last_cached_ts = cached.last().unwrap().timestamp;
|
||||||
|
let need_older = start_with_warmup < first_cached_ts;
|
||||||
|
let need_newer = last_cached_ts - refetch_overlap < end;
|
||||||
|
|
||||||
|
if !need_older && !need_newer {
|
||||||
|
cache_hits += 1;
|
||||||
|
tracing::info!(" {}: {} bars from cache (fully cached)", symbol, cached.len());
|
||||||
|
all_data.insert(symbol.to_string(), cached);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
cache_hits += 1;
|
||||||
|
let mut merged = cached;
|
||||||
|
|
||||||
|
// Fetch older data if requested start is before earliest cache
|
||||||
|
if need_older {
|
||||||
|
let fetch_older_end = first_cached_ts + refetch_overlap;
|
||||||
|
tracing::info!(
|
||||||
|
" {} (fetching older: {} to {})...",
|
||||||
|
symbol,
|
||||||
|
start_with_warmup.format("%Y-%m-%d"),
|
||||||
|
fetch_older_end.format("%Y-%m-%d")
|
||||||
|
);
|
||||||
|
|
||||||
|
match client
|
||||||
|
.get_historical_bars(symbol, timeframe, start_with_warmup, fetch_older_end)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(old_bars) => {
|
||||||
|
merged = old_bars.into_iter().chain(merged).collect();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(" {}: older fetch failed: {}", symbol, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch newer data if cache doesn't cover requested end
|
||||||
|
if need_newer {
|
||||||
|
let fetch_from = last_cached_ts - refetch_overlap;
|
||||||
|
tracing::info!(
|
||||||
|
" {} (fetching newer: {} to {})...",
|
||||||
|
symbol,
|
||||||
|
fetch_from.format("%Y-%m-%d"),
|
||||||
|
end.format("%Y-%m-%d")
|
||||||
|
);
|
||||||
|
|
||||||
|
match client
|
||||||
|
.get_historical_bars(symbol, timeframe, fetch_from, end)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(new_bars) => {
|
||||||
|
// Remove the overlap region from merged before appending
|
||||||
|
merged.retain(|b| b.timestamp < fetch_from);
|
||||||
|
merged.extend(new_bars);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(" {}: newer fetch failed: {}", symbol, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedup and sort
|
||||||
|
merged.sort_by_key(|b| b.timestamp);
|
||||||
|
merged.dedup_by_key(|b| b.timestamp);
|
||||||
|
|
||||||
|
tracing::info!(" {}: {} bars total (merged)", symbol, merged.len());
|
||||||
|
save_cached_bars(symbol, timeframe, &merged);
|
||||||
|
all_data.insert(symbol.to_string(), merged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Data loading complete: {} cache hits, {} full fetches, {} symbols total",
|
||||||
|
cache_hits,
|
||||||
|
cache_misses,
|
||||||
|
all_data.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(all_data)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use anyhow::{Context, Result};
|
|||||||
use chrono::{DateTime, Datelike, Duration, NaiveDate, Timelike, Utc};
|
use chrono::{DateTime, Datelike, Duration, NaiveDate, Timelike, Utc};
|
||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
|
|
||||||
use crate::alpaca::{fetch_backtest_data, AlpacaClient};
|
use crate::alpaca::{fetch_backtest_data, fetch_backtest_data_with_dates, AlpacaClient};
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
get_all_symbols, get_sector, Timeframe, ATR_STOP_MULTIPLIER,
|
get_all_symbols, get_sector, Timeframe, ATR_STOP_MULTIPLIER,
|
||||||
ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER, HOURS_PER_DAY,
|
ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER, HOURS_PER_DAY,
|
||||||
@@ -16,9 +16,13 @@ use crate::config::{
|
|||||||
DRAWDOWN_TIER2_PCT, DRAWDOWN_TIER2_BARS,
|
DRAWDOWN_TIER2_PCT, DRAWDOWN_TIER2_BARS,
|
||||||
DRAWDOWN_TIER3_PCT, DRAWDOWN_TIER3_BARS,
|
DRAWDOWN_TIER3_PCT, DRAWDOWN_TIER3_BARS,
|
||||||
DRAWDOWN_TIER3_REQUIRE_BULL,
|
DRAWDOWN_TIER3_REQUIRE_BULL,
|
||||||
|
HOURLY_DRAWDOWN_TIER1_PCT, HOURLY_DRAWDOWN_TIER1_BARS,
|
||||||
|
HOURLY_DRAWDOWN_TIER2_PCT, HOURLY_DRAWDOWN_TIER2_BARS,
|
||||||
|
HOURLY_DRAWDOWN_TIER3_PCT, HOURLY_DRAWDOWN_TIER3_BARS,
|
||||||
EQUITY_CURVE_SMA_PERIOD,
|
EQUITY_CURVE_SMA_PERIOD,
|
||||||
REGIME_SPY_SYMBOL, REGIME_EMA_SHORT, REGIME_EMA_LONG,
|
REGIME_SPY_SYMBOL, REGIME_EMA_SHORT, REGIME_EMA_LONG,
|
||||||
REGIME_CAUTION_SIZE_FACTOR, REGIME_CAUTION_THRESHOLD_BUMP,
|
REGIME_CAUTION_SIZE_FACTOR, REGIME_CAUTION_THRESHOLD_BUMP,
|
||||||
|
HOURLY_REGIME_CAUTION_SIZE_FACTOR, HOURLY_REGIME_CAUTION_THRESHOLD_BUMP, ALLOW_LONGS_IN_BEAR_MARKET,
|
||||||
};
|
};
|
||||||
use crate::indicators::{calculate_all_indicators, calculate_ema, determine_market_regime, generate_signal};
|
use crate::indicators::{calculate_all_indicators, calculate_ema, determine_market_regime, generate_signal};
|
||||||
use crate::strategy::Strategy;
|
use crate::strategy::Strategy;
|
||||||
@@ -112,23 +116,41 @@ impl Backtester {
|
|||||||
///
|
///
|
||||||
/// On resume, the peak is reset to the current portfolio value to prevent
|
/// On resume, the peak is reset to the current portfolio value to prevent
|
||||||
/// cascading re-triggers from the same drawdown event.
|
/// cascading re-triggers from the same drawdown event.
|
||||||
|
/// Get the drawdown tier thresholds for the current timeframe.
|
||||||
|
fn drawdown_tiers(&self) -> (f64, usize, f64, usize, f64, usize) {
|
||||||
|
if self.timeframe == Timeframe::Hourly {
|
||||||
|
(
|
||||||
|
HOURLY_DRAWDOWN_TIER1_PCT, HOURLY_DRAWDOWN_TIER1_BARS,
|
||||||
|
HOURLY_DRAWDOWN_TIER2_PCT, HOURLY_DRAWDOWN_TIER2_BARS,
|
||||||
|
HOURLY_DRAWDOWN_TIER3_PCT, HOURLY_DRAWDOWN_TIER3_BARS,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
DRAWDOWN_TIER1_PCT, DRAWDOWN_TIER1_BARS,
|
||||||
|
DRAWDOWN_TIER2_PCT, DRAWDOWN_TIER2_BARS,
|
||||||
|
DRAWDOWN_TIER3_PCT, DRAWDOWN_TIER3_BARS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn update_drawdown_state(&mut self, portfolio_value: f64) {
|
fn update_drawdown_state(&mut self, portfolio_value: f64) {
|
||||||
if portfolio_value > self.peak_portfolio_value {
|
if portfolio_value > self.peak_portfolio_value {
|
||||||
self.peak_portfolio_value = portfolio_value;
|
self.peak_portfolio_value = portfolio_value;
|
||||||
}
|
}
|
||||||
|
|
||||||
let drawdown_pct = (self.peak_portfolio_value - portfolio_value) / self.peak_portfolio_value;
|
let drawdown_pct = (self.peak_portfolio_value - portfolio_value) / self.peak_portfolio_value;
|
||||||
|
let (t1_pct, t1_bars, t2_pct, t2_bars, t3_pct, t3_bars) = self.drawdown_tiers();
|
||||||
|
|
||||||
// Trigger halt at the lowest tier that matches (if not already halted)
|
// Trigger halt at the lowest tier that matches (if not already halted)
|
||||||
if !self.drawdown_halt && drawdown_pct >= DRAWDOWN_TIER1_PCT {
|
if !self.drawdown_halt && drawdown_pct >= t1_pct {
|
||||||
// Determine severity tier
|
// Determine severity tier
|
||||||
let (halt_bars, tier_name) = if drawdown_pct >= DRAWDOWN_TIER3_PCT {
|
let (halt_bars, tier_name) = if drawdown_pct >= t3_pct {
|
||||||
self.drawdown_requires_bull = DRAWDOWN_TIER3_REQUIRE_BULL;
|
self.drawdown_requires_bull = DRAWDOWN_TIER3_REQUIRE_BULL;
|
||||||
(DRAWDOWN_TIER3_BARS, "TIER 3 (SEVERE)")
|
(t3_bars, "TIER 3 (SEVERE)")
|
||||||
} else if drawdown_pct >= DRAWDOWN_TIER2_PCT {
|
} else if drawdown_pct >= t2_pct {
|
||||||
(DRAWDOWN_TIER2_BARS, "TIER 2")
|
(t2_bars, "TIER 2")
|
||||||
} else {
|
} else {
|
||||||
(DRAWDOWN_TIER1_BARS, "TIER 1")
|
(t1_bars, "TIER 1")
|
||||||
};
|
};
|
||||||
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -145,14 +167,14 @@ impl Backtester {
|
|||||||
|
|
||||||
// Upgrade severity if drawdown deepens while already halted
|
// Upgrade severity if drawdown deepens while already halted
|
||||||
if self.drawdown_halt && drawdown_pct > self.drawdown_halt_severity {
|
if self.drawdown_halt && drawdown_pct > self.drawdown_halt_severity {
|
||||||
if drawdown_pct >= DRAWDOWN_TIER3_PCT && self.drawdown_halt_severity < DRAWDOWN_TIER3_PCT {
|
if drawdown_pct >= t3_pct && self.drawdown_halt_severity < t3_pct {
|
||||||
self.drawdown_requires_bull = DRAWDOWN_TIER3_REQUIRE_BULL;
|
self.drawdown_requires_bull = DRAWDOWN_TIER3_REQUIRE_BULL;
|
||||||
self.drawdown_halt_start = Some(self.current_bar); // Reset timer for deeper tier
|
self.drawdown_halt_start = Some(self.current_bar); // Reset timer for deeper tier
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"Drawdown deepened to {:.2}% — UPGRADED to TIER 3. Requires BULL regime.",
|
"Drawdown deepened to {:.2}% — UPGRADED to TIER 3. Requires BULL regime.",
|
||||||
drawdown_pct * 100.0
|
drawdown_pct * 100.0
|
||||||
);
|
);
|
||||||
} else if drawdown_pct >= DRAWDOWN_TIER2_PCT && self.drawdown_halt_severity < DRAWDOWN_TIER2_PCT {
|
} else if drawdown_pct >= t2_pct && self.drawdown_halt_severity < t2_pct {
|
||||||
self.drawdown_halt_start = Some(self.current_bar);
|
self.drawdown_halt_start = Some(self.current_bar);
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"Drawdown deepened to {:.2}% — upgraded to TIER 2.",
|
"Drawdown deepened to {:.2}% — upgraded to TIER 2.",
|
||||||
@@ -165,12 +187,12 @@ impl Backtester {
|
|||||||
// Auto-resume after time-based cooldown
|
// Auto-resume after time-based cooldown
|
||||||
if self.drawdown_halt {
|
if self.drawdown_halt {
|
||||||
if let Some(halt_start) = self.drawdown_halt_start {
|
if let Some(halt_start) = self.drawdown_halt_start {
|
||||||
let required_bars = if self.drawdown_halt_severity >= DRAWDOWN_TIER3_PCT {
|
let required_bars = if self.drawdown_halt_severity >= t3_pct {
|
||||||
DRAWDOWN_TIER3_BARS
|
t3_bars
|
||||||
} else if self.drawdown_halt_severity >= DRAWDOWN_TIER2_PCT {
|
} else if self.drawdown_halt_severity >= t2_pct {
|
||||||
DRAWDOWN_TIER2_BARS
|
t2_bars
|
||||||
} else {
|
} else {
|
||||||
DRAWDOWN_TIER1_BARS
|
t1_bars
|
||||||
};
|
};
|
||||||
|
|
||||||
let time_served = self.current_bar >= halt_start + required_bars;
|
let time_served = self.current_bar >= halt_start + required_bars;
|
||||||
@@ -693,10 +715,17 @@ impl Backtester {
|
|||||||
self.current_regime = regime;
|
self.current_regime = regime;
|
||||||
|
|
||||||
// Regime-based sizing factor and threshold adjustment
|
// Regime-based sizing factor and threshold adjustment
|
||||||
|
// Use timeframe-specific parameters: hourly needs defensiveness, daily needs aggression
|
||||||
let regime_size_factor = match regime {
|
let regime_size_factor = match regime {
|
||||||
MarketRegime::Bull => 1.0,
|
MarketRegime::Bull => 1.0,
|
||||||
MarketRegime::Caution => REGIME_CAUTION_SIZE_FACTOR,
|
MarketRegime::Caution => {
|
||||||
MarketRegime::Bear => 0.0, // No new longs
|
if self.timeframe == Timeframe::Hourly {
|
||||||
|
HOURLY_REGIME_CAUTION_SIZE_FACTOR
|
||||||
|
} else {
|
||||||
|
REGIME_CAUTION_SIZE_FACTOR
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MarketRegime::Bear => if ALLOW_LONGS_IN_BEAR_MARKET { 1.0 } else { 0.0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Log regime changes (only on transitions)
|
// Log regime changes (only on transitions)
|
||||||
@@ -772,11 +801,17 @@ impl Backtester {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: Process buys (only for top momentum stocks)
|
// Phase 2: Process buys (only for top momentum stocks)
|
||||||
// In Bear regime, skip the entire buy phase (no new longs).
|
|
||||||
if regime.allows_new_longs() {
|
if regime.allows_new_longs() {
|
||||||
// In Caution regime, raise the buy threshold to require stronger signals
|
// In Caution regime, raise the buy threshold to require stronger signals
|
||||||
|
// Use timeframe-specific parameters: hourly needs high bump, daily needs low bump
|
||||||
let buy_threshold_bump = match regime {
|
let buy_threshold_bump = match regime {
|
||||||
MarketRegime::Caution => REGIME_CAUTION_THRESHOLD_BUMP,
|
MarketRegime::Caution => {
|
||||||
|
if self.timeframe == Timeframe::Hourly {
|
||||||
|
HOURLY_REGIME_CAUTION_THRESHOLD_BUMP
|
||||||
|
} else {
|
||||||
|
REGIME_CAUTION_THRESHOLD_BUMP
|
||||||
|
}
|
||||||
|
},
|
||||||
_ => 0.0,
|
_ => 0.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -885,6 +920,432 @@ impl Backtester {
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run the backtest simulation with specific date range.
|
||||||
|
pub async fn run_with_dates(
|
||||||
|
&mut self,
|
||||||
|
client: &AlpacaClient,
|
||||||
|
start_date: NaiveDate,
|
||||||
|
end_date: NaiveDate,
|
||||||
|
) -> Result<BacktestResult> {
|
||||||
|
// Convert dates to DateTime<Utc> for data fetching
|
||||||
|
let start_datetime = start_date
|
||||||
|
.and_hms_opt(0, 0, 0)
|
||||||
|
.unwrap()
|
||||||
|
.and_local_timezone(Utc)
|
||||||
|
.earliest()
|
||||||
|
.unwrap();
|
||||||
|
let end_datetime = end_date
|
||||||
|
.and_hms_opt(23, 59, 59)
|
||||||
|
.unwrap()
|
||||||
|
.and_local_timezone(Utc)
|
||||||
|
.latest()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Calculate years for metrics
|
||||||
|
let days_diff = (end_date - start_date).num_days();
|
||||||
|
let years = days_diff as f64 / 365.0;
|
||||||
|
|
||||||
|
let symbols = get_all_symbols();
|
||||||
|
|
||||||
|
// Calculate warmup period
|
||||||
|
let warmup_period = self.strategy.params.min_bars() + 10;
|
||||||
|
let warmup_calendar_days = if self.timeframe == Timeframe::Hourly {
|
||||||
|
(warmup_period as f64 / HOURS_PER_DAY as f64 * 1.5) as i64
|
||||||
|
} else {
|
||||||
|
(warmup_period as f64 * 1.5) as i64
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!("{}", "=".repeat(70));
|
||||||
|
tracing::info!("STARTING BACKTEST");
|
||||||
|
tracing::info!("Initial Capital: ${:.2}", self.initial_capital);
|
||||||
|
tracing::info!(
|
||||||
|
"Period: {} to {} ({:.2} years, {:.1} months)",
|
||||||
|
start_date.format("%Y-%m-%d"),
|
||||||
|
end_date.format("%Y-%m-%d"),
|
||||||
|
years,
|
||||||
|
years * 12.0
|
||||||
|
);
|
||||||
|
tracing::info!("Timeframe: {:?} bars", self.timeframe);
|
||||||
|
tracing::info!(
|
||||||
|
"Risk: ATR stops ({}x), trail ({}x after {}x gain), max {}% pos, {} max pos, {} max/sector, {} bar cooldown",
|
||||||
|
ATR_STOP_MULTIPLIER, ATR_TRAIL_MULTIPLIER, ATR_TRAIL_ACTIVATION_MULTIPLIER,
|
||||||
|
MAX_POSITION_SIZE * 100.0, MAX_CONCURRENT_POSITIONS, MAX_SECTOR_POSITIONS,
|
||||||
|
REENTRY_COOLDOWN_BARS
|
||||||
|
);
|
||||||
|
tracing::info!("Slippage: {} bps per trade", SLIPPAGE_BPS);
|
||||||
|
if self.timeframe == Timeframe::Hourly {
|
||||||
|
tracing::info!(
|
||||||
|
"Parameters scaled {}x (e.g., RSI: {}, EMA_TREND: {})",
|
||||||
|
HOURS_PER_DAY,
|
||||||
|
self.strategy.params.rsi_period,
|
||||||
|
self.strategy.params.ema_trend
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tracing::info!("{}", "=".repeat(70));
|
||||||
|
|
||||||
|
// Fetch historical data with custom date range
|
||||||
|
let raw_data = fetch_backtest_data_with_dates(
|
||||||
|
client,
|
||||||
|
&symbols.iter().map(|s| *s).collect::<Vec<_>>(),
|
||||||
|
start_datetime,
|
||||||
|
end_datetime,
|
||||||
|
self.timeframe,
|
||||||
|
warmup_calendar_days,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if raw_data.is_empty() {
|
||||||
|
anyhow::bail!("No historical data available for backtesting");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate indicators for all symbols
|
||||||
|
let mut data: HashMap<String, Vec<IndicatorRow>> = HashMap::new();
|
||||||
|
for (symbol, bars) in &raw_data {
|
||||||
|
let min_bars = self.strategy.params.min_bars();
|
||||||
|
if bars.len() < min_bars {
|
||||||
|
tracing::warn!(
|
||||||
|
"{}: Only {} bars, need {}. Skipping.",
|
||||||
|
symbol,
|
||||||
|
bars.len(),
|
||||||
|
min_bars
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let indicators = calculate_all_indicators(bars, &self.strategy.params);
|
||||||
|
data.insert(symbol.clone(), indicators);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-compute SPY regime EMAs for the entire backtest period.
|
||||||
|
let spy_key = REGIME_SPY_SYMBOL.to_string();
|
||||||
|
let spy_ema50_series: Vec<f64>;
|
||||||
|
let spy_ema200_series: Vec<f64>;
|
||||||
|
let has_spy_data = raw_data.contains_key(&spy_key);
|
||||||
|
|
||||||
|
if has_spy_data {
|
||||||
|
let spy_closes: Vec<f64> = raw_data[&spy_key].iter().map(|b| b.close).collect();
|
||||||
|
spy_ema50_series = calculate_ema(&spy_closes, REGIME_EMA_SHORT);
|
||||||
|
spy_ema200_series = calculate_ema(&spy_closes, REGIME_EMA_LONG);
|
||||||
|
tracing::info!(
|
||||||
|
"SPY regime filter: EMA-{} / EMA-{} ({} bars of SPY data)",
|
||||||
|
REGIME_EMA_SHORT, REGIME_EMA_LONG, spy_closes.len()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
spy_ema50_series = vec![];
|
||||||
|
spy_ema200_series = vec![];
|
||||||
|
tracing::warn!(
|
||||||
|
"SPY data not available — market regime filter DISABLED. \
|
||||||
|
All bars will be treated as BULL regime."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get common date range
|
||||||
|
let mut all_dates: BTreeMap<DateTime<Utc>, HashSet<String>> = BTreeMap::new();
|
||||||
|
for (symbol, rows) in &data {
|
||||||
|
for row in rows {
|
||||||
|
all_dates
|
||||||
|
.entry(row.timestamp)
|
||||||
|
.or_insert_with(HashSet::new)
|
||||||
|
.insert(symbol.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let all_dates: Vec<DateTime<Utc>> = all_dates.keys().copied().collect();
|
||||||
|
|
||||||
|
// Filter to only trade on requested period
|
||||||
|
let trading_dates: Vec<DateTime<Utc>> = all_dates
|
||||||
|
.iter()
|
||||||
|
.filter(|&&d| d >= start_datetime && d <= end_datetime)
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Ensure we have enough warmup
|
||||||
|
let trading_dates = if !trading_dates.is_empty() {
|
||||||
|
let first_trading_idx = all_dates
|
||||||
|
.iter()
|
||||||
|
.position(|&d| d == trading_dates[0])
|
||||||
|
.unwrap_or(0);
|
||||||
|
if first_trading_idx < warmup_period {
|
||||||
|
trading_dates
|
||||||
|
.into_iter()
|
||||||
|
.skip(warmup_period - first_trading_idx)
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
trading_dates
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
trading_dates
|
||||||
|
};
|
||||||
|
|
||||||
|
if trading_dates.is_empty() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"No trading days available after warmup. \n Try a longer backtest period (at least 4 months recommended)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"\nSimulating {} trading days (after {}-day warmup)...",
|
||||||
|
trading_dates.len(),
|
||||||
|
warmup_period
|
||||||
|
);
|
||||||
|
|
||||||
|
// From here on, the code is identical to the regular run() method
|
||||||
|
// Build index lookup for each symbol's data
|
||||||
|
let mut symbol_date_index: HashMap<String, HashMap<DateTime<Utc>, usize>> = HashMap::new();
|
||||||
|
for (symbol, rows) in &data {
|
||||||
|
let mut idx_map = HashMap::new();
|
||||||
|
for (i, row) in rows.iter().enumerate() {
|
||||||
|
idx_map.insert(row.timestamp, i);
|
||||||
|
}
|
||||||
|
symbol_date_index.insert(symbol.clone(), idx_map);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build SPY raw bar index
|
||||||
|
let spy_raw_date_index: HashMap<DateTime<Utc>, usize> = if has_spy_data {
|
||||||
|
raw_data[&spy_key]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, bar)| (bar.timestamp, i))
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
HashMap::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Main simulation loop (identical to run())
|
||||||
|
for (day_num, current_date) in trading_dates.iter().enumerate() {
|
||||||
|
self.current_bar = day_num;
|
||||||
|
self.new_positions_this_bar = 0;
|
||||||
|
self.prune_old_day_trades(current_date.date_naive());
|
||||||
|
|
||||||
|
// Get current prices and momentum for all symbols
|
||||||
|
let mut current_prices: HashMap<String, f64> = HashMap::new();
|
||||||
|
let mut momentum_scores: HashMap<String, f64> = HashMap::new();
|
||||||
|
|
||||||
|
for (symbol, rows) in &data {
|
||||||
|
if let Some(&idx) =
|
||||||
|
symbol_date_index.get(symbol).and_then(|m| m.get(current_date))
|
||||||
|
{
|
||||||
|
let row = &rows[idx];
|
||||||
|
current_prices.insert(symbol.clone(), row.close);
|
||||||
|
if !row.momentum.is_nan() {
|
||||||
|
momentum_scores.insert(symbol.clone(), row.momentum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let portfolio_value = self.get_portfolio_value(¤t_prices);
|
||||||
|
|
||||||
|
// SPY Market Regime Detection
|
||||||
|
let regime = if has_spy_data {
|
||||||
|
if let (Some(&spy_raw_idx), Some(spy_indicator_row)) = (
|
||||||
|
spy_raw_date_index.get(current_date),
|
||||||
|
data.get(&spy_key)
|
||||||
|
.and_then(|rows| {
|
||||||
|
symbol_date_index
|
||||||
|
.get(&spy_key)
|
||||||
|
.and_then(|m| m.get(current_date))
|
||||||
|
.map(|&i| &rows[i])
|
||||||
|
}),
|
||||||
|
) {
|
||||||
|
let ema50 = if spy_raw_idx < spy_ema50_series.len() {
|
||||||
|
spy_ema50_series[spy_raw_idx]
|
||||||
|
} else {
|
||||||
|
f64::NAN
|
||||||
|
};
|
||||||
|
let ema200 = if spy_raw_idx < spy_ema200_series.len() {
|
||||||
|
spy_ema200_series[spy_raw_idx]
|
||||||
|
} else {
|
||||||
|
f64::NAN
|
||||||
|
};
|
||||||
|
determine_market_regime(spy_indicator_row, ema50, ema200)
|
||||||
|
} else {
|
||||||
|
MarketRegime::Caution
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
MarketRegime::Bull
|
||||||
|
};
|
||||||
|
self.current_regime = regime;
|
||||||
|
|
||||||
|
// Regime-based sizing factor
|
||||||
|
let regime_size_factor = match regime {
|
||||||
|
MarketRegime::Bull => 1.0,
|
||||||
|
MarketRegime::Caution => REGIME_CAUTION_SIZE_FACTOR,
|
||||||
|
MarketRegime::Bear => if ALLOW_LONGS_IN_BEAR_MARKET { 1.0 } else { 0.0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
if day_num % 100 == 0 {
|
||||||
|
tracing::info!(" Market regime: {} (SPY)", regime.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update drawdown circuit breaker
|
||||||
|
self.update_drawdown_state(portfolio_value);
|
||||||
|
|
||||||
|
// Increment bars_held for all positions
|
||||||
|
for pos in self.positions.values_mut() {
|
||||||
|
pos.bars_held += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Momentum ranking
|
||||||
|
let mut ranked_symbols: Vec<String> = momentum_scores.keys().cloned().collect();
|
||||||
|
ranked_symbols.sort_by(|a, b| {
|
||||||
|
let ma = momentum_scores.get(a).unwrap_or(&0.0);
|
||||||
|
let mb = momentum_scores.get(b).unwrap_or(&0.0);
|
||||||
|
mb.partial_cmp(ma).unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
let top_momentum_symbols: HashSet<String> = ranked_symbols
|
||||||
|
.iter()
|
||||||
|
.take(TOP_MOMENTUM_COUNT)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Phase 1: Process sells
|
||||||
|
let position_symbols: Vec<String> = self.positions.keys().cloned().collect();
|
||||||
|
for symbol in position_symbols {
|
||||||
|
let rows = match data.get(&symbol) {
|
||||||
|
Some(r) => r,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let idx = match symbol_date_index
|
||||||
|
.get(&symbol)
|
||||||
|
.and_then(|m| m.get(current_date))
|
||||||
|
{
|
||||||
|
Some(&i) => i,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if idx < 1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_row = &rows[idx];
|
||||||
|
let previous_row = &rows[idx - 1];
|
||||||
|
|
||||||
|
if current_row.rsi.is_nan() || current_row.macd.is_nan() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut signal = generate_signal(&symbol, current_row, previous_row);
|
||||||
|
|
||||||
|
// Check stop-loss/take-profit/trailing stop/time exit
|
||||||
|
if let Some(sl_tp) =
|
||||||
|
self.check_stop_loss_take_profit(&symbol, signal.current_price)
|
||||||
|
{
|
||||||
|
signal.signal = sl_tp;
|
||||||
|
}
|
||||||
|
|
||||||
|
let was_stop_loss = matches!(signal.signal, Signal::StrongSell);
|
||||||
|
|
||||||
|
if signal.signal.is_sell() {
|
||||||
|
self.execute_sell(&symbol, signal.current_price, *current_date, was_stop_loss, portfolio_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Process buys
|
||||||
|
if regime.allows_new_longs() {
|
||||||
|
let buy_threshold_bump = match regime {
|
||||||
|
MarketRegime::Caution => REGIME_CAUTION_THRESHOLD_BUMP,
|
||||||
|
_ => 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for symbol in &ranked_symbols {
|
||||||
|
if symbol == REGIME_SPY_SYMBOL {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = match data.get(symbol) {
|
||||||
|
Some(r) => r,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !top_momentum_symbols.contains(symbol) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let idx = match symbol_date_index
|
||||||
|
.get(symbol)
|
||||||
|
.and_then(|m| m.get(current_date))
|
||||||
|
{
|
||||||
|
Some(&i) => i,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if idx < 1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_row = &rows[idx];
|
||||||
|
let previous_row = &rows[idx - 1];
|
||||||
|
|
||||||
|
if current_row.rsi.is_nan() || current_row.macd.is_nan() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let signal = generate_signal(symbol, current_row, previous_row);
|
||||||
|
|
||||||
|
let effective_buy = if buy_threshold_bump > 0.0 {
|
||||||
|
let approx_score = signal.confidence * 10.0;
|
||||||
|
approx_score >= (4.0 + buy_threshold_bump) && signal.signal.is_buy()
|
||||||
|
} else {
|
||||||
|
signal.signal.is_buy()
|
||||||
|
};
|
||||||
|
|
||||||
|
if effective_buy {
|
||||||
|
self.execute_buy(
|
||||||
|
symbol,
|
||||||
|
signal.current_price,
|
||||||
|
*current_date,
|
||||||
|
portfolio_value,
|
||||||
|
&signal,
|
||||||
|
regime_size_factor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record equity
|
||||||
|
self.equity_history.push(EquityPoint {
|
||||||
|
date: *current_date,
|
||||||
|
portfolio_value: self.get_portfolio_value(¤t_prices),
|
||||||
|
cash: self.cash,
|
||||||
|
positions_count: self.positions.len(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Progress update
|
||||||
|
if (day_num + 1) % 100 == 0 {
|
||||||
|
tracing::info!(
|
||||||
|
" Processed {}/{} days... Portfolio: ${:.2} (positions: {})",
|
||||||
|
day_num + 1,
|
||||||
|
trading_dates.len(),
|
||||||
|
self.equity_history
|
||||||
|
.last()
|
||||||
|
.map(|e| e.portfolio_value)
|
||||||
|
.unwrap_or(0.0),
|
||||||
|
self.positions.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close all remaining positions at final prices
|
||||||
|
let final_date = trading_dates.last().copied().unwrap_or_else(Utc::now);
|
||||||
|
let position_symbols: Vec<String> = self.positions.keys().cloned().collect();
|
||||||
|
|
||||||
|
for symbol in position_symbols {
|
||||||
|
if let Some(rows) = data.get(&symbol) {
|
||||||
|
if let Some(last_row) = rows.last() {
|
||||||
|
self.execute_sell(&symbol, last_row.close, final_date, false, f64::MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate results
|
||||||
|
let result = self.calculate_results(years)?;
|
||||||
|
|
||||||
|
// Print summary
|
||||||
|
self.print_summary(&result);
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
/// Calculate performance metrics from backtest.
|
/// Calculate performance metrics from backtest.
|
||||||
fn calculate_results(&self, years: f64) -> Result<BacktestResult> {
|
fn calculate_results(&self, years: f64) -> Result<BacktestResult> {
|
||||||
if self.equity_history.is_empty() {
|
if self.equity_history.is_empty() {
|
||||||
@@ -1097,15 +1558,13 @@ impl Backtester {
|
|||||||
" Max Per Sector: {:>15}",
|
" Max Per Sector: {:>15}",
|
||||||
MAX_SECTOR_POSITIONS
|
MAX_SECTOR_POSITIONS
|
||||||
);
|
);
|
||||||
|
{
|
||||||
|
let (t1p, t1b, t2p, t2b, t3p, t3b) = self.drawdown_tiers();
|
||||||
println!(
|
println!(
|
||||||
" Drawdown Halt: {:>13.0}%/{:.0}%/{:.0}% ({}/{}/{} bars)",
|
" Drawdown Halt: {:>13.0}%/{:.0}%/{:.0}% ({}/{}/{} bars)",
|
||||||
DRAWDOWN_TIER1_PCT * 100.0,
|
t1p * 100.0, t2p * 100.0, t3p * 100.0, t1b, t2b, t3b,
|
||||||
DRAWDOWN_TIER2_PCT * 100.0,
|
|
||||||
DRAWDOWN_TIER3_PCT * 100.0,
|
|
||||||
DRAWDOWN_TIER1_BARS,
|
|
||||||
DRAWDOWN_TIER2_BARS,
|
|
||||||
DRAWDOWN_TIER3_BARS,
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
println!(
|
println!(
|
||||||
" Market Regime Filter: {:>15}",
|
" Market Regime Filter: {:>15}",
|
||||||
format!("SPY EMA-{}/EMA-{}", REGIME_EMA_SHORT, REGIME_EMA_LONG)
|
format!("SPY EMA-{}/EMA-{}", REGIME_EMA_SHORT, REGIME_EMA_LONG)
|
||||||
|
|||||||
@@ -115,6 +115,14 @@ impl TradingBot {
|
|||||||
Ok(bot)
|
Ok(bot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_entry_atrs(&self) -> HashMap<String, f64> {
|
||||||
|
self.strategy.entry_atrs.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_high_water_marks(&self) -> HashMap<String, f64> {
|
||||||
|
self.strategy.high_water_marks.clone()
|
||||||
|
}
|
||||||
|
|
||||||
// ── Persistence helpers ──────────────────────────────────────────
|
// ── Persistence helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
fn load_json_map<V: serde::de::DeserializeOwned>(
|
fn load_json_map<V: serde::de::DeserializeOwned>(
|
||||||
|
|||||||
@@ -78,6 +78,16 @@ pub const RISK_PER_TRADE: f64 = 0.015; // 1.5% risk per trade (8 positions * 1.5
|
|||||||
pub const ATR_STOP_MULTIPLIER: f64 = 3.5; // Wide stops reduce false stop-outs (the #1 loss source)
|
pub const ATR_STOP_MULTIPLIER: f64 = 3.5; // Wide stops reduce false stop-outs (the #1 loss source)
|
||||||
pub const ATR_TRAIL_MULTIPLIER: f64 = 3.0; // Wide trail so winners run longer
|
pub const ATR_TRAIL_MULTIPLIER: f64 = 3.0; // Wide trail so winners run longer
|
||||||
pub const ATR_TRAIL_ACTIVATION_MULTIPLIER: f64 = 2.0; // Don't activate trail too early
|
pub const ATR_TRAIL_ACTIVATION_MULTIPLIER: f64 = 2.0; // Don't activate trail too early
|
||||||
|
// Tiered trailing stop: tight trail for small gains, wide trail for big gains
|
||||||
|
pub const EARLY_TRAIL_ACTIVATION_MULTIPLIER: f64 = 0.5; // Activate tight trail after 0.5x ATR gain
|
||||||
|
pub const EARLY_TRAIL_MULTIPLIER: f64 = 1.5; // Tight trail distance for small gains
|
||||||
|
// Breakeven protection: once in profit, don't let it become a big loss
|
||||||
|
pub const BREAKEVEN_ACTIVATION_PCT: f64 = 0.02; // Activate after 2% gain (meaningful, not noise)
|
||||||
|
pub const BREAKEVEN_MAX_LOSS_PCT: f64 = 0.005; // Once activated, don't give back more than 0.5% from entry
|
||||||
|
// Slow bleeder exit: cut losers that never showed promise
|
||||||
|
pub const SLOW_BLEED_BARS: usize = 20; // Grace period before checking
|
||||||
|
pub const SLOW_BLEED_MAX_LOSS: f64 = 0.02; // If down >2% after grace period and never up >1%, cut
|
||||||
|
pub const SLOW_BLEED_MIN_GAIN: f64 = 0.01; // Must have shown at least 1% gain to survive
|
||||||
// Portfolio-level controls
|
// Portfolio-level controls
|
||||||
pub const MAX_CONCURRENT_POSITIONS: usize = 8; // Fewer positions = higher conviction per trade
|
pub const MAX_CONCURRENT_POSITIONS: usize = 8; // Fewer positions = higher conviction per trade
|
||||||
pub const MAX_SECTOR_POSITIONS: usize = 2;
|
pub const MAX_SECTOR_POSITIONS: usize = 2;
|
||||||
@@ -102,11 +112,23 @@ pub const RAMPUP_PERIOD_BARS: usize = 15; // Faster ramp-up
|
|||||||
pub const REGIME_SPY_SYMBOL: &str = "SPY";
|
pub const REGIME_SPY_SYMBOL: &str = "SPY";
|
||||||
pub const REGIME_EMA_SHORT: usize = 50; // Fast regime EMA
|
pub const REGIME_EMA_SHORT: usize = 50; // Fast regime EMA
|
||||||
pub const REGIME_EMA_LONG: usize = 200; // Slow regime EMA (the "golden cross" line)
|
pub const REGIME_EMA_LONG: usize = 200; // Slow regime EMA (the "golden cross" line)
|
||||||
/// In Caution regime, multiply position size by this factor.
|
/// In Caution regime, multiply position size by this factor (DAILY bars).
|
||||||
/// Reduced from 0.5 to 0.25: the 2022 bear showed Caution still bleeds at 50% size.
|
/// Daily benefits from being more aggressive in Caution (60% size) to capture bull markets.
|
||||||
pub const REGIME_CAUTION_SIZE_FACTOR: f64 = 0.25;
|
pub const REGIME_CAUTION_SIZE_FACTOR: f64 = 0.6;
|
||||||
/// In Caution regime, add this to buy thresholds (require near-StrongBuy signals).
|
/// In Caution regime, add this to buy thresholds (DAILY bars).
|
||||||
pub const REGIME_CAUTION_THRESHOLD_BUMP: f64 = 3.0;
|
/// Daily needs lower bump (1.0) to participate in bull rallies.
|
||||||
|
pub const REGIME_CAUTION_THRESHOLD_BUMP: f64 = 1.0;
|
||||||
|
|
||||||
|
/// In Caution regime, multiply position size by this factor (HOURLY bars).
|
||||||
|
/// Hourly needs to be very defensive (25% size) due to intraday noise.
|
||||||
|
pub const HOURLY_REGIME_CAUTION_SIZE_FACTOR: f64 = 0.25;
|
||||||
|
/// In Caution regime, add this to buy thresholds (HOURLY bars).
|
||||||
|
/// Hourly needs high bump (3.0) to avoid whipsaws.
|
||||||
|
pub const HOURLY_REGIME_CAUTION_THRESHOLD_BUMP: f64 = 3.0;
|
||||||
|
|
||||||
|
/// If true, the bot is allowed to open new long positions during a Bear market regime.
|
||||||
|
/// This is a master switch for testing/debugging purposes.
|
||||||
|
pub const ALLOW_LONGS_IN_BEAR_MARKET: bool = false;
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Scaled Drawdown Circuit Breaker
|
// Scaled Drawdown Circuit Breaker
|
||||||
@@ -114,16 +136,25 @@ pub const REGIME_CAUTION_THRESHOLD_BUMP: f64 = 3.0;
|
|||||||
// The old fixed 10-bar cooldown is inadequate for real bear markets.
|
// The old fixed 10-bar cooldown is inadequate for real bear markets.
|
||||||
// Scale the halt duration with severity so that deeper drawdowns force
|
// Scale the halt duration with severity so that deeper drawdowns force
|
||||||
// longer cooling periods. At 25%+ DD, also require bull regime to resume.
|
// longer cooling periods. At 25%+ DD, also require bull regime to resume.
|
||||||
pub const DRAWDOWN_TIER1_PCT: f64 = 0.12; // 12% → 15 bars (catch earlier)
|
// Daily drawdown tiers: relaxed to avoid halting on normal 10-15% bull pullbacks
|
||||||
pub const DRAWDOWN_TIER1_BARS: usize = 15;
|
pub const DRAWDOWN_TIER1_PCT: f64 = 0.18; // 18% → 10 bars
|
||||||
pub const DRAWDOWN_TIER2_PCT: f64 = 0.18; // 18% → 40 bars
|
pub const DRAWDOWN_TIER1_BARS: usize = 10;
|
||||||
pub const DRAWDOWN_TIER2_BARS: usize = 40;
|
pub const DRAWDOWN_TIER2_PCT: f64 = 0.25; // 25% → 30 bars
|
||||||
pub const DRAWDOWN_TIER3_PCT: f64 = 0.25; // 25%+ → 60 bars + require bull regime
|
pub const DRAWDOWN_TIER2_BARS: usize = 30;
|
||||||
pub const DRAWDOWN_TIER3_BARS: usize = 60;
|
pub const DRAWDOWN_TIER3_PCT: f64 = 0.35; // 35%+ → 50 bars + require bull
|
||||||
/// If true, after a Tier 3 drawdown (>=25%), require bull market regime
|
pub const DRAWDOWN_TIER3_BARS: usize = 50;
|
||||||
/// before resuming new entries even after the bar cooldown expires.
|
/// If true, after a Tier 3 drawdown, require bull market regime to resume.
|
||||||
pub const DRAWDOWN_TIER3_REQUIRE_BULL: bool = true;
|
pub const DRAWDOWN_TIER3_REQUIRE_BULL: bool = true;
|
||||||
|
|
||||||
|
// Hourly drawdown tiers: tighter because hourly has more whipsaw exposure
|
||||||
|
// and the bot needs to cut losses faster to preserve capital in bear periods.
|
||||||
|
pub const HOURLY_DRAWDOWN_TIER1_PCT: f64 = 0.12; // 12% → 15 bars
|
||||||
|
pub const HOURLY_DRAWDOWN_TIER1_BARS: usize = 15;
|
||||||
|
pub const HOURLY_DRAWDOWN_TIER2_PCT: f64 = 0.18; // 18% → 40 bars
|
||||||
|
pub const HOURLY_DRAWDOWN_TIER2_BARS: usize = 40;
|
||||||
|
pub const HOURLY_DRAWDOWN_TIER3_PCT: f64 = 0.25; // 25%+ → 60 bars + require bull
|
||||||
|
pub const HOURLY_DRAWDOWN_TIER3_BARS: usize = 60;
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Trailing Equity Curve Stop
|
// Trailing Equity Curve Stop
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -12,13 +12,27 @@ use std::path::Path;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tower_http::cors::CorsLayer;
|
use tower_http::cors::CorsLayer;
|
||||||
|
|
||||||
use crate::alpaca::AlpacaClient;
|
use crate::{
|
||||||
use crate::paths::LIVE_EQUITY_FILE;
|
alpaca::AlpacaClient,
|
||||||
use crate::types::EquitySnapshot;
|
config::{
|
||||||
|
ATR_STOP_MULTIPLIER, ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER,
|
||||||
|
BREAKEVEN_ACTIVATION_PCT, BREAKEVEN_MAX_LOSS_PCT,
|
||||||
|
EARLY_TRAIL_ACTIVATION_MULTIPLIER, EARLY_TRAIL_MULTIPLIER,
|
||||||
|
},
|
||||||
|
paths::{LIVE_ENTRY_ATRS_FILE, LIVE_EQUITY_FILE, LIVE_HIGH_WATER_MARKS_FILE},
|
||||||
|
types::EquitySnapshot,
|
||||||
|
};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub struct DashboardInitData {
|
||||||
|
pub entry_atrs: HashMap<String, f64>,
|
||||||
|
pub high_water_marks: HashMap<String, f64>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared state for the dashboard.
|
/// Shared state for the dashboard.
|
||||||
pub struct DashboardState {
|
pub struct DashboardState {
|
||||||
pub client: AlpacaClient,
|
pub client: AlpacaClient,
|
||||||
|
pub init_data: DashboardInitData,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -48,6 +62,8 @@ struct PositionResponse {
|
|||||||
unrealized_pnl: f64,
|
unrealized_pnl: f64,
|
||||||
pnl_pct: f64,
|
pnl_pct: f64,
|
||||||
change_today: f64,
|
change_today: f64,
|
||||||
|
trail_status: String,
|
||||||
|
stop_loss_price: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -363,6 +379,8 @@ const HTML_TEMPLATE: &str = r#"<!DOCTYPE html>
|
|||||||
<div class="position-detail"><div class="position-detail-label">Current</div><div class="position-detail-value">${formatCurrency(pos.current_price)}</div></div>
|
<div class="position-detail"><div class="position-detail-label">Current</div><div class="position-detail-value">${formatCurrency(pos.current_price)}</div></div>
|
||||||
<div class="position-detail"><div class="position-detail-label">P&L</div><div class="position-detail-value ${pnlClass}">${formatCurrency(pos.unrealized_pnl, true)}</div></div>
|
<div class="position-detail"><div class="position-detail-label">P&L</div><div class="position-detail-value ${pnlClass}">${formatCurrency(pos.unrealized_pnl, true)}</div></div>
|
||||||
<div class="position-detail"><div class="position-detail-label">Today</div><div class="position-detail-value ${changeClass}">${changeSign}${pos.change_today.toFixed(2)}%</div></div>
|
<div class="position-detail"><div class="position-detail-label">Today</div><div class="position-detail-value ${changeClass}">${changeSign}${pos.change_today.toFixed(2)}%</div></div>
|
||||||
|
<div class="position-detail"><div class="position-detail-label">Trail Status</div><div class="position-detail-value">${pos.trail_status}</div></div>
|
||||||
|
<div class="position-detail"><div class="position-detail-label">Stop Loss</div><div class="position-detail-value">${formatCurrency(pos.stop_loss_price)}</div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -548,12 +566,52 @@ async fn api_positions(State(state): State<Arc<DashboardState>>) -> impl IntoRes
|
|||||||
Ok(positions) => {
|
Ok(positions) => {
|
||||||
let mut result: Vec<PositionResponse> = positions
|
let mut result: Vec<PositionResponse> = positions
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p| PositionResponse {
|
.map(|p| {
|
||||||
|
let entry_price = p.avg_entry_price.parse().unwrap_or(0.0);
|
||||||
|
let current_price = p.current_price.parse().unwrap_or(0.0);
|
||||||
|
let pnl_pct = if entry_price > 0.0 {
|
||||||
|
(current_price - entry_price) / entry_price
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let entry_atr = state.init_data.entry_atrs.get(&p.symbol).copied().unwrap_or(0.0);
|
||||||
|
let high_water_mark = state.init_data.high_water_marks.get(&p.symbol).copied().unwrap_or(entry_price);
|
||||||
|
|
||||||
|
let activation_gain = if entry_atr > 0.0 {
|
||||||
|
(ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let best_pnl = (high_water_mark - entry_price) / entry_price;
|
||||||
|
let big_activation = if entry_atr > 0.0 {
|
||||||
|
(ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price
|
||||||
|
} else { 0.0 };
|
||||||
|
let small_activation = if entry_atr > 0.0 {
|
||||||
|
(EARLY_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price
|
||||||
|
} else { 0.0 };
|
||||||
|
|
||||||
|
let (trail_status, stop_loss_price) = if best_pnl >= BREAKEVEN_ACTIVATION_PCT && pnl_pct <= -BREAKEVEN_MAX_LOSS_PCT {
|
||||||
|
("Breakeven!".to_string(), entry_price * (1.0 - BREAKEVEN_MAX_LOSS_PCT))
|
||||||
|
} else if entry_atr > 0.0 && best_pnl >= big_activation {
|
||||||
|
let trail_distance = ATR_TRAIL_MULTIPLIER * entry_atr;
|
||||||
|
let stop_price = high_water_mark - trail_distance;
|
||||||
|
("Wide Trail".to_string(), stop_price)
|
||||||
|
} else if entry_atr > 0.0 && pnl_pct >= small_activation {
|
||||||
|
let trail_distance = EARLY_TRAIL_MULTIPLIER * entry_atr;
|
||||||
|
let stop_price = high_water_mark - trail_distance;
|
||||||
|
("Tight Trail".to_string(), stop_price)
|
||||||
|
} else {
|
||||||
|
("Inactive".to_string(), entry_price - ATR_STOP_MULTIPLIER * entry_atr)
|
||||||
|
};
|
||||||
|
|
||||||
|
PositionResponse {
|
||||||
symbol: p.symbol.clone(),
|
symbol: p.symbol.clone(),
|
||||||
qty: p.qty.parse().unwrap_or(0.0),
|
qty: p.qty.parse().unwrap_or(0.0),
|
||||||
market_value: p.market_value.parse().unwrap_or(0.0),
|
market_value: p.market_value.parse().unwrap_or(0.0),
|
||||||
avg_entry_price: p.avg_entry_price.parse().unwrap_or(0.0),
|
avg_entry_price: entry_price,
|
||||||
current_price: p.current_price.parse().unwrap_or(0.0),
|
current_price,
|
||||||
unrealized_pnl: p.unrealized_pl.parse().unwrap_or(0.0),
|
unrealized_pnl: p.unrealized_pl.parse().unwrap_or(0.0),
|
||||||
pnl_pct: p.unrealized_plpc.parse::<f64>().unwrap_or(0.0) * 100.0,
|
pnl_pct: p.unrealized_plpc.parse::<f64>().unwrap_or(0.0) * 100.0,
|
||||||
change_today: p
|
change_today: p
|
||||||
@@ -562,6 +620,9 @@ async fn api_positions(State(state): State<Arc<DashboardState>>) -> impl IntoRes
|
|||||||
.and_then(|s| s.parse::<f64>().ok())
|
.and_then(|s| s.parse::<f64>().ok())
|
||||||
.unwrap_or(0.0)
|
.unwrap_or(0.0)
|
||||||
* 100.0,
|
* 100.0,
|
||||||
|
trail_status,
|
||||||
|
stop_loss_price,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -614,8 +675,12 @@ async fn api_orders(State(state): State<Arc<DashboardState>>) -> impl IntoRespon
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Start the dashboard web server.
|
/// Start the dashboard web server.
|
||||||
pub async fn start_dashboard(client: AlpacaClient, port: u16) -> anyhow::Result<()> {
|
pub async fn start_dashboard(
|
||||||
let state = Arc::new(DashboardState { client });
|
client: AlpacaClient,
|
||||||
|
port: u16,
|
||||||
|
init_data: DashboardInitData,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let state = Arc::new(DashboardState { client, init_data });
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(index))
|
.route("/", get(index))
|
||||||
|
|||||||
66
src/main.rs
66
src/main.rs
@@ -50,7 +50,8 @@ use crate::config::{Timeframe, DEFAULT_INITIAL_CAPITAL};
|
|||||||
Backtest 6 months: invest-bot --backtest --months 6\n \
|
Backtest 6 months: invest-bot --backtest --months 6\n \
|
||||||
Backtest 1y 6m: invest-bot --backtest --years 1 --months 6\n \
|
Backtest 1y 6m: invest-bot --backtest --years 1 --months 6\n \
|
||||||
Custom capital: invest-bot --backtest --years 5 --capital 50000\n \
|
Custom capital: invest-bot --backtest --years 5 --capital 50000\n \
|
||||||
Hourly backtest: invest-bot --backtest --years 1 --timeframe hourly"
|
Hourly backtest: invest-bot --backtest --years 1 --timeframe hourly\n \
|
||||||
|
Custom date range: invest-bot --backtest --start-date 2007-01-01 --end-date 2008-12-31"
|
||||||
)]
|
)]
|
||||||
struct Args {
|
struct Args {
|
||||||
/// Run in backtest mode instead of live trading
|
/// Run in backtest mode instead of live trading
|
||||||
@@ -65,6 +66,14 @@ struct Args {
|
|||||||
#[arg(short, long, default_value_t = 0.0)]
|
#[arg(short, long, default_value_t = 0.0)]
|
||||||
months: f64,
|
months: f64,
|
||||||
|
|
||||||
|
/// Start date for backtest (YYYY-MM-DD). Overrides --years/--months if provided.
|
||||||
|
#[arg(long, value_name = "YYYY-MM-DD")]
|
||||||
|
start_date: Option<String>,
|
||||||
|
|
||||||
|
/// End date for backtest (YYYY-MM-DD). Defaults to now if not provided.
|
||||||
|
#[arg(long, value_name = "YYYY-MM-DD")]
|
||||||
|
end_date: Option<String>,
|
||||||
|
|
||||||
/// Initial capital for backtesting
|
/// Initial capital for backtesting
|
||||||
#[arg(short, long, default_value_t = DEFAULT_INITIAL_CAPITAL)]
|
#[arg(short, long, default_value_t = DEFAULT_INITIAL_CAPITAL)]
|
||||||
capital: f64,
|
capital: f64,
|
||||||
@@ -171,14 +180,45 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn run_backtest(api_key: String, api_secret: String, args: Args) -> Result<()> {
|
async fn run_backtest(api_key: String, api_secret: String, args: Args) -> Result<()> {
|
||||||
// Combine years and months (default to 1 year if neither specified)
|
use chrono::NaiveDate;
|
||||||
let total_years = args.years + (args.months / 12.0);
|
|
||||||
let total_years = if total_years <= 0.0 { 1.0 } else { total_years };
|
|
||||||
|
|
||||||
let client = AlpacaClient::new(api_key, api_secret)?;
|
let client = AlpacaClient::new(api_key, api_secret)?;
|
||||||
let mut backtester = Backtester::new(args.capital, args.timeframe);
|
let mut backtester = Backtester::new(args.capital, args.timeframe);
|
||||||
|
|
||||||
let result = backtester.run(&client, total_years).await?;
|
let result = if args.start_date.is_some() || args.end_date.is_some() {
|
||||||
|
// Custom date range mode
|
||||||
|
let start_date = if let Some(ref s) = args.start_date {
|
||||||
|
NaiveDate::parse_from_str(s, "%Y-%m-%d")
|
||||||
|
.context("Invalid start date format. Use YYYY-MM-DD (e.g., 2007-01-01)")?
|
||||||
|
} else {
|
||||||
|
// If no start date provided, default to 1 year before end date
|
||||||
|
let end = if let Some(ref e) = args.end_date {
|
||||||
|
NaiveDate::parse_from_str(e, "%Y-%m-%d")?
|
||||||
|
} else {
|
||||||
|
chrono::Utc::now().date_naive()
|
||||||
|
};
|
||||||
|
end - chrono::Duration::days(365)
|
||||||
|
};
|
||||||
|
|
||||||
|
let end_date = if let Some(ref e) = args.end_date {
|
||||||
|
NaiveDate::parse_from_str(e, "%Y-%m-%d")
|
||||||
|
.context("Invalid end date format. Use YYYY-MM-DD (e.g., 2008-12-31)")?
|
||||||
|
} else {
|
||||||
|
chrono::Utc::now().date_naive()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate date range
|
||||||
|
if start_date >= end_date {
|
||||||
|
anyhow::bail!("Start date must be before end date");
|
||||||
|
}
|
||||||
|
|
||||||
|
backtester.run_with_dates(&client, start_date, end_date).await?
|
||||||
|
} else {
|
||||||
|
// Years/months mode (existing behavior)
|
||||||
|
let total_years = args.years + (args.months / 12.0);
|
||||||
|
let total_years = if total_years <= 0.0 { 1.0 } else { total_years };
|
||||||
|
backtester.run(&client, total_years).await?
|
||||||
|
};
|
||||||
|
|
||||||
// Save results to CSV
|
// Save results to CSV
|
||||||
save_backtest_results(&result)?;
|
save_backtest_results(&result)?;
|
||||||
@@ -192,17 +232,27 @@ async fn run_live_trading(api_key: String, api_secret: String, args: Args) -> Re
|
|||||||
.parse()
|
.parse()
|
||||||
.unwrap_or(5000);
|
.unwrap_or(5000);
|
||||||
|
|
||||||
|
// Create the bot first to load its state
|
||||||
|
let mut bot = TradingBot::new(api_key.clone(), api_secret.clone(), args.timeframe).await?;
|
||||||
|
|
||||||
// Create a separate client for the dashboard
|
// Create a separate client for the dashboard
|
||||||
let dashboard_client = AlpacaClient::new(api_key.clone(), api_secret.clone())?;
|
let dashboard_client = AlpacaClient::new(api_key.clone(), api_secret.clone())?;
|
||||||
|
|
||||||
|
// Extract data for the dashboard
|
||||||
|
let init_data = dashboard::DashboardInitData {
|
||||||
|
entry_atrs: bot.get_entry_atrs(),
|
||||||
|
high_water_marks: bot.get_high_water_marks(),
|
||||||
|
};
|
||||||
|
|
||||||
// Spawn dashboard in background
|
// Spawn dashboard in background
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = dashboard::start_dashboard(dashboard_client, dashboard_port).await {
|
if let Err(e) =
|
||||||
|
dashboard::start_dashboard(dashboard_client, dashboard_port, init_data).await
|
||||||
|
{
|
||||||
tracing::error!("Dashboard error: {}", e);
|
tracing::error!("Dashboard error: {}", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Run the trading bot
|
// Now run the bot's main loop
|
||||||
let mut bot = TradingBot::new(api_key, api_secret, args.timeframe).await?;
|
|
||||||
bot.run().await
|
bot.run().await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,13 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
get_sector, IndicatorParams, Timeframe, ATR_STOP_MULTIPLIER,
|
get_sector, IndicatorParams, Timeframe, ATR_STOP_MULTIPLIER,
|
||||||
ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER, MAX_LOSS_PCT, MAX_POSITION_SIZE,
|
ATR_TRAIL_ACTIVATION_MULTIPLIER, ATR_TRAIL_MULTIPLIER,
|
||||||
MIN_ATR_PCT, RISK_PER_TRADE, STOP_LOSS_PCT, TIME_EXIT_BARS,
|
BREAKEVEN_ACTIVATION_PCT, BREAKEVEN_MAX_LOSS_PCT,
|
||||||
|
EARLY_TRAIL_ACTIVATION_MULTIPLIER, EARLY_TRAIL_MULTIPLIER,
|
||||||
|
MAX_LOSS_PCT, MAX_POSITION_SIZE,
|
||||||
|
MIN_ATR_PCT, RISK_PER_TRADE,
|
||||||
|
SLOW_BLEED_BARS, SLOW_BLEED_MAX_LOSS, SLOW_BLEED_MIN_GAIN,
|
||||||
|
STOP_LOSS_PCT, TIME_EXIT_BARS,
|
||||||
TRAILING_STOP_ACTIVATION, TRAILING_STOP_DISTANCE,
|
TRAILING_STOP_ACTIVATION, TRAILING_STOP_DISTANCE,
|
||||||
};
|
};
|
||||||
use crate::types::{Signal, TradeSignal};
|
use crate::types::{Signal, TradeSignal};
|
||||||
@@ -66,18 +71,14 @@ impl Strategy {
|
|||||||
/// Check if stop-loss, trailing stop, or time exit should trigger.
|
/// Check if stop-loss, trailing stop, or time exit should trigger.
|
||||||
///
|
///
|
||||||
/// Exit priority (checked in order):
|
/// Exit priority (checked in order):
|
||||||
/// 1. Hard max-loss cap (MAX_LOSS_PCT) -- absolute worst-case, gap protection
|
/// 1. Hard max-loss cap (MAX_LOSS_PCT) -- gap protection
|
||||||
/// 2. ATR-based stop-loss (ATR_STOP_MULTIPLIER * ATR) -- primary risk control
|
/// 2. ATR-based stop-loss -- primary risk control
|
||||||
/// 3. Fixed % stop-loss (STOP_LOSS_PCT) -- fallback when ATR unavailable
|
/// 3. Fixed % stop-loss -- fallback when ATR unavailable
|
||||||
/// 4. ATR trailing stop (ATR_TRAIL_MULTIPLIER * ATR from HWM) -- profit protection
|
/// 4. Breakeven ratchet -- once in profit, never lose more than 1%
|
||||||
/// 5. Time-based exit (TIME_EXIT_BARS) -- only if position is LOSING
|
/// 5. Tiered trailing stop:
|
||||||
///
|
/// - Small gains (0.5x ATR): tight trail (1.5x ATR)
|
||||||
/// Key design decisions:
|
/// - Big gains (2.0x ATR): wide trail (3.0x ATR)
|
||||||
/// - Trailing stop activates early (1.5x ATR) but has wide distance (2.5x ATR)
|
/// 6. Time-based exit -- only if position is LOSING
|
||||||
/// so winners have room to breathe but profits are protected.
|
|
||||||
/// - Time exit ONLY sells losers. Winners at the time limit are doing fine;
|
|
||||||
/// the trailing stop handles profit-taking on them.
|
|
||||||
/// - Max loss is wide enough to avoid being hit by normal ATR-level moves.
|
|
||||||
pub fn check_stop_loss_take_profit(
|
pub fn check_stop_loss_take_profit(
|
||||||
&mut self,
|
&mut self,
|
||||||
symbol: &str,
|
symbol: &str,
|
||||||
@@ -115,22 +116,39 @@ impl Strategy {
|
|||||||
return Some(Signal::StrongSell);
|
return Some(Signal::StrongSell);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. ATR-based trailing stop (profit protection)
|
// 4. Breakeven ratchet: once we've been in profit, cap downside to -1%
|
||||||
// Activates earlier than before (1.5x ATR gain) so profits are locked in.
|
if pnl_pct <= -BREAKEVEN_MAX_LOSS_PCT {
|
||||||
// Distance is wider (2.5x ATR from HWM) so normal retracements don't trigger it.
|
if let Some(&high_water) = self.high_water_marks.get(symbol) {
|
||||||
let activation_gain = if entry_atr > 0.0 {
|
let best_pnl = (high_water - entry_price) / entry_price;
|
||||||
(ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price
|
if best_pnl >= BREAKEVEN_ACTIVATION_PCT {
|
||||||
|
// Was in profit but now losing > 1% — get out
|
||||||
|
return Some(Signal::Sell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Tiered ATR trailing stop (profit protection)
|
||||||
|
// Tier 1: small gains (0.5x ATR) → tight trail (1.5x ATR)
|
||||||
|
// Tier 2: big gains (2.0x ATR) → wide trail (3.0x ATR) to let winners run
|
||||||
|
if let Some(&high_water) = self.high_water_marks.get(symbol) {
|
||||||
|
let best_pnl = (high_water - entry_price) / entry_price;
|
||||||
|
|
||||||
|
let (activation_gain, trail_distance) = if entry_atr > 0.0 {
|
||||||
|
let big_activation = (ATR_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price;
|
||||||
|
let small_activation = (EARLY_TRAIL_ACTIVATION_MULTIPLIER * entry_atr) / entry_price;
|
||||||
|
|
||||||
|
if best_pnl >= big_activation {
|
||||||
|
// Tier 2: big winner — wide trail
|
||||||
|
(big_activation, ATR_TRAIL_MULTIPLIER * entry_atr)
|
||||||
} else {
|
} else {
|
||||||
TRAILING_STOP_ACTIVATION
|
// Tier 1: small gain — tight trail
|
||||||
|
(small_activation, EARLY_TRAIL_MULTIPLIER * entry_atr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(TRAILING_STOP_ACTIVATION, high_water * TRAILING_STOP_DISTANCE)
|
||||||
};
|
};
|
||||||
|
|
||||||
if pnl_pct >= activation_gain {
|
if pnl_pct >= activation_gain {
|
||||||
if let Some(&high_water) = self.high_water_marks.get(symbol) {
|
|
||||||
let trail_distance = if entry_atr > 0.0 {
|
|
||||||
ATR_TRAIL_MULTIPLIER * entry_atr
|
|
||||||
} else {
|
|
||||||
high_water * TRAILING_STOP_DISTANCE
|
|
||||||
};
|
|
||||||
let trailing_stop_price = high_water - trail_distance;
|
let trailing_stop_price = high_water - trail_distance;
|
||||||
if current_price <= trailing_stop_price {
|
if current_price <= trailing_stop_price {
|
||||||
return Some(Signal::Sell);
|
return Some(Signal::Sell);
|
||||||
@@ -138,10 +156,20 @@ impl Strategy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Time-based exit: only for LOSING positions (capital efficiency)
|
// 6. Slow bleeder exit: cut losers that never showed promise
|
||||||
|
// After grace period, if down >2% and never showed >1% gain, it's dead money
|
||||||
|
if bars_held >= SLOW_BLEED_BARS && pnl_pct <= -SLOW_BLEED_MAX_LOSS {
|
||||||
|
let best_pnl = self.high_water_marks
|
||||||
|
.get(symbol)
|
||||||
|
.map(|&hwm| (hwm - entry_price) / entry_price)
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
if best_pnl < SLOW_BLEED_MIN_GAIN {
|
||||||
|
return Some(Signal::Sell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Time-based exit: only for LOSING positions (capital efficiency)
|
||||||
// Winners at the time limit are managed by the trailing stop.
|
// Winners at the time limit are managed by the trailing stop.
|
||||||
// This prevents the old behavior of dumping winners just because they
|
|
||||||
// haven't hit an arbitrary activation threshold in N bars.
|
|
||||||
if bars_held >= TIME_EXIT_BARS && pnl_pct < 0.0 {
|
if bars_held >= TIME_EXIT_BARS && pnl_pct < 0.0 {
|
||||||
return Some(Signal::Sell);
|
return Some(Signal::Sell);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Data types and structures for the trading bot.
|
//! Data types and structures for the trading bot.
|
||||||
|
|
||||||
|
use crate::config::ALLOW_LONGS_IN_BEAR_MARKET;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -27,7 +28,10 @@ impl MarketRegime {
|
|||||||
|
|
||||||
/// Whether new long entries are permitted in this regime.
|
/// Whether new long entries are permitted in this regime.
|
||||||
pub fn allows_new_longs(&self) -> bool {
|
pub fn allows_new_longs(&self) -> bool {
|
||||||
!matches!(self, MarketRegime::Bear)
|
match self {
|
||||||
|
MarketRegime::Bear => ALLOW_LONGS_IN_BEAR_MARKET,
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user