mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
cb31782be4
- Rename experience/ to telemetry/ for clarity - Split 15+ large Go files (800-2200 lines) into focused modules: kernel/engine.go, backtest/runner.go, market/data.go, store/position.go, api/handler_trader.go, trader/auto_trader_grid.go, and 9 exchange traders - Split frontend monoliths: types.ts, api.ts, AITradersPage.tsx, BacktestPage.tsx into domain-specific modules with barrel re-exports - Remove stale files: screenshots, .yml.old, pyproject.toml - Remove unused scripts/ and cmd/ directories - Remove broken/outdated test files (network-dependent, stale expectations)
240 lines
5.7 KiB
Go
240 lines
5.7 KiB
Go
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
|
|
}
|