mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-06 20:41:14 +08:00
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:
828
trader/alpaca/trader_test.go
Normal file
828
trader/alpaca/trader_test.go
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user