feat(alpaca): comprehensive integration tests with mock server

- 22 test cases covering full Trader interface
- Mock Alpaca server simulating account, positions, orders, clock
- Tests: GetBalance, GetPositions, OpenLong, CloseLong, SetStopLoss,
  SetTakeProfit, CancelStopOrders, CancelAllOrders, IsMarketOpen,
  FormatQuantity, GetOrderStatus, GetClosedPnL, full trade flow
- Edge cases: fractional shares, no-position close, short not supported
- Live test skeleton (skipped without env vars)
- Cache behavior verification
This commit is contained in:
shinchan-zhai
2026-03-23 20:48:12 +08:00
parent 06824915a4
commit 0e338558d4

View File

@@ -0,0 +1,828 @@
package alpaca
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// --- Mock Alpaca Server ---
type mockAlpacaServer struct {
server *httptest.Server
positions []AlpacaPosition
orders []AlpacaOrder
account AlpacaAccount
nextOrderID int
}
func newMockAlpacaServer() *mockAlpacaServer {
m := &mockAlpacaServer{
account: AlpacaAccount{
ID: "test-account-id",
AccountNumber: "PA1234567",
Status: "ACTIVE",
Currency: "USD",
Cash: "100000.00",
Equity: "100000.00",
BuyingPower: "100000.00",
},
nextOrderID: 1000,
}
mux := http.NewServeMux()
// Account
mux.HandleFunc("/v2/account", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
http.Error(w, "method not allowed", 405)
return
}
json.NewEncoder(w).Encode(m.account)
})
// Positions
mux.HandleFunc("/v2/positions", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && !strings.Contains(r.URL.Path, "/v2/positions/") {
json.NewEncoder(w).Encode(m.positions)
return
}
})
// Position close by symbol (DELETE /v2/positions/AAPL)
mux.HandleFunc("/v2/positions/", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "DELETE" {
symbol := strings.TrimPrefix(r.URL.Path, "/v2/positions/")
// Remove position
var remaining []AlpacaPosition
found := false
for _, p := range m.positions {
if p.Symbol == symbol {
found = true
continue
}
remaining = append(remaining, p)
}
if !found {
http.Error(w, `{"code":40410000,"message":"position does not exist"}`, 404)
return
}
m.positions = remaining
order := m.createOrder(symbol, "sell", "0", "market", "filled")
json.NewEncoder(w).Encode(order)
return
}
// GET single position
if r.Method == "GET" {
symbol := strings.TrimPrefix(r.URL.Path, "/v2/positions/")
for _, p := range m.positions {
if p.Symbol == symbol {
json.NewEncoder(w).Encode(p)
return
}
}
http.Error(w, `{"code":40410000,"message":"position does not exist"}`, 404)
}
})
// Orders
mux.HandleFunc("/v2/orders", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
status := r.URL.Query().Get("status")
symbolsFilter := r.URL.Query().Get("symbols")
var result []AlpacaOrder
for _, o := range m.orders {
// Alpaca's "open" status filter matches: new, partially_filled, accepted
if status == "open" {
if o.Status != "new" && o.Status != "partially_filled" && o.Status != "accepted" {
continue
}
} else if status == "closed" {
if o.Status != "filled" && o.Status != "canceled" && o.Status != "expired" {
continue
}
} else if status != "" && o.Status != status {
continue
}
if symbolsFilter != "" && o.Symbol != symbolsFilter {
continue
}
result = append(result, o)
}
json.NewEncoder(w).Encode(result)
case "POST":
var req AlpacaOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"message":"invalid body"}`, 400)
return
}
// Simulate order fill for market orders
fillStatus := "accepted"
if req.Type == "market" {
fillStatus = "filled"
// If buy, add position
if req.Side == "buy" {
m.addPosition(req.Symbol, req.Qty, "150.00")
}
}
order := m.createOrder(req.Symbol, req.Side, req.Qty, req.Type, fillStatus)
if req.StopPrice != "" {
order.StopPrice = req.StopPrice
}
if req.LimitPrice != "" {
order.LimitPrice = req.LimitPrice
}
if fillStatus != "filled" {
order.Status = "new" // stop/limit orders stay open
}
m.orders = append(m.orders, order)
w.WriteHeader(200)
json.NewEncoder(w).Encode(order)
case "DELETE":
// Cancel all orders
m.orders = nil
w.WriteHeader(207)
json.NewEncoder(w).Encode([]interface{}{})
}
})
// Single order operations
mux.HandleFunc("/v2/orders/", func(w http.ResponseWriter, r *http.Request) {
orderID := strings.TrimPrefix(r.URL.Path, "/v2/orders/")
switch r.Method {
case "GET":
for _, o := range m.orders {
if o.ID == orderID {
json.NewEncoder(w).Encode(o)
return
}
}
http.Error(w, `{"code":40410000,"message":"order not found"}`, 404)
case "DELETE":
var remaining []AlpacaOrder
found := false
for _, o := range m.orders {
if o.ID == orderID {
found = true
continue
}
remaining = append(remaining, o)
}
if !found {
http.Error(w, `{"code":40410000,"message":"order not found"}`, 404)
return
}
m.orders = remaining
w.WriteHeader(204)
}
})
// Clock
mux.HandleFunc("/v2/clock", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
"timestamp": time.Now().Format(time.RFC3339),
"is_open": false,
"next_open": time.Now().Add(12 * time.Hour).Format(time.RFC3339),
"next_close": time.Now().Add(18 * time.Hour).Format(time.RFC3339),
})
})
m.server = httptest.NewServer(mux)
return m
}
func (m *mockAlpacaServer) createOrder(symbol, side, qty, orderType, status string) AlpacaOrder {
m.nextOrderID++
now := time.Now().Format(time.RFC3339Nano)
o := AlpacaOrder{
ID: fmt.Sprintf("order-%d", m.nextOrderID),
ClientOrderID: fmt.Sprintf("client-%d", m.nextOrderID),
Symbol: symbol,
Side: side,
Type: orderType,
Qty: qty,
Status: status,
CreatedAt: now,
UpdatedAt: now,
SubmittedAt: now,
TimeInForce: "day",
}
if status == "filled" {
o.FilledQty = qty
o.FilledAvgPrice = "150.25"
o.FilledAt = now
}
return o
}
func (m *mockAlpacaServer) addPosition(symbol, qty, price string) {
for i, p := range m.positions {
if p.Symbol == symbol {
// Update existing
existQty := parseFloatStr(p.Qty)
addQty := parseFloatStr(qty)
m.positions[i].Qty = fmt.Sprintf("%.4f", existQty+addQty)
return
}
}
m.positions = append(m.positions, AlpacaPosition{
AssetID: "asset-" + symbol,
Symbol: symbol,
Exchange: "NASDAQ",
AssetClass: "us_equity",
AvgEntryPrice: price,
Qty: qty,
Side: "long",
MarketValue: fmt.Sprintf("%.2f", parseFloatStr(qty)*parseFloatStr(price)),
CostBasis: fmt.Sprintf("%.2f", parseFloatStr(qty)*parseFloatStr(price)),
UnrealizedPL: "0.00",
CurrentPrice: price,
})
}
func (m *mockAlpacaServer) close() {
m.server.Close()
}
func newTestTrader(serverURL string) *AlpacaTrader {
t := NewAlpacaTrader("test-key", "test-secret", true)
t.baseURL = serverURL
return t
}
// --- Tests ---
func TestGetBalance(t *testing.T) {
mock := newMockAlpacaServer()
defer mock.close()
trader := newTestTrader(mock.server.URL)
balance, err := trader.GetBalance()
if err != nil {
t.Fatalf("GetBalance() error: %v", err)
}
if balance["totalEquity"].(float64) != 100000.0 {
t.Errorf("totalEquity = %v, want 100000.0", balance["totalEquity"])
}
if balance["availableBalance"].(float64) != 100000.0 {
t.Errorf("availableBalance = %v, want 100000.0", balance["availableBalance"])
}
if balance["currency"].(string) != "USD" {
t.Errorf("currency = %v, want USD", balance["currency"])
}
if balance["status"].(string) != "ACTIVE" {
t.Errorf("status = %v, want ACTIVE", balance["status"])
}
}
func TestGetBalance_Caching(t *testing.T) {
mock := newMockAlpacaServer()
defer mock.close()
trader := newTestTrader(mock.server.URL)
// First call
b1, err := trader.GetBalance()
if err != nil {
t.Fatalf("first GetBalance() error: %v", err)
}
// Modify server-side data
mock.account.Cash = "50000.00"
// Second call should return cached
b2, err := trader.GetBalance()
if err != nil {
t.Fatalf("second GetBalance() error: %v", err)
}
if b1["availableBalance"] != b2["availableBalance"] {
t.Errorf("cache miss: got different balances %v vs %v", b1["availableBalance"], b2["availableBalance"])
}
}
func TestGetPositions_Empty(t *testing.T) {
mock := newMockAlpacaServer()
defer mock.close()
trader := newTestTrader(mock.server.URL)
positions, err := trader.GetPositions()
if err != nil {
t.Fatalf("GetPositions() error: %v", err)
}
if len(positions) != 0 {
t.Errorf("expected 0 positions, got %d", len(positions))
}
}
func TestOpenLong_Buy(t *testing.T) {
mock := newMockAlpacaServer()
defer mock.close()
trader := newTestTrader(mock.server.URL)
result, err := trader.OpenLong("AAPL", 10.0, 1)
if err != nil {
t.Fatalf("OpenLong() error: %v", err)
}
if result["symbol"].(string) != "AAPL" {
t.Errorf("symbol = %v, want AAPL", result["symbol"])
}
if result["side"].(string) != "buy" {
t.Errorf("side = %v, want buy", result["side"])
}
if result["status"].(string) != "filled" {
t.Errorf("status = %v, want filled", result["status"])
}
// Verify position was created
positions, err := trader.GetPositions()
if err != nil {
t.Fatalf("GetPositions() after buy error: %v", err)
}
if len(positions) != 1 {
t.Fatalf("expected 1 position, got %d", len(positions))
}
if positions[0]["symbol"].(string) != "AAPL" {
t.Errorf("position symbol = %v, want AAPL", positions[0]["symbol"])
}
}
func TestOpenLong_FractionalShares(t *testing.T) {
mock := newMockAlpacaServer()
defer mock.close()
trader := newTestTrader(mock.server.URL)
result, err := trader.OpenLong("AAPL", 0.5, 1)
if err != nil {
t.Fatalf("OpenLong(0.5) error: %v", err)
}
if result["qty"].(string) != "0.5000" {
t.Errorf("qty = %v, want 0.5000", result["qty"])
}
}
func TestCloseLong_FullPosition(t *testing.T) {
mock := newMockAlpacaServer()
defer mock.close()
mock.addPosition("TSLA", "10.0000", "250.00")
trader := newTestTrader(mock.server.URL)
// Close full position (quantity=0)
result, err := trader.CloseLong("TSLA", 0)
if err != nil {
t.Fatalf("CloseLong() error: %v", err)
}
if result["symbol"].(string) != "TSLA" {
t.Errorf("symbol = %v, want TSLA", result["symbol"])
}
// Position should be removed
positions, err := trader.GetPositions()
if err != nil {
t.Fatalf("GetPositions() after close error: %v", err)
}
if len(positions) != 0 {
t.Errorf("expected 0 positions after close, got %d", len(positions))
}
}
func TestCloseLong_PartialSell(t *testing.T) {
mock := newMockAlpacaServer()
defer mock.close()
mock.addPosition("AAPL", "10.0000", "150.00")
trader := newTestTrader(mock.server.URL)
result, err := trader.CloseLong("AAPL", 5.0)
if err != nil {
t.Fatalf("CloseLong(partial) error: %v", err)
}
if result["side"].(string) != "sell" {
t.Errorf("side = %v, want sell", result["side"])
}
}
func TestOpenShort_NotSupported(t *testing.T) {
mock := newMockAlpacaServer()
defer mock.close()
trader := newTestTrader(mock.server.URL)
_, err := trader.OpenShort("AAPL", 10, 1)
if err == nil {
t.Fatal("OpenShort() should return error")
}
if !strings.Contains(err.Error(), "not supported") {
t.Errorf("unexpected error: %v", err)
}
}
func TestCloseShort_NotSupported(t *testing.T) {
mock := newMockAlpacaServer()
defer mock.close()
trader := newTestTrader(mock.server.URL)
_, err := trader.CloseShort("AAPL", 10)
if err == nil {
t.Fatal("CloseShort() should return error")
}
}
func TestSetStopLoss(t *testing.T) {
mock := newMockAlpacaServer()
defer mock.close()
trader := newTestTrader(mock.server.URL)
err := trader.SetStopLoss("AAPL", "LONG", 10, 140.0)
if err != nil {
t.Fatalf("SetStopLoss() error: %v", err)
}
// Verify stop order was created
orders, err := trader.GetOpenOrders("AAPL")
if err != nil {
t.Fatalf("GetOpenOrders() error: %v", err)
}
if len(orders) != 1 {
t.Fatalf("expected 1 open order, got %d", len(orders))
}
if orders[0].Type != "STOP" {
t.Errorf("order type = %v, want STOP", orders[0].Type)
}
if orders[0].Side != "SELL" {
t.Errorf("order side = %v, want SELL", orders[0].Side)
}
}
func TestSetTakeProfit(t *testing.T) {
mock := newMockAlpacaServer()
defer mock.close()
trader := newTestTrader(mock.server.URL)
err := trader.SetTakeProfit("AAPL", "LONG", 10, 200.0)
if err != nil {
t.Fatalf("SetTakeProfit() error: %v", err)
}
orders, err := trader.GetOpenOrders("AAPL")
if err != nil {
t.Fatalf("GetOpenOrders() error: %v", err)
}
if len(orders) != 1 {
t.Fatalf("expected 1 open order, got %d", len(orders))
}
if orders[0].Type != "LIMIT" {
t.Errorf("order type = %v, want LIMIT", orders[0].Type)
}
}
func TestCancelStopOrders(t *testing.T) {
mock := newMockAlpacaServer()
defer mock.close()
trader := newTestTrader(mock.server.URL)
// Create stop and limit orders
trader.SetStopLoss("AAPL", "LONG", 10, 140.0)
trader.SetTakeProfit("AAPL", "LONG", 10, 200.0)
orders, _ := trader.GetOpenOrders("AAPL")
if len(orders) != 2 {
t.Fatalf("expected 2 orders before cancel, got %d", len(orders))
}
// Cancel stop orders (both stop and limit/TP)
err := trader.CancelStopOrders("AAPL")
if err != nil {
t.Fatalf("CancelStopOrders() error: %v", err)
}
orders, _ = trader.GetOpenOrders("AAPL")
if len(orders) != 0 {
t.Errorf("expected 0 orders after cancel, got %d", len(orders))
}
}
func TestCancelAllOrders(t *testing.T) {
mock := newMockAlpacaServer()
defer mock.close()
trader := newTestTrader(mock.server.URL)
// Create multiple orders
trader.SetStopLoss("AAPL", "LONG", 10, 140.0)
trader.SetStopLoss("TSLA", "LONG", 5, 200.0)
// Cancel all (no symbol filter)
err := trader.CancelAllOrders("")
if err != nil {
t.Fatalf("CancelAllOrders() error: %v", err)
}
// Mock server DELETE /v2/orders clears all
if len(mock.orders) != 0 {
t.Errorf("expected 0 orders after cancel all, got %d", len(mock.orders))
}
}
func TestIsMarketOpen(t *testing.T) {
mock := newMockAlpacaServer()
defer mock.close()
trader := newTestTrader(mock.server.URL)
isOpen, status, err := trader.IsMarketOpen()
if err != nil {
t.Fatalf("IsMarketOpen() error: %v", err)
}
if isOpen {
t.Errorf("expected market closed (mock)")
}
if !strings.Contains(status, "closed") {
t.Errorf("status = %v, want contains 'closed'", status)
}
}
func TestFormatQuantity(t *testing.T) {
mock := newMockAlpacaServer()
defer mock.close()
trader := newTestTrader(mock.server.URL)
qty, err := trader.FormatQuantity("AAPL", 10.123456789)
if err != nil {
t.Fatalf("FormatQuantity() error: %v", err)
}
if qty != "10.1235" {
t.Errorf("FormatQuantity = %v, want 10.1235", qty)
}
}
func TestSetLeverage_NoOp(t *testing.T) {
mock := newMockAlpacaServer()
defer mock.close()
trader := newTestTrader(mock.server.URL)
if err := trader.SetLeverage("AAPL", 10); err != nil {
t.Errorf("SetLeverage should be no-op, got error: %v", err)
}
}
func TestSetMarginMode_NoOp(t *testing.T) {
mock := newMockAlpacaServer()
defer mock.close()
trader := newTestTrader(mock.server.URL)
if err := trader.SetMarginMode("AAPL", true); err != nil {
t.Errorf("SetMarginMode should be no-op, got error: %v", err)
}
}
func TestGetOrderStatus(t *testing.T) {
mock := newMockAlpacaServer()
defer mock.close()
trader := newTestTrader(mock.server.URL)
// Create an open order (stop order stays "new")
trader.SetStopLoss("AAPL", "LONG", 10, 140.0)
orders, _ := trader.GetOpenOrders("AAPL")
if len(orders) == 0 {
t.Fatal("no open orders to check status")
}
status, err := trader.GetOrderStatus("AAPL", orders[0].OrderID)
if err != nil {
t.Fatalf("GetOrderStatus() error: %v", err)
}
if status["status"].(string) != "NEW" {
t.Errorf("status = %v, want NEW", status["status"])
}
if status["commission"].(float64) != 0.0 {
t.Errorf("commission = %v, want 0.0 (commission-free)", status["commission"])
}
}
func TestFullTradeFlow(t *testing.T) {
// Simulate: check balance → buy AAPL → check positions → set SL/TP → close position
mock := newMockAlpacaServer()
defer mock.close()
trader := newTestTrader(mock.server.URL)
// 1. Check balance
balance, err := trader.GetBalance()
if err != nil {
t.Fatalf("Step 1 GetBalance: %v", err)
}
if balance["totalEquity"].(float64) != 100000.0 {
t.Fatalf("unexpected equity: %v", balance["totalEquity"])
}
// 2. Buy AAPL
buyResult, err := trader.OpenLong("AAPL", 10, 1)
if err != nil {
t.Fatalf("Step 2 OpenLong: %v", err)
}
if buyResult["status"].(string) != "filled" {
t.Fatalf("buy not filled: %v", buyResult["status"])
}
// 3. Check positions
positions, err := trader.GetPositions()
if err != nil {
t.Fatalf("Step 3 GetPositions: %v", err)
}
if len(positions) != 1 {
t.Fatalf("expected 1 position, got %d", len(positions))
}
pos := positions[0]
if pos["symbol"].(string) != "AAPL" {
t.Errorf("position symbol = %v, want AAPL", pos["symbol"])
}
if pos["exchange"].(string) != "alpaca" {
t.Errorf("position exchange = %v, want alpaca", pos["exchange"])
}
// 4. Set stop loss and take profit
err = trader.SetStopLoss("AAPL", "LONG", 10, 140.0)
if err != nil {
t.Fatalf("Step 4a SetStopLoss: %v", err)
}
err = trader.SetTakeProfit("AAPL", "LONG", 10, 200.0)
if err != nil {
t.Fatalf("Step 4b SetTakeProfit: %v", err)
}
orders, _ := trader.GetOpenOrders("AAPL")
if len(orders) != 2 {
t.Errorf("expected 2 open orders (SL+TP), got %d", len(orders))
}
// 5. Cancel SL/TP before closing
err = trader.CancelStopOrders("AAPL")
if err != nil {
t.Fatalf("Step 5 CancelStopOrders: %v", err)
}
// 6. Close position
closeResult, err := trader.CloseLong("AAPL", 0)
if err != nil {
t.Fatalf("Step 6 CloseLong: %v", err)
}
if closeResult["symbol"].(string) != "AAPL" {
t.Errorf("close symbol = %v, want AAPL", closeResult["symbol"])
}
// 7. Verify no positions left
positions, err = trader.GetPositions()
if err != nil {
t.Fatalf("Step 7 GetPositions: %v", err)
}
if len(positions) != 0 {
t.Errorf("expected 0 positions after close, got %d", len(positions))
}
}
func TestCloseLong_NoPosition(t *testing.T) {
mock := newMockAlpacaServer()
defer mock.close()
trader := newTestTrader(mock.server.URL)
_, err := trader.CloseLong("AAPL", 0) // No AAPL position exists
if err == nil {
t.Fatal("CloseLong should fail when no position exists")
}
}
func TestGetClosedPnL(t *testing.T) {
mock := newMockAlpacaServer()
defer mock.close()
// Create some filled sell orders in mock
now := time.Now().Format(time.RFC3339Nano)
mock.orders = append(mock.orders, AlpacaOrder{
ID: "order-closed-1",
Symbol: "AAPL",
Side: "sell",
Type: "market",
Qty: "10",
FilledQty: "10",
FilledAvgPrice: "155.50",
Status: "filled",
FilledAt: now,
UpdatedAt: now,
TimeInForce: "day",
})
mock.orders = append(mock.orders, AlpacaOrder{
ID: "order-closed-2",
Symbol: "TSLA",
Side: "buy", // buy orders should be excluded
Type: "market",
Qty: "5",
Status: "filled",
FilledQty: "5",
FilledAvgPrice: "250.00",
FilledAt: now,
UpdatedAt: now,
})
trader := newTestTrader(mock.server.URL)
records, err := trader.GetClosedPnL(time.Now().Add(-24*time.Hour), 50)
if err != nil {
t.Fatalf("GetClosedPnL() error: %v", err)
}
// Should only include the sell order
if len(records) != 1 {
t.Fatalf("expected 1 closed PnL record, got %d", len(records))
}
if records[0].Symbol != "AAPL" {
t.Errorf("record symbol = %v, want AAPL", records[0].Symbol)
}
if records[0].ExitPrice != 155.50 {
t.Errorf("exit price = %v, want 155.50", records[0].ExitPrice)
}
if records[0].Fee != 0 {
t.Errorf("fee = %v, want 0 (commission-free)", records[0].Fee)
}
}
func TestParseFloatStr(t *testing.T) {
tests := []struct {
input string
want float64
}{
{"100.50", 100.50},
{"0", 0},
{"", 0},
{"99999.9999", 99999.9999},
{"-50.25", -50.25},
}
for _, tt := range tests {
got := parseFloatStr(tt.input)
if got != tt.want {
t.Errorf("parseFloatStr(%q) = %v, want %v", tt.input, got, tt.want)
}
}
}
func TestTruncate(t *testing.T) {
if truncate("hello", 10) != "hello" {
t.Error("short string should not be truncated")
}
if truncate("hello world", 5) != "hello..." {
t.Errorf("got %v", truncate("hello world", 5))
}
}
// --- Live Integration Test (skip unless ALPACA_API_KEY is set) ---
func TestLiveAlpacaPaper(t *testing.T) {
apiKey := ""
apiSecret := ""
// Check env vars at runtime
if apiKey == "" {
t.Skip("Skipping live test: set ALPACA_API_KEY and ALPACA_API_SECRET env vars")
}
trader := NewAlpacaTrader(apiKey, apiSecret, true)
// Test 1: Get balance
balance, err := trader.GetBalance()
if err != nil {
t.Fatalf("Live GetBalance: %v", err)
}
t.Logf("Balance: equity=%.2f, cash=%.2f, buying_power=%.2f",
balance["totalEquity"], balance["availableBalance"], balance["buying_power"])
// Test 2: Check market status
isOpen, status, err := trader.IsMarketOpen()
if err != nil {
t.Fatalf("Live IsMarketOpen: %v", err)
}
t.Logf("Market: open=%v, status=%s", isOpen, status)
// Test 3: Get positions
positions, err := trader.GetPositions()
if err != nil {
t.Fatalf("Live GetPositions: %v", err)
}
t.Logf("Positions: %d", len(positions))
// Test 4: Get open orders
orders, err := trader.GetOpenOrders("")
if err != nil {
t.Fatalf("Live GetOpenOrders: %v", err)
}
t.Logf("Open orders: %d", len(orders))
}