Files
wickra/examples/go/internal/market/market.go
T
kingchencandGitHub 75eefbbd08 examples: fix and harmonize the strategy backtests across all languages (#324)
The strategy_* examples were only syntax-smoked in CI, never run, which hid two
classes of problem:

1. Python strategy_macd_adx / strategy_bollinger_squeeze passed three separate
   arguments to the candle indicators ADX/ATR, whose .update() takes a single
   candle — a TypeError at runtime — and read the ADX tuple at index 0 (plus_di)
   instead of 2 (adx). Both fixed.

2. The Go / C# / R / Java strategies defaulted to synthetic data and used a
   different (annualised) one-line summary, so they printed wildly different
   numbers from the Rust/Python/Node/C/WASM suite. Rewrite them to the shared
   per-trade backtest (load the bundled BTCUSDT CSV by default, same entry/exit
   logic, same print_summary output).

All nine runnable bindings now print byte-identical backtest summaries on the
same data (MACD+ADX 246 trades / -47.19%, RSI 37 / -17.84%, Bollinger 1 / -7.82%),
verified by diffing each language's output against the Python reference. WASM
shares the same logic and bundled dataset (browser-rendered).
2026-06-17 17:56:22 +02:00

226 lines
6.4 KiB
Go

// Package market provides deterministic synthetic market data, a small OHLCV
// CSV loader, and an equity-curve summary shared by the offline Go examples so
// they run without network access. It mirrors the helpers used by the Python,
// C, and C# example suites.
package market
import (
"fmt"
"math"
"os"
"path/filepath"
"runtime"
wickra "github.com/wickra-lib/wickra/bindings/go"
)
// Bar is one OHLCV bar with a millisecond timestamp.
type Bar struct {
Open float64
High float64
Low float64
Close float64
Volume float64
Timestamp int64
}
// SyntheticPrices returns a reproducible price path (trend + two cycles), with
// no randomness, starting at 100.
func SyntheticPrices(count int) []float64 {
return SyntheticPricesFrom(count, 100.0)
}
// SyntheticPricesFrom is SyntheticPrices with an explicit starting level.
func SyntheticPricesFrom(count int, start float64) []float64 {
prices := make([]float64, count)
for i := range prices {
fi := float64(i)
prices[i] = start + 12.0*math.Sin(fi*0.05) + 5.0*math.Sin(fi*0.013) + fi*0.01
}
return prices
}
// SyntheticCandles returns a reproducible OHLCV series derived from
// SyntheticPrices, one bar per hour.
func SyntheticCandles(count int) []Bar {
return SyntheticCandlesStep(count, 0, 3_600_000)
}
// SyntheticCandlesStep is SyntheticCandles with an explicit start timestamp and
// per-bar step in milliseconds.
func SyntheticCandlesStep(count int, startTimestamp, stepMs int64) []Bar {
prices := SyntheticPrices(count + 1)
bars := make([]Bar, count)
for i := 0; i < count; i++ {
fi := float64(i)
op := prices[i]
cl := prices[i+1]
high := math.Max(op, cl) + 0.5 + math.Abs(math.Sin(fi*0.7))
low := math.Min(op, cl) - 0.5 - math.Abs(math.Cos(fi*0.7))
volume := 1000.0 + 500.0*(1.0+math.Sin(fi*0.1))
bars[i] = Bar{op, high, low, cl, volume, startTimestamp + int64(i)*stepMs}
}
return bars
}
// LoadOhlcvCsv loads a timestamp,open,high,low,close,volume OHLCV CSV with
// Wickra's native CandleReader (header validation, BOM and field-whitespace
// tolerance) — no manual CSV parsing.
func LoadOhlcvCsv(path string) ([]Bar, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
reader, err := wickra.NewCandleReader(string(data))
if err != nil {
return nil, err
}
defer reader.Close()
candles := reader.Read()
bars := make([]Bar, len(candles))
for i, c := range candles {
bars[i] = Bar{c.Open, c.High, c.Low, c.Close, c.Volume, c.Timestamp}
}
return bars, nil
}
// EquityResult holds summary statistics for a long-only equity curve.
type EquityResult struct {
TotalReturnPct float64
Sharpe float64
MaxDrawdownPct float64
Trades int
FinalEquity float64
}
// Summarize turns a stream of per-bar fractional returns (0.01 == +1%) into a
// PnL / Sharpe / max-drawdown summary, annualised by periodsPerYear.
func Summarize(periodReturns []float64, trades int, periodsPerYear float64) EquityResult {
equity, peak, maxDrawdown := 1.0, 1.0, 0.0
for _, r := range periodReturns {
equity *= 1.0 + r
peak = math.Max(peak, equity)
if peak > 0 {
maxDrawdown = math.Max(maxDrawdown, (peak-equity)/peak)
}
}
mean := 0.0
if len(periodReturns) > 0 {
var sum float64
for _, r := range periodReturns {
sum += r
}
mean = sum / float64(len(periodReturns))
}
variance := 0.0
if len(periodReturns) > 1 {
var ss float64
for _, r := range periodReturns {
ss += (r - mean) * (r - mean)
}
variance = ss / float64(len(periodReturns)-1)
}
stdDev := math.Sqrt(variance)
sharpe := 0.0
if stdDev > 1e-12 {
sharpe = mean / stdDev * math.Sqrt(periodsPerYear)
}
return EquityResult{
TotalReturnPct: (equity - 1.0) * 100.0,
Sharpe: sharpe,
MaxDrawdownPct: maxDrawdown * 100.0,
Trades: trades,
FinalEquity: equity,
}
}
// Print writes a one-line summary of an equity result.
func Print(name string, r EquityResult) {
fmt.Printf("%-26s return=%8.2f%% sharpe=%6.2f maxDD=%6.2f%% trades=%d\n",
name, r.TotalReturnPct, r.Sharpe, r.MaxDrawdownPct, r.Trades)
}
// BundledCandles loads one of the checked-in datasets under examples/data,
// resolved relative to this source file so it works from any working directory.
func BundledCandles(filename string) []Bar {
_, self, _, _ := runtime.Caller(0)
path := filepath.Join(filepath.Dir(self), "..", "..", "..", "data", filename)
bars, err := LoadOhlcvCsv(path)
if err != nil {
panic(err)
}
return bars
}
// PrintSummary prints the per-trade backtest summary shared verbatim with the
// Rust, Python, Node and C example suites (same labels, same numbers).
func PrintSummary(name string, firstPrice, lastPrice float64, bars int, closedTrades []float64, finalEquity float64, equityCurve []float64) {
buyHold := lastPrice / firstPrice
stratReturn := finalEquity - 1.0
bhReturn := buyHold - 1.0
wins, losses := 0, 0
best, worst := 0.0, 0.0
for i, r := range closedTrades {
if r > 0 {
wins++
} else if r < 0 {
losses++
}
if i == 0 || r > best {
best = r
}
if i == 0 || r < worst {
worst = r
}
}
n := len(closedTrades)
mean := 0.0
if n > 0 {
var sum float64
for _, r := range closedTrades {
sum += r
}
mean = sum / float64(n)
}
variance := 0.0
if n > 1 {
var ss float64
for _, r := range closedTrades {
ss += (r - mean) * (r - mean)
}
variance = ss / float64(n-1)
}
sharpe := 0.0
if variance > 0 {
sharpe = mean / math.Sqrt(variance)
}
peak, maxDD := 1.0, 0.0
if len(equityCurve) > 0 {
peak = equityCurve[0]
}
for _, eq := range equityCurve {
if eq > peak {
peak = eq
}
if dd := (peak - eq) / peak; dd > maxDD {
maxDD = dd
}
}
fmt.Printf("=== %s ===\n", name)
fmt.Printf("%-23s%d\n", "Bars:", bars)
fmt.Printf("%-23s%d (W%d / L%d)\n", "Trades:", n, wins, losses)
fmt.Printf("%-23s%+.2f%%\n", "Strategy return:", stratReturn*100)
fmt.Printf("%-23s%+.2f%%\n", "Buy & Hold return:", bhReturn*100)
fmt.Printf("%-23s%+.2f%%\n", "Excess over BH:", (stratReturn-bhReturn)*100)
fmt.Printf("%-23s%.2f%%\n", "Max drawdown:", maxDD*100)
fmt.Printf("%-23s%.2f (mean %+.4f, stddev %.4f)\n", "Per-trade Sharpe:", sharpe, mean, math.Sqrt(variance))
fmt.Printf("%-23s%+.2f%% / %+.2f%%\n", "Best / worst trade:", best*100, worst*100)
fmt.Println()
fmt.Println("NOTE: Educational example — fees, slippage, funding costs and tax " +
"effects are simplified or omitted. Past performance is not " +
"indicative of future results.")
}