package com.goldenchart.service;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.goldenchart.dto.*;
import com.goldenchart.dto.BacktestResponse.Signal;
import com.goldenchart.dto.BacktestResponse.Stats;
import com.goldenchart.entity.GcBacktestResult;
import com.goldenchart.entity.GcStrategy;
import com.goldenchart.repository.GcBacktestResultRepository;
import com.goldenchart.repository.GcStrategyRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.ta4j.core.*;
import org.ta4j.core.bars.TimeBarBuilderFactory;
import org.ta4j.core.indicators.helpers.ClosePriceIndicator;
import org.ta4j.core.num.Num;
import org.ta4j.core.rules.BooleanRule;
import org.ta4j.core.rules.OrRule;
import org.ta4j.core.rules.StopGainRule;
import org.ta4j.core.rules.StopLossRule;
import org.ta4j.core.rules.TrailingStopLossRule;

import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;
import java.util.*;

/**
 * Ta4j 기반 백테스팅 실행 서비스.
 *
 * <ul>
 *   <li>TradingRecord 를 정상 populate → AnalysisCriterion 전체 활용</li>
 *   <li>StopLoss / StopGain / TrailingStop / Commission / Slippage 반영</li>
 *   <li>실행 결과를 gc_backtest_result 테이블에 저장</li>
 * </ul>
 */
@Service
@RequiredArgsConstructor
@Slf4j
public class BacktestingService {

    private final StrategyDslToTa4jAdapter adapter;
    private final GcStrategyRepository strategyRepository;
    private final GcBacktestResultRepository resultRepository;
    private final ObjectMapper objectMapper;

    private static final BacktestSettingsDto DEFAULT_SETTINGS = new BacktestSettingsDto();

    // ── Public API ────────────────────────────────────────────────────────────

    public BacktestResponse run(BacktestRequest req) {
        if (req.getBars() == null || req.getBars().isEmpty()) {
            return emptyResponse("캔들 데이터가 없습니다.");
        }

        JsonNode buyDsl  = req.getBuyCondition();
        JsonNode sellDsl = req.getSellCondition();

        String strategyName = req.getStrategyName() != null ? req.getStrategyName() : "전략";

        if (req.getStrategyId() != null) {
            Optional<GcStrategy> opt = strategyRepository.findById(req.getStrategyId());
            if (opt.isEmpty()) return emptyResponse("전략을 찾을 수 없습니다: id=" + req.getStrategyId());
            GcStrategy strat = opt.get();
            strategyName = strat.getName() != null ? strat.getName() : strategyName;
            try {
                if (strat.getBuyConditionJson() != null)
                    buyDsl = objectMapper.readTree(strat.getBuyConditionJson());
                if (strat.getSellConditionJson() != null)
                    sellDsl = objectMapper.readTree(strat.getSellConditionJson());
            } catch (Exception e) {
                log.warn("전략 DSL JSON 파싱 실패: {}", e.getMessage());
                return emptyResponse("전략 DSL 파싱 오류");
            }
        }

        Map<String, Map<String, Object>> params = req.getIndicatorParams() != null
                ? req.getIndicatorParams() : Map.of();

        BacktestSettingsDto cfg = req.getSettings() != null ? req.getSettings() : DEFAULT_SETTINGS;

        BarSeries series = buildSeries(req.getBars(), req.getTimeframe());
        int n = series.getBarCount();
        if (n == 0) return emptyResponse("유효한 캔들 데이터가 없습니다.");

        Rule entryRule    = adapter.toRule(buyDsl, series, params);
        Rule baseExitRule = (sellDsl != null && !sellDsl.isNull())
                ? adapter.toRule(sellDsl, series, params)
                : new BooleanRule(false);
        Rule exitRule     = buildExitRule(baseExitRule, series, cfg);

        return runBacktest(series, entryRule, exitRule, req, cfg, strategyName);
    }

    // ── 청산 규칙 합성 ────────────────────────────────────────────────────────

    private Rule buildExitRule(Rule baseExit, BarSeries series, BacktestSettingsDto cfg) {
        Rule result = baseExit;
        ClosePriceIndicator close = new ClosePriceIndicator(series);

        if (Boolean.TRUE.equals(cfg.getStopLossEnabled())) {
            double pct = cfg.getStopLossPct() != null ? cfg.getStopLossPct().doubleValue() : 2.0;
            result = new OrRule(result, new StopLossRule(close, series.numFactory().numOf(pct)));
        }
        if (Boolean.TRUE.equals(cfg.getTakeProfitEnabled())) {
            double pct = cfg.getTakeProfitPct() != null ? cfg.getTakeProfitPct().doubleValue() : 5.0;
            result = new OrRule(result, new StopGainRule(close, series.numFactory().numOf(pct)));
        }
        if (Boolean.TRUE.equals(cfg.getTrailingStopEnabled())) {
            double pct = cfg.getTrailingStopPct() != null ? cfg.getTrailingStopPct().doubleValue() : 2.0;
            result = new OrRule(result, new TrailingStopLossRule(close, series.numFactory().numOf(pct)));
        }
        return result;
    }

    // ── 백테스팅 루프 ─────────────────────────────────────────────────────────

    private BacktestResponse runBacktest(BarSeries series, Rule entryRule, Rule exitRule,
                                         BacktestRequest req, BacktestSettingsDto cfg,
                                         String strategyName) {

        BaseTradingRecord record = new BaseTradingRecord();

        List<Signal>   signals     = new ArrayList<>();
        boolean        inPosition  = false;
        double         entryPrice  = 0;
        int            entryBarIdx = -1;
        int            lastExitBar = -1;
        int            reentryWait = cfg.getReentryWaitBars() != null ? cfg.getReentryWaitBars() : 0;
        String         direction   = cfg.getPositionDirection() != null ? cfg.getPositionDirection() : "LONG";
        // positionMode: LONG_ONLY(기본) | SIGNAL_ONLY(포지션 락 우회)
        String         posMode     = cfg.getPositionMode() != null ? cfg.getPositionMode() : "LONG_ONLY";
        boolean        signalOnly  = "SIGNAL_ONLY".equals(posMode);

        double  initCap       = cfg.getInitialCapital()  != null ? cfg.getInitialCapital().doubleValue()  : 10_000_000.0;
        double  tradeSizePct  = cfg.getTradeSizeValue()  != null ? cfg.getTradeSizeValue().doubleValue() / 100.0 : 1.0;
        boolean partialExit   = Boolean.TRUE.equals(cfg.getPartialExitEnabled());
        double  partialPct    = cfg.getPartialExitPct()  != null ? cfg.getPartialExitPct().doubleValue() / 100.0 : 0.5;
        boolean partialDone   = false;

        double equity = initCap;

        int barCount = series.getBarCount();

        for (int i = 0; i < barCount; i++) {
            double closePrice = getPrice(series, req.getBars(), i, cfg.getEntryPriceType());
            double exitPrice  = getPrice(series, req.getBars(), i, cfg.getExitPriceType());
            long   time       = series.getBar(i).getEndTime().getEpochSecond();

            // ── SIGNAL_ONLY 모드: 포지션 상태와 무관하게 순수 지표 규칙 충족 여부만 판정 ──
            if (signalOnly) {
                boolean enterOk = entryRule.isSatisfied(i);
                boolean exitOk  = exitRule.isSatisfied(i);

                if (enterOk) {
                    double effEntry = applySlippage(closePrice, cfg, true);
                    String sigType  = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY";
                    signals.add(Signal.builder()
                            .time(time).type(sigType).price(effEntry).barIndex(i).build());
                    // 실제 포지션 추적은 LONG_ONLY 모드와 동일하게 유지 (수익 계산용)
                    if (!inPosition) {
                        double shares = (equity * tradeSizePct) / effEntry;
                        record.enter(i, series.numFactory().numOf(effEntry), series.numFactory().numOf(shares));
                        entryPrice  = effEntry;
                        entryBarIdx = i;
                        inPosition  = true;
                        partialDone = false;
                    }
                } else if (exitOk) {
                    double effExit = applySlippage(exitPrice, cfg, false);
                    String sigType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL";
                    signals.add(Signal.builder()
                            .time(time).type(sigType).price(effExit).barIndex(i).build());
                    // 수익 계산: 실제 포지션이 있을 때만
                    if (inPosition) {
                        double commission = calcCommissionRate(cfg) * 2;
                        double rawReturn  = "SHORT".equals(direction)
                                ? (entryPrice - effExit) / entryPrice
                                : (effExit - entryPrice) / entryPrice;
                        double size = partialDone ? (1.0 - partialPct) : 1.0;
                        equity += equity * tradeSizePct * size * (rawReturn - commission);
                        Num numExitPrice = series.numFactory().numOf(effExit);
                        Num numShares    = record.getCurrentPosition().getEntry().getAmount();
                        record.exit(i, numExitPrice, numShares);
                        inPosition  = false;
                        lastExitBar = i;
                        partialDone = false;
                    }
                }
                continue; // SIGNAL_ONLY 처리 완료, 다음 봉으로
            }

            // ── LONG_ONLY 모드: 표준 포지션 제약 로직 ────────────────────────────
            if (!inPosition) {
                if (i - lastExitBar <= reentryWait && lastExitBar >= 0) continue;

                boolean doEnter = entryRule.isSatisfied(i, record);
                if (!doEnter && "SHORT".equals(direction))
                    doEnter = exitRule.isSatisfied(i, record);

                if (doEnter) {
                    double effEntry = applySlippage(closePrice, cfg, true);
                    double shares   = (equity * tradeSizePct) / effEntry;

                    Num numPrice  = series.numFactory().numOf(effEntry);
                    Num numShares = series.numFactory().numOf(shares);
                    record.enter(i, numPrice, numShares);

                    String sigType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY";
                    signals.add(Signal.builder()
                            .time(time).type(sigType).price(effEntry).barIndex(i).build());

                    entryPrice  = effEntry;
                    entryBarIdx = i;
                    inPosition  = true;
                    partialDone = false;
                }
            } else {
                // 분할 청산: exit 조건 처음 충족 시 일부만 청산
                if (partialExit && !partialDone && exitRule.isSatisfied(i, record)) {
                    double effExit     = applySlippage(exitPrice, cfg, false);
                    double partShares  = record.getCurrentPosition().getEntry().getAmount().doubleValue() * partialPct;
                    double partReturn  = "SHORT".equals(direction)
                            ? (entryPrice - effExit) / entryPrice
                            : (effExit - entryPrice) / entryPrice;
                    double commission  = calcCommissionRate(cfg) * 2;
                    equity += equity * tradeSizePct * partialPct * (partReturn - commission);

                    signals.add(Signal.builder()
                            .time(time).type("PARTIAL_SELL").price(effExit).barIndex(i).build());
                    partialDone = true;
                    continue;
                }

                if (exitRule.isSatisfied(i, record)) {
                    double effExit    = applySlippage(exitPrice, cfg, false);
                    double commission = calcCommissionRate(cfg) * 2;
                    double rawReturn  = "SHORT".equals(direction)
                            ? (entryPrice - effExit) / entryPrice
                            : (effExit - entryPrice) / entryPrice;
                    double netReturn  = rawReturn - commission;

                    double size = partialDone ? (1.0 - partialPct) : 1.0;
                    equity += equity * tradeSizePct * size * netReturn;

                    Num numExitPrice  = series.numFactory().numOf(effExit);
                    Num numShares     = record.getCurrentPosition().getEntry().getAmount();
                    record.exit(i, numExitPrice, numShares);

                    String sigType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL";
                    signals.add(Signal.builder()
                            .time(time).type(sigType).price(effExit).barIndex(i).build());

                    inPosition  = false;
                    lastExitBar = i;
                    partialDone = false;
                }
            }
        }

        // ── AnalysisCriterion 전체 계산 ───────────────────────────────────────
        BacktestAnalysisDto analysis = calcAnalysis(series, record, cfg, initCap, equity);

        // ── Stats (하위 호환) ─────────────────────────────────────────────────
        Stats stats = toStats(analysis, signals);

        // ── DB 저장 ───────────────────────────────────────────────────────────
        Long resultId = saveResult(req, cfg, signals, analysis, series, strategyName);

        return BacktestResponse.builder()
                .signals(signals)
                .stats(stats)
                .analysis(analysis)
                .resultId(resultId)
                .build();
    }

    // ── Ta4j AnalysisCriterion 전체 계산 ─────────────────────────────────────

    private BacktestAnalysisDto calcAnalysis(BarSeries series, TradingRecord record,
                                              BacktestSettingsDto cfg, double initCap, double finalEquity) {
        BacktestAnalysisDto.BacktestAnalysisDtoBuilder b = BacktestAnalysisDto.builder()
                .initialCapital(initCap)
                .finalEquity(finalEquity);

        double totalReturnPct   = safeCalc(() -> calcTotalReturnPct(series, record));
        double grossProfit      = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.pnl.GrossProfitCriterion", series, record));
        double grossLoss        = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.pnl.GrossLossCriterion", series, record));
        double avgReturnPct     = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.pnl.AverageProfitLossCriterion", series, record));
        double profitLossRatio  = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.pnl.ProfitLossRatioCriterion", series, record));

        int positions   = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfPositionsCriterion", series, record));
        int winning     = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfWinningPositionsCriterion", series, record));
        int losing      = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfLosingPositionsCriterion", series, record));
        int breakEven   = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfBreakEvenPositionsCriterion", series, record));
        double winRate  = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.WinningPositionsRatioCriterion", series, record));

        double maxDrawdown  = safeCalc(() -> calcMaxDrawdown(series, record));
        double maxRunup     = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.MaximumRunupCriterion", series, record));
        double sharpe       = safeCalc(() -> calcSharpeRatio(series, record));
        double sortino      = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.SortinoRatioCriterion", series, record));
        double calmar       = (maxDrawdown != 0) ? totalReturnPct / Math.abs(maxDrawdown) : 0.0;
        double var95        = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.ValueAtRiskCriterion", series, record));
        double es           = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.ExpectedShortfallCriterion", series, record));

        double buyHoldPct   = safeCalc(() -> calcBuyAndHold(series, record));
        double vsBuyHold    = (buyHoldPct != 0) ? (1 + totalReturnPct) / (1 + buyHoldPct) : 0.0;

        // 금액 기준 총 손익 재계산
        double totalPnl = finalEquity - initCap;

        // positions = 0 이면 record에서 직접 추출 시도
        if (positions == 0) positions = record.getPositionCount();
        if (winning == 0 && positions > 0) {
            winning   = (int) record.getPositions().stream().filter(p -> p.isClosed() && p.getProfit().isPositive()).count();
            losing    = (int) record.getPositions().stream().filter(p -> p.isClosed() && p.getProfit().isNegative()).count();
            breakEven = positions - winning - losing;
        }
        if (winRate == 0 && positions > 0) winRate = (double) winning / positions;

        return b
                .totalReturnPct(totalReturnPct)
                .totalProfitLoss(totalPnl)
                .grossProfit(grossProfit)
                .grossLoss(grossLoss)
                .avgReturnPct(avgReturnPct)
                .profitLossRatio(profitLossRatio)
                .numberOfPositions(positions)
                .numberOfWinning(winning)
                .numberOfLosing(losing)
                .numberOfBreakEven(breakEven)
                .winRate(winRate)
                .maxDrawdownPct(maxDrawdown)
                .maxRunupPct(maxRunup)
                .sharpeRatio(sharpe)
                .sortinoRatio(sortino)
                .calmarRatio(calmar)
                .valueAtRisk95(var95)
                .expectedShortfall(es)
                .buyAndHoldReturnPct(buyHoldPct)
                .vsBuyAndHold(vsBuyHold)
                .build();
    }

    // ── 개별 Criterion 계산 헬퍼 ─────────────────────────────────────────────

    private double calcTotalReturnPct(BarSeries series, TradingRecord record) throws Exception {
        // 총 수익률 = (finalEquity - initialCapital) / initialCapital
        // TotalProfitLossPercentageCriterion 또는 ReturnCriterion 시도
        try {
            Class<?> cls = Class.forName("org.ta4j.core.criteria.pnl.TotalProfitLossPercentageCriterion");
            AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance();
            return criterion.calculate(series, record).doubleValue();
        } catch (Exception ignored) {}
        try {
            Class<?> cls = Class.forName("org.ta4j.core.criteria.ReturnCriterion");
            AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance();
            return criterion.calculate(series, record).doubleValue() - 1.0;
        } catch (Exception ignored) {}
        // fallback: 거래 수익 직접 계산
        return record.getPositions().stream()
                .filter(Position::isClosed)
                .mapToDouble(p -> p.getProfit().doubleValue() / p.getEntry().getNetPrice().doubleValue())
                .sum();
    }

    private double calcMaxDrawdown(BarSeries series, TradingRecord record) throws Exception {
        try {
            Class<?> cls = Class.forName("org.ta4j.core.criteria.MaximumDrawdownCriterion");
            AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance();
            return criterion.calculate(series, record).doubleValue();
        } catch (Exception ignored) {}
        return 0.0;
    }

    private double calcSharpeRatio(BarSeries series, TradingRecord record) throws Exception {
        try {
            Class<?> cls = Class.forName("org.ta4j.core.criteria.SharpeRatioCriterion");
            AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance();
            return criterion.calculate(series, record).doubleValue();
        } catch (Exception ignored) {}
        return 0.0;
    }

    private double calcBuyAndHold(BarSeries series, TradingRecord record) throws Exception {
        try {
            Class<?> cls = Class.forName("org.ta4j.core.criteria.EnterAndHoldReturnCriterion");
            AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance();
            double v = criterion.calculate(series, record).doubleValue();
            // 일부 버전은 비율 반환, 일부는 배수 반환
            return v > 10 ? v / 100.0 : v;
        } catch (Exception ignored) {}
        // fallback: 첫봉~마지막봉 종가 변화율
        if (series.getBarCount() >= 2) {
            double first = series.getBar(0).getClosePrice().doubleValue();
            double last  = series.getBar(series.getBarCount() - 1).getClosePrice().doubleValue();
            return (last - first) / first;
        }
        return 0.0;
    }

    private double calcCriterion(String className, BarSeries series, TradingRecord record) throws Exception {
        Class<?> cls = Class.forName(className);
        AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance();
        return criterion.calculate(series, record).doubleValue();
    }

    private int calcCriterionInt(String className, BarSeries series, TradingRecord record) throws Exception {
        Class<?> cls = Class.forName(className);
        AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance();
        return (int) Math.round(criterion.calculate(series, record).doubleValue());
    }

    private double safeCalc(CriterionSupplier supplier) {
        try { return supplier.get(); }
        catch (Exception e) {
            log.debug("Criterion 계산 실패 (무시): {}", e.getMessage());
            return 0.0;
        }
    }

    private int safeCalcInt(CriterionSupplier supplier) {
        try { return (int) Math.round(supplier.get()); }
        catch (Exception e) { return 0; }
    }

    @FunctionalInterface
    interface CriterionSupplier { double get() throws Exception; }

    // ── DB 저장 ───────────────────────────────────────────────────────────────

    private Long saveResult(BacktestRequest req, BacktestSettingsDto cfg,
                             List<Signal> signals, BacktestAnalysisDto analysis,
                             BarSeries series, String strategyName) {
        try {
            List<OhlcvBar> bars = req.getBars();
            long fromTime = bars.isEmpty() ? 0 : bars.get(0).getTime();
            long toTime   = bars.isEmpty() ? 0 : bars.get(bars.size() - 1).getTime();

            GcBacktestResult entity = GcBacktestResult.builder()
                    .deviceId(req.getDeviceId())
                    .strategyId(req.getStrategyId())
                    .strategyName(strategyName)
                    .symbol(req.getSymbol() != null ? req.getSymbol() : "UNKNOWN")
                    .timeframe(req.getTimeframe())
                    .barCount(bars.size())
                    .fromTime(fromTime)
                    .toTime(toTime)
                    .settingsJson(objectMapper.writeValueAsString(cfg))
                    .signalsJson(objectMapper.writeValueAsString(signals))
                    .analysisJson(objectMapper.writeValueAsString(analysis))
                    .totalReturn(BigDecimal.valueOf(analysis.getTotalReturnPct()))
                    .winRate(BigDecimal.valueOf(analysis.getWinRate()))
                    .totalTrades(analysis.getNumberOfPositions())
                    .maxDrawdown(BigDecimal.valueOf(analysis.getMaxDrawdownPct()))
                    .sharpeRatio(BigDecimal.valueOf(analysis.getSharpeRatio()))
                    .finalEquity(BigDecimal.valueOf(analysis.getFinalEquity()))
                    .build();

            return resultRepository.save(entity).getId();
        } catch (Exception e) {
            log.warn("백테스팅 결과 DB 저장 실패: {}", e.getMessage());
            return null;
        }
    }

    // ── Stats 변환 (하위 호환) ────────────────────────────────────────────────

    private Stats toStats(BacktestAnalysisDto a, List<Signal> signals) {
        int buySignals  = (int) signals.stream().filter(s -> "BUY".equals(s.getType()) || "SHORT_ENTRY".equals(s.getType())).count();
        int sellSignals = (int) signals.stream().filter(s -> "SELL".equals(s.getType()) || "SHORT_EXIT".equals(s.getType())).count();
        return Stats.builder()
                .totalSignals(signals.size())
                .buySignals(buySignals)
                .sellSignals(sellSignals)
                .totalTrades(a.getNumberOfPositions())
                .winTrades(a.getNumberOfWinning())
                .winRate(a.getWinRate())
                .totalReturn(a.getTotalReturnPct())
                .maxDrawdown(a.getMaxDrawdownPct())
                .avgReturn(a.getAvgReturnPct())
                .finalEquity(a.getFinalEquity())
                .build();
    }

    // ── 가격 결정 ─────────────────────────────────────────────────────────────

    private double getPrice(BarSeries series, List<OhlcvBar> bars, int i, String priceType) {
        if ("NEXT_OPEN".equals(priceType) && i + 1 < bars.size())
            return bars.get(i + 1).getOpen();
        if ("OPEN".equals(priceType))
            return series.getBar(i).getOpenPrice().doubleValue();
        if ("HIGH".equals(priceType))
            return series.getBar(i).getHighPrice().doubleValue();
        if ("LOW".equals(priceType))
            return series.getBar(i).getLowPrice().doubleValue();
        return series.getBar(i).getClosePrice().doubleValue();
    }

    private double applySlippage(double price, BacktestSettingsDto cfg, boolean isBuy) {
        double slip = cfg.getSlippageRate() != null ? cfg.getSlippageRate().doubleValue() : 0.0005;
        return isBuy ? price * (1 + slip) : price * (1 - slip);
    }

    private double calcCommissionRate(BacktestSettingsDto cfg) {
        if ("ZERO".equals(cfg.getCommissionType())) return 0.0;
        return cfg.getCommissionRate() != null ? cfg.getCommissionRate().doubleValue() : 0.0015;
    }

    // ── BarSeries 빌드 ────────────────────────────────────────────────────────

    private BarSeries buildSeries(List<OhlcvBar> bars, String timeframe) {
        BarSeries series = new BaseBarSeriesBuilder()
                .withNumFactory(org.ta4j.core.num.DoubleNumFactory.getInstance())
                .build();
        TimeBarBuilderFactory factory = new TimeBarBuilderFactory();
        Duration period = timeframeToDuration(timeframe);
        for (OhlcvBar b : bars) {
            // b.getTime() = 봉 시작 시각. ta4j Bar.endTime = 봉 종료 시각이므로 duration 가산.
            Instant endInst = Instant.ofEpochSecond(b.getTime()).plus(period);
            factory.createBarBuilder(series)
                    .timePeriod(period).endTime(endInst)
                    .openPrice(b.getOpen()).highPrice(b.getHigh())
                    .lowPrice(b.getLow()).closePrice(b.getClose())
                    .volume(b.getVolume()).add();
        }
        return series;
    }

    private static Duration timeframeToDuration(String tf) {
        if (tf == null) return Duration.ofMinutes(1);
        return switch (tf) {
            case "1m"  -> Duration.ofMinutes(1);
            case "3m"  -> Duration.ofMinutes(3);
            case "5m"  -> Duration.ofMinutes(5);
            case "15m" -> Duration.ofMinutes(15);
            case "30m" -> Duration.ofMinutes(30);
            case "1h"  -> Duration.ofHours(1);
            case "4h"  -> Duration.ofHours(4);
            case "1D"  -> Duration.ofDays(1);
            case "1W"  -> Duration.ofDays(7);
            case "1M"  -> Duration.ofDays(30);
            default    -> Duration.ofMinutes(1);
        };
    }

    private BacktestResponse emptyResponse(String reason) {
        log.warn("[BacktestingService] 빈 결과: {}", reason);
        BacktestAnalysisDto emptyAnalysis = BacktestAnalysisDto.builder().build();
        return BacktestResponse.builder()
                .signals(List.of())
                .stats(Stats.builder().build())
                .analysis(emptyAnalysis)
                .build();
    }
}
