package backtest import ( "fmt" "sort" "time" "nofx/logger" "nofx/store" ) func (r *Runner) persistMetadata() { state := r.snapshotState() meta := r.buildMetadata(state, r.Status()) meta.CreatedAt = r.createdAt if err := SaveRunMetadata(meta); err != nil { logger.Infof("failed to save run metadata for %s: %v", r.cfg.RunID, err) } else { if err := updateRunIndex(meta, &r.cfg); err != nil { logger.Infof("failed to update index for %s: %v", r.cfg.RunID, err) } } } func (r *Runner) logDecision(record *store.DecisionRecord) error { if record == nil { return nil } persistDecisionRecord(r.cfg.RunID, record) return nil } func (r *Runner) persistMetrics(force bool) { if r.cfg.RunID == "" { return } if !force && !r.lastMetricsWrite.IsZero() { if time.Since(r.lastMetricsWrite) < metricsWriteInterval { return } } state := r.snapshotState() metrics, err := CalculateMetrics(r.cfg.RunID, &r.cfg, &state) if err != nil { logger.Infof("failed to compute metrics for %s: %v", r.cfg.RunID, err) return } if metrics == nil { return } if err := PersistMetrics(r.cfg.RunID, metrics); err != nil { logger.Infof("failed to persist metrics for %s: %v", r.cfg.RunID, err) return } r.lastMetricsWrite = time.Now() } func (r *Runner) buildMetadata(state BacktestState, runState RunState) *RunMetadata { if state.Liquidated && runState != RunStateLiquidated { runState = RunStateLiquidated } progress := progressPercent(state, r.cfg) summary := RunSummary{ SymbolCount: len(r.cfg.Symbols), DecisionTF: r.cfg.DecisionTimeframe, ProcessedBars: state.BarIndex, ProgressPct: progress, EquityLast: state.Equity, MaxDrawdownPct: state.MaxDrawdownPct, Liquidated: state.Liquidated, LiquidationNote: state.LiquidationNote, } meta := &RunMetadata{ RunID: r.cfg.RunID, UserID: r.cfg.UserID, State: runState, LastError: r.lastErrorString(), Summary: summary, } return meta } func progressPercent(state BacktestState, cfg BacktestConfig) float64 { duration := cfg.Duration() if duration <= 0 { return 0 } if state.BarTimestamp == 0 { return 0 } start := time.Unix(cfg.StartTS, 0) end := time.Unix(cfg.EndTS, 0) current := time.UnixMilli(state.BarTimestamp) if !current.After(start) { return 0 } if current.After(end) { return 100 } elapsed := current.Sub(start) pct := float64(elapsed) / float64(duration) * 100 if pct > 100 { pct = 100 } if pct < 0 { pct = 0 } return pct } func (r *Runner) maybeCheckpoint() error { state := r.snapshotState() shouldCheckpoint := false if r.cfg.CheckpointIntervalBars > 0 && state.BarIndex > 0 && state.BarIndex%r.cfg.CheckpointIntervalBars == 0 { shouldCheckpoint = true } interval := time.Duration(r.cfg.CheckpointIntervalSeconds) * time.Second if interval <= 0 { interval = 2 * time.Second } if time.Since(r.lastCheckpoint) >= interval { shouldCheckpoint = true } if !shouldCheckpoint { return nil } if err := r.saveCheckpoint(state); err != nil { return err } return nil } func (r *Runner) snapshotForCheckpoint(state BacktestState) []PositionSnapshot { res := make([]PositionSnapshot, 0, len(state.Positions)) for _, pos := range state.Positions { res = append(res, pos) } sort.Slice(res, func(i, j int) bool { if res[i].Symbol == res[j].Symbol { return res[i].Side < res[j].Side } return res[i].Symbol < res[j].Symbol }) return res } func (r *Runner) buildCheckpointFromState(state BacktestState) *Checkpoint { return &Checkpoint{ BarIndex: state.BarIndex, BarTimestamp: state.BarTimestamp, Cash: state.Cash, Equity: state.Equity, UnrealizedPnL: state.UnrealizedPnL, RealizedPnL: state.RealizedPnL, Positions: r.snapshotForCheckpoint(state), DecisionCycle: state.DecisionCycle, Liquidated: state.Liquidated, LiquidationNote: state.LiquidationNote, MaxEquity: state.MaxEquity, MinEquity: state.MinEquity, MaxDrawdownPct: state.MaxDrawdownPct, AICacheRef: r.cachePath, } } func (r *Runner) saveCheckpoint(state BacktestState) error { ckpt := r.buildCheckpointFromState(state) if ckpt == nil { return nil } if err := SaveCheckpoint(r.cfg.RunID, ckpt); err != nil { return err } r.lastCheckpoint = time.Now() return nil } func (r *Runner) forceCheckpoint() { state := r.snapshotState() if err := r.saveCheckpoint(state); err != nil { logger.Infof("failed to save checkpoint for %s: %v", r.cfg.RunID, err) } } func (r *Runner) RestoreFromCheckpoint() error { ckpt, err := LoadCheckpoint(r.cfg.RunID) if err != nil { return err } return r.applyCheckpoint(ckpt) } func (r *Runner) applyCheckpoint(ckpt *Checkpoint) error { if ckpt == nil { return fmt.Errorf("checkpoint is nil") } r.account.RestoreFromSnapshots(ckpt.Cash, ckpt.RealizedPnL, ckpt.Positions) r.stateMu.Lock() defer r.stateMu.Unlock() r.state.BarIndex = ckpt.BarIndex r.state.BarTimestamp = ckpt.BarTimestamp r.state.Cash = ckpt.Cash r.state.Equity = ckpt.Equity r.state.UnrealizedPnL = ckpt.UnrealizedPnL r.state.RealizedPnL = ckpt.RealizedPnL r.state.DecisionCycle = ckpt.DecisionCycle r.state.Liquidated = ckpt.Liquidated r.state.LiquidationNote = ckpt.LiquidationNote r.state.MaxEquity = ckpt.MaxEquity r.state.MinEquity = ckpt.MinEquity r.state.MaxDrawdownPct = ckpt.MaxDrawdownPct r.state.Positions = snapshotsToMap(ckpt.Positions) r.state.LastUpdate = time.Now().UTC() r.lastCheckpoint = time.Now() return nil } func snapshotsToMap(snaps []PositionSnapshot) map[string]PositionSnapshot { positions := make(map[string]PositionSnapshot, len(snaps)) for _, snap := range snaps { key := fmt.Sprintf("%s:%s", snap.Symbol, snap.Side) positions[key] = snap } return positions }