mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-13 07:46:54 +08:00
- 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
829 lines
21 KiB
Go
829 lines
21 KiB
Go
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))
|
|
}
|