//! Trading-specific reward functions for DQN

// CANONICAL TYPE IMPORTS - Use common::Decimal
use std::collections::VecDeque;
use serde::{Deserialize, Serialize};
// For Decimal::from_f64
use common::types::Price;
use rust_decimal::Decimal;

use super::action_space::FactoredAction;
use super::agent::{TradingAction, TradingState};
use ml_core::MLError;
use super::circuit_breaker::CircuitBreakerConfig;

/// Differential Sharpe Ratio (DSR) for incremental reward shaping.
///
/// Implements the Moody & Saffell (2001) formula for online Sharpe ratio
/// estimation. The DSR measures the marginal change in the Sharpe ratio
/// from each new return, producing a dense reward signal that directly
/// optimizes risk-adjusted performance.
///
/// # Algorithm
/// Given a new return `r_t`:
/// 1. Update EMA of returns: `A_t` = A_{t-1} + eta * (`r_t` - A_{t-1})
/// 2. Update EMA of squared returns: `B_t` = B_{t-1} + eta * (`r_t^2` - B_{t-1})
/// 3. Compute DSR: `D_t` = (B_{t-1} * `delta_A` - 0.5 * A_{t-1} * `delta_B`) / (B_{t-1} - A_{t-1}^2)^{3/2}
///    where `delta_A` = `r_t` - A_{t-1}, `delta_B` = `r_t^2` - B_{t-1}
///
/// # References
/// - Moody, J. & Saffell, M. (2001). "Learning to Trade via Direct Reinforcement."
///   IEEE Transactions on Neural Networks, 12(4), 875-889.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DifferentialSharpeRatio {
    /// Exponential moving average of returns
    ema_return: f64,
    /// Exponential moving average of squared returns
    ema_return_sq: f64,
    /// Decay rate for EMA updates (controls lookback window)
    eta: f64,
    /// Whether EMAs have been initialized with a first observation
    initialized: bool,
}

impl DifferentialSharpeRatio {
    /// Create a new DSR calculator with the given decay rate.
    ///
    /// # Arguments
    /// * `eta` - EMA decay rate, clamped to [0.0001, 0.1].
    ///   - Small eta (~0.001): long lookback, smooth DSR signal
    ///   - Large eta (~0.05): short lookback, responsive DSR signal
    ///   - Default: 0.01 (~100-step effective window)
    pub const fn new(eta: f64) -> Self {
        Self {
            ema_return: 0.0,
            ema_return_sq: 0.0,
            eta: eta.clamp(0.0001, 0.1),
            initialized: false,
        }
    }

    /// Compute the incremental DSR for a new return observation.
    ///
    /// On the first call, initializes EMAs from the observation and returns 0.0
    /// (no prior statistics to compute a differential from).
    ///
    /// # Arguments
    /// * `r_t` - The raw return at time t (e.g., percentage `PnL`)
    ///
    /// # Returns
    /// The differential Sharpe ratio, clamped to [-5.0, 5.0] to prevent
    /// extreme values from destabilizing Q-learning updates.
    pub fn step(&mut self, r_t: f64) -> f64 {
        if !self.initialized {
            // First observation: seed EMAs, no differential to compute yet
            self.ema_return = r_t;
            self.ema_return_sq = r_t * r_t;
            self.initialized = true;
            return 0.0;
        }

        // Deltas from previous EMA estimates
        let delta_a = r_t - self.ema_return;
        let delta_b = r_t * r_t - self.ema_return_sq;

        // Variance estimate: Var = E[r^2] - E[r]^2
        let variance = self.ema_return_sq - self.ema_return * self.ema_return;

        // Update EMAs (do this after computing deltas but before returning)
        self.ema_return += self.eta * delta_a;
        self.ema_return_sq += self.eta * delta_b;

        // Degenerate case: near-zero variance means constant returns,
        // DSR formula has a division by variance^{3/2} which would blow up.
        // Fallback: scale raw PnL to approximate DSR magnitude range.
        if variance < 1e-12 {
            return (r_t * 100.0).clamp(-5.0, 5.0);
        }

        // Moody & Saffell DSR formula:
        // D_t = (B * delta_A - 0.5 * A * delta_B) / (B - A^2)^{3/2}
        // where A = ema_return (before update), B = ema_return_sq (before update)
        // We use the pre-update values stored in delta computation above.
        let a_prev = self.ema_return - self.eta * delta_a; // recover pre-update A
        let b_prev = self.ema_return_sq - self.eta * delta_b; // recover pre-update B

        let numerator = b_prev * delta_a - 0.5 * a_prev * delta_b;
        let denominator = variance.powf(1.5);

        let dsr = numerator / denominator;
        dsr.clamp(-5.0, 5.0)
    }

    /// Reset internal state for episode boundaries.
    ///
    /// Must be called between episodes to prevent cross-episode EMA leakage.
    /// After reset, the next `step()` call will re-initialize EMAs.
    pub const fn reset(&mut self) {
        self.ema_return = 0.0;
        self.ema_return_sq = 0.0;
        self.initialized = false;
    }

    /// Synchronize CPU DSR state from GPU `epoch_state` readback.
    ///
    /// Called after GPU experience collection to keep the CPU-side DSR
    /// consistent with the GPU kernel's EMA accumulators. This enables
    /// accurate logging and checkpointing of DSR state.
    ///
    /// # Arguments
    /// * `ema_a` - GPU `epoch_state[5]` (EMA of returns)
    /// * `ema_b` - GPU `epoch_state[6]` (EMA of squared returns)
    pub const fn sync_from_gpu(&mut self, ema_a: f64, ema_b: f64) {
        self.ema_return = ema_a;
        self.ema_return_sq = ema_b;
        self.initialized = true;
    }
}

/// Configuration for reward function
///
/// NOTE: Reward normalization and DSR are now computed entirely in the GPU
/// experience-collection kernel (`experience_kernels.cu`).  The CPU-side
/// `RewardNormalizer` and `use_dsr` toggle have been removed.  The composite
/// reward weights (`w_dsr`, `w_pnl`, `w_dd`, `w_idle`, etc.) live in
/// `DQNHyperparameters` and are passed directly to the kernel launch args.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RewardConfig {
    /// Weight for P&L component
    pub pnl_weight: Decimal,
    /// Weight for risk penalty
    pub risk_weight: Decimal,
    /// Weight for transaction cost penalty
    pub cost_weight: Decimal,
    /// Price movement threshold for dynamic HOLD reward (as fraction, e.g., 0.02 = 2%)
    pub movement_threshold: Decimal,
    /// Weight for diversity penalty (negative value to penalize low entropy, -0.1 default)
    pub diversity_weight: Decimal,
    /// Use percentage-based P&L instead of absolute dollar changes (default: true) - Bug #17 fix
    pub use_percentage_pnl: bool,
    /// Circuit breaker configuration for risk management
    pub circuit_breaker_config: CircuitBreakerConfig,
    /// Triple barrier profit target scaling factor (1.0 = 100% bonus, 0.5 = 50% bonus)
    pub triple_barrier_profit_bonus: Decimal,
    /// Triple barrier stop loss penalty scaling factor (1.0 = 100% penalty, 0.5 = 50% penalty)
    pub triple_barrier_stop_penalty: Decimal,
    /// Weight for Sharpe ratio component (WAVE 26 P1.3)
    pub sharpe_weight: Decimal,
    /// Rolling window size for Sharpe ratio calculation (WAVE 26 P1.3)
    pub sharpe_window: usize,
    /// EMA decay rate for DSR calculation, clamped to [0.0001, 0.1].
    /// Smaller values = longer lookback window. Default: 0.01 (~100-step window).
    pub dsr_eta: f64,
    /// Initial capital for denormalizing portfolio features.
    /// Used to convert normalized portfolio values back to absolute for reward calculations.
    /// Must match the value passed to `PortfolioTracker::new()`.
    pub initial_capital: f64,
}

impl Default for RewardConfig {
    fn default() -> Self {
        Self {
            pnl_weight: Decimal::ONE,
            risk_weight: Decimal::try_from(0.1).unwrap_or(Decimal::ZERO),
            cost_weight: Decimal::ONE, // Bug #2 fix: Apply transaction costs at full weight (was 0.05)
            movement_threshold: Decimal::try_from(0.01).unwrap_or(Decimal::ZERO), // 1% matches data distribution
            diversity_weight: Decimal::try_from(-0.1).unwrap_or(Decimal::ZERO),
            use_percentage_pnl: true,   // Bug #17 fix: scale-invariant percentage returns
            circuit_breaker_config: CircuitBreakerConfig::default(),
            triple_barrier_profit_bonus: Decimal::try_from(0.5).unwrap_or(Decimal::ZERO), // 50% bonus for hitting profit target
            triple_barrier_stop_penalty: Decimal::try_from(0.5).unwrap_or(Decimal::ZERO), // 50% penalty for hitting stop loss
            sharpe_weight: Decimal::try_from(0.3).unwrap_or(Decimal::ZERO), // WAVE 26 P1.3: 30% weight for Sharpe ratio
            sharpe_window: 20, // WAVE 26 P1.3: 20-step rolling window for Sharpe calculation
            dsr_eta: 0.01, // ~100-step effective EMA window
            initial_capital: 100_000.0,
        }
    }
}

impl RewardConfig {
    /// Builder for `RewardConfig`
    pub fn builder() -> RewardConfigBuilder {
        RewardConfigBuilder::default()
    }

    /// Validate configuration to prevent gradient explosion
    ///
    /// # Fix #2: Force Percentage Rewards + Validation
    ///
    /// MANDATORY VALIDATION to prevent gradient explosion:
    /// - `use_percentage_pnl` MUST be true (otherwise Q-values explode to ±50,000)
    ///
    /// ## Root Cause
    /// Absolute rewards (+-2000) cause steady-state Q-values of +-53,476
    /// Percentage rewards (+-0.02) cause steady-state Q-values of +-0.5
    /// This 100,000x difference is the root cause of gradient explosion.
    pub fn validate(&self) -> Result<(), MLError> {
        // MANDATORY VALIDATION: use_percentage_pnl MUST be true
        if !self.use_percentage_pnl {
            return Err(MLError::ConfigError("FATAL: use_percentage_pnl=false causes gradient explosion. \
                 Q-values explode to ±50,000, gradients to millions. \
                 MUST use percentage_pnl=true for numerical stability.".to_owned()));
        }

        // Validate initial_capital is positive (needed for denormalization)
        if self.initial_capital <= 0.0 {
            return Err(MLError::ConfigError(format!(
                "initial_capital must be > 0.0, got {}. Required for portfolio feature denormalization.",
                self.initial_capital
            )));
        }

        Ok(())
    }
}

/// Builder for `RewardConfig`
#[derive(Debug, Default)]
pub struct RewardConfigBuilder {
    pnl_weight: Option<f64>,
    activity_bonus_weight: Option<f64>,
    use_percentage_pnl: Option<bool>,
    circuit_breaker_config: Option<CircuitBreakerConfig>,
    dsr_eta: Option<f64>,
}

impl RewardConfigBuilder {
    pub const fn pnl_weight(mut self, weight: f64) -> Self {
        self.pnl_weight = Some(weight);
        self
    }

    pub const fn activity_bonus_weight(mut self, weight: f64) -> Self {
        self.activity_bonus_weight = Some(weight);
        self
    }

    pub const fn use_percentage_pnl(mut self, enable: bool) -> Self {
        self.use_percentage_pnl = Some(enable);
        self
    }

    pub const fn circuit_breaker_config(mut self, config: CircuitBreakerConfig) -> Self {
        self.circuit_breaker_config = Some(config);
        self
    }

    pub const fn dsr_eta(mut self, eta: f64) -> Self {
        self.dsr_eta = Some(eta);
        self
    }

    pub fn build(self) -> Result<RewardConfig, MLError> {
        Ok(RewardConfig {
            pnl_weight: Decimal::try_from(self.pnl_weight.unwrap_or(1.0))
                .map_err(|e| MLError::InvalidInput(format!("Invalid pnl_weight: {}", e)))?,
            risk_weight: Decimal::try_from(0.1).unwrap_or(Decimal::ZERO),
            cost_weight: Decimal::ONE, // Bug #2 fix: Apply transaction costs at full weight (was 0.05)
            movement_threshold: Decimal::try_from(0.01).unwrap_or(Decimal::ZERO),
            diversity_weight: Decimal::try_from(self.activity_bonus_weight.unwrap_or(0.0))
                .map_err(|e| MLError::InvalidInput(format!("Invalid activity_bonus_weight: {}", e)))?,
            use_percentage_pnl: self.use_percentage_pnl.unwrap_or(true),
            circuit_breaker_config: self.circuit_breaker_config.unwrap_or_default(),
            triple_barrier_profit_bonus: Decimal::try_from(0.5).unwrap_or(Decimal::ZERO),
            triple_barrier_stop_penalty: Decimal::try_from(0.5).unwrap_or(Decimal::ZERO),
            sharpe_weight: Decimal::try_from(0.3).unwrap_or(Decimal::ZERO),
            sharpe_window: 100,
            dsr_eta: self.dsr_eta.unwrap_or(0.01),
            initial_capital: 100_000.0,
        })
    }
}

/// Risk metrics for trading state
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RiskMetrics {
    /// Value at Risk (95%)
    pub var_95: Decimal,
    /// Maximum drawdown
    pub max_drawdown: Decimal,
    /// Sharpe ratio
    pub sharpe_ratio: Decimal,
    /// Volatility
    pub volatility: Decimal,
}

/// Market data snapshot
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MarketData {
    /// Current bid price
    pub bid: Price,
    /// Current ask price
    pub ask: Price,
    /// Bid-ask spread
    pub spread: Price,
    /// Volume
    pub volume: Decimal,
}

/// Calculate normalized Shannon entropy for the 5-exposure DQN action distribution.
///
/// # Arguments
/// * `recent_actions` - Sliding window of recent actions (last 100 actions)
///
/// # Returns
/// Normalized entropy `H/H_max` in [0.0, 1.0]:
///   0.0 = all actions identical (complete collapse)
///   1.0 = uniform distribution over all 5 exposure levels
fn calculate_entropy(recent_actions: &[FactoredAction]) -> Decimal {
    if recent_actions.is_empty() {
        return Decimal::ONE; // Default to high entropy if no history
    }

    // Count over 5 DQN exposure levels (Short100, Short50, Flat, Long50, Long100)
    let mut counts = [0_u32; 5];
    for action in recent_actions {
        let idx = action.exposure as usize;
        if idx < 5 {
            counts[idx] += 1;
        }
    }

    let total = recent_actions.len() as f64;
    let mut entropy_raw = 0.0_f64;

    for &count in &counts {
        if count > 0 {
            let p = count as f64 / total;
            entropy_raw -= p * p.ln();
        }
    }

    // Normalize by max entropy = ln(5) ≈ 1.609 → range [0, 1]
    let max_entropy = 5.0_f64.ln();
    let normalized = if max_entropy > 0.0 {
        entropy_raw / max_entropy
    } else {
        1.0
    };

    Decimal::try_from(normalized).unwrap_or(Decimal::ONE)
}

/// Reward function for `DQN` training
///
/// NOTE: The primary reward path now runs in the GPU experience-collection
/// kernel.  This CPU-side `RewardFunction` is retained for:
/// - DSR state sync between GPU and CPU (logging / checkpointing)
/// - Epoch-level state resets
/// - CPU-fallback reward calculation (testing / evaluation)
#[derive(Debug)]
pub struct RewardFunction {
    /// Configuration
    config: RewardConfig,
    /// Previous rewards for tracking (`VecDeque` for O(1) front removal)
    reward_history: VecDeque<Decimal>,
    /// Enable debug logging (`REWARD_DEBUG`, gradient norms, etc.)
    debug_logging: bool,
    /// Rolling buffer of returns for Sharpe ratio calculation (WAVE 26 P1.3)
    returns_buffer: std::collections::VecDeque<f64>,
    /// Differential Sharpe Ratio calculator (always enabled)
    dsr: DifferentialSharpeRatio,
}

impl RewardFunction {
    /// Create a new reward function with debug logging disabled
    ///
    /// # Errors
    /// Returns `MLError::ConfigError` if config validation fails (e.g., `use_percentage_pnl=false`)
    pub fn new(config: RewardConfig) -> Result<Self, MLError> {
        Self::new_with_debug(config, false)
    }

    /// Create a new reward function with configurable debug logging
    ///
    /// # Arguments
    /// * `config` - Reward function configuration
    /// * `debug_logging` - Enable debug logging (`REWARD_DEBUG`, gradient norms, etc.)
    ///
    /// # Errors
    /// Returns `MLError::ConfigError` if config validation fails (e.g., `use_percentage_pnl=false`)
    pub fn new_with_debug(config: RewardConfig, debug_logging: bool) -> Result<Self, MLError> {
        // Fix #2: Validate config on construction to prevent gradient explosion
        config.validate()?;

        let dsr = DifferentialSharpeRatio::new(config.dsr_eta);

        Ok(Self {
            config: config.clone(),
            reward_history: VecDeque::new(),
            debug_logging,
            returns_buffer: std::collections::VecDeque::with_capacity(config.sharpe_window),
            dsr,
        })
    }

    /// Builder for `RewardFunction`
    pub fn builder() -> RewardConfigBuilder {
        RewardConfig::builder()
    }

    /// Validate that a `TradingState` has the required portfolio features
    ///
    /// # Required Structure
    /// Portfolio features must have length >= 3:
    /// - [0]: Portfolio value (cash + unrealized P&L)
    /// - [1]: Position size (signed: +Long, -Short, 0=flat)
    /// - [2]: Bid-ask spread
    ///
    /// # Returns
    /// Ok(()) if valid, Err(MLError) with descriptive message if invalid
    fn validate_portfolio_features(state: &TradingState) -> Result<(), MLError> {
        if state.portfolio_features.len() < 3 {
            return Err(MLError::InvalidInput(
                format!(
                    "TradingState portfolio_features must have length >= 3 (got {}). Expected: [portfolio_value, position_size, spread]",
                    state.portfolio_features.len()
                )
            ));
        }
        Ok(())
    }

    /// Calculate reward for a state transition
    ///
    /// # Arguments
    /// * `action` - Trading action taken (`FactoredAction` with exposure, order type, urgency)
    /// * `current_state` - Current trading state (128-dim: 4 price + 121 technical + 3 portfolio)
    /// * `next_state` - Next trading state after action (128-dim structure)
    /// * `recent_actions` - Sliding window of last 100 actions for diversity penalty
    ///
    /// # Validation
    /// Validates that both states have proper `portfolio_features` (length >= 3).
    /// Logs warnings if features are missing but continues with default values.
    ///
    /// # Note
    /// Converts `FactoredAction` to legacy `TradingAction` for reward calculation logic.
    pub fn calculate_reward(
        &mut self,
        action: FactoredAction,
        current_state: &TradingState,
        next_state: &TradingState,
        recent_actions: &[FactoredAction],
    ) -> Result<Decimal, MLError> {
        // Validate portfolio features (non-fatal, logs warnings)
        if let Err(e) = Self::validate_portfolio_features(current_state) {
            tracing::warn!("Current state validation: {}", e);
        }
        if let Err(e) = Self::validate_portfolio_features(next_state) {
            tracing::warn!("Next state validation: {}", e);
        }

        // Convert to simple Buy/Sell/Hold for reward logic
        let trading_action = action.to_trading_action();

        let base_reward = match trading_action {
            TradingAction::Buy | TradingAction::Sell => {
                // Calculate P&L-based reward scaled by the agent's target exposure
                let pnl_reward = self.calculate_pnl_reward(current_state, next_state, action.target_exposure())?;

                // Calculate risk penalty
                let risk_penalty = self.calculate_risk_penalty(next_state);

                // Calculate transaction cost penalty
                let cost_penalty = self.calculate_cost_penalty(action, current_state, next_state);

                self.config.pnl_weight * pnl_reward
                    - self.config.risk_weight * risk_penalty
                    - self.config.cost_weight * cost_penalty
            },
            TradingAction::Hold => {
                // Neutral hold reward: 0.0 (no penalty, no bonus).
                // Idle penalty is now in the GPU composite reward kernel (w_idle).
                Decimal::ZERO
            },
        };

        // Calculate diversity bonus (in-training regularization)
        // Entropy is normalized [0,1] over 5 exposure actions. Threshold 0.3 ≈ ~2 distinct actions.
        let entropy = calculate_entropy(recent_actions);
        let entropy_threshold = Decimal::try_from(0.3).unwrap_or(Decimal::ZERO);
        let diversity_bonus = if entropy < entropy_threshold {
            // Scale penalty proportional to trading signal (~0.0005)
            Decimal::try_from(-0.0005).unwrap_or(Decimal::ZERO)
        } else {
            Decimal::ZERO // No penalty for balanced actions
        };

        let pnl_base_reward = base_reward + diversity_bonus;

        // DSR path: pass raw PnL + diversity through DSR transform for dense
        // risk-adjusted reward. Normalization lives in the GPU kernel now.
        let raw_pnl: f64 = pnl_base_reward.try_into()
            .map_err(|e| MLError::InvalidInput(format!("Reward conversion failed: {}", e)))?;

        let final_reward_f64: f64 = self.dsr.step(raw_pnl);

        // Convert back to Decimal for storage
        let final_reward_decimal = Decimal::try_from(final_reward_f64)
            .unwrap_or(base_reward);

        self.reward_history.push_back(final_reward_decimal);
        if self.reward_history.len() > 1000 {
            self.reward_history.pop_front();
        }

        Ok(final_reward_decimal)
    }

    /// Apply triple barrier label scaling to reward
    ///
    /// # Arguments
    /// * `base_reward` - The base reward before triple barrier adjustments
    /// * `barrier_label` - The triple barrier exit label (1=profit, -1=stop, 0=time)
    ///
    /// # Returns
    /// Adjusted reward with triple barrier bonus/penalty applied
    ///
    /// # Logic
    /// - Profit target (label=1): Apply positive bonus (+50% default)
    /// - Stop loss (label=-1): Apply negative penalty (-50% default)
    /// - Time expiry (label=0): No adjustment (use base reward)
    ///
    /// # Expected Impact
    /// - Reinforces profitable exits (hitting profit targets)
    /// - Discourages hitting stop losses
    /// - Neutral for time-based exits (let P&L speak for itself)
    pub fn apply_triple_barrier_scaling(
        &self,
        base_reward: Decimal,
        barrier_label: i8,
    ) -> Decimal {
        match barrier_label {
            1 => {
                // Profit target hit: add bonus
                let bonus = base_reward.abs() * self.config.triple_barrier_profit_bonus;
                base_reward + bonus
            },
            -1 => {
                // Stop loss hit: subtract penalty
                let penalty = base_reward.abs() * self.config.triple_barrier_stop_penalty;
                base_reward - penalty
            },
            _ => {
                // Time expiry or no barrier: use base reward unchanged
                base_reward
            },
        }
    }

    /// Calculate P&L-based reward component
    ///
    /// # BUG #17 FIX: Percentage-based P&L
    /// Uses percentage returns instead of absolute dollar changes to achieve scale-invariance.
    /// This solves the non-stationarity problem where rewards change based on portfolio size.
    ///
    /// # Formula
    /// - Percentage return: `pct_return = (next_value - current_value) / current_value`
    /// - Expected range: -0.02 to +0.02 (±2% moves per step)
    ///
    /// # Why Percentage-based?
    /// 1. **Scale-invariant**: $2K profit on $100K = 2% same as $20K on $1M
    /// 2. **Stationary**: Reward distribution stable across portfolio growth
    /// 3. **Prevents drift**: Absolute rewards would explode as portfolio grows
    ///
    /// # Portfolio Features Structure
    /// The 128-dimensional state vector is structured as:
    /// - [0..124]: 125 market features (from feature extraction pipeline)
    /// - [125]: Portfolio value (cash + unrealized P&L) - accessed via `portfolio_features`[0]
    /// - [126]: Position size (signed: +Long, -Short, 0=flat) - accessed via `portfolio_features`[1]
    /// - [127]: Bid-ask spread - accessed via `portfolio_features`[2]
    ///
    /// Note: `portfolio_features` is a 3-element vector extracted from indices [125,126,127]
    #[allow(clippy::cognitive_complexity, clippy::unnecessary_wraps)]
    fn calculate_pnl_reward(
        &self,
        current_state: &TradingState,
        next_state: &TradingState,
        target_exposure: f64,
    ) -> Result<Decimal, MLError> {
        // Validate portfolio_features length (defensive check)
        if current_state.portfolio_features.is_empty() {
            tracing::debug!(
                "Current state portfolio_features is empty, using 100000.0 for portfolio value"
            );
        }
        if next_state.portfolio_features.is_empty() {
            tracing::debug!("Next state portfolio_features is empty, using 100000.0 for portfolio value");
        }

        // portfolio_features[0] is NORMALIZED: (portfolio_value / initial_capital) - 1.0
        // where 0.0 = at initial capital, +0.01 = 1% gain, -0.01 = 1% loss.
        // Denormalize to absolute value for the reward function's zero-check.
        // Use initial_capital from config (default $100K if not set).
        let initial_capital = self.config.initial_capital;
        let default_val = Decimal::try_from(initial_capital).unwrap_or(Decimal::ONE_HUNDRED);
        let current_normalized = *current_state.portfolio_features.first().unwrap_or(&0.0) as f64;
        let next_normalized = *next_state.portfolio_features.first().unwrap_or(&0.0) as f64;
        let current_value =
            Decimal::try_from((current_normalized + 1.0) * initial_capital)
                .unwrap_or(default_val);
        let next_value =
            Decimal::try_from((next_normalized + 1.0) * initial_capital)
                .unwrap_or(default_val);

        // Bug #17 Fix: Use percentage-based P&L or absolute dollar change
        let pnl_reward = if self.config.use_percentage_pnl {
            // Percentage-based: pct_return = (next - current) / current
            // Avoid division by zero
            if current_value <= Decimal::ZERO {
                tracing::debug!("Current portfolio value <= 0, using fallback reward 0.0");
                Decimal::ZERO
            } else {
                let pct_return = (next_value - current_value) / current_value;

                // REWARD_DEBUG: Diagnostic logging to confirm percentage-based rewards
                // Expected: pct_return ±0.02 (±2% per step)
                // If broken: pct_return would be 100x-1000x larger (±20 to ±2000)
                if self.debug_logging {
                    tracing::info!(
                        "REWARD_DEBUG: use_percentage_pnl={}, current_value={:.2}, next_value={:.2}, pct_return={:.6}",
                        self.config.use_percentage_pnl,
                        current_value,
                        next_value,
                        pct_return
                    );
                }

                // Scale PnL by agent's target exposure so the reward reflects the
                // agent's intended position:
                //   Long100 (+1.0): full market return
                //   Short100 (-1.0): inverted market return (profit when market drops)
                //   Flat (0.0): zero PnL (no skin in the game)
                //   Long50/Short50 (±0.5): half-size position
                let exposure_decimal = Decimal::try_from(target_exposure)
                    .unwrap_or(Decimal::ONE);
                pct_return * exposure_decimal
            }
        } else {
            // Absolute dollar change (original implementation)
            let pnl_change = next_value - current_value;

            // REWARD_DEBUG: Diagnostic logging if absolute rewards are used (BUG!)
            if self.debug_logging {
                tracing::warn!(
                    "REWARD_DEBUG: using ABSOLUTE rewards! use_percentage_pnl={}, pnl_change={:.2}, scaled_reward={:.6}",
                    self.config.use_percentage_pnl,
                    pnl_change,
                    pnl_change / Decimal::try_from(10000.0).unwrap_or(Decimal::ONE)
                );
            }

            // Scale down by 10,000 to keep reward magnitude reasonable
            pnl_change / Decimal::try_from(10000.0).unwrap_or(Decimal::ONE)
        };

        Ok(pnl_reward)
    }

    /// Calculate risk penalty
    ///
    /// # Portfolio Features
    /// - `portfolio_features`[1] = Position size (signed: +Long, -Short, 0=flat)
    fn calculate_risk_penalty(&self, state: &TradingState) -> Decimal {
        // Validate portfolio_features length (defensive check)
        if state.portfolio_features.len() < 2 {
            tracing::warn!(
                "State portfolio_features has length < 2, cannot access position size, using 0.0"
            );
            return Decimal::ZERO;
        }

        // Simple risk penalty based on position size
        // portfolio_features[1] = position size (signed)
        let position_size =
            Decimal::try_from(state.portfolio_features.get(1).unwrap_or(&0.0).abs() as f64)
                .unwrap_or(Decimal::ZERO);
        let threshold = Decimal::try_from(0.8).unwrap_or(Decimal::ZERO);
        let multiplier = Decimal::try_from(5.0).unwrap_or(Decimal::ZERO);

        // Penalize excessive position sizes (assuming normalized features)
        if position_size > threshold {
            (position_size - threshold) * multiplier // Escalating penalty
        } else {
            Decimal::ZERO
        }
    }

    /// Calculate transaction cost penalty using actual exchange fees
    ///
    /// # BUG FIX: Use `action.transaction_cost()` instead of spread estimation
    ///
    /// **Root Cause**: Previous implementation used bid-ask spread × 0.5 (~0.0005 = 0.05%),
    /// which underestimated actual exchange fees by 3x for market orders.
    ///
    /// **Solution**: Use `action.transaction_cost()` to get actual order type fees:
    /// - Market orders: 0.15% (0.0015)
    /// - Limit maker: 0.05% (0.0005)
    /// - `IoC`: 0.10% (0.0010)
    ///
    /// # Formula
    /// ```text
    /// cost_penalty = position_change × price × transaction_cost_rate
    /// ```
    ///
    /// # Example
    /// For a position change of 1.0 contracts at $5000:
    /// - Old (spread): 1.0 × 0.001 × 0.5 = 0.0005 (~$0.50 equivalent)
    /// - New (market): 1.0 × 5000 × 0.0015 = $7.50 (actual cost)
    /// - Improvement: 15x more accurate
    ///
    /// # Portfolio Features
    /// - `portfolio_features`[1] = Position size (signed: +Long, -Short, 0=flat)
    /// - `portfolio_features`[0] = Portfolio value (for price estimation)
    ///
    /// # Arguments
    /// * `action` - The `FactoredAction` taken (contains order type for cost calculation)
    /// * `current_state` - Current trading state
    /// * `next_state` - Next trading state after action
    ///
    /// # Returns
    /// Transaction cost penalty as a fraction of portfolio value (e.g., 0.0015 for 0.15%)
    fn calculate_cost_penalty(
        &self,
        action: FactoredAction,
        current_state: &TradingState,
        next_state: &TradingState,
    ) -> Decimal {
        // Validate portfolio_features length (defensive check)
        if current_state.portfolio_features.len() < 2 {
            tracing::debug!("Current state portfolio_features has length < 2, cannot calculate cost penalty, using 0.0");
            return Decimal::ZERO;
        }
        if next_state.portfolio_features.len() < 2 {
            tracing::debug!("Next state portfolio_features has length < 2, cannot calculate cost penalty, using 0.0");
            return Decimal::ZERO;
        }

        // Estimate transaction costs based on spread and position change
        // portfolio_features[1] = position size (signed)
        let current_position =
            Decimal::try_from(*current_state.portfolio_features.get(1).unwrap_or(&0.0) as f64)
                .unwrap_or(Decimal::ZERO);
        let next_position =
            Decimal::try_from(*next_state.portfolio_features.get(1).unwrap_or(&0.0) as f64)
                .unwrap_or(Decimal::ZERO);

        let position_change = (next_position - current_position).abs();
        
        // If no position change, no transaction cost
        if position_change == Decimal::ZERO {
            return Decimal::ZERO;
        }

        // Get actual transaction cost rate from the action's order type
        // Market: 0.0015 (0.15%), LimitMaker: 0.0005 (0.05%), IoC: 0.0010 (0.10%)
        let tx_cost_rate = Decimal::try_from(action.transaction_cost())
            .unwrap_or(Decimal::try_from(0.0015).unwrap_or(Decimal::ZERO));
        
        // Extract current price from portfolio value and position
        // For a rough estimate: price ≈ portfolio_value / (1 + |position|)
        // This assumes position is normalized (0-1 range)
        #[allow(unused_variables)]
        let _portfolio_value =
            Decimal::try_from(*current_state.portfolio_features.first().unwrap_or(&100000.0) as f64)
                .unwrap_or(Decimal::try_from(100000.0).unwrap_or(Decimal::ZERO));
        
        // Apply urgency multiplier: Patient=0.5x, Normal=1.0x, Aggressive=1.5x
        // This ensures the urgency dimension affects the learned value function during training
        let urgency_mult = Decimal::try_from(action.urgency_weight())
            .unwrap_or(Decimal::ONE);

        // For percentage-based penalty: cost_rate × position_change × urgency
        // This gives a penalty proportional to the trade size and urgency
        // Example: 1.0 position change × 0.0015 × 1.5 (aggressive) = 0.00225 penalty
        let cost_penalty = position_change * tx_cost_rate * urgency_mult;

        tracing::trace!(
            "Transaction cost: position_change={:.4}, tx_rate={:.4}, urgency={:.2}, penalty={:.6}",
            position_change,
            tx_cost_rate,
            urgency_mult,
            cost_penalty
        );
        
        cost_penalty
    }

    /// Get average reward over recent history
    pub fn get_average_reward(&self, window: usize) -> Decimal {
        let window = window.min(self.reward_history.len());
        if window == 0 {
            return Decimal::ZERO;
        }

        let start = self.reward_history.len() - window;
        let sum: Decimal = self.reward_history.iter().skip(start).sum();
        sum / Decimal::from(window as u64)
    }

    /// Reset the DSR calculator state for episode boundaries.
    ///
    /// Call this between episodes to prevent cross-episode EMA leakage.
    pub const fn reset_dsr(&mut self) {
        self.dsr.reset();
    }

    /// Synchronize CPU DSR state from GPU `epoch_state` readback.
    ///
    /// After GPU experience collection, the kernel's DSR EMA accumulators
    /// are authoritative. This method copies them into the CPU-side DSR
    /// calculator for logging, checkpointing, and cross-epoch warm-start.
    pub const fn sync_dsr_from_gpu(&mut self, ema_a: f64, ema_b: f64) {
        self.dsr.sync_from_gpu(ema_a, ema_b);
    }

    /// Reset all epoch-level state for reward stationarity between epochs.
    /// Resets DSR calculator, returns buffer, and reward history.
    pub fn reset_epoch_state(&mut self) {
        self.reset_dsr();
        self.returns_buffer.clear();
        self.reward_history.clear();
    }

    /// Get reward statistics
    pub fn get_stats(&self) -> RewardStats {
        RewardStats {
            total_rewards: self.reward_history.len(),
            average_reward: if self.reward_history.is_empty() {
                Decimal::ZERO
            } else {
                let sum = self.reward_history.iter().sum::<Decimal>();
                sum / Decimal::from(self.reward_history.len() as u64)
            },
            max_reward: self
                .reward_history
                .iter()
                .fold(Decimal::MIN, |a, &b| a.max(b)),
            min_reward: self
                .reward_history
                .iter()
                .fold(Decimal::MAX, |a, &b| a.min(b)),
        }
    }
}

/// Reward statistics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RewardStats {
    /// Total number of rewards calculated
    pub total_rewards: usize,
    /// Average reward value
    pub average_reward: Decimal,
    /// Maximum reward observed
    pub max_reward: Decimal,
    /// Minimum reward observed
    pub min_reward: Decimal,
}

/// Calculate rewards for a batch of state transitions
///
/// # Arguments
/// * `reward_fn` - Reward function instance
/// * `actions` - Batch of actions taken (`FactoredActions`)
/// * `current_states` - Batch of current states
/// * `next_states` - Batch of next states
/// * `recent_actions` - Sliding window of last 100 actions (for diversity penalty)
///
/// # Returns
/// Vector of rewards corresponding to each state transition
pub fn calculate_batch_rewards(
    reward_fn: &mut RewardFunction,
    actions: &[FactoredAction],
    current_states: &[TradingState],
    next_states: &[TradingState],
    recent_actions: &[FactoredAction],
) -> Result<Vec<Decimal>, MLError> {
    if actions.len() != current_states.len() || actions.len() != next_states.len() {
        return Err(MLError::InvalidInput(
            "Batch size mismatch between actions, current states, and next states".to_owned(),
        ));
    }

    let mut rewards = Vec::with_capacity(actions.len());

    for (i, action) in actions.iter().enumerate() {
        let reward = reward_fn.calculate_reward(
            *action,
            &current_states[i],
            &next_states[i],
            recent_actions,
        )?;
        rewards.push(reward);
    }

    Ok(rewards)
}

#[cfg(test)]
#[allow(
    clippy::unnecessary_wraps,
    clippy::excessive_precision,
    clippy::useless_vec,
    dead_code
)]
mod tests {
    use super::*;
    use tracing::info;


    /// Create test state with proper 128-dim structure
    ///
    /// Structure:
    /// - 4 price features (OHLC)
    /// - 121 technical indicators (expanded to match production)
    /// - 0 market microstructure features (included in technical indicators)
    /// - 3 portfolio features [value, position, spread]
    ///
    /// Total: 4 + 121 + 3 = 128 dimensions
    fn create_test_state() -> TradingState {
        TradingState {
            price_features: vec![100.0, 100.0, 100.0, 100.0], // OHLC
            technical_indicators: vec![0.5; 121],             // 121 technical indicators
            market_features: vec![], // Market features included in technical indicators
            portfolio_features: vec![1.0, 0.0, 0.0001], // [portfolio_value, position, spread]
            regime_features: Vec::new(),
        }
    }

    #[test]
    fn test_reward_calculation() -> anyhow::Result<()> {
        // Test reward calculation concepts with Decimal
        let gain = Decimal::try_from(0.01).unwrap(); // 1% gain
        let scale = Decimal::try_from(100.0).unwrap();
        let reward = gain * scale; // Scale to reward

        assert!(reward > Decimal::ZERO);
        Ok(())
    }

    #[test]
    fn test_hold_reward() -> anyhow::Result<()> {
        // Test hold reward concepts with Decimal
        let hold_reward = Decimal::try_from(0.001).unwrap();
        let threshold = Decimal::try_from(0.01).unwrap();
        assert!(hold_reward >= Decimal::ZERO);
        assert!(hold_reward < threshold);
        Ok(())
    }

    #[test]
    fn test_transaction_costs() -> anyhow::Result<()> {
        // Test transaction cost concepts with Decimal
        let base_reward = Decimal::try_from(0.1).unwrap();
        let transaction_cost = Decimal::try_from(0.05).unwrap();
        let net_reward = base_reward - transaction_cost;

        assert!(net_reward < base_reward);
        assert!(transaction_cost > Decimal::ZERO);
        Ok(())
    }

    #[test]
    fn test_batch_rewards() -> anyhow::Result<()> {
        // Test batch reward processing concepts with Decimal
        let batch_size = 2;
        let rewards = vec![
            Decimal::try_from(0.1).unwrap(),
            Decimal::try_from(-0.05).unwrap(),
        ];

        assert_eq!(rewards.len(), batch_size);
        assert!(rewards[0] > Decimal::ZERO);
        assert!(rewards[1] < Decimal::ZERO);
        Ok(())
    }

    #[test]
    fn test_transaction_cost_fix_market_orders() -> anyhow::Result<()> {
        use crate::action_space::{ExposureLevel, OrderType, Urgency};

        // Create reward function with default config
        let config = RewardConfig::default();
        let reward_fn = RewardFunction::new(config)?;

        // Create states with position change: 0.0 → 1.0 (buy 1 contract)
        let current_state = TradingState {
            price_features: vec![0.0; 4],
            technical_indicators: vec![0.5; 121],
            market_features: vec![],
            portfolio_features: vec![100000.0, 0.0, 0.001], // $100K portfolio, 0 position, 0.1% spread
            regime_features: vec![],
        };

        let next_state = TradingState {
            price_features: vec![0.0; 4],
            technical_indicators: vec![0.5; 121],
            market_features: vec![],
            portfolio_features: vec![100000.0, 1.0, 0.001], // Same value, +1.0 position
            regime_features: vec![],
        };

        // Test Market order (0.15% fee)
        let market_action = FactoredAction::new(
            ExposureLevel::Long100,
            OrderType::Market,
            Urgency::Normal,
        );
        let market_cost = reward_fn.calculate_cost_penalty(market_action, &current_state, &next_state);

        // Expected: 1.0 position change × 0.0015 × 1.0 (Normal urgency) = 0.0015 (0.15%)
        let expected_market = Decimal::try_from(0.0015).unwrap();
        assert!(
            (market_cost - expected_market).abs() < Decimal::try_from(0.0001).unwrap(),
            "Market+Normal order cost should be 0.0015, got {}",
            market_cost
        );

        // Test LimitMaker order (0.05% fee) with Patient urgency (0.5x)
        let limit_action = FactoredAction::new(
            ExposureLevel::Long100,
            OrderType::LimitMaker,
            Urgency::Patient,
        );
        let limit_cost = reward_fn.calculate_cost_penalty(limit_action, &current_state, &next_state);

        // Expected: 1.0 position change × 0.0005 × 0.5 (Patient urgency) = 0.00025
        let expected_limit = Decimal::try_from(0.00025).unwrap();
        assert!(
            (limit_cost - expected_limit).abs() < Decimal::try_from(0.0001).unwrap(),
            "LimitMaker+Patient order cost should be 0.00025, got {}",
            limit_cost
        );

        // Test IoC order (0.10% fee) with Aggressive urgency (1.5x)
        let ioc_action = FactoredAction::new(
            ExposureLevel::Long100,
            OrderType::IoC,
            Urgency::Aggressive,
        );
        let ioc_cost = reward_fn.calculate_cost_penalty(ioc_action, &current_state, &next_state);

        // Expected: 1.0 position change × 0.0010 × 1.5 (Aggressive urgency) = 0.0015
        let expected_ioc = Decimal::try_from(0.0015).unwrap();
        assert!(
            (ioc_cost - expected_ioc).abs() < Decimal::try_from(0.0001).unwrap(),
            "IoC+Aggressive order cost should be 0.0015, got {}",
            ioc_cost
        );

        // Verify market+normal is 6x more expensive than limit+patient
        // (0.0015 / 0.00025 = 6.0)
        assert!(
            market_cost > limit_cost * Decimal::try_from(5.0).unwrap(),
            "Market+Normal ({}) should be ~6x more expensive than LimitMaker+Patient ({})",
            market_cost,
            limit_cost
        );

        Ok(())
    }

    #[test]
    fn test_transaction_cost_realistic_scenario() -> anyhow::Result<()> {
        use crate::action_space::{ExposureLevel, OrderType, Urgency};

        // Simulate realistic trading scenario from audit report
        // Agent makes small profit ($0.10) but actual cost is $4.50
        let config = RewardConfig::default();
        let mut reward_fn = RewardFunction::new(config)?;

        // Initial state: $10,000 portfolio, flat position
        let initial_state = TradingState {
            price_features: vec![0.0; 4],
            technical_indicators: vec![0.5; 121],
            market_features: vec![],
            portfolio_features: vec![10000.0, 0.0, 0.001], // $10K, flat, 0.1% spread
            regime_features: vec![],
        };

        // After trade: Small profit of $0.10 (0.001% gain), position = 1.0
        let after_trade_state = TradingState {
            price_features: vec![0.0; 4],
            technical_indicators: vec![0.5; 121],
            market_features: vec![],
            portfolio_features: vec![10000.10, 1.0, 0.001], // $10K + $0.10, long 1.0
            regime_features: vec![],
        };

        // Market order action
        let market_action = FactoredAction::new(
            ExposureLevel::Long100,
            OrderType::Market,
            Urgency::Normal,
        );

        // Calculate transaction cost penalty
        let tx_cost = reward_fn.calculate_cost_penalty(
            market_action,
            &initial_state,
            &after_trade_state,
        );

        // Transaction cost: 1.0 position change × 0.0015 = 0.0015 (0.15%)
        // On $10K portfolio: 0.0015 × 10000 = $15 equivalent cost
        let expected_cost = Decimal::try_from(0.0015).unwrap();
        assert!(
            (tx_cost - expected_cost).abs() < Decimal::try_from(0.0001).unwrap(),
            "Expected cost ~0.0015, got {}",
            tx_cost
        );

        // Calculate full reward (PnL - cost)
        let reward = reward_fn.calculate_reward(
            market_action,
            &initial_state,
            &after_trade_state,
            &[],
        )?;

        // PnL component: (10000.10 - 10000.0) / 10000.0 = 0.00001 (0.001%)
        // Cost penalty: 0.0015 (0.15%) weighted by cost_weight (1.0) - Bug #2 fix
        // Expected: Small positive PnL minus significant cost → negative net reward
        // The agent should learn NOT to make this trade
        
        // Verify cost penalty is ~150x larger than PnL gain (before weighting)
        let pnl_component = Decimal::try_from(0.00001).unwrap(); // 0.001%
        let cost_component = expected_cost; // 0.15%
        
        assert!(
            cost_component > pnl_component * Decimal::try_from(100.0).unwrap(),
            "Transaction cost (0.15%) should be ~150x larger than PnL (0.001%)"
        );

        // The actual reward will be normalized and clamped, but the key insight is:
        // With correct transaction costs, unprofitable trades will have negative rewards
        info!(reward = %reward, "Reward for $0.10 profit with $15 cost");

        Ok(())
    }

    #[test]
    fn test_transaction_cost_zero_for_hold() -> anyhow::Result<()> {
        use crate::action_space::{ExposureLevel, OrderType, Urgency};

        let config = RewardConfig::default();
        let reward_fn = RewardFunction::new(config)?;

        // Create states with NO position change (hold scenario)
        let current_state = TradingState {
            price_features: vec![0.0; 4],
            technical_indicators: vec![0.5; 121],
            market_features: vec![],
            portfolio_features: vec![100000.0, 0.5, 0.001], // $100K portfolio, 0.5 position
            regime_features: vec![],
        };

        let next_state = TradingState {
            price_features: vec![0.0; 4],
            technical_indicators: vec![0.5; 121],
            market_features: vec![],
            portfolio_features: vec![100000.0, 0.5, 0.001], // Same position (hold)
            regime_features: vec![],
        };

        // Test that HOLD action has zero transaction cost
        let hold_action = FactoredAction::new(
            ExposureLevel::Flat,
            OrderType::Market,
            Urgency::Normal,
        );
        let hold_cost = reward_fn.calculate_cost_penalty(hold_action, &current_state, &next_state);

        assert_eq!(
            hold_cost,
            Decimal::ZERO,
            "HOLD action should have zero transaction cost, got {}",
            hold_cost
        );

        Ok(())
    }

    #[test]
    fn test_dsr_basic() {
        let mut dsr = DifferentialSharpeRatio::new(0.01);

        // First step should return 0.0 (initialization)
        let first = dsr.step(0.001);
        assert!(
            first.abs() < f64::EPSILON,
            "First DSR step should be 0.0, got {}",
            first
        );

        // Positive return should produce positive DSR
        let positive = dsr.step(0.02);
        assert!(
            positive > 0.0,
            "Positive return should produce positive DSR, got {}",
            positive
        );

        // Negative return should produce negative DSR
        let negative = dsr.step(-0.03);
        assert!(
            negative < 0.0,
            "Negative return should produce negative DSR, got {}",
            negative
        );
    }

    #[test]
    fn test_dsr_bounded() {
        let mut dsr = DifferentialSharpeRatio::new(0.01);

        // Initialize with a small return
        dsr.step(0.001);

        // Extreme positive return should be clamped to 5.0
        let extreme_pos = dsr.step(1000.0);
        assert!(
            extreme_pos <= 5.0,
            "Extreme positive DSR should be clamped to 5.0, got {}",
            extreme_pos
        );
        assert!(
            extreme_pos >= -5.0,
            "DSR should be >= -5.0, got {}",
            extreme_pos
        );

        // Extreme negative return should be clamped to -5.0
        let extreme_neg = dsr.step(-1000.0);
        assert!(
            extreme_neg >= -5.0,
            "Extreme negative DSR should be clamped to -5.0, got {}",
            extreme_neg
        );
        assert!(
            extreme_neg <= 5.0,
            "DSR should be <= 5.0, got {}",
            extreme_neg
        );
    }

    #[test]
    fn test_dsr_reset() {
        let mut dsr = DifferentialSharpeRatio::new(0.01);

        // Feed some data
        dsr.step(0.01);
        dsr.step(0.02);
        dsr.step(-0.01);
        assert!(dsr.initialized, "DSR should be initialized after steps");

        // Reset
        dsr.reset();
        assert!(
            !dsr.initialized,
            "DSR should not be initialized after reset"
        );

        // First step after reset should return 0.0 again
        let after_reset = dsr.step(0.005);
        assert!(
            after_reset.abs() < f64::EPSILON,
            "First step after reset should be 0.0, got {}",
            after_reset
        );
    }

    #[test]
    fn test_dsr_config_roundtrip() -> anyhow::Result<()> {
        let config = RewardConfig::builder()
            .dsr_eta(0.02)
            .build()?;

        assert!(
            (config.dsr_eta - 0.02).abs() < f64::EPSILON,
            "dsr_eta should be 0.02, got {}",
            config.dsr_eta
        );

        // Verify defaults when not set
        let default_config = RewardConfig::builder().build()?;
        assert!(
            (default_config.dsr_eta - 0.01).abs() < f64::EPSILON,
            "dsr_eta should default to 0.01, got {}",
            default_config.dsr_eta
        );

        Ok(())
    }
}
