Compare commits

...

4 Commits

Author SHA1 Message Date
eda716edad even more profits 2026-02-26 17:15:30 +00:00
84461319a0 more profit 2026-02-26 17:05:57 +00:00
4476c04512 atr tracking 2026-02-25 20:04:58 +00:00
62847846d0 gg 2026-02-13 22:00:24 +00:00
12 changed files with 195 additions and 186 deletions

View File

@@ -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

View File

@@ -0,0 +1 @@
/nix/store/j9250k63yp54q9r2m0xnca8lxjcfadv0-source

View File

@@ -1 +0,0 @@
/nix/store/vanbyn1mbsqmff9in675grd5lqpr69zl-source

View File

@@ -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

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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>(

View File

@@ -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
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════

View File

@@ -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))

View File

@@ -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
} }

View File

@@ -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);
} }

View File

@@ -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,
}
} }
} }