Compare commits
4 Commits
0e820852fa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| eda716edad | |||
| 84461319a0 | |||
| 4476c04512 | |||
| 62847846d0 |
@@ -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
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Analyze SPY regime detection during backtest periods."""
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
import yfinance as yf
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
def calculate_ema(series, period):
|
|
||||||
"""Calculate EMA using pandas."""
|
|
||||||
return series.ewm(span=period, adjust=False).mean()
|
|
||||||
|
|
||||||
def determine_regime(price, ema50, ema200):
|
|
||||||
"""Replicate the Rust regime detection logic."""
|
|
||||||
if pd.isna(price) or pd.isna(ema50) or pd.isna(ema200):
|
|
||||||
return "Caution"
|
|
||||||
|
|
||||||
# Bear: price below 200 EMA AND 50 EMA below 200 EMA
|
|
||||||
if price < ema200 and ema50 < ema200:
|
|
||||||
return "Bear"
|
|
||||||
|
|
||||||
# Caution: price below 50 EMA
|
|
||||||
if price < ema50:
|
|
||||||
return "Caution"
|
|
||||||
|
|
||||||
# Bull: price above both, 50 above 200
|
|
||||||
if ema50 > ema200:
|
|
||||||
return "Bull"
|
|
||||||
|
|
||||||
# Edge case: price above both but 50 still below 200
|
|
||||||
return "Caution"
|
|
||||||
|
|
||||||
def analyze_period(start_date, end_date, period_name):
|
|
||||||
"""Analyze SPY regime for a given period."""
|
|
||||||
print(f"\n{'='*70}")
|
|
||||||
print(f"{period_name}: {start_date} to {end_date}")
|
|
||||||
print('='*70)
|
|
||||||
|
|
||||||
# Fetch SPY data with extra warmup for EMA-200
|
|
||||||
warmup_start = (datetime.strptime(start_date, '%Y-%m-%d') - timedelta(days=400)).strftime('%Y-%m-%d')
|
|
||||||
spy = yf.download('SPY', start=warmup_start, end=end_date, progress=False)
|
|
||||||
|
|
||||||
if spy.empty:
|
|
||||||
print(f"ERROR: No SPY data available for {period_name}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Calculate EMAs
|
|
||||||
spy['EMA50'] = calculate_ema(spy['Close'], 50)
|
|
||||||
spy['EMA200'] = calculate_ema(spy['Close'], 200)
|
|
||||||
|
|
||||||
# Determine regime for each day
|
|
||||||
spy['Regime'] = spy.apply(
|
|
||||||
lambda row: determine_regime(row['Close'], row['EMA50'], row['EMA200']),
|
|
||||||
axis=1
|
|
||||||
)
|
|
||||||
|
|
||||||
# Filter to actual trading period
|
|
||||||
trading_period = spy[start_date:end_date].copy()
|
|
||||||
|
|
||||||
if trading_period.empty:
|
|
||||||
print(f"ERROR: No trading data for {period_name}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Calculate SPY return
|
|
||||||
spy_start = trading_period['Close'].iloc[0]
|
|
||||||
spy_end = trading_period['Close'].iloc[-1]
|
|
||||||
spy_return = (spy_end - spy_start) / spy_start * 100
|
|
||||||
|
|
||||||
print(f"\nSPY Performance:")
|
|
||||||
print(f" Start: ${spy_start:.2f}")
|
|
||||||
print(f" End: ${spy_end:.2f}")
|
|
||||||
print(f" Return: {spy_return:+.2f}%")
|
|
||||||
|
|
||||||
# Count regime days
|
|
||||||
regime_counts = trading_period['Regime'].value_counts()
|
|
||||||
total_days = len(trading_period)
|
|
||||||
|
|
||||||
print(f"\nRegime Distribution ({total_days} trading days):")
|
|
||||||
for regime in ['Bull', 'Caution', 'Bear']:
|
|
||||||
count = regime_counts.get(regime, 0)
|
|
||||||
pct = count / total_days * 100
|
|
||||||
print(f" {regime:8s}: {count:4d} days ({pct:5.1f}%)")
|
|
||||||
|
|
||||||
# Show regime transitions
|
|
||||||
regime_changes = trading_period[trading_period['Regime'] != trading_period['Regime'].shift(1)]
|
|
||||||
if len(regime_changes) > 0:
|
|
||||||
print(f"\nRegime Transitions ({len(regime_changes)} total):")
|
|
||||||
for date, row in regime_changes.head(20).iterrows():
|
|
||||||
print(f" {date.strftime('%Y-%m-%d')}: {row['Regime']:8s} (SPY: ${row['Close']:.2f}, "
|
|
||||||
f"EMA50: ${row['EMA50']:.2f}, EMA200: ${row['EMA200']:.2f})")
|
|
||||||
if len(regime_changes) > 20:
|
|
||||||
print(f" ... and {len(regime_changes) - 20} more transitions")
|
|
||||||
|
|
||||||
# Identify problematic Bear periods during bull markets
|
|
||||||
bear_days = trading_period[trading_period['Regime'] == 'Bear']
|
|
||||||
if len(bear_days) > 0:
|
|
||||||
print(f"\n⚠️ WARNING: {len(bear_days)} days classified as BEAR:")
|
|
||||||
for date, row in bear_days.head(10).iterrows():
|
|
||||||
print(f" {date.strftime('%Y-%m-%d')}: SPY=${row['Close']:.2f}, "
|
|
||||||
f"EMA50=${row['EMA50']:.2f}, EMA200=${row['EMA200']:.2f}")
|
|
||||||
if len(bear_days) > 10:
|
|
||||||
print(f" ... and {len(bear_days) - 10} more Bear days")
|
|
||||||
|
|
||||||
# Show first and last months in detail
|
|
||||||
print(f"\nFirst Month Detail:")
|
|
||||||
first_month = trading_period.head(22)[['Close', 'EMA50', 'EMA200', 'Regime']]
|
|
||||||
for date, row in first_month.iterrows():
|
|
||||||
print(f" {date.strftime('%Y-%m-%d')}: {row['Regime']:8s} | "
|
|
||||||
f"SPY: ${row['Close']:7.2f} | EMA50: ${row['EMA50']:7.2f} | EMA200: ${row['EMA200']:7.2f}")
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
# Analyze 2023
|
|
||||||
analyze_period('2023-01-01', '2023-12-31', '2023 Backtest')
|
|
||||||
|
|
||||||
# Analyze 2024
|
|
||||||
analyze_period('2024-01-01', '2024-12-31', '2024 Backtest')
|
|
||||||
|
|
||||||
print(f"\n{'='*70}")
|
|
||||||
print("Analysis complete.")
|
|
||||||
print('='*70)
|
|
||||||
@@ -22,7 +22,7 @@ use crate::config::{
|
|||||||
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,
|
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;
|
||||||
@@ -725,7 +725,7 @@ impl Backtester {
|
|||||||
REGIME_CAUTION_SIZE_FACTOR
|
REGIME_CAUTION_SIZE_FACTOR
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
MarketRegime::Bear => 0.0, // No new longs
|
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)
|
||||||
@@ -801,7 +801,6 @@ 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
|
// Use timeframe-specific parameters: hourly needs high bump, daily needs low bump
|
||||||
@@ -1170,7 +1169,7 @@ impl Backtester {
|
|||||||
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 => REGIME_CAUTION_SIZE_FACTOR,
|
||||||
MarketRegime::Bear => 0.0,
|
MarketRegime::Bear => if ALLOW_LONGS_IN_BEAR_MARKET { 1.0 } else { 0.0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
if day_num % 100 == 0 {
|
if day_num % 100 == 0 {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -116,6 +126,10 @@ pub const HOURLY_REGIME_CAUTION_SIZE_FACTOR: f64 = 0.25;
|
|||||||
/// Hourly needs high bump (3.0) to avoid whipsaws.
|
/// Hourly needs high bump (3.0) to avoid whipsaws.
|
||||||
pub const HOURLY_REGIME_CAUTION_THRESHOLD_BUMP: f64 = 3.0;
|
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
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
16
src/main.rs
16
src/main.rs
@@ -232,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