ADD: added first version

This commit is contained in:
hwinkel
2026-01-14 22:44:12 +01:00
commit 2b892123e1
18 changed files with 580 additions and 0 deletions

82
internal/alpaca/client.go Normal file
View 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
View 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,
})
}

View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,10 @@
package model
type Signal int
const (
Hold Signal = iota
Buy
Sell
)

View 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
}

View File

@@ -0,0 +1,7 @@
package strategy
import "alpaca-bot/internal/model"
type Strategy interface {
OnBar(bar model.Bar) model.Signal
}