ADD: added first version
This commit is contained in:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# ---> Go
|
||||||
|
# If you prefer the allow list template instead of the deny list, see community template:
|
||||||
|
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||||
|
#
|
||||||
|
# Binaries for programs and plugins
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directories (remove the comment below to include it)
|
||||||
|
# vendor/
|
||||||
|
|
||||||
|
Results/
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
|
||||||
|
# env file
|
||||||
|
.env
|
||||||
|
|
||||||
96
cmd/backtest/main.go
Normal file
96
cmd/backtest/main.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"alpaca-bot/internal/alpaca"
|
||||||
|
"alpaca-bot/internal/backtest"
|
||||||
|
"alpaca-bot/internal/config"
|
||||||
|
"alpaca-bot/internal/logger"
|
||||||
|
"alpaca-bot/internal/strategy"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
_ = godotenv.Load()
|
||||||
|
log := logger.New("[BACKTEST]")
|
||||||
|
|
||||||
|
cfg := config.LoadAlpaca()
|
||||||
|
|
||||||
|
// CLI flags
|
||||||
|
symbol := flag.String("symbol", "AAPL", "Stock symbol")
|
||||||
|
tf := flag.String("tf", "1Day", "Timeframe")
|
||||||
|
startStr := flag.String("start", "2023-01-01", "Start date")
|
||||||
|
endStr := flag.String("end", "2026-01-01", "End date")
|
||||||
|
cash := flag.Float64("cash", 10000, "Start capital")
|
||||||
|
risk := flag.Float64("risk", 0.02, "Risk per trade (fraction)")
|
||||||
|
atrPeriod := flag.Int("atr", 14, "ATR period")
|
||||||
|
atrMult := flag.Float64("atrMult", 1.5, "ATR multiplier for stop")
|
||||||
|
tpMult := flag.Float64("tpMult", 2.0, "Take profit multiplier")
|
||||||
|
fast := flag.Int("fast", 12, "Fast EMA")
|
||||||
|
slow := flag.Int("slow", 26, "Slow EMA")
|
||||||
|
out := flag.String("out", "output/backtest.xlsx", "Excel output path")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
start, err := time.Parse("2006-01-02", *startStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("invalid start date:", err)
|
||||||
|
}
|
||||||
|
end, err := time.Parse("2006-01-02", *endStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("invalid end date:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create output folder
|
||||||
|
if err := os.MkdirAll(filepath.Dir(*out), 0755); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load historical bars from Alpaca
|
||||||
|
client := alpaca.NewClient(cfg)
|
||||||
|
bars, err := client.GetHistoricalBars(*symbol, *tf, start, end)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
log.Printf("Loaded %d bars for %s", len(bars), *symbol)
|
||||||
|
|
||||||
|
// Initialize EMA crossover strategy
|
||||||
|
strat := strategy.NewEMACross(*fast, *slow)
|
||||||
|
|
||||||
|
// Initialize backtest engine with ATR stops and TakeProfit
|
||||||
|
engine := backtest.NewEngine(*symbol, strat, *cash, *risk, *atrPeriod, *atrMult, *tpMult)
|
||||||
|
engine.Run(bars)
|
||||||
|
|
||||||
|
log.Printf("Backtest finished | Trades: %d | End Cash: %.2f", len(engine.Trades), engine.Cash)
|
||||||
|
|
||||||
|
// Export trades to Excel
|
||||||
|
f := excelize.NewFile()
|
||||||
|
sheet := "Backtest"
|
||||||
|
f.NewSheet(sheet)
|
||||||
|
|
||||||
|
headers := []string{"Time", "Symbol", "Side", "Price", "Qty", "Cash", "StopPrice", "TakeProfit"}
|
||||||
|
for i, h := range headers {
|
||||||
|
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
||||||
|
f.SetCellValue(sheet, cell, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, t := range engine.Trades {
|
||||||
|
row := i + 2
|
||||||
|
values := []interface{}{t.Time.Format("2006-01-02 15:04:05"), t.Symbol, t.Side, t.Price, t.Qty, t.Cash, t.StopPrice, t.TakeProfit}
|
||||||
|
for colIdx, val := range values {
|
||||||
|
cell, _ := excelize.CoordinatesToCellName(colIdx+1, row)
|
||||||
|
f.SetCellValue(sheet, cell, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.SaveAs(*out); err != nil {
|
||||||
|
log.Fatal("failed to save Excel:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Backtest Excel saved to %s", *out)
|
||||||
|
}
|
||||||
28
go.mod
Normal file
28
go.mod
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
module alpaca-bot
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/markcheno/go-talib v0.0.0-20250114000313-ec55a20c902f
|
||||||
|
github.com/xuri/excelize/v2 v2.10.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
cloud.google.com/go v0.118.0 // indirect
|
||||||
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
|
github.com/shopspring/decimal v1.3.1 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/alpacahq/alpaca-trade-api-go/v3 v3.9.0
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||||
|
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.1 // indirect
|
||||||
|
github.com/xuri/efp v0.0.1 // indirect
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||||
|
golang.org/x/crypto v0.43.0 // indirect
|
||||||
|
golang.org/x/net v0.46.0 // indirect
|
||||||
|
golang.org/x/text v0.30.0 // indirect
|
||||||
|
)
|
||||||
BIN
go.mod:Zone.Identifier
Normal file
BIN
go.mod:Zone.Identifier
Normal file
Binary file not shown.
43
go.sum
Normal file
43
go.sum
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
cloud.google.com/go v0.118.0 h1:tvZe1mgqRxpiVa3XlIGMiPcEUbP1gNXELgD4y/IXmeQ=
|
||||||
|
cloud.google.com/go v0.118.0/go.mod h1:zIt2pkedt/mo+DQjcT4/L3NDxzHPR29j5HcclNH+9PM=
|
||||||
|
github.com/alpacahq/alpaca-trade-api-go/v3 v3.9.0 h1:UqrbAa9gncu6GeCxf6vs09jw/n/o+pd6nziRjk3Twjg=
|
||||||
|
github.com/alpacahq/alpaca-trade-api-go/v3 v3.9.0/go.mod h1:BM5f01Jh+mmcEK/Y5kS6XsQojVSuUM8HL4MQgrRtyis=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
|
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
|
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
|
github.com/markcheno/go-talib v0.0.0-20250114000313-ec55a20c902f h1:iKq//xEUUaeRoXNcAshpK4W8eSm7HtgI0aNznWtX7lk=
|
||||||
|
github.com/markcheno/go-talib v0.0.0-20250114000313-ec55a20c902f/go.mod h1:3YUtoVrKWu2ql+iAeRyepSz3fy6a+19hJzGS88+u4u0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
|
||||||
|
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
|
||||||
|
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
|
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
|
||||||
|
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
|
github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
|
||||||
|
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4=
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||||
|
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||||
|
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||||
|
github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4=
|
||||||
|
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||||
|
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||||
|
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||||
|
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||||
|
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||||
|
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||||
|
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||||
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
82
internal/alpaca/client.go
Normal file
82
internal/alpaca/client.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package alpaca
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"alpaca-bot/internal/config"
|
||||||
|
"alpaca-bot/internal/model"
|
||||||
|
|
||||||
|
"github.com/alpacahq/alpaca-trade-api-go/v3/alpaca"
|
||||||
|
"github.com/alpacahq/alpaca-trade-api-go/v3/marketdata"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
alpacaClient *alpaca.Client
|
||||||
|
mdClient *marketdata.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new Alpaca client
|
||||||
|
func NewClient(cfg config.AlpacaConfig) *Client {
|
||||||
|
c := alpaca.NewClient(alpaca.ClientOpts{
|
||||||
|
APIKey: cfg.ApiKey,
|
||||||
|
APISecret: cfg.ApiSecret,
|
||||||
|
BaseURL: cfg.TradeURL,
|
||||||
|
})
|
||||||
|
|
||||||
|
md := marketdata.NewClient(marketdata.ClientOpts{
|
||||||
|
APIKey: cfg.ApiKey,
|
||||||
|
APISecret: cfg.ApiSecret,
|
||||||
|
})
|
||||||
|
|
||||||
|
return &Client{
|
||||||
|
alpacaClient: c,
|
||||||
|
mdClient: md,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch historical bars
|
||||||
|
func (c *Client) GetHistoricalBars(symbol string, timeframe string, start, end time.Time) ([]model.Bar, error) {
|
||||||
|
req := marketdata.GetBarsRequest{
|
||||||
|
TimeFrame: marketdata.OneHour, // e.g. "1Day", "1Min"
|
||||||
|
Start: start,
|
||||||
|
End: end,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.mdClient.GetBars(symbol, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bars := []model.Bar{}
|
||||||
|
for _, b := range resp {
|
||||||
|
bars = append(bars, model.Bar{
|
||||||
|
Symbol: symbol,
|
||||||
|
Time: b.Timestamp,
|
||||||
|
Open: b.Open,
|
||||||
|
High: b.High,
|
||||||
|
Low: b.Low,
|
||||||
|
Close: b.Close,
|
||||||
|
// Volume: b.Volume,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return bars, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example: submit an order
|
||||||
|
func (c *Client) SubmitOrder(symbol string, qty *decimal.Decimal, side alpaca.Side, orderType alpaca.OrderType, timeInForce alpaca.TimeInForce) (*alpaca.Order, error) {
|
||||||
|
orderReq := alpaca.PlaceOrderRequest{
|
||||||
|
Symbol: symbol,
|
||||||
|
Qty: qty,
|
||||||
|
Side: side,
|
||||||
|
Type: orderType,
|
||||||
|
TimeInForce: timeInForce,
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := c.alpacaClient.PlaceOrder(orderReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return order, nil
|
||||||
|
}
|
||||||
121
internal/backtest/engine.go
Normal file
121
internal/backtest/engine.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package backtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"alpaca-bot/internal/model"
|
||||||
|
"alpaca-bot/internal/strategy"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
talib "github.com/markcheno/go-talib"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Engine struct {
|
||||||
|
Strategy strategy.Strategy
|
||||||
|
Cash float64
|
||||||
|
Position int
|
||||||
|
Trades []model.Trade
|
||||||
|
Symbol string
|
||||||
|
RiskPercent float64 // fraction of cash to risk per trade
|
||||||
|
ATRPeriod int
|
||||||
|
ATRMultiplier float64
|
||||||
|
TakeProfitMultiplier float64 // e.g., 2 for 2:1 reward/risk
|
||||||
|
highs, lows, closes []float64
|
||||||
|
currentStop float64
|
||||||
|
currentTakeProfit float64
|
||||||
|
entryPrice float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEngine(symbol string, strat strategy.Strategy, cash float64, risk float64, atrPeriod int, atrMult float64, tpMult float64) *Engine {
|
||||||
|
return &Engine{
|
||||||
|
Symbol: symbol,
|
||||||
|
Strategy: strat,
|
||||||
|
Cash: cash,
|
||||||
|
RiskPercent: risk,
|
||||||
|
ATRPeriod: atrPeriod,
|
||||||
|
ATRMultiplier: atrMult,
|
||||||
|
TakeProfitMultiplier: tpMult,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes the backtest with ATR stops and take profit
|
||||||
|
func (e *Engine) Run(bars []model.Bar) {
|
||||||
|
for _, bar := range bars {
|
||||||
|
// collect prices for ATR
|
||||||
|
e.highs = append(e.highs, bar.High)
|
||||||
|
e.lows = append(e.lows, bar.Low)
|
||||||
|
e.closes = append(e.closes, bar.Close)
|
||||||
|
|
||||||
|
// skip until enough bars for ATR
|
||||||
|
if len(e.closes) <= e.ATRPeriod {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculate ATR safely
|
||||||
|
highsSlice := e.highs[len(e.highs)-e.ATRPeriod-1:]
|
||||||
|
lowsSlice := e.lows[len(e.lows)-e.ATRPeriod-1:]
|
||||||
|
closesSlice := e.closes[len(e.closes)-e.ATRPeriod-1:]
|
||||||
|
|
||||||
|
atrValues := talib.Atr(highsSlice, lowsSlice, closesSlice, e.ATRPeriod)
|
||||||
|
if len(atrValues) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
atr := atrValues[len(atrValues)-1]
|
||||||
|
if atr <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
stopDistance := atr * e.ATRMultiplier
|
||||||
|
|
||||||
|
// check existing position for stop loss / take profit
|
||||||
|
if e.Position > 0 {
|
||||||
|
// stop loss hit
|
||||||
|
if bar.Low <= e.currentStop {
|
||||||
|
e.Cash += float64(e.Position) * e.currentStop
|
||||||
|
e.record("SELL_STOP", e.currentStop, bar.Time, e.Position, e.currentStop, 0)
|
||||||
|
e.Position = 0
|
||||||
|
} else if bar.High >= e.currentTakeProfit {
|
||||||
|
e.Cash += float64(e.Position) * e.currentTakeProfit
|
||||||
|
e.record("SELL_TP", e.currentTakeProfit, bar.Time, e.Position, e.currentStop, e.currentTakeProfit)
|
||||||
|
e.Position = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// get new signal
|
||||||
|
signal := e.Strategy.OnBar(bar)
|
||||||
|
switch signal {
|
||||||
|
case model.Buy:
|
||||||
|
if e.Position == 0 { // only enter new position if flat
|
||||||
|
riskCash := e.Cash * e.RiskPercent
|
||||||
|
qty := int(riskCash / stopDistance)
|
||||||
|
if qty <= 0 || e.Cash < float64(qty)*bar.Close {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e.Position = qty
|
||||||
|
e.Cash -= float64(qty) * bar.Close
|
||||||
|
e.entryPrice = bar.Close
|
||||||
|
e.currentStop = bar.Close - stopDistance
|
||||||
|
e.currentTakeProfit = bar.Close + stopDistance*e.TakeProfitMultiplier
|
||||||
|
e.record("BUY", bar.Close, bar.Time, qty, e.currentStop, e.currentTakeProfit)
|
||||||
|
}
|
||||||
|
case model.Sell:
|
||||||
|
if e.Position > 0 { // manual exit signal
|
||||||
|
e.Cash += float64(e.Position) * bar.Close
|
||||||
|
e.record("SELL_SIGNAL", bar.Close, bar.Time, e.Position, e.currentStop, e.currentTakeProfit)
|
||||||
|
e.Position = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// record logs each trade
|
||||||
|
func (e *Engine) record(side string, price float64, t time.Time, qty int, stopPrice float64, takeProfit float64) {
|
||||||
|
e.Trades = append(e.Trades, model.Trade{
|
||||||
|
Time: t,
|
||||||
|
Symbol: e.Symbol,
|
||||||
|
Side: side,
|
||||||
|
Price: price,
|
||||||
|
Qty: qty,
|
||||||
|
Cash: e.Cash,
|
||||||
|
StopPrice: stopPrice,
|
||||||
|
TakeProfit: takeProfit,
|
||||||
|
})
|
||||||
|
}
|
||||||
32
internal/backtest/result.go
Normal file
32
internal/backtest/result.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package backtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"alpaca-bot/internal/model"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExportToExcel(trades []model.Trade, path string) error {
|
||||||
|
f := excelize.NewFile()
|
||||||
|
sheet := "Backtest"
|
||||||
|
f.NewSheet(sheet)
|
||||||
|
|
||||||
|
headers := []string{"Time", "Symbol", "Side", "Price", "Qty", "Cash"}
|
||||||
|
for i, h := range headers {
|
||||||
|
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
||||||
|
f.SetCellValue(sheet, cell, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, t := range trades {
|
||||||
|
row := i + 2
|
||||||
|
f.SetCellValue(sheet, fmt.Sprintf("A%d", row), t.Time)
|
||||||
|
f.SetCellValue(sheet, fmt.Sprintf("B%d", row), t.Symbol)
|
||||||
|
f.SetCellValue(sheet, fmt.Sprintf("C%d", row), t.Side)
|
||||||
|
f.SetCellValue(sheet, fmt.Sprintf("D%d", row), t.Price)
|
||||||
|
f.SetCellValue(sheet, fmt.Sprintf("E%d", row), t.Qty)
|
||||||
|
f.SetCellValue(sheet, fmt.Sprintf("F%d", row), t.Cash)
|
||||||
|
}
|
||||||
|
|
||||||
|
return f.SaveAs(path)
|
||||||
|
}
|
||||||
19
internal/config/alpaca.go
Normal file
19
internal/config/alpaca.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
type AlpacaConfig struct {
|
||||||
|
ApiKey string
|
||||||
|
ApiSecret string
|
||||||
|
DataURL string
|
||||||
|
TradeURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadAlpaca() AlpacaConfig {
|
||||||
|
return AlpacaConfig{
|
||||||
|
ApiKey: os.Getenv("ALPACA_API_KEY"),
|
||||||
|
ApiSecret: os.Getenv("ALPACA_API_SECRET"),
|
||||||
|
DataURL: os.Getenv("ALPACA_DATA_URL"),
|
||||||
|
TradeURL: os.Getenv("ALPACA_TRADE_URL"),
|
||||||
|
}
|
||||||
|
}
|
||||||
14
internal/data/bars.go
Normal file
14
internal/data/bars.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import "alpaca-bot/internal/model"
|
||||||
|
|
||||||
|
func LoadBars(symbol string) []model.Bar {
|
||||||
|
return []model.Bar{
|
||||||
|
{Symbol: symbol, Close: 150},
|
||||||
|
{Symbol: symbol, Close: 152},
|
||||||
|
{Symbol: symbol, Close: 149},
|
||||||
|
{Symbol: symbol, Close: 155},
|
||||||
|
{Symbol: symbol, Close: 160},
|
||||||
|
{Symbol: symbol, Close: 158},
|
||||||
|
}
|
||||||
|
}
|
||||||
10
internal/logger/logger.go
Normal file
10
internal/logger/logger.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func New(prefix string) *log.Logger {
|
||||||
|
return log.New(os.Stdout, prefix+" ", log.LstdFlags|log.Lshortfile)
|
||||||
|
}
|
||||||
13
internal/model/bar.go
Normal file
13
internal/model/bar.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Bar struct {
|
||||||
|
Symbol string
|
||||||
|
Time time.Time
|
||||||
|
Open float64
|
||||||
|
High float64
|
||||||
|
Low float64
|
||||||
|
Close float64
|
||||||
|
Volume int64
|
||||||
|
}
|
||||||
14
internal/model/trade.go
Normal file
14
internal/model/trade.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Trade struct {
|
||||||
|
Time time.Time
|
||||||
|
Symbol string
|
||||||
|
Side string
|
||||||
|
Price float64
|
||||||
|
Qty int
|
||||||
|
Cash float64
|
||||||
|
StopPrice float64 // ATR-based stop
|
||||||
|
TakeProfit float64 // ATR-based take profit
|
||||||
|
}
|
||||||
10
internal/model/types.go
Normal file
10
internal/model/types.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
type Signal int
|
||||||
|
|
||||||
|
const (
|
||||||
|
Hold Signal = iota
|
||||||
|
Buy
|
||||||
|
Sell
|
||||||
|
)
|
||||||
|
|
||||||
60
internal/strategy/ema_cross.go
Normal file
60
internal/strategy/ema_cross.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package strategy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"alpaca-bot/internal/model"
|
||||||
|
|
||||||
|
talib "github.com/markcheno/go-talib"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EMACross struct {
|
||||||
|
FastPeriod int
|
||||||
|
SlowPeriod int
|
||||||
|
prices []float64
|
||||||
|
lastFast float64
|
||||||
|
lastSlow float64
|
||||||
|
init bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEMACross(fast, slow int) *EMACross {
|
||||||
|
return &EMACross{
|
||||||
|
FastPeriod: fast,
|
||||||
|
SlowPeriod: slow,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EMACross) OnBar(bar model.Bar) model.Signal {
|
||||||
|
s.prices = append(s.prices, bar.Close)
|
||||||
|
|
||||||
|
if len(s.prices) < s.SlowPeriod {
|
||||||
|
return model.Hold
|
||||||
|
}
|
||||||
|
|
||||||
|
fast := talib.Ema(s.prices, s.FastPeriod)
|
||||||
|
slow := talib.Ema(s.prices, s.SlowPeriod)
|
||||||
|
|
||||||
|
curFast := fast[len(fast)-1]
|
||||||
|
curSlow := slow[len(slow)-1]
|
||||||
|
|
||||||
|
if !s.init {
|
||||||
|
s.lastFast = curFast
|
||||||
|
s.lastSlow = curSlow
|
||||||
|
s.init = true
|
||||||
|
return model.Hold
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.lastFast <= s.lastSlow && curFast > curSlow {
|
||||||
|
s.lastFast = curFast
|
||||||
|
s.lastSlow = curSlow
|
||||||
|
return model.Buy
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.lastFast >= s.lastSlow && curFast < curSlow {
|
||||||
|
s.lastFast = curFast
|
||||||
|
s.lastSlow = curSlow
|
||||||
|
return model.Sell
|
||||||
|
}
|
||||||
|
|
||||||
|
s.lastFast = curFast
|
||||||
|
s.lastSlow = curSlow
|
||||||
|
return model.Hold
|
||||||
|
}
|
||||||
7
internal/strategy/strategy.go
Normal file
7
internal/strategy/strategy.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package strategy
|
||||||
|
|
||||||
|
import "alpaca-bot/internal/model"
|
||||||
|
|
||||||
|
type Strategy interface {
|
||||||
|
OnBar(bar model.Bar) model.Signal
|
||||||
|
}
|
||||||
BIN
output/backtest.xlsx
Executable file
BIN
output/backtest.xlsx
Executable file
Binary file not shown.
Reference in New Issue
Block a user