ADD: added first version
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user