mirror of
https://github.com/luxfi/broker.git
synced 2026-09-24 00:25:18 +00:00
388 lines
9.3 KiB
Go
388 lines
9.3 KiB
Go
package lx
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/luxfi/broker/pkg/types"
|
|
"github.com/luxfi/zap"
|
|
)
|
|
|
|
// startTestDEX boots a ZAP node that speaks the lx schema — decodes the
|
|
// request by opcode, encodes a typed response using schema.go helpers.
|
|
// Returns the listen address and a stop func.
|
|
func startTestDEX(t *testing.T) (string, func()) {
|
|
t.Helper()
|
|
|
|
// Pick an ephemeral port up front.
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
port := ln.Addr().(*net.TCPAddr).Port
|
|
ln.Close()
|
|
|
|
node := zap.NewNode(zap.NodeConfig{
|
|
NodeID: "luxdex",
|
|
ServiceType: dexServiceType,
|
|
Port: port,
|
|
NoDiscovery: true,
|
|
})
|
|
|
|
handler := func(_ context.Context, _ string, msg *zap.Message) (*zap.Message, error) {
|
|
op, _ := unpackFlags(msg.Flags())
|
|
root := msg.Root()
|
|
|
|
var respBytes []byte
|
|
switch op {
|
|
case OpGetSnapshot:
|
|
sym := readSymbolReq(root)
|
|
respBytes = buildSnapshotResp(snapshot{
|
|
symbol: sym,
|
|
lastPrice: 100.50,
|
|
lastSize: 0.25,
|
|
lastNs: 1_700_000_000_000_000_000,
|
|
bidPrice: 100.45,
|
|
bidSize: 1.5,
|
|
askPrice: 100.55,
|
|
askSize: 1.8,
|
|
quoteNs: 1_700_000_000_500_000_000,
|
|
})
|
|
|
|
case OpGetAsset:
|
|
sym := readSymbolReq(root)
|
|
respBytes = buildAssetResp(asset{
|
|
id: "asset-1",
|
|
symbol: sym,
|
|
name: "Bitcoin",
|
|
class: "crypto",
|
|
exchange: "lx",
|
|
status: "active",
|
|
provider: "lx",
|
|
tradable: true,
|
|
})
|
|
|
|
case OpGetOrder:
|
|
account, orderID := readAccountOrderReq(root)
|
|
respBytes = buildOrderResp(OpGetOrder, order{
|
|
id: orderID,
|
|
clientID: "cli-1",
|
|
account: account,
|
|
symbol: "BTC/USD",
|
|
side: "buy",
|
|
orderType: "limit",
|
|
tif: "gtc",
|
|
status: "filled",
|
|
qty: 0.1,
|
|
filledQty: 0.1,
|
|
limitPrice: 100.0,
|
|
avgFillPrice: 100.25,
|
|
createdNs: 1_700_000_000_000_000_000,
|
|
filledNs: 1_700_000_000_500_000_000,
|
|
})
|
|
|
|
case OpCreateOrder:
|
|
req := readCreateOrderReq(root)
|
|
respBytes = buildOrderResp(OpCreateOrder, order{
|
|
id: "new-order",
|
|
clientID: req.clientID,
|
|
account: req.account,
|
|
symbol: req.symbol,
|
|
side: req.side,
|
|
orderType: req.orderType,
|
|
tif: req.tif,
|
|
status: "accepted",
|
|
qty: req.qty,
|
|
limitPrice: req.limitPrice,
|
|
createdNs: 1_700_000_000_000_000_000,
|
|
})
|
|
|
|
case OpCancelOrder:
|
|
respBytes = buildEmptyResp(OpCancelOrder)
|
|
|
|
default:
|
|
respBytes = buildError("not_implemented", "opcode not handled in test", op)
|
|
}
|
|
|
|
return zap.Parse(respBytes)
|
|
}
|
|
|
|
for _, op := range []uint16{
|
|
OpGetSnapshot, OpGetAsset, OpGetOrder, OpCreateOrder, OpCancelOrder,
|
|
} {
|
|
node.Handle(op, handler)
|
|
}
|
|
|
|
if err := node.Start(); err != nil {
|
|
t.Fatalf("start dex node: %v", err)
|
|
}
|
|
|
|
return "127.0.0.1:" + strconv.Itoa(port), node.Stop
|
|
}
|
|
|
|
// ── Schema round-trip sanity checks ────────────────────────────────────
|
|
|
|
func TestSchema_SymbolReq_RoundTrip(t *testing.T) {
|
|
raw := buildSymbolReq(OpGetSnapshot, "BTC/USD")
|
|
msg, err := zap.Parse(raw)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
op, status := unpackFlags(msg.Flags())
|
|
if op != OpGetSnapshot || status != StatusOK {
|
|
t.Fatalf("flags: op=0x%x status=0x%x", op, status)
|
|
}
|
|
if got := readSymbolReq(msg.Root()); got != "BTC/USD" {
|
|
t.Fatalf("symbol: got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSchema_Snapshot_RoundTrip(t *testing.T) {
|
|
raw := buildSnapshotResp(snapshot{
|
|
symbol: "ETH/USD",
|
|
lastPrice: 2500.0,
|
|
bidPrice: 2499.0,
|
|
askPrice: 2501.0,
|
|
lastNs: 1_700_000_000_000_000_000,
|
|
})
|
|
msg, err := zap.Parse(raw)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
s := readSnapshotRoot(msg.Root())
|
|
if s.symbol != "ETH/USD" || s.lastPrice != 2500.0 {
|
|
t.Fatalf("snapshot: %+v", s)
|
|
}
|
|
if s.bidPrice != 2499.0 || s.askPrice != 2501.0 {
|
|
t.Fatalf("book: %+v", s)
|
|
}
|
|
}
|
|
|
|
func TestSchema_CreateOrder_RoundTrip(t *testing.T) {
|
|
raw := buildCreateOrderReq(createOrderReq{
|
|
account: "acct-1",
|
|
symbol: "BTC/USD",
|
|
side: "buy",
|
|
orderType: "limit",
|
|
tif: "gtc",
|
|
clientID: "cli-1",
|
|
qty: 0.5,
|
|
limitPrice: 95000.0,
|
|
})
|
|
msg, err := zap.Parse(raw)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
req := readCreateOrderReq(msg.Root())
|
|
if req.account != "acct-1" || req.symbol != "BTC/USD" {
|
|
t.Fatalf("strings: %+v", req)
|
|
}
|
|
if req.qty != 0.5 || req.limitPrice != 95000.0 {
|
|
t.Fatalf("floats: %+v", req)
|
|
}
|
|
}
|
|
|
|
func TestSchema_Error_RoundTrip(t *testing.T) {
|
|
raw := buildError("not_found", "no such order", OpGetOrder)
|
|
msg, err := zap.Parse(raw)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
_, status := unpackFlags(msg.Flags())
|
|
if status != StatusErr {
|
|
t.Fatalf("status: 0x%x", status)
|
|
}
|
|
code, message := readError(msg.Root())
|
|
if code != "not_found" || message != "no such order" {
|
|
t.Fatalf("err: %q %q", code, message)
|
|
}
|
|
}
|
|
|
|
func TestSchema_CSV(t *testing.T) {
|
|
parts := []string{"BTC/USD", "ETH/USD", "SOL/USD"}
|
|
got := splitCSV(joinCSV(parts))
|
|
if len(got) != 3 || got[0] != "BTC/USD" || got[2] != "SOL/USD" {
|
|
t.Fatalf("csv round-trip: %v", got)
|
|
}
|
|
if splitCSV("") != nil {
|
|
t.Fatal("empty csv should split to nil")
|
|
}
|
|
}
|
|
|
|
// ── Provider-level ZAP roundtrip tests ─────────────────────────────────
|
|
|
|
func TestProvider_GetSnapshot(t *testing.T) {
|
|
addr, stop := startTestDEX(t)
|
|
defer stop()
|
|
|
|
p := New(Config{DEXAddr: addr})
|
|
defer p.Close()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
snap, err := p.GetSnapshot(ctx, "BTC/USD")
|
|
if err != nil {
|
|
t.Fatalf("GetSnapshot: %v", err)
|
|
}
|
|
if snap.Symbol != "BTC/USD" {
|
|
t.Errorf("symbol: got %q", snap.Symbol)
|
|
}
|
|
if snap.LatestTrade == nil || snap.LatestTrade.Price != 100.50 {
|
|
t.Errorf("trade: got %+v", snap.LatestTrade)
|
|
}
|
|
if snap.LatestQuote == nil || snap.LatestQuote.BidPrice != 100.45 {
|
|
t.Errorf("quote: got %+v", snap.LatestQuote)
|
|
}
|
|
}
|
|
|
|
func TestProvider_GetAsset(t *testing.T) {
|
|
addr, stop := startTestDEX(t)
|
|
defer stop()
|
|
|
|
p := New(Config{DEXAddr: addr})
|
|
defer p.Close()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
a, err := p.GetAsset(ctx, "BTC/USD")
|
|
if err != nil {
|
|
t.Fatalf("GetAsset: %v", err)
|
|
}
|
|
if a.Symbol != "BTC/USD" || a.Name != "Bitcoin" || a.Class != "crypto" {
|
|
t.Errorf("asset: %+v", a)
|
|
}
|
|
if !a.Tradable {
|
|
t.Error("should be tradable")
|
|
}
|
|
}
|
|
|
|
func TestProvider_GetOrder(t *testing.T) {
|
|
addr, stop := startTestDEX(t)
|
|
defer stop()
|
|
|
|
p := New(Config{DEXAddr: addr})
|
|
defer p.Close()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
o, err := p.GetOrder(ctx, "acct-1", "ord-42")
|
|
if err != nil {
|
|
t.Fatalf("GetOrder: %v", err)
|
|
}
|
|
if o.ID != "ord-42" || o.Symbol != "BTC/USD" {
|
|
t.Errorf("order: %+v", o)
|
|
}
|
|
if o.Status != "filled" || o.Qty != "0.1" {
|
|
t.Errorf("fields: status=%q qty=%q", o.Status, o.Qty)
|
|
}
|
|
}
|
|
|
|
func TestProvider_CreateOrder(t *testing.T) {
|
|
addr, stop := startTestDEX(t)
|
|
defer stop()
|
|
|
|
p := New(Config{DEXAddr: addr})
|
|
defer p.Close()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
req := &types.CreateOrderRequest{
|
|
Symbol: "BTC/USD",
|
|
Side: "buy",
|
|
Type: "limit",
|
|
TimeInForce: "gtc",
|
|
Qty: "0.5",
|
|
LimitPrice: "95000.00",
|
|
}
|
|
o, err := p.CreateOrder(ctx, "acct-1", req)
|
|
if err != nil {
|
|
t.Fatalf("CreateOrder: %v", err)
|
|
}
|
|
if o.ID != "new-order" || o.Side != "buy" {
|
|
t.Errorf("order: %+v", o)
|
|
}
|
|
if o.Status != "accepted" {
|
|
t.Errorf("status: %q", o.Status)
|
|
}
|
|
}
|
|
|
|
func TestProvider_CancelOrder(t *testing.T) {
|
|
addr, stop := startTestDEX(t)
|
|
defer stop()
|
|
|
|
p := New(Config{DEXAddr: addr})
|
|
defer p.Close()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if err := p.CancelOrder(ctx, "acct-1", "ord-1"); err != nil {
|
|
t.Fatalf("CancelOrder: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProvider_ErrorPropagation(t *testing.T) {
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
port := ln.Addr().(*net.TCPAddr).Port
|
|
ln.Close()
|
|
|
|
node := zap.NewNode(zap.NodeConfig{
|
|
NodeID: "luxdex",
|
|
ServiceType: dexServiceType,
|
|
Port: port,
|
|
NoDiscovery: true,
|
|
})
|
|
node.Handle(OpGetAsset, func(_ context.Context, _ string, msg *zap.Message) (*zap.Message, error) {
|
|
raw := buildError("not_found", "no such symbol", OpGetAsset)
|
|
return zap.Parse(raw)
|
|
})
|
|
if err := node.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer node.Stop()
|
|
|
|
p := New(Config{DEXAddr: "127.0.0.1:" + strconv.Itoa(port)})
|
|
defer p.Close()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
_, err = p.GetAsset(ctx, "XXX")
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
rpcErr, ok := err.(*RPCError)
|
|
if !ok {
|
|
t.Fatalf("want *RPCError, got %T: %v", err, err)
|
|
}
|
|
if rpcErr.Code != "not_found" || rpcErr.Message != "no such symbol" {
|
|
t.Errorf("err: %+v", rpcErr)
|
|
}
|
|
}
|
|
|
|
func TestProvider_Name(t *testing.T) {
|
|
if (&Provider{}).Name() != "lx" {
|
|
t.Fatal("Name should be \"lx\"")
|
|
}
|
|
}
|
|
|
|
func TestProvider_DefaultsApplied(t *testing.T) {
|
|
p := New(Config{})
|
|
if p.cfg.DEXAddr != DefaultDEXAddr {
|
|
t.Errorf("DEXAddr: got %q", p.cfg.DEXAddr)
|
|
}
|
|
if p.cfg.MPCAddr != DefaultMPCAddr {
|
|
t.Errorf("MPCAddr: got %q", p.cfg.MPCAddr)
|
|
}
|
|
}
|