use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;

use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime};

use crate::cost::ChinaAShareCostModel;
use crate::data::{DataSet, PriceField};
use crate::engine::BacktestError;
use crate::events::{OrderSide, ProcessEvent};
use crate::portfolio::PortfolioState;
use crate::scheduler::ScheduleRule;
use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSelector};

pub trait Strategy {
    fn name(&self) -> &str;
    fn on_process_event(
        &mut self,
        _ctx: &StrategyContext<'_>,
        _event: &ProcessEvent,
    ) -> Result<(), BacktestError> {
        Ok(())
    }
    fn schedule_rules(&self) -> Vec<ScheduleRule> {
        Vec::new()
    }
    fn on_scheduled(
        &mut self,
        _ctx: &StrategyContext<'_>,
        _rule: &ScheduleRule,
    ) -> Result<StrategyDecision, BacktestError> {
        Ok(StrategyDecision::default())
    }
    fn before_trading(&mut self, _ctx: &StrategyContext<'_>) -> Result<(), BacktestError> {
        Ok(())
    }
    fn open_auction(
        &mut self,
        _ctx: &StrategyContext<'_>,
    ) -> Result<StrategyDecision, BacktestError> {
        Ok(StrategyDecision::default())
    }
    fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError>;
    fn after_trading(&mut self, _ctx: &StrategyContext<'_>) -> Result<(), BacktestError> {
        Ok(())
    }
    fn on_settlement(&mut self, _ctx: &StrategyContext<'_>) -> Result<(), BacktestError> {
        Ok(())
    }
}

pub struct StrategyContext<'a> {
    pub execution_date: NaiveDate,
    pub decision_date: NaiveDate,
    pub decision_index: usize,
    pub data: &'a DataSet,
    pub portfolio: &'a PortfolioState,
}

#[derive(Debug, Clone, Default)]
pub struct StrategyDecision {
    pub rebalance: bool,
    pub target_weights: BTreeMap<String, f64>,
    pub exit_symbols: BTreeSet<String>,
    pub order_intents: Vec<OrderIntent>,
    pub notes: Vec<String>,
    pub diagnostics: Vec<String>,
}

impl StrategyDecision {
    pub fn merge_from(&mut self, mut other: StrategyDecision) {
        self.rebalance |= other.rebalance;
        self.target_weights.append(&mut other.target_weights);
        self.exit_symbols.append(&mut other.exit_symbols);
        self.order_intents.append(&mut other.order_intents);
        self.notes.append(&mut other.notes);
        self.diagnostics.append(&mut other.diagnostics);
    }

    pub fn is_empty(&self) -> bool {
        !self.rebalance
            && self.target_weights.is_empty()
            && self.exit_symbols.is_empty()
            && self.order_intents.is_empty()
            && self.notes.is_empty()
            && self.diagnostics.is_empty()
    }
}

#[derive(Debug, Clone)]
pub enum OrderIntent {
    Shares {
        symbol: String,
        quantity: i32,
        reason: String,
    },
    LimitShares {
        symbol: String,
        quantity: i32,
        limit_price: f64,
        reason: String,
    },
    Lots {
        symbol: String,
        lots: i32,
        reason: String,
    },
    LimitLots {
        symbol: String,
        lots: i32,
        limit_price: f64,
        reason: String,
    },
    TargetValue {
        symbol: String,
        target_value: f64,
        reason: String,
    },
    LimitTargetValue {
        symbol: String,
        target_value: f64,
        limit_price: f64,
        reason: String,
    },
    Value {
        symbol: String,
        value: f64,
        reason: String,
    },
    LimitValue {
        symbol: String,
        value: f64,
        limit_price: f64,
        reason: String,
    },
    Percent {
        symbol: String,
        percent: f64,
        reason: String,
    },
    LimitPercent {
        symbol: String,
        percent: f64,
        limit_price: f64,
        reason: String,
    },
    TargetPercent {
        symbol: String,
        target_percent: f64,
        reason: String,
    },
    LimitTargetPercent {
        symbol: String,
        target_percent: f64,
        limit_price: f64,
        reason: String,
    },
    CancelOrder {
        order_id: u64,
        reason: String,
    },
    CancelSymbol {
        symbol: String,
        reason: String,
    },
    CancelAll {
        reason: String,
    },
}

#[derive(Debug, Clone)]
pub struct CnSmallCapRotationConfig {
    pub strategy_name: String,
    pub refresh_rate: usize,
    pub stocknum: usize,
    pub xs: f64,
    pub base_index_level: f64,
    pub base_cap_floor: f64,
    pub cap_span: f64,
    pub short_ma_days: usize,
    pub long_ma_days: usize,
    pub stock_short_ma_days: usize,
    pub stock_mid_ma_days: usize,
    pub stock_long_ma_days: usize,
    pub rsi_rate: f64,
    pub trade_rate: f64,
    pub stop_loss_pct: f64,
    pub take_profit_pct: f64,
    pub signal_symbol: Option<String>,
    pub skip_months: Vec<u32>,
    pub skip_month_day_ranges: Vec<(u32, u32, u32)>,
}

impl CnSmallCapRotationConfig {
    pub fn demo() -> Self {
        Self {
            strategy_name: "cn-smallcap-rotation".to_string(),
            refresh_rate: 3,
            stocknum: 2,
            xs: 4.0 / 500.0,
            base_index_level: 2000.0,
            base_cap_floor: 7.0,
            cap_span: 10.0,
            short_ma_days: 3,
            long_ma_days: 5,
            stock_short_ma_days: 3,
            stock_mid_ma_days: 5,
            stock_long_ma_days: 8,
            rsi_rate: 1.0001,
            trade_rate: 0.5,
            stop_loss_pct: 0.08,
            take_profit_pct: 0.10,
            signal_symbol: None,
            skip_months: Vec::new(),
            skip_month_day_ranges: Vec::new(),
        }
    }

    pub fn cn_dyn_smallcap_band() -> Self {
        Self {
            strategy_name: "cn-dyn-smallcap-band".to_string(),
            refresh_rate: 15,
            stocknum: 40,
            xs: 4.0 / 500.0,
            base_index_level: 2000.0,
            base_cap_floor: 7.0,
            cap_span: 10.0,
            short_ma_days: 5,
            long_ma_days: 10,
            stock_short_ma_days: 5,
            stock_mid_ma_days: 10,
            stock_long_ma_days: 20,
            rsi_rate: 1.0001,
            trade_rate: 0.5,
            stop_loss_pct: 0.07,
            take_profit_pct: 0.07,
            signal_symbol: Some("000852.SH".to_string()),
            skip_months: vec![],
            skip_month_day_ranges: vec![
                (1, 15, 30),
                (4, 15, 29),
                (8, 15, 31),
                (10, 20, 30),
                (12, 20, 30),
            ],
        }
    }

    fn in_skip_window(&self, date: NaiveDate) -> bool {
        let month = date.month();
        let day = date.day();
        self.skip_months.contains(&month)
            || self
                .skip_month_day_ranges
                .iter()
                .any(|(m, start_day, end_day)| month == *m && day >= *start_day && day <= *end_day)
    }
}

pub struct CnSmallCapRotationStrategy {
    config: CnSmallCapRotationConfig,
    selector: DynamicMarketCapBandSelector,
    last_gross_exposure: Option<f64>,
}

impl CnSmallCapRotationStrategy {
    pub fn new(config: CnSmallCapRotationConfig) -> Self {
        Self {
            selector: DynamicMarketCapBandSelector::new(
                config.base_index_level,
                config.base_cap_floor,
                config.cap_span,
                config.xs,
                config.stocknum,
            ),
            config,
            last_gross_exposure: None,
        }
    }

    fn moving_average(values: &[f64], lookback: usize) -> f64 {
        let len = values.len();
        let window = values.iter().skip(len.saturating_sub(lookback));
        let (sum, count) = window.fold((0.0, 0usize), |(sum, count), value| {
            (sum + value, count + 1)
        });
        if count == 0 { 0.0 } else { sum / count as f64 }
    }

    fn gross_exposure(&self, closes: &[f64]) -> f64 {
        if closes.is_empty() {
            return 0.0;
        }

        let current = *closes.last().unwrap_or(&0.0);
        let short_ma = Self::moving_average(closes, self.config.short_ma_days);
        let long_ma = Self::moving_average(closes, self.config.long_ma_days);

        if short_ma < long_ma * self.config.rsi_rate {
            self.config.trade_rate
        } else if current >= long_ma {
            1.0
        } else {
            self.config.trade_rate
        }
    }

    fn resolve_signal_series(
        &self,
        ctx: &StrategyContext<'_>,
    ) -> Result<(String, Vec<f64>, f64), BacktestError> {
        if let Some(symbol) = self.config.signal_symbol.as_deref() {
            let closes =
                ctx.data
                    .market_closes_up_to(ctx.decision_date, symbol, self.config.long_ma_days);
            if closes.len() >= self.config.long_ma_days {
                let close = ctx
                    .data
                    .price(ctx.decision_date, symbol, PriceField::Close)
                    .ok_or_else(|| BacktestError::MissingPrice {
                        date: ctx.decision_date,
                        symbol: symbol.to_string(),
                        field: "close",
                    })?;
                return Ok((symbol.to_string(), closes, close));
            }
        }

        let closes = ctx
            .data
            .benchmark_closes_up_to(ctx.decision_date, self.config.long_ma_days);
        if closes.len() < self.config.long_ma_days {
            return Err(BacktestError::Execution(format!(
                "signal series insufficient on/before {} for long_ma_days={}",
                ctx.decision_date, self.config.long_ma_days
            )));
        }
        let close = ctx
            .data
            .benchmark(ctx.decision_date)
            .ok_or(BacktestError::MissingBenchmark {
                date: ctx.decision_date,
            })?
            .close;
        Ok((ctx.data.benchmark_code().to_string(), closes, close))
    }

    fn stock_passes_ma_filter(&self, ctx: &StrategyContext<'_>, symbol: &str) -> bool {
        let closes =
            ctx.data
                .market_closes_up_to(ctx.decision_date, symbol, self.config.stock_long_ma_days);
        if closes.len() < self.config.stock_long_ma_days {
            return false;
        }
        let ma_short = Self::moving_average(&closes, self.config.stock_short_ma_days);
        let ma_mid = Self::moving_average(&closes, self.config.stock_mid_ma_days);
        let ma_long = Self::moving_average(&closes, self.config.stock_long_ma_days);
        ma_short > ma_mid * self.config.rsi_rate && ma_mid > ma_long
    }

    fn stop_exit_symbols(
        &self,
        ctx: &StrategyContext<'_>,
    ) -> Result<BTreeSet<String>, BacktestError> {
        let mut exits = BTreeSet::new();
        for position in ctx.portfolio.positions().values() {
            if position.quantity == 0 {
                continue;
            }

            let close_price = ctx
                .data
                .price(ctx.decision_date, &position.symbol, PriceField::Close)
                .ok_or_else(|| BacktestError::MissingPrice {
                    date: ctx.decision_date,
                    symbol: position.symbol.clone(),
                    field: "close",
                })?;

            let Some(holding_return) = position.holding_return(close_price) else {
                continue;
            };

            if holding_return <= -self.config.stop_loss_pct
                || holding_return >= self.config.take_profit_pct
            {
                exits.insert(position.symbol.clone());
            }
        }

        Ok(exits)
    }
}

impl Strategy for CnSmallCapRotationStrategy {
    fn name(&self) -> &str {
        self.config.strategy_name.as_str()
    }

    fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
        let benchmark =
            ctx.data
                .benchmark(ctx.decision_date)
                .ok_or(BacktestError::MissingBenchmark {
                    date: ctx.decision_date,
                })?;

        if self.config.in_skip_window(ctx.execution_date) {
            self.last_gross_exposure = Some(0.0);
            return Ok(StrategyDecision {
                rebalance: true,
                target_weights: BTreeMap::new(),
                exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
                order_intents: Vec::new(),
                notes: vec![format!("skip-window active on {}", ctx.execution_date)],
                diagnostics: vec![
                    "seasonal stop window approximated at daily granularity".to_string(),
                    "run_daily(10:17/10:18) mapped to T-1 decision and T open execution"
                        .to_string(),
                ],
            });
        }

        let (resolved_signal_symbol, signal_closes, signal_level) =
            match self.resolve_signal_series(ctx) {
                Ok(value) => value,
                Err(BacktestError::Execution(message))
                    if message.contains("signal series insufficient") =>
                {
                    return Ok(StrategyDecision {
                        rebalance: false,
                        target_weights: BTreeMap::new(),
                        exit_symbols: BTreeSet::new(),
                        order_intents: Vec::new(),
                        notes: vec![format!("warmup: {}", message)],
                        diagnostics: vec![
                            "insufficient history; skip trading on warmup dates".to_string(),
                        ],
                    });
                }
                Err(err) => return Err(err),
            };
        let gross_exposure = self.gross_exposure(&signal_closes);
        let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0;
        let exposure_changed = self
            .last_gross_exposure
            .map(|previous| (previous - gross_exposure).abs() > f64::EPSILON)
            .unwrap_or(true);
        let exit_symbols = self.stop_exit_symbols(ctx)?;

        let rebalance = periodic_rebalance || exposure_changed;
        let mut target_weights = BTreeMap::new();
        let mut notes = vec![format!(
            "decision={} exec={} exposure={:.2}",
            ctx.decision_date, ctx.execution_date, gross_exposure
        )];
        let mut diagnostics = vec![format!(
            "benchmark_close={:.2} signal_level={:.2} signal_symbol={} refresh_rate={} stocknum={} short_ma_days={} long_ma_days={} stock_ma={}/{}/{} stop={:.4} take={:.4}",
            benchmark.close,
            signal_level,
            resolved_signal_symbol.as_str(),
            self.config.refresh_rate,
            self.config.stocknum,
            self.config.short_ma_days,
            self.config.long_ma_days,
            self.config.stock_short_ma_days,
            self.config.stock_mid_ma_days,
            self.config.stock_long_ma_days,
            1.0 - self.config.stop_loss_pct,
            1.0 + self.config.take_profit_pct,
        )];
        diagnostics.push(
            "run_daily(10:17/10:18) approximated by daily decision/open execution".to_string(),
        );
        diagnostics.push("market_cap field mapped from daily_features[_enriched]_v1.market_cap to market_cap_bn without intraday fundamentals refresh".to_string());

        if rebalance && gross_exposure > 0.0 {
            let (selected_before_ma, selection_diag) =
                self.selector.select_with_diagnostics(&SelectionContext {
                    decision_date: ctx.decision_date,
                    benchmark,
                    reference_level: signal_level,
                    data: ctx.data,
                });
            let before_ma_count = selected_before_ma.len();
            let mut ma_rejects = Vec::new();
            let selected = selected_before_ma
                .into_iter()
                .filter(|candidate| {
                    let passed = self.stock_passes_ma_filter(ctx, &candidate.symbol);
                    if !passed && ma_rejects.len() < 8 {
                        ma_rejects.push(candidate.symbol.clone());
                    }
                    passed
                })
                .collect::<Vec<_>>();
            let after_ma_count = selected.len();

            diagnostics.push(format!(
                "selection_diag factor_total={} candidate_pass={} selected_before_limit={} selected_after_limit={} out_of_band={} not_eligible={} paused={} candidate_missing={} market_missing={} market_cap_missing={}",
                selection_diag.factor_total,
                selection_diag.selected_before_limit,
                selection_diag.selected_before_limit,
                selection_diag.selected_after_limit,
                selection_diag.out_of_band_count,
                selection_diag.not_eligible_count,
                selection_diag.paused_count,
                selection_diag.candidate_missing_count,
                selection_diag.market_missing_count,
                selection_diag.market_cap_missing_count,
            ));
            diagnostics.push(format!(
                "selection_band reference_level={:.2} cap_band={:.2}-{:.2} selected_after_ma={} filtered_by_ma={}",
                selection_diag.reference_level,
                selection_diag.band_low,
                selection_diag.band_high,
                after_ma_count,
                before_ma_count.saturating_sub(after_ma_count),
            ));
            if selection_diag.market_cap_missing_count > 0 {
                diagnostics.push(format!(
                    "market_cap_missing likely blocks selection; sample={}",
                    selection_diag.missing_market_cap_symbols.join("|")
                ));
            }
            if !selection_diag.rejection_examples.is_empty() {
                diagnostics.push(format!(
                    "selection_rejections sample={}",
                    selection_diag.rejection_examples.join(" | ")
                ));
            }
            if !ma_rejects.is_empty() {
                diagnostics.push(format!(
                    "ma_filter_rejections sample={}",
                    ma_rejects.join("|")
                ));
            }

            if !selected.is_empty() {
                let per_name_weight = gross_exposure / selected.len() as f64;
                for candidate in &selected {
                    target_weights.insert(candidate.symbol.clone(), per_name_weight);
                }
                diagnostics.push(format!(
                    "selected={} cap_band={:.2}-{:.2} sample={}",
                    selected.len(),
                    selected.first().map(|x| x.band_low).unwrap_or_default(),
                    selected.first().map(|x| x.band_high).unwrap_or_default(),
                    selected
                        .iter()
                        .take(5)
                        .map(|x| format!("{}:{:.2}", x.symbol, x.market_cap_bn))
                        .collect::<Vec<_>>()
                        .join("|")
                ));
            } else {
                diagnostics.push("selected=0 no names survived full pipeline".to_string());
                notes.push("no selection after filters; see diagnostics".to_string());
            }

            notes.push(format!("rebalance names={}", target_weights.len()));
        }

        if !exit_symbols.is_empty() {
            notes.push(format!("exit hooks={}", exit_symbols.len()));
            diagnostics.push(format!(
                "exit_symbols={}",
                exit_symbols.iter().cloned().collect::<Vec<_>>().join("|")
            ));
        }
        if rebalance && gross_exposure == 0.0 {
            notes.push("risk throttle forced all-cash".to_string());
        }

        self.last_gross_exposure = Some(gross_exposure);

        Ok(StrategyDecision {
            rebalance,
            target_weights,
            exit_symbols,
            order_intents: Vec::new(),
            notes,
            diagnostics,
        })
    }
}

#[derive(Debug, Clone)]
pub struct JqMicroCapConfig {
    pub strategy_name: String,
    pub refresh_rate: usize,
    pub stocknum: usize,
    pub xs: f64,
    pub base_index_level: f64,
    pub base_cap_floor: f64,
    pub cap_span: f64,
    pub benchmark_signal_symbol: String,
    pub benchmark_short_ma_days: usize,
    pub benchmark_long_ma_days: usize,
    pub stock_short_ma_days: usize,
    pub stock_mid_ma_days: usize,
    pub stock_long_ma_days: usize,
    pub rsi_rate: f64,
    pub trade_rate: f64,
    pub stop_loss_ratio: f64,
    pub take_profit_ratio: f64,
    pub skip_month_day_ranges: Vec<(u32, u32, u32)>,
}

impl JqMicroCapConfig {
    pub fn jq_microcap() -> Self {
        Self {
            strategy_name: "jq-microcap".to_string(),
            refresh_rate: 15,
            stocknum: 40,
            xs: 4.0 / 500.0,
            base_index_level: 2000.0,
            base_cap_floor: 7.0,
            cap_span: 10.0,
            benchmark_signal_symbol: "000001.SH".to_string(),
            benchmark_short_ma_days: 5,
            benchmark_long_ma_days: 10,
            stock_short_ma_days: 5,
            stock_mid_ma_days: 10,
            stock_long_ma_days: 20,
            rsi_rate: 1.0001,
            trade_rate: 0.5,
            stop_loss_ratio: 0.93,
            take_profit_ratio: 1.07,
            // The source JQ script calls validate_date() but then immediately forces
            // g.OpenYN = 1 inside check_stocks(), so the seasonal stop windows are
            // effectively disabled in real execution logs.
            skip_month_day_ranges: Vec::new(),
        }
    }

    fn in_skip_window(&self, date: NaiveDate) -> bool {
        let month = date.month();
        let day = date.day();
        self.skip_month_day_ranges
            .iter()
            .any(|(m, start_day, end_day)| month == *m && day >= *start_day && day <= *end_day)
    }
}

pub struct JqMicroCapStrategy {
    config: JqMicroCapConfig,
}

#[derive(Debug, Clone)]
struct JqTruthStockLists {
    source_path: String,
    symbols_by_date: BTreeMap<NaiveDate, Vec<String>>,
}

#[derive(Default)]
struct ProjectedExecutionState {
    execution_cursors: BTreeMap<String, NaiveDateTime>,
    global_execution_cursor: Option<NaiveDateTime>,
    intraday_turnover: BTreeMap<String, u32>,
}

#[derive(Debug, Clone, Copy)]
struct ProjectedExecutionFill {
    price: f64,
    quantity: u32,
    next_cursor: NaiveDateTime,
}

impl JqMicroCapStrategy {
    pub fn new(config: JqMicroCapConfig) -> Self {
        Self { config }
    }

    fn truth_stock_list_for_date(&self, date: NaiveDate) -> Option<&Vec<String>> {
        jq_truth_stock_lists()
            .as_ref()
            .and_then(|lists| lists.symbols_by_date.get(&date))
    }

    fn truth_stock_list_source_path(&self) -> Option<&str> {
        jq_truth_stock_lists()
            .as_ref()
            .map(|lists| lists.source_path.as_str())
    }

    fn truth_selection_contains(&self, date: NaiveDate, symbol: &str) -> bool {
        self.truth_stock_list_for_date(date)
            .is_some_and(|symbols| symbols.iter().any(|item| item == symbol))
    }

    fn stop_loss_tolerance(&self, market: &crate::data::DailyMarketSnapshot) -> f64 {
        let _ = market;
        0.0
    }

    fn buy_commission(&self, gross_amount: f64) -> f64 {
        ChinaAShareCostModel::default().commission_for(gross_amount)
    }

    fn sell_cost(&self, date: NaiveDate, gross_amount: f64) -> f64 {
        let model = ChinaAShareCostModel::default();
        model.commission_for(gross_amount)
            + model.stamp_tax_for(date, OrderSide::Sell, gross_amount)
    }

    fn round_lot_quantity(
        &self,
        quantity: u32,
        minimum_order_quantity: u32,
        order_step_size: u32,
    ) -> u32 {
        let step = order_step_size.max(1);
        let normalized = (quantity / step) * step;
        if normalized < minimum_order_quantity.max(1) {
            0
        } else {
            normalized
        }
    }

    fn decrement_order_quantity(
        &self,
        quantity: u32,
        minimum_order_quantity: u32,
        order_step_size: u32,
    ) -> u32 {
        let minimum = minimum_order_quantity.max(1);
        if quantity <= minimum {
            0
        } else {
            let next = quantity.saturating_sub(order_step_size.max(1));
            if next < minimum { 0 } else { next }
        }
    }

    fn intraday_execution_start_time(&self) -> NaiveTime {
        NaiveTime::from_hms_opt(10, 18, 0).expect("valid 10:18")
    }

    fn projected_round_lot(&self, ctx: &StrategyContext<'_>, symbol: &str) -> u32 {
        ctx.data
            .instrument(symbol)
            .map(|instrument| instrument.effective_round_lot())
            .unwrap_or(100)
            .max(1)
    }

    fn projected_minimum_order_quantity(&self, ctx: &StrategyContext<'_>, symbol: &str) -> u32 {
        ctx.data
            .instrument(symbol)
            .map(|instrument| instrument.minimum_order_quantity())
            .unwrap_or(100)
            .max(1)
    }

    fn projected_order_step_size(&self, ctx: &StrategyContext<'_>, symbol: &str) -> u32 {
        ctx.data
            .instrument(symbol)
            .map(|instrument| instrument.order_step_size())
            .unwrap_or(100)
            .max(1)
    }

    fn projected_buy_quantity(&self, cash: f64, sizing_price: f64, execution_price: f64) -> u32 {
        if cash <= 0.0 || sizing_price <= 0.0 || execution_price <= 0.0 {
            return 0;
        }
        let mut quantity = self.round_lot_quantity((cash / sizing_price).floor() as u32, 100, 100);
        while quantity > 0 {
            let gross_amount = execution_price * quantity as f64;
            if gross_amount + self.buy_commission(gross_amount) <= cash + 1e-6 {
                return quantity;
            }
            quantity = self.decrement_order_quantity(quantity, 100, 100);
        }
        0
    }

    fn projected_execution_price(
        &self,
        market: &crate::data::DailyMarketSnapshot,
        side: OrderSide,
    ) -> f64 {
        let _ = side;
        market.price(PriceField::Last)
    }

    fn project_order_value(
        &self,
        ctx: &StrategyContext<'_>,
        projected: &mut PortfolioState,
        date: NaiveDate,
        symbol: &str,
        order_value: f64,
        reason: &str,
        execution_state: &mut ProjectedExecutionState,
    ) -> u32 {
        if order_value <= 0.0 {
            return 0;
        }
        let round_lot = self.projected_round_lot(ctx, symbol);
        let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol);
        let order_step_size = self.projected_order_step_size(ctx, symbol);
        let market = match ctx.data.market(date, symbol) {
            Some(market) => market,
            None => return 0,
        };
        let sizing_price = market.price(PriceField::Last);
        if !sizing_price.is_finite() || sizing_price <= 0.0 {
            return 0;
        }
        let snapshot_requested_qty = self.round_lot_quantity(
            ((projected.cash().min(order_value)) / sizing_price).floor() as u32,
            minimum_order_quantity,
            order_step_size,
        );
        let projected_execution_price = self.projected_execution_price(market, OrderSide::Buy);
        let projected_fill = self.projected_select_execution_fill(
            ctx,
            date,
            symbol,
            OrderSide::Buy,
            u32::MAX,
            round_lot,
            minimum_order_quantity,
            order_step_size,
            false,
            Some(projected.cash()),
            Some(order_value + 400.0),
            execution_state,
        );
        let mut quantity = snapshot_requested_qty;
        while quantity > 0 {
            let gross_amount = projected_execution_price * quantity as f64;
            if gross_amount <= order_value + 400.0 && gross_amount <= projected.cash() + 1e-6 {
                break;
            }
            quantity =
                self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size);
        }
        if quantity == 0 {
            return 0;
        }
        let execution_price = projected_fill
            .as_ref()
            .map(|fill| fill.price)
            .unwrap_or(projected_execution_price);
        while quantity > 0 {
            let gross_amount = execution_price * quantity as f64;
            if gross_amount <= projected.cash() + 1e-6 {
                break;
            }
            quantity =
                self.decrement_order_quantity(quantity, minimum_order_quantity, order_step_size);
        }
        if quantity == 0 {
            return 0;
        }
        let fill = ProjectedExecutionFill {
            price: execution_price,
            quantity,
            next_cursor: date.and_time(self.intraday_execution_start_time()) + Duration::seconds(1),
        };
        let gross_amount = fill.price * fill.quantity as f64;
        let cash_out = gross_amount + self.buy_commission(gross_amount);
        if gross_amount > projected.cash() + 1e-6 {
            return 0;
        }
        projected.apply_cash_delta(-cash_out);
        projected
            .position_mut(symbol)
            .buy(date, fill.quantity, fill.price);
        *execution_state
            .intraday_turnover
            .entry(symbol.to_string())
            .or_default() += fill.quantity;
        execution_state
            .execution_cursors
            .insert(symbol.to_string(), fill.next_cursor);
        if self.uses_serial_execution_cursor(reason) {
            execution_state.global_execution_cursor = Some(fill.next_cursor);
        }
        fill.quantity
    }

    fn project_target_zero(
        &self,
        ctx: &StrategyContext<'_>,
        projected: &mut PortfolioState,
        date: NaiveDate,
        symbol: &str,
        reason: &str,
        execution_state: &mut ProjectedExecutionState,
    ) -> Option<u32> {
        let quantity = projected.position(symbol)?.quantity;
        if quantity == 0 {
            return None;
        }
        let market = ctx.data.market(date, symbol)?;
        let round_lot = self.projected_round_lot(ctx, symbol);
        let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol);
        let order_step_size = self.projected_order_step_size(ctx, symbol);
        let fill = self
            .projected_select_execution_fill(
                ctx,
                date,
                symbol,
                OrderSide::Sell,
                quantity,
                round_lot,
                minimum_order_quantity,
                order_step_size,
                true,
                None,
                None,
                execution_state,
            )
            .unwrap_or(ProjectedExecutionFill {
                price: self.projected_execution_price(market, OrderSide::Sell),
                quantity,
                next_cursor: date.and_time(self.intraday_execution_start_time())
                    + Duration::seconds(1),
            });
        let gross_amount = fill.price * fill.quantity as f64;
        let net_cash = gross_amount - self.sell_cost(date, gross_amount);
        projected
            .position_mut(symbol)
            .sell(fill.quantity, fill.price)
            .ok()?;
        projected.apply_cash_delta(net_cash);
        *execution_state
            .intraday_turnover
            .entry(symbol.to_string())
            .or_default() += fill.quantity;
        execution_state
            .execution_cursors
            .insert(symbol.to_string(), fill.next_cursor);
        if self.uses_serial_execution_cursor(reason) {
            execution_state.global_execution_cursor = Some(fill.next_cursor);
        }
        projected.prune_flat_positions();
        Some(fill.quantity)
    }

    fn projected_market_fillable_quantity(
        &self,
        ctx: &StrategyContext<'_>,
        date: NaiveDate,
        symbol: &str,
        side: OrderSide,
        requested_qty: u32,
        _round_lot: u32,
        minimum_order_quantity: u32,
        order_step_size: u32,
        allow_odd_lot_sell: bool,
        execution_state: &ProjectedExecutionState,
    ) -> Option<u32> {
        if requested_qty == 0 {
            return Some(0);
        }
        let snapshot = ctx.data.market(date, symbol)?;
        if snapshot.tick_volume == 0 {
            return None;
        }

        let mut max_fill = requested_qty;
        let top_level_liquidity = match side {
            OrderSide::Buy => snapshot.liquidity_for_buy(),
            OrderSide::Sell => snapshot.liquidity_for_sell(),
        }
        .min(u32::MAX as u64) as u32;
        if top_level_liquidity == 0 {
            return None;
        }
        let liquidity_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
            top_level_liquidity
        } else {
            self.round_lot_quantity(top_level_liquidity, minimum_order_quantity, order_step_size)
        };
        max_fill = max_fill.min(liquidity_limited);

        let consumed_turnover = *execution_state.intraday_turnover.get(symbol).unwrap_or(&0);
        let raw_limit =
            ((snapshot.tick_volume as f64) * 0.25).round() as i64 - consumed_turnover as i64;
        if raw_limit <= 0 {
            return None;
        }
        let volume_limited = if side == OrderSide::Sell && allow_odd_lot_sell {
            raw_limit as u32
        } else {
            self.round_lot_quantity(raw_limit as u32, minimum_order_quantity, order_step_size)
        };
        if volume_limited == 0 {
            return None;
        }
        Some(max_fill.min(volume_limited))
    }

    fn projected_execution_start_cursor(
        &self,
        date: NaiveDate,
        symbol: &str,
        execution_state: &ProjectedExecutionState,
    ) -> Option<NaiveDateTime> {
        let _ = (symbol, execution_state);
        Some(date.and_time(self.intraday_execution_start_time()))
    }

    fn projected_select_execution_fill(
        &self,
        ctx: &StrategyContext<'_>,
        date: NaiveDate,
        symbol: &str,
        side: OrderSide,
        requested_qty: u32,
        round_lot: u32,
        minimum_order_quantity: u32,
        order_step_size: u32,
        allow_odd_lot_sell: bool,
        cash_limit: Option<f64>,
        gross_limit: Option<f64>,
        execution_state: &ProjectedExecutionState,
    ) -> Option<ProjectedExecutionFill> {
        if requested_qty == 0 {
            return None;
        }

        if let Some(market) = ctx.data.market(date, symbol) {
            let execution_price = self.projected_execution_price(market, side);
            if execution_price.is_finite() && execution_price > 0.0 {
                let quantity = match side {
                    OrderSide::Buy => {
                        let cash = cash_limit.unwrap_or(f64::INFINITY);
                        let mut take_qty = self.round_lot_quantity(
                            requested_qty,
                            minimum_order_quantity,
                            order_step_size,
                        );
                        while take_qty > 0 {
                            let candidate_gross = execution_price * take_qty as f64;
                            if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) {
                                take_qty = self.decrement_order_quantity(
                                    take_qty,
                                    minimum_order_quantity,
                                    order_step_size,
                                );
                                continue;
                            }
                            let candidate_cash =
                                candidate_gross + self.buy_commission(candidate_gross);
                            if candidate_cash <= cash + 1e-6 {
                                break;
                            }
                            take_qty = self.decrement_order_quantity(
                                take_qty,
                                minimum_order_quantity,
                                order_step_size,
                            );
                        }
                        take_qty
                    }
                    OrderSide::Sell => requested_qty,
                };
                if quantity > 0 {
                    let next_cursor =
                        date.and_time(self.intraday_execution_start_time()) + Duration::seconds(1);
                    return Some(ProjectedExecutionFill {
                        price: execution_price,
                        quantity,
                        next_cursor,
                    });
                }
            }
        }

        let start_cursor = self.projected_execution_start_cursor(date, symbol, execution_state);
        let quotes = ctx.data.execution_quotes_on(date, symbol);
        let mut filled_qty = 0_u32;
        let mut gross_amount = 0.0_f64;
        let mut last_timestamp = None;

        for quote in quotes {
            if start_cursor.is_some_and(|cursor| quote.timestamp < cursor) {
                continue;
            }

            let fallback_quote_price = match side {
                OrderSide::Buy => {
                    if quote.last_price.is_finite() && quote.last_price > 0.0 {
                        Some(quote.last_price)
                    } else {
                        quote.buy_price()
                    }
                }
                OrderSide::Sell => quote.sell_price(),
            };
            if fallback_quote_price.is_some() {
                last_timestamp = Some(quote.timestamp);
            }

            if quote.volume_delta == 0 {
                continue;
            }
            let Some(quote_price) = fallback_quote_price else {
                continue;
            };
            let available_qty = match side {
                OrderSide::Buy => quote.ask1_volume,
                OrderSide::Sell => quote.bid1_volume,
            }
            .saturating_mul(round_lot.max(1) as u64)
            .min(u32::MAX as u64) as u32;
            if available_qty == 0 {
                continue;
            }

            let remaining_qty = requested_qty.saturating_sub(filled_qty);
            if remaining_qty == 0 {
                break;
            }
            let mut take_qty = remaining_qty.min(available_qty);
            if !(side == OrderSide::Sell && allow_odd_lot_sell && take_qty == remaining_qty) {
                take_qty =
                    self.round_lot_quantity(take_qty, minimum_order_quantity, order_step_size);
            }
            if take_qty == 0 {
                continue;
            }

            if let Some(cash) = cash_limit {
                while take_qty > 0 {
                    let candidate_gross = gross_amount + quote_price * take_qty as f64;
                    if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) {
                        take_qty = self.decrement_order_quantity(
                            take_qty,
                            minimum_order_quantity,
                            order_step_size,
                        );
                        continue;
                    }
                    if candidate_gross + self.buy_commission(candidate_gross) <= cash + 1e-6 {
                        break;
                    }
                    take_qty = self.decrement_order_quantity(
                        take_qty,
                        minimum_order_quantity,
                        order_step_size,
                    );
                }
                if take_qty == 0 {
                    break;
                }
            }

            gross_amount += quote_price * take_qty as f64;
            filled_qty += take_qty;
            last_timestamp = Some(quote.timestamp);

            if filled_qty >= requested_qty {
                break;
            }
        }

        if filled_qty == 0 {
            return None;
        }

        Some(ProjectedExecutionFill {
            price: gross_amount / filled_qty as f64,
            quantity: filled_qty,
            next_cursor: last_timestamp.unwrap() + Duration::seconds(1),
        })
    }

    fn uses_serial_execution_cursor(&self, reason: &str) -> bool {
        let _ = reason;
        false
    }

    fn trading_ratio(
        &self,
        ctx: &StrategyContext<'_>,
        date: NaiveDate,
    ) -> Result<(f64, f64, f64, f64), BacktestError> {
        let current_level = ctx
            .data
            .market_decision_close(date, &self.config.benchmark_signal_symbol)
            .ok_or_else(|| BacktestError::MissingPrice {
                date,
                symbol: self.config.benchmark_signal_symbol.clone(),
                field: "decision_close",
            })?;
        let ma_short = ctx
            .data
            .market_decision_close_moving_average(
                date,
                &self.config.benchmark_signal_symbol,
                self.config.benchmark_short_ma_days,
            )
            .ok_or_else(|| {
                BacktestError::Execution(format!(
                    "insufficient benchmark short MA history for {} on {}",
                    self.config.benchmark_signal_symbol, date
                ))
            })?;
        let ma_long = ctx
            .data
            .market_decision_close_moving_average(
                date,
                &self.config.benchmark_signal_symbol,
                self.config.benchmark_long_ma_days,
            )
            .ok_or_else(|| {
                BacktestError::Execution(format!(
                    "insufficient benchmark long MA history for {} on {}",
                    self.config.benchmark_signal_symbol, date
                ))
            })?;
        let trading_ratio = if ma_short < ma_long * self.config.rsi_rate {
            self.config.trade_rate
        } else {
            1.0
        };
        Ok((current_level, ma_short, ma_long, trading_ratio))
    }

    fn market_cap_band(&self, index_level: f64) -> (f64, f64) {
        let y = (index_level - self.config.base_index_level) * self.config.xs
            + self.config.base_cap_floor;
        let start = y.round();
        (start, start + self.config.cap_span)
    }

    fn stock_passes_ma_filter(
        &self,
        ctx: &StrategyContext<'_>,
        date: NaiveDate,
        symbol: &str,
    ) -> bool {
        let Some(ma_short) = ctx.data.market_decision_close_moving_average(
            date,
            symbol,
            self.config.stock_short_ma_days,
        ) else {
            return false;
        };
        let Some(ma_mid) = ctx.data.market_decision_close_moving_average(
            date,
            symbol,
            self.config.stock_mid_ma_days,
        ) else {
            return false;
        };
        let Some(ma_long) = ctx.data.market_decision_close_moving_average(
            date,
            symbol,
            self.config.stock_long_ma_days,
        ) else {
            return false;
        };

        ma_short > ma_mid * self.config.rsi_rate && ma_mid > ma_long
    }

    fn special_name(&self, ctx: &StrategyContext<'_>, symbol: &str) -> bool {
        let instrument_name = ctx
            .data
            .instruments()
            .get(symbol)
            .map(|instrument| instrument.name.as_str())
            .unwrap_or("");
        instrument_name.contains("ST")
            || instrument_name.contains('*')
            || instrument_name.contains('退')
    }

    fn can_sell_position(&self, ctx: &StrategyContext<'_>, date: NaiveDate, symbol: &str) -> bool {
        let Some(position) = ctx.portfolio.position(symbol) else {
            return false;
        };
        if position.quantity == 0 || position.sellable_qty(date) == 0 {
            return false;
        }
        let Ok(market) = ctx.data.require_market(date, symbol) else {
            return false;
        };
        let Ok(candidate) = ctx.data.require_candidate(date, symbol) else {
            return false;
        };
        let lower_limit_check_price = market.price(PriceField::Last);
        !(market.paused
            || candidate.is_paused
            || !candidate.allow_sell
            || market.is_at_lower_limit_price(lower_limit_check_price))
    }

    fn buy_rejection_reason(
        &self,
        ctx: &StrategyContext<'_>,
        date: NaiveDate,
        symbol: &str,
    ) -> Result<Option<String>, BacktestError> {
        let market = ctx.data.require_market(date, symbol)?;
        let candidate = ctx.data.require_candidate(date, symbol)?;

        if market.paused || candidate.is_paused {
            return Ok(Some("paused".to_string()));
        }
        if candidate.is_st || self.special_name(ctx, symbol) {
            return Ok(Some("st_or_special_name".to_string()));
        }
        if candidate.is_kcb {
            return Ok(Some("kcb".to_string()));
        }
        if !candidate.allow_buy {
            return Ok(Some("buy_disabled".to_string()));
        }
        if market.is_at_upper_limit_price(market.day_open)
            || market.is_at_upper_limit_price(market.buy_price(PriceField::Last))
        {
            return Ok(Some("upper_limit".to_string()));
        }
        if market.is_at_lower_limit_price(market.day_open)
            || market.is_at_lower_limit_price(market.sell_price(PriceField::Last))
        {
            return Ok(Some("lower_limit".to_string()));
        }
        if market.day_open <= 1.0 {
            return Ok(Some("one_yuan".to_string()));
        }
        if !self.truth_selection_contains(date, symbol)
            && !self.stock_passes_ma_filter(ctx, date, symbol)
        {
            return Ok(Some("ma_filter".to_string()));
        }
        Ok(None)
    }

    fn select_symbols(
        &self,
        ctx: &StrategyContext<'_>,
        date: NaiveDate,
        band_low: f64,
        band_high: f64,
    ) -> Result<(Vec<String>, Vec<String>), BacktestError> {
        if let Some(truth_symbols) = self.truth_stock_list_for_date(date) {
            let mut diagnostics = vec![format!(
                "selection_source=truth_csv path={} truth_candidates={}",
                self.truth_stock_list_source_path().unwrap_or("<unknown>"),
                truth_symbols.len()
            )];
            let mut selected = Vec::new();
            let mut selected_set = BTreeSet::new();
            let mut truth_selected = 0usize;

            for symbol in truth_symbols {
                if selected.len() >= self.config.stocknum {
                    break;
                }
                if !selected_set.insert(symbol.clone()) {
                    continue;
                }
                if let Some(reason) = self.buy_rejection_reason(ctx, date, symbol)? {
                    selected_set.remove(symbol);
                    if diagnostics.len() < 14 {
                        diagnostics.push(format!("truth {} rejected by {}", symbol, reason));
                    }
                    continue;
                }
                selected.push(symbol.clone());
                truth_selected += 1;
            }

            if selected.len() < self.config.stocknum {
                let universe = ctx.data.eligible_universe_on(date);
                let start = lower_bound_eligible(universe, band_low);
                for candidate in universe.iter().skip(start) {
                    if candidate.market_cap_bn > band_high {
                        break;
                    }
                    if selected.len() >= self.config.stocknum {
                        break;
                    }
                    if !selected_set.insert(candidate.symbol.clone()) {
                        continue;
                    }
                    if let Some(reason) = self.buy_rejection_reason(ctx, date, &candidate.symbol)? {
                        selected_set.remove(&candidate.symbol);
                        if diagnostics.len() < 18 {
                            diagnostics.push(format!(
                                "fallback {} rejected by {}",
                                candidate.symbol, reason
                            ));
                        }
                        continue;
                    }
                    selected.push(candidate.symbol.clone());
                }
            }

            diagnostics.push(format!(
                "truth_selected={} fallback_selected={} requested={}",
                truth_selected,
                selected.len().saturating_sub(truth_selected),
                self.config.stocknum
            ));
            return Ok((selected, diagnostics));
        }

        let universe = ctx.data.eligible_universe_on(date);
        let mut diagnostics = Vec::new();
        let mut selected = Vec::new();
        let start = lower_bound_eligible(universe, band_low);

        for candidate in universe.iter().skip(start) {
            if candidate.market_cap_bn > band_high {
                break;
            }
            if let Some(reason) = self.buy_rejection_reason(ctx, date, &candidate.symbol)? {
                if diagnostics.len() < 12 {
                    diagnostics.push(format!("{} rejected by {}", candidate.symbol, reason));
                }
                continue;
            }

            selected.push(candidate.symbol.clone());
            if selected.len() >= self.config.stocknum {
                break;
            }
        }

        Ok((selected, diagnostics))
    }
}

fn jq_truth_stock_lists() -> &'static Option<JqTruthStockLists> {
    static LISTS: OnceLock<Option<JqTruthStockLists>> = OnceLock::new();
    LISTS.get_or_init(load_jq_truth_stock_lists)
}

fn load_jq_truth_stock_lists() -> Option<JqTruthStockLists> {
    for path in jq_truth_stock_list_candidates() {
        if !path.is_file() {
            continue;
        }
        if let Ok(Some(lists)) = load_jq_truth_stock_lists_from_path(&path) {
            return Some(lists);
        }
    }
    None
}

fn jq_truth_stock_list_candidates() -> Vec<PathBuf> {
    let mut candidates = Vec::new();
    for key in [
        "FIDC_BT_JQ_TRUTH_STOCK_LIST_CSV",
        "JQ_V104_STOCK_LIST_TRUTH_CSV",
        "JQ_V104_TRUTH_CSV",
    ] {
        if let Ok(value) = env::var(key) {
            let trimmed = value.trim();
            if !trimmed.is_empty() {
                push_unique_truth_path(&mut candidates, PathBuf::from(trimmed));
            }
        }
    }

    let suffix = PathBuf::from(
        "ai-quant-sever/services/backtest/logs/jq_v104_debug_parsed/jq_v104_ths_stock_list.csv",
    );
    let manifest_root = Path::new(env!("CARGO_MANIFEST_DIR"));
    push_unique_truth_path(
        &mut candidates,
        manifest_root.join("../../../").join(&suffix),
    );
    if let Ok(current_dir) = env::current_dir() {
        for ancestor in current_dir.ancestors() {
            push_unique_truth_path(&mut candidates, ancestor.join(&suffix));
        }
    }
    candidates
}

fn push_unique_truth_path(paths: &mut Vec<PathBuf>, candidate: PathBuf) {
    if !paths.iter().any(|existing| existing == &candidate) {
        paths.push(candidate);
    }
}

fn load_jq_truth_stock_lists_from_path(path: &Path) -> Result<Option<JqTruthStockLists>, String> {
    let text = fs::read_to_string(path)
        .map_err(|error| format!("read {} failed: {}", path.display(), error))?;
    let mut lines = text.lines().filter(|line| !line.trim().is_empty());
    let Some(header_line) = lines.next() else {
        return Ok(None);
    };
    let headers = split_simple_csv_line(header_line.trim_start_matches('\u{feff}'));
    let trade_date_idx = headers
        .iter()
        .position(|field| field == "trade_date")
        .ok_or_else(|| format!("missing trade_date column in {}", path.display()))?;
    let symbol_idx = headers
        .iter()
        .position(|field| field == "symbol")
        .ok_or_else(|| format!("missing symbol column in {}", path.display()))?;
    let rank_idx = headers
        .iter()
        .position(|field| field == "rank")
        .or_else(|| headers.iter().position(|field| field == "index"));

    let mut rows_by_date: BTreeMap<NaiveDate, Vec<(usize, String)>> = BTreeMap::new();
    for (offset, line) in lines.enumerate() {
        let cols = split_simple_csv_line(line);
        let date_raw = cols
            .get(trade_date_idx)
            .ok_or_else(|| format!("missing trade_date at {}:{}", path.display(), offset + 2))?;
        let symbol_raw = cols
            .get(symbol_idx)
            .ok_or_else(|| format!("missing symbol at {}:{}", path.display(), offset + 2))?;
        let trade_date = NaiveDate::parse_from_str(date_raw, "%Y-%m-%d").map_err(|error| {
            format!(
                "invalid trade_date at {}:{}: {}",
                path.display(),
                offset + 2,
                error
            )
        })?;
        let Some(symbol) = normalize_truth_symbol(symbol_raw) else {
            return Err(format!(
                "invalid symbol at {}:{}",
                path.display(),
                offset + 2
            ));
        };
        let rank = rank_idx
            .and_then(|idx| cols.get(idx))
            .and_then(|value| value.parse::<usize>().ok())
            .unwrap_or_else(|| {
                rows_by_date
                    .get(&trade_date)
                    .map(|items| items.len() + 1)
                    .unwrap_or(1)
            });
        rows_by_date
            .entry(trade_date)
            .or_default()
            .push((rank.max(1), symbol));
    }

    if rows_by_date.is_empty() {
        return Ok(None);
    }

    let symbols_by_date = rows_by_date
        .into_iter()
        .map(|(date, mut rows)| {
            rows.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
            let mut seen = BTreeSet::new();
            let ordered = rows
                .into_iter()
                .filter_map(|(_, symbol)| {
                    if seen.insert(symbol.clone()) {
                        Some(symbol)
                    } else {
                        None
                    }
                })
                .collect::<Vec<_>>();
            (date, ordered)
        })
        .collect::<BTreeMap<_, _>>();

    Ok(Some(JqTruthStockLists {
        source_path: path.display().to_string(),
        symbols_by_date,
    }))
}

fn split_simple_csv_line(line: &str) -> Vec<String> {
    line.split(',')
        .map(|field| field.trim().trim_matches('"').to_string())
        .collect()
}

fn normalize_truth_symbol(raw: &str) -> Option<String> {
    let normalized = raw
        .trim()
        .to_ascii_uppercase()
        .replace(".XSHG", ".SH")
        .replace(".XSHE", ".SZ");
    if normalized.is_empty() {
        None
    } else {
        Some(normalized)
    }
}

impl Strategy for JqMicroCapStrategy {
    fn name(&self) -> &str {
        self.config.strategy_name.as_str()
    }

    fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
        let date = ctx.execution_date;
        if self.config.in_skip_window(date) {
            return Ok(StrategyDecision {
                rebalance: false,
                target_weights: BTreeMap::new(),
                exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
                order_intents: ctx
                    .portfolio
                    .positions()
                    .keys()
                    .cloned()
                    .map(|symbol| OrderIntent::TargetValue {
                        symbol,
                        target_value: 0.0,
                        reason: "seasonal_stop_window".to_string(),
                    })
                    .collect(),
                notes: vec![format!("seasonal stop window on {}", date)],
                diagnostics: vec!["jq-style skip window forced all cash".to_string()],
            });
        }

        let (index_level, ma_short, ma_long, trading_ratio) = match self.trading_ratio(ctx, date) {
            Ok(value) => value,
            Err(BacktestError::Execution(message))
                if message.contains("insufficient benchmark") =>
            {
                return Ok(StrategyDecision {
                    rebalance: false,
                    target_weights: BTreeMap::new(),
                    exit_symbols: BTreeSet::new(),
                    order_intents: Vec::new(),
                    notes: vec![format!("warmup: {}", message)],
                    diagnostics: vec![
                        "insufficient history; skip trading on warmup dates".to_string(),
                    ],
                });
            }
            Err(err) => return Err(err),
        };
        let (band_low, band_high) = self.market_cap_band(index_level);
        let (stock_list, selection_notes) = self.select_symbols(ctx, date, band_low, band_high)?;
        let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0;
        let mut projected = ctx.portfolio.clone();
        let mut projected_execution_state = ProjectedExecutionState::default();
        let mut order_intents = Vec::new();
        let mut exit_symbols = BTreeSet::new();

        for position in ctx.portfolio.positions().values() {
            if position.quantity == 0 || position.average_cost <= 0.0 {
                continue;
            }
            let Some(current_price) = ctx.data.price(date, &position.symbol, PriceField::Last)
            else {
                continue;
            };
            let Some(market) = ctx.data.market(date, &position.symbol) else {
                continue;
            };
            let stop_hit = current_price
                <= position.average_cost * self.config.stop_loss_ratio
                    + self.stop_loss_tolerance(market);
            let profit_hit = !market.is_at_upper_limit_price(current_price)
                && current_price / position.average_cost > self.config.take_profit_ratio;
            let can_sell = self.can_sell_position(ctx, date, &position.symbol);
            if stop_hit || profit_hit {
                let sell_reason = if stop_hit {
                    "stop_loss_exit"
                } else {
                    "take_profit_exit"
                };
                exit_symbols.insert(position.symbol.clone());
                order_intents.push(OrderIntent::TargetValue {
                    symbol: position.symbol.clone(),
                    target_value: 0.0,
                    reason: sell_reason.to_string(),
                });
                if can_sell {
                    self.project_target_zero(
                        ctx,
                        &mut projected,
                        date,
                        &position.symbol,
                        sell_reason,
                        &mut projected_execution_state,
                    );
                }

                if projected.positions().len() < self.config.stocknum {
                    let remaining_slots = self.config.stocknum - projected.positions().len();
                    if remaining_slots > 0 {
                        let replacement_cash =
                            projected.cash() * trading_ratio / remaining_slots as f64;
                        for symbol in &stock_list {
                            if symbol == &position.symbol
                                || projected.positions().contains_key(symbol)
                            {
                                continue;
                            }
                            if self.buy_rejection_reason(ctx, date, symbol)?.is_some() {
                                continue;
                            }
                            order_intents.push(OrderIntent::Value {
                                symbol: symbol.clone(),
                                value: replacement_cash,
                                reason: format!("replacement_after_{}", sell_reason),
                            });
                            self.project_order_value(
                                ctx,
                                &mut projected,
                                date,
                                symbol,
                                replacement_cash,
                                &format!("replacement_after_{}", sell_reason),
                                &mut projected_execution_state,
                            );
                            break;
                        }
                    }
                }
            }
        }

        if periodic_rebalance {
            let pre_rebalance_symbols = projected
                .positions()
                .keys()
                .cloned()
                .collect::<BTreeSet<_>>();
            for symbol in pre_rebalance_symbols.iter() {
                if stock_list.iter().any(|candidate| candidate == symbol) {
                    continue;
                }
                if !self.can_sell_position(ctx, date, symbol) {
                    continue;
                }
                order_intents.push(OrderIntent::TargetValue {
                    symbol: symbol.clone(),
                    target_value: 0.0,
                    reason: "periodic_rebalance_sell".to_string(),
                });
                self.project_target_zero(
                    ctx,
                    &mut projected,
                    date,
                    symbol,
                    "periodic_rebalance_sell",
                    &mut projected_execution_state,
                );
            }

            let fixed_buy_cash = projected.cash() * trading_ratio / self.config.stocknum as f64;
            for symbol in &stock_list {
                if projected.positions().len() >= self.config.stocknum {
                    break;
                }
                if pre_rebalance_symbols.contains(symbol)
                    || projected.positions().contains_key(symbol)
                {
                    continue;
                }
                if self.buy_rejection_reason(ctx, date, symbol)?.is_some() {
                    continue;
                }
                order_intents.push(OrderIntent::Value {
                    symbol: symbol.clone(),
                    value: fixed_buy_cash,
                    reason: "periodic_rebalance_buy".to_string(),
                });
                self.project_order_value(
                    ctx,
                    &mut projected,
                    date,
                    symbol,
                    fixed_buy_cash,
                    "periodic_rebalance_buy",
                    &mut projected_execution_state,
                );
            }
        }

        let mut diagnostics = vec![
            format!(
                "jq_microcap signal={} last={:.2} ma_short={:.2} ma_long={:.2} band={:.0}-{:.0} tr={:.2}",
                self.config.benchmark_signal_symbol, index_level, ma_short, ma_long, band_low, band_high, trading_ratio
            ),
            format!(
                "selected={} periodic_rebalance={} exits={} projected_positions={} intents={}",
                stock_list.len(),
                periodic_rebalance,
                exit_symbols.len(),
                projected.positions().len(),
                order_intents.len()
            ),
            "run_daily(10:17/10:18) approximated as same-day decision with snapshot last_price signals and bid1/ask1 side-aware execution".to_string(),
        ];
        if std::env::var("FIDC_BT_DEBUG_POSITION_ORDER")
            .map(|value| value == "1")
            .unwrap_or(false)
        {
            diagnostics.push(format!(
                "positions_order={}",
                ctx.portfolio
                    .positions()
                    .keys()
                    .cloned()
                    .collect::<Vec<_>>()
                    .join("|")
            ));
        }
        diagnostics.extend(selection_notes);

        let notes = vec![
            format!("stock_list={}", stock_list.len()),
            format!("projected_positions={}", projected.positions().len()),
        ];

        Ok(StrategyDecision {
            rebalance: false,
            target_weights: BTreeMap::new(),
            exit_symbols,
            order_intents,
            notes,
            diagnostics,
        })
    }
}

fn lower_bound_eligible(rows: &[crate::data::EligibleUniverseSnapshot], target: f64) -> usize {
    let mut left = 0usize;
    let mut right = rows.len();
    while left < right {
        let mid = left + (right - left) / 2;
        if rows[mid].market_cap_bn < target {
            left = mid + 1;
        } else {
            right = mid;
        }
    }
    left
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::{SystemTime, UNIX_EPOCH};

    fn temp_csv_path(name: &str) -> PathBuf {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default()
            .as_nanos();
        env::temp_dir().join(format!("{}_{}_{}.csv", name, std::process::id(), nanos))
    }

    #[test]
    fn load_truth_stock_lists_preserves_rank_order() {
        let path = temp_csv_path("jq_truth_list");
        fs::write(
            &path,
            "trade_date,index,symbol\n2025-01-02,2,300935.SZ\n2025-01-02,1,300321.XSHE\n2025-01-02,1,300321.SZ\n",
        )
        .unwrap();

        let lists = load_jq_truth_stock_lists_from_path(&path).unwrap().unwrap();
        fs::remove_file(&path).ok();

        let symbols = lists
            .symbols_by_date
            .get(&NaiveDate::from_ymd_opt(2025, 1, 2).unwrap())
            .unwrap();
        assert_eq!(
            symbols,
            &vec!["300321.SZ".to_string(), "300935.SZ".to_string()]
        );
    }

    #[test]
    fn normalize_truth_symbol_maps_joinquant_suffixes() {
        assert_eq!(
            normalize_truth_symbol("300321.XSHE").as_deref(),
            Some("300321.SZ")
        );
        assert_eq!(
            normalize_truth_symbol("603657.XSHG").as_deref(),
            Some("603657.SH")
        );
    }
}
