mirror of
https://github.com/luxfi/broker.git
synced 2026-09-24 00:25:18 +00:00
334 lines
11 KiB
Go
334 lines
11 KiB
Go
package currencycloud
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/luxfi/broker/pkg/types"
|
|
)
|
|
|
|
// CurrencyCloud (Visa) — Institutional FX trading + cross-border payments.
|
|
// 35+ currencies, competitive spot FX, forward contracts.
|
|
|
|
const (
|
|
ProdURL = "https://api.currencycloud.com"
|
|
DemoURL = "https://devapi.currencycloud.com"
|
|
)
|
|
|
|
type Config struct{ BaseURL, LoginID, APIKey string }
|
|
|
|
type Provider struct {
|
|
cfg Config
|
|
client *http.Client
|
|
authToken string
|
|
tokenExp time.Time
|
|
}
|
|
|
|
func New(cfg Config) *Provider {
|
|
if cfg.BaseURL == "" {
|
|
cfg.BaseURL = ProdURL
|
|
}
|
|
return &Provider{cfg: cfg, client: &http.Client{Timeout: 30 * time.Second}}
|
|
}
|
|
|
|
func (p *Provider) Name() string { return "currencycloud" }
|
|
|
|
var errNotSupported = fmt.Errorf("not supported by currencycloud")
|
|
|
|
func (p *Provider) authenticate(ctx context.Context) error {
|
|
if p.authToken != "" && time.Now().Before(p.tokenExp) {
|
|
return nil
|
|
}
|
|
form := url.Values{"login_id": {p.cfg.LoginID}, "api_key": {p.cfg.APIKey}}
|
|
req, err := http.NewRequestWithContext(ctx, "POST", p.cfg.BaseURL+"/v2/authenticate/api", strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
resp, err := p.client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
data, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode >= 400 {
|
|
return fmt.Errorf("currencycloud auth error %d: %s", resp.StatusCode, string(data))
|
|
}
|
|
var result struct {
|
|
AuthToken string `json:"auth_token"`
|
|
}
|
|
json.Unmarshal(data, &result)
|
|
p.authToken = result.AuthToken
|
|
p.tokenExp = time.Now().Add(25 * time.Minute) // tokens last 30min
|
|
return nil
|
|
}
|
|
|
|
func (p *Provider) doRequest(ctx context.Context, method, path string, body interface{}) ([]byte, error) {
|
|
if err := p.authenticate(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
var reqBody io.Reader
|
|
if body != nil {
|
|
if form, ok := body.(url.Values); ok {
|
|
reqBody = strings.NewReader(form.Encode())
|
|
} else {
|
|
b, _ := json.Marshal(body)
|
|
reqBody = bytes.NewReader(b)
|
|
}
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, p.cfg.BaseURL+path, reqBody)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("X-Auth-Token", p.authToken)
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
resp, err := p.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
data, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode >= 400 {
|
|
return nil, fmt.Errorf("currencycloud error %d: %s", resp.StatusCode, string(data))
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// CurrencyCloud uses sub-accounts, not brokerage accounts
|
|
func (p *Provider) CreateAccount(_ context.Context, _ *types.CreateAccountRequest) (*types.Account, error) {
|
|
return nil, errNotSupported
|
|
}
|
|
|
|
func (p *Provider) GetAccount(ctx context.Context, id string) (*types.Account, error) {
|
|
data, err := p.doRequest(ctx, "GET", "/v2/accounts/"+id, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var resp struct {
|
|
ID string `json:"id"`
|
|
AccountName string `json:"account_name"`
|
|
Status string `json:"status"`
|
|
}
|
|
json.Unmarshal(data, &resp)
|
|
return &types.Account{ID: resp.ID, Provider: "currencycloud", ProviderID: resp.ID, AccountNumber: resp.AccountName, Status: resp.Status}, nil
|
|
}
|
|
|
|
func (p *Provider) ListAccounts(ctx context.Context) ([]*types.Account, error) {
|
|
data, err := p.doRequest(ctx, "GET", "/v2/accounts/find", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var resp struct {
|
|
Accounts []struct {
|
|
ID string `json:"id"`
|
|
AccountName string `json:"account_name"`
|
|
Status string `json:"status"`
|
|
} `json:"accounts"`
|
|
}
|
|
json.Unmarshal(data, &resp)
|
|
accts := make([]*types.Account, 0, len(resp.Accounts))
|
|
for _, a := range resp.Accounts {
|
|
accts = append(accts, &types.Account{ID: a.ID, Provider: "currencycloud", ProviderID: a.ID, AccountNumber: a.AccountName, Status: a.Status})
|
|
}
|
|
return accts, nil
|
|
}
|
|
|
|
func (p *Provider) GetPortfolio(ctx context.Context, id string) (*types.Portfolio, error) {
|
|
data, err := p.doRequest(ctx, "GET", "/v2/balances/find", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var resp struct {
|
|
Balances []struct {
|
|
Currency string `json:"currency"`
|
|
Amount string `json:"amount"`
|
|
} `json:"balances"`
|
|
}
|
|
json.Unmarshal(data, &resp)
|
|
positions := make([]types.Position, 0, len(resp.Balances))
|
|
for _, b := range resp.Balances {
|
|
positions = append(positions, types.Position{Symbol: b.Currency, Qty: b.Amount, AssetClass: "forex"})
|
|
}
|
|
return &types.Portfolio{AccountID: id, Positions: positions}, nil
|
|
}
|
|
|
|
// FX "orders" are conversions in CurrencyCloud
|
|
func (p *Provider) CreateOrder(ctx context.Context, accountID string, req *types.CreateOrderRequest) (*types.Order, error) {
|
|
// Parse symbol as currency pair (e.g. GBPUSD -> buy GBP, sell USD)
|
|
if len(req.Symbol) != 6 {
|
|
return nil, fmt.Errorf("symbol must be 6-char FX pair (e.g. GBPUSD)")
|
|
}
|
|
buyCcy := req.Symbol[:3]
|
|
sellCcy := req.Symbol[3:]
|
|
form := url.Values{
|
|
"buy_currency": {buyCcy},
|
|
"sell_currency": {sellCcy},
|
|
"amount": {req.Qty},
|
|
"fixed_side": {"buy"},
|
|
"term_agreement": {"true"},
|
|
}
|
|
data, err := p.doRequest(ctx, "POST", "/v2/conversions/create", form)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var resp struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
ClientRate string `json:"client_rate"`
|
|
}
|
|
json.Unmarshal(data, &resp)
|
|
return &types.Order{ID: resp.ID, Provider: "currencycloud", ProviderID: resp.ID, AccountID: accountID, Symbol: req.Symbol, Qty: req.Qty, Side: req.Side, Type: "fx_spot", Status: resp.Status, FilledAvgPrice: resp.ClientRate, CreatedAt: time.Now()}, nil
|
|
}
|
|
|
|
func (p *Provider) ListOrders(ctx context.Context, accountID string) ([]*types.Order, error) {
|
|
data, err := p.doRequest(ctx, "GET", "/v2/conversions/find", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var resp struct {
|
|
Conversions []struct {
|
|
ID string `json:"id"`
|
|
BuyCurrency string `json:"buy_currency"`
|
|
SellCurrency string `json:"sell_currency"`
|
|
Status string `json:"status"`
|
|
} `json:"conversions"`
|
|
}
|
|
json.Unmarshal(data, &resp)
|
|
orders := make([]*types.Order, 0, len(resp.Conversions))
|
|
for _, c := range resp.Conversions {
|
|
orders = append(orders, &types.Order{ID: c.ID, Provider: "currencycloud", ProviderID: c.ID, Symbol: c.BuyCurrency + c.SellCurrency, Status: c.Status})
|
|
}
|
|
return orders, nil
|
|
}
|
|
|
|
func (p *Provider) GetOrder(ctx context.Context, accountID, orderID string) (*types.Order, error) {
|
|
data, err := p.doRequest(ctx, "GET", "/v2/conversions/"+orderID, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var resp struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
ClientRate string `json:"client_rate"`
|
|
}
|
|
json.Unmarshal(data, &resp)
|
|
return &types.Order{ID: resp.ID, Provider: "currencycloud", ProviderID: resp.ID, Status: resp.Status, FilledAvgPrice: resp.ClientRate}, nil
|
|
}
|
|
|
|
func (p *Provider) CancelOrder(ctx context.Context, accountID, orderID string) error {
|
|
_, err := p.doRequest(ctx, "POST", "/v2/conversions/"+orderID+"/cancel", nil)
|
|
return err
|
|
}
|
|
|
|
func (p *Provider) CreateTransfer(_ context.Context, _ string, _ *types.CreateTransferRequest) (*types.Transfer, error) {
|
|
return nil, errNotSupported
|
|
}
|
|
func (p *Provider) ListTransfers(_ context.Context, _ string) ([]*types.Transfer, error) {
|
|
return nil, errNotSupported
|
|
}
|
|
func (p *Provider) CreateBankRelationship(_ context.Context, _, _, _, _, _ string) (*types.BankRelationship, error) {
|
|
return nil, errNotSupported
|
|
}
|
|
func (p *Provider) ListBankRelationships(_ context.Context, _ string) ([]*types.BankRelationship, error) {
|
|
return nil, errNotSupported
|
|
}
|
|
|
|
// FX pairs as assets
|
|
func (p *Provider) ListAssets(_ context.Context, _ string) ([]*types.Asset, error) {
|
|
pairs := []string{"EURUSD", "GBPUSD", "USDJPY", "USDCHF", "AUDUSD", "USDCAD", "NZDUSD", "EURGBP", "EURJPY", "GBPJPY",
|
|
"EURCAD", "EURAUD", "EURNZD", "GBPAUD", "GBPCAD", "AUDCAD", "AUDNZD", "AUDJPY", "CADJPY", "CHFJPY",
|
|
"USDSGD", "USDHKD", "USDMXN", "USDZAR", "USDTRY", "USDPLN", "USDSEK", "USDNOK", "USDDKK", "USDCZK",
|
|
"USDHUF", "USDILS", "USDTHB", "USDINR", "USDCNH"}
|
|
assets := make([]*types.Asset, 0, len(pairs))
|
|
for _, pair := range pairs {
|
|
assets = append(assets, &types.Asset{ID: pair, Provider: "currencycloud", Symbol: pair, Name: pair[:3] + "/" + pair[3:], Class: "forex", Status: "active", Tradable: true})
|
|
}
|
|
return assets, nil
|
|
}
|
|
|
|
func (p *Provider) GetAsset(_ context.Context, sym string) (*types.Asset, error) {
|
|
return &types.Asset{ID: sym, Provider: "currencycloud", Symbol: sym, Name: sym[:3] + "/" + sym[3:], Class: "forex", Status: "active", Tradable: true}, nil
|
|
}
|
|
|
|
// FX rates as market snapshots
|
|
func (p *Provider) GetSnapshot(ctx context.Context, symbol string) (*types.MarketSnapshot, error) {
|
|
if len(symbol) != 6 {
|
|
return nil, fmt.Errorf("symbol must be 6-char FX pair")
|
|
}
|
|
pair := symbol[:3] + symbol[3:]
|
|
form := url.Values{"currency_pair": {pair}}
|
|
data, err := p.doRequest(ctx, "GET", "/v2/rates/detailed?currency_pair="+pair, form)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var resp struct {
|
|
BidPrice float64 `json:"client_buy_price,string"`
|
|
OfferPrice float64 `json:"client_sell_price,string"`
|
|
}
|
|
json.Unmarshal(data, &resp)
|
|
mid := (resp.BidPrice + resp.OfferPrice) / 2
|
|
return &types.MarketSnapshot{Symbol: symbol, LatestQuote: &types.Quote{BidPrice: resp.BidPrice, AskPrice: resp.OfferPrice}, LatestTrade: &types.Trade{Price: mid}}, nil
|
|
}
|
|
|
|
func (p *Provider) GetSnapshots(ctx context.Context, symbols []string) (map[string]*types.MarketSnapshot, error) {
|
|
result := make(map[string]*types.MarketSnapshot)
|
|
for _, sym := range symbols {
|
|
snap, err := p.GetSnapshot(ctx, sym)
|
|
if err == nil {
|
|
result[sym] = snap
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (p *Provider) GetBars(_ context.Context, _, _, _, _ string, _ int) ([]*types.Bar, error) {
|
|
return nil, errNotSupported
|
|
}
|
|
|
|
func (p *Provider) GetLatestTrades(ctx context.Context, symbols []string) (map[string]*types.Trade, error) {
|
|
snaps, err := p.GetSnapshots(ctx, symbols)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make(map[string]*types.Trade)
|
|
for sym, snap := range snaps {
|
|
if snap.LatestTrade != nil {
|
|
result[sym] = snap.LatestTrade
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (p *Provider) GetLatestQuotes(ctx context.Context, symbols []string) (map[string]*types.Quote, error) {
|
|
snaps, err := p.GetSnapshots(ctx, symbols)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make(map[string]*types.Quote)
|
|
for sym, snap := range snaps {
|
|
if snap.LatestQuote != nil {
|
|
result[sym] = snap.LatestQuote
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (p *Provider) GetClock(_ context.Context) (*types.MarketClock, error) {
|
|
// FX market is open 24/5
|
|
now := time.Now().UTC()
|
|
wd := now.Weekday()
|
|
isOpen := wd >= time.Monday && wd <= time.Friday
|
|
return &types.MarketClock{IsOpen: isOpen, Timestamp: now.Format(time.RFC3339)}, nil
|
|
}
|
|
|
|
func (p *Provider) GetCalendar(_ context.Context, _, _ string) ([]*types.MarketCalendarDay, error) {
|
|
return nil, errNotSupported
|
|
}
|