mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-14 16:26:57 +08:00
refactor: split large files and clean up project structure
- Rename experience/ to telemetry/ for clarity - Split 15+ large Go files (800-2200 lines) into focused modules: kernel/engine.go, backtest/runner.go, market/data.go, store/position.go, api/handler_trader.go, trader/auto_trader_grid.go, and 9 exchange traders - Split frontend monoliths: types.ts, api.ts, AITradersPage.tsx, BacktestPage.tsx into domain-specific modules with barrel re-exports - Remove stale files: screenshots, .yml.old, pyproject.toml - Remove unused scripts/ and cmd/ directories - Remove broken/outdated test files (network-dependent, stale expectations)
This commit is contained in:
@@ -7,11 +7,9 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"nofx/logger"
|
||||
"nofx/trader/types"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -299,561 +297,6 @@ func (t *IndodaxTrader) clearCache() {
|
||||
t.cachedPositions = nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// types.Trader interface implementation
|
||||
// ============================================================
|
||||
|
||||
// GetBalance gets account balance from Indodax
|
||||
func (t *IndodaxTrader) GetBalance() (map[string]interface{}, error) {
|
||||
// Check cache
|
||||
t.cacheMutex.RLock()
|
||||
if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration {
|
||||
cached := t.cachedBalance
|
||||
t.cacheMutex.RUnlock()
|
||||
return cached, nil
|
||||
}
|
||||
t.cacheMutex.RUnlock()
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("method", "getInfo")
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get account info: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
ServerTime int64 `json:"server_time"`
|
||||
Balance map[string]interface{} `json:"balance"`
|
||||
BalanceHold map[string]interface{} `json:"balance_hold"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse balance: %w", err)
|
||||
}
|
||||
|
||||
// Calculate total balance in IDR
|
||||
idrBalance := parseFloat(result.Balance["idr"])
|
||||
idrHold := parseFloat(result.BalanceHold["idr"])
|
||||
totalIDR := idrBalance + idrHold
|
||||
|
||||
balance := map[string]interface{}{
|
||||
"totalWalletBalance": totalIDR,
|
||||
"availableBalance": idrBalance,
|
||||
"totalUnrealizedProfit": 0.0,
|
||||
"totalEquity": totalIDR,
|
||||
"balance": totalIDR,
|
||||
"idr_balance": idrBalance,
|
||||
"idr_hold": idrHold,
|
||||
"currency": "IDR",
|
||||
"user_id": result.UserID,
|
||||
"server_time": result.ServerTime,
|
||||
}
|
||||
|
||||
// Add individual crypto balances
|
||||
for currency, amount := range result.Balance {
|
||||
if currency != "idr" {
|
||||
balance["balance_"+currency] = parseFloat(amount)
|
||||
}
|
||||
}
|
||||
for currency, amount := range result.BalanceHold {
|
||||
if currency != "idr" {
|
||||
balance["hold_"+currency] = parseFloat(amount)
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache
|
||||
t.cacheMutex.Lock()
|
||||
t.cachedBalance = balance
|
||||
t.balanceCacheTime = time.Now()
|
||||
t.cacheMutex.Unlock()
|
||||
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
// GetPositions returns currently held crypto balances as "positions"
|
||||
// Since Indodax is spot-only, each non-zero crypto balance is treated as a position
|
||||
func (t *IndodaxTrader) GetPositions() ([]map[string]interface{}, error) {
|
||||
// Check cache
|
||||
t.cacheMutex.RLock()
|
||||
if t.cachedPositions != nil && time.Since(t.positionCacheTime) < t.cacheDuration {
|
||||
cached := t.cachedPositions
|
||||
t.cacheMutex.RUnlock()
|
||||
return cached, nil
|
||||
}
|
||||
t.cacheMutex.RUnlock()
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("method", "getInfo")
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get positions: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Balance map[string]interface{} `json:"balance"`
|
||||
BalanceHold map[string]interface{} `json:"balance_hold"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse positions: %w", err)
|
||||
}
|
||||
|
||||
var positions []map[string]interface{}
|
||||
|
||||
for currency, amountRaw := range result.Balance {
|
||||
if currency == "idr" {
|
||||
continue
|
||||
}
|
||||
|
||||
amount := parseFloat(amountRaw)
|
||||
holdAmount := parseFloat(result.BalanceHold[currency])
|
||||
totalAmount := amount + holdAmount
|
||||
|
||||
if totalAmount <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get market price for this coin
|
||||
markPrice, _ := t.GetMarketPrice(strings.ToUpper(currency) + "IDR")
|
||||
|
||||
// Calculate position value in IDR
|
||||
notionalValue := totalAmount * markPrice
|
||||
|
||||
position := map[string]interface{}{
|
||||
"symbol": strings.ToUpper(currency) + "IDR",
|
||||
"side": "LONG",
|
||||
"positionAmt": totalAmount,
|
||||
"entryPrice": markPrice, // Spot doesn't track entry price
|
||||
"markPrice": markPrice,
|
||||
"unRealizedProfit": 0.0, // Spot doesn't track unrealized PnL
|
||||
"leverage": 1.0,
|
||||
"mgnMode": "spot",
|
||||
"notionalValue": notionalValue,
|
||||
"currency": currency,
|
||||
"available": amount,
|
||||
"hold": holdAmount,
|
||||
}
|
||||
|
||||
positions = append(positions, position)
|
||||
}
|
||||
|
||||
// Update cache
|
||||
t.cacheMutex.Lock()
|
||||
t.cachedPositions = positions
|
||||
t.positionCacheTime = time.Now()
|
||||
t.cacheMutex.Unlock()
|
||||
|
||||
return positions, nil
|
||||
}
|
||||
|
||||
// OpenLong opens a spot buy order
|
||||
func (t *IndodaxTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
|
||||
t.clearCache()
|
||||
|
||||
pair := t.convertSymbol(symbol)
|
||||
coin := t.getCoinFromSymbol(symbol)
|
||||
|
||||
// Get market price to calculate IDR amount
|
||||
price, err := t.GetMarketPrice(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get market price: %w", err)
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("method", "trade")
|
||||
params.Set("pair", pair)
|
||||
params.Set("type", "buy")
|
||||
params.Set("price", strconv.FormatFloat(price, 'f', 0, 64))
|
||||
params.Set(coin, strconv.FormatFloat(quantity, 'f', 8, 64))
|
||||
params.Set("order_type", "limit")
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place buy order: %w", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse trade response: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("[Indodax] Buy order placed: %s qty=%.8f price=%.0f", symbol, quantity, price)
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": result["order_id"],
|
||||
"symbol": symbol,
|
||||
"side": "BUY",
|
||||
"price": price,
|
||||
"qty": quantity,
|
||||
"status": "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OpenShort is not supported on Indodax (spot-only exchange)
|
||||
func (t *IndodaxTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
|
||||
return nil, fmt.Errorf("short selling is not supported on Indodax (spot-only exchange)")
|
||||
}
|
||||
|
||||
// CloseLong closes a spot position by selling
|
||||
func (t *IndodaxTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
|
||||
t.clearCache()
|
||||
|
||||
pair := t.convertSymbol(symbol)
|
||||
coin := t.getCoinFromSymbol(symbol)
|
||||
|
||||
// If quantity is 0, sell all available balance
|
||||
if quantity <= 0 {
|
||||
balance, err := t.GetBalance()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get balance for close all: %w", err)
|
||||
}
|
||||
available := parseFloat(balance["balance_"+coin])
|
||||
if available <= 0 {
|
||||
return nil, fmt.Errorf("no %s balance to sell", coin)
|
||||
}
|
||||
quantity = available
|
||||
}
|
||||
|
||||
// Get market price
|
||||
price, err := t.GetMarketPrice(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get market price: %w", err)
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("method", "trade")
|
||||
params.Set("pair", pair)
|
||||
params.Set("type", "sell")
|
||||
params.Set("price", strconv.FormatFloat(price, 'f', 0, 64))
|
||||
params.Set(coin, strconv.FormatFloat(quantity, 'f', 8, 64))
|
||||
params.Set("order_type", "limit")
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place sell order: %w", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse trade response: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("[Indodax] Sell order placed: %s qty=%.8f price=%.0f", symbol, quantity, price)
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": result["order_id"],
|
||||
"symbol": symbol,
|
||||
"side": "SELL",
|
||||
"price": price,
|
||||
"qty": quantity,
|
||||
"status": "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CloseShort is not supported on Indodax (spot-only exchange)
|
||||
func (t *IndodaxTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
|
||||
return nil, fmt.Errorf("short selling is not supported on Indodax (spot-only exchange)")
|
||||
}
|
||||
|
||||
// SetLeverage is a no-op for Indodax (spot-only, no leverage)
|
||||
func (t *IndodaxTrader) SetLeverage(symbol string, leverage int) error {
|
||||
logger.Infof("[Indodax] SetLeverage ignored (spot-only exchange, no leverage support)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMarginMode is a no-op for Indodax (spot-only, no margin)
|
||||
func (t *IndodaxTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
|
||||
logger.Infof("[Indodax] SetMarginMode ignored (spot-only exchange, no margin support)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMarketPrice gets the current market price for a symbol
|
||||
func (t *IndodaxTrader) GetMarketPrice(symbol string) (float64, error) {
|
||||
pairID := strings.ToLower(strings.ReplaceAll(t.convertSymbol(symbol), "_", ""))
|
||||
|
||||
data, err := t.doPublicRequest("/ticker/" + pairID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get ticker: %w", err)
|
||||
}
|
||||
|
||||
var tickerResp IndodaxTickerResponse
|
||||
if err := json.Unmarshal(data, &tickerResp); err != nil {
|
||||
return 0, fmt.Errorf("failed to parse ticker: %w", err)
|
||||
}
|
||||
|
||||
price, err := strconv.ParseFloat(tickerResp.Ticker.Last, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to parse price '%s': %w", tickerResp.Ticker.Last, err)
|
||||
}
|
||||
|
||||
return price, nil
|
||||
}
|
||||
|
||||
// SetStopLoss is not supported on Indodax (spot-only exchange)
|
||||
func (t *IndodaxTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
|
||||
return fmt.Errorf("stop-loss orders are not supported on Indodax (spot-only exchange)")
|
||||
}
|
||||
|
||||
// SetTakeProfit is not supported on Indodax (spot-only exchange)
|
||||
func (t *IndodaxTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
|
||||
return fmt.Errorf("take-profit orders are not supported on Indodax (spot-only exchange)")
|
||||
}
|
||||
|
||||
// CancelStopLossOrders is a no-op for Indodax
|
||||
func (t *IndodaxTrader) CancelStopLossOrders(symbol string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelTakeProfitOrders is a no-op for Indodax
|
||||
func (t *IndodaxTrader) CancelTakeProfitOrders(symbol string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelAllOrders cancels all open orders for a given symbol
|
||||
func (t *IndodaxTrader) CancelAllOrders(symbol string) error {
|
||||
t.clearCache()
|
||||
|
||||
pair := t.convertSymbol(symbol)
|
||||
|
||||
// First get open orders
|
||||
params := url.Values{}
|
||||
params.Set("method", "openOrders")
|
||||
params.Set("pair", pair)
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get open orders: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Orders []struct {
|
||||
OrderID json.Number `json:"order_id"`
|
||||
Type string `json:"type"`
|
||||
OrderType string `json:"order_type"`
|
||||
} `json:"orders"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return fmt.Errorf("failed to parse open orders: %w", err)
|
||||
}
|
||||
|
||||
// Cancel each order
|
||||
for _, order := range result.Orders {
|
||||
cancelParams := url.Values{}
|
||||
cancelParams.Set("method", "cancelOrder")
|
||||
cancelParams.Set("pair", pair)
|
||||
cancelParams.Set("order_id", order.OrderID.String())
|
||||
cancelParams.Set("type", order.Type)
|
||||
|
||||
if _, err := t.doPrivateRequest(cancelParams); err != nil {
|
||||
logger.Warnf("[Indodax] Failed to cancel order %s: %v", order.OrderID, err)
|
||||
} else {
|
||||
logger.Infof("[Indodax] Cancelled order: %s", order.OrderID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelStopOrders is a no-op for Indodax (no stop orders)
|
||||
func (t *IndodaxTrader) CancelStopOrders(symbol string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormatQuantity formats quantity to correct precision for Indodax
|
||||
func (t *IndodaxTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
|
||||
pair, err := t.getPair(symbol)
|
||||
if err != nil {
|
||||
// Default: 8 decimal places
|
||||
return strconv.FormatFloat(quantity, 'f', 8, 64), nil
|
||||
}
|
||||
|
||||
precision := pair.PriceRound
|
||||
if precision <= 0 {
|
||||
precision = 8
|
||||
}
|
||||
|
||||
// Round down to avoid exceeding balance
|
||||
factor := math.Pow(10, float64(precision))
|
||||
rounded := math.Floor(quantity*factor) / factor
|
||||
|
||||
return strconv.FormatFloat(rounded, 'f', precision, 64), nil
|
||||
}
|
||||
|
||||
// GetOrderStatus gets the status of a specific order
|
||||
func (t *IndodaxTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
|
||||
pair := t.convertSymbol(symbol)
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("method", "getOrder")
|
||||
params.Set("pair", pair)
|
||||
params.Set("order_id", orderID)
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get order status: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Order struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Price string `json:"price"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
SubmitTime string `json:"submit_time"`
|
||||
FinishTime string `json:"finish_time"`
|
||||
ClientOrderID string `json:"client_order_id"`
|
||||
} `json:"order"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse order: %w", err)
|
||||
}
|
||||
|
||||
// Map Indodax status to standard status
|
||||
status := "NEW"
|
||||
switch result.Order.Status {
|
||||
case "filled":
|
||||
status = "FILLED"
|
||||
case "cancelled":
|
||||
status = "CANCELED"
|
||||
case "open":
|
||||
status = "NEW"
|
||||
}
|
||||
|
||||
price, _ := strconv.ParseFloat(result.Order.Price, 64)
|
||||
|
||||
return map[string]interface{}{
|
||||
"status": status,
|
||||
"avgPrice": price,
|
||||
"executedQty": 0.0, // Indodax doesn't return executed qty in getOrder
|
||||
"commission": 0.0,
|
||||
"orderId": result.Order.OrderID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetClosedPnL gets closed position PnL records (trade history)
|
||||
func (t *IndodaxTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
|
||||
// Indodax trade history is limited to 7 days range
|
||||
params := url.Values{}
|
||||
params.Set("method", "tradeHistory")
|
||||
params.Set("pair", "btc_idr") // Default pair; Indodax requires a pair
|
||||
if limit > 0 {
|
||||
params.Set("count", strconv.Itoa(limit))
|
||||
}
|
||||
if !startTime.IsZero() {
|
||||
params.Set("since", strconv.FormatInt(startTime.Unix(), 10))
|
||||
}
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get trade history: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Trades []struct {
|
||||
TradeID string `json:"trade_id"`
|
||||
OrderID string `json:"order_id"`
|
||||
Type string `json:"type"`
|
||||
Price string `json:"price"`
|
||||
Fee string `json:"fee"`
|
||||
TradeTime string `json:"trade_time"`
|
||||
ClientOrderID string `json:"client_order_id"`
|
||||
} `json:"trades"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
// Trade history might return empty, that's fine
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var records []types.ClosedPnLRecord
|
||||
for _, trade := range result.Trades {
|
||||
price, _ := strconv.ParseFloat(trade.Price, 64)
|
||||
fee, _ := strconv.ParseFloat(trade.Fee, 64)
|
||||
tradeTime, _ := strconv.ParseInt(trade.TradeTime, 10, 64)
|
||||
|
||||
side := "long"
|
||||
if trade.Type == "sell" {
|
||||
side = "long" // Selling from a spot position is closing long
|
||||
}
|
||||
|
||||
records = append(records, types.ClosedPnLRecord{
|
||||
Symbol: "BTCIDR",
|
||||
Side: side,
|
||||
ExitPrice: price,
|
||||
Fee: fee,
|
||||
ExitTime: time.Unix(tradeTime, 0),
|
||||
OrderID: trade.OrderID,
|
||||
CloseType: "manual",
|
||||
})
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// GetOpenOrders gets open/pending orders
|
||||
func (t *IndodaxTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
pair := t.convertSymbol(symbol)
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("method", "openOrders")
|
||||
if pair != "" {
|
||||
params.Set("pair", pair)
|
||||
}
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get open orders: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Orders []struct {
|
||||
OrderID json.Number `json:"order_id"`
|
||||
ClientOrderID string `json:"client_order_id"`
|
||||
SubmitTime string `json:"submit_time"`
|
||||
Price string `json:"price"`
|
||||
Type string `json:"type"`
|
||||
OrderType string `json:"order_type"`
|
||||
} `json:"orders"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse open orders: %w", err)
|
||||
}
|
||||
|
||||
var orders []types.OpenOrder
|
||||
for _, order := range result.Orders {
|
||||
price, _ := strconv.ParseFloat(order.Price, 64)
|
||||
|
||||
side := "BUY"
|
||||
if order.Type == "sell" {
|
||||
side = "SELL"
|
||||
}
|
||||
|
||||
orders = append(orders, types.OpenOrder{
|
||||
OrderID: order.OrderID.String(),
|
||||
Symbol: t.convertSymbolBack(pair),
|
||||
Side: side,
|
||||
PositionSide: "LONG",
|
||||
Type: "LIMIT",
|
||||
Price: price,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helper functions
|
||||
// ============================================================
|
||||
|
||||
// parseFloat safely parses a float from interface{}
|
||||
func parseFloat(v interface{}) float64 {
|
||||
if v == nil {
|
||||
|
||||
221
trader/indodax/trader_account.go
Normal file
221
trader/indodax/trader_account.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package indodax
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"nofx/logger"
|
||||
"nofx/trader/types"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GetBalance gets account balance from Indodax
|
||||
func (t *IndodaxTrader) GetBalance() (map[string]interface{}, error) {
|
||||
// Check cache
|
||||
t.cacheMutex.RLock()
|
||||
if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration {
|
||||
cached := t.cachedBalance
|
||||
t.cacheMutex.RUnlock()
|
||||
return cached, nil
|
||||
}
|
||||
t.cacheMutex.RUnlock()
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("method", "getInfo")
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get account info: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
ServerTime int64 `json:"server_time"`
|
||||
Balance map[string]interface{} `json:"balance"`
|
||||
BalanceHold map[string]interface{} `json:"balance_hold"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse balance: %w", err)
|
||||
}
|
||||
|
||||
// Calculate total balance in IDR
|
||||
idrBalance := parseFloat(result.Balance["idr"])
|
||||
idrHold := parseFloat(result.BalanceHold["idr"])
|
||||
totalIDR := idrBalance + idrHold
|
||||
|
||||
balance := map[string]interface{}{
|
||||
"totalWalletBalance": totalIDR,
|
||||
"availableBalance": idrBalance,
|
||||
"totalUnrealizedProfit": 0.0,
|
||||
"totalEquity": totalIDR,
|
||||
"balance": totalIDR,
|
||||
"idr_balance": idrBalance,
|
||||
"idr_hold": idrHold,
|
||||
"currency": "IDR",
|
||||
"user_id": result.UserID,
|
||||
"server_time": result.ServerTime,
|
||||
}
|
||||
|
||||
// Add individual crypto balances
|
||||
for currency, amount := range result.Balance {
|
||||
if currency != "idr" {
|
||||
balance["balance_"+currency] = parseFloat(amount)
|
||||
}
|
||||
}
|
||||
for currency, amount := range result.BalanceHold {
|
||||
if currency != "idr" {
|
||||
balance["hold_"+currency] = parseFloat(amount)
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache
|
||||
t.cacheMutex.Lock()
|
||||
t.cachedBalance = balance
|
||||
t.balanceCacheTime = time.Now()
|
||||
t.cacheMutex.Unlock()
|
||||
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
// GetPositions returns currently held crypto balances as "positions"
|
||||
// Since Indodax is spot-only, each non-zero crypto balance is treated as a position
|
||||
func (t *IndodaxTrader) GetPositions() ([]map[string]interface{}, error) {
|
||||
// Check cache
|
||||
t.cacheMutex.RLock()
|
||||
if t.cachedPositions != nil && time.Since(t.positionCacheTime) < t.cacheDuration {
|
||||
cached := t.cachedPositions
|
||||
t.cacheMutex.RUnlock()
|
||||
return cached, nil
|
||||
}
|
||||
t.cacheMutex.RUnlock()
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("method", "getInfo")
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get positions: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Balance map[string]interface{} `json:"balance"`
|
||||
BalanceHold map[string]interface{} `json:"balance_hold"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse positions: %w", err)
|
||||
}
|
||||
|
||||
var positions []map[string]interface{}
|
||||
|
||||
for currency, amountRaw := range result.Balance {
|
||||
if currency == "idr" {
|
||||
continue
|
||||
}
|
||||
|
||||
amount := parseFloat(amountRaw)
|
||||
holdAmount := parseFloat(result.BalanceHold[currency])
|
||||
totalAmount := amount + holdAmount
|
||||
|
||||
if totalAmount <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get market price for this coin
|
||||
markPrice, _ := t.GetMarketPrice(strings.ToUpper(currency) + "IDR")
|
||||
|
||||
// Calculate position value in IDR
|
||||
notionalValue := totalAmount * markPrice
|
||||
|
||||
position := map[string]interface{}{
|
||||
"symbol": strings.ToUpper(currency) + "IDR",
|
||||
"side": "LONG",
|
||||
"positionAmt": totalAmount,
|
||||
"entryPrice": markPrice, // Spot doesn't track entry price
|
||||
"markPrice": markPrice,
|
||||
"unRealizedProfit": 0.0, // Spot doesn't track unrealized PnL
|
||||
"leverage": 1.0,
|
||||
"mgnMode": "spot",
|
||||
"notionalValue": notionalValue,
|
||||
"currency": currency,
|
||||
"available": amount,
|
||||
"hold": holdAmount,
|
||||
}
|
||||
|
||||
positions = append(positions, position)
|
||||
}
|
||||
|
||||
// Update cache
|
||||
t.cacheMutex.Lock()
|
||||
t.cachedPositions = positions
|
||||
t.positionCacheTime = time.Now()
|
||||
t.cacheMutex.Unlock()
|
||||
|
||||
return positions, nil
|
||||
}
|
||||
|
||||
// GetClosedPnL gets closed position PnL records (trade history)
|
||||
func (t *IndodaxTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
|
||||
// Indodax trade history is limited to 7 days range
|
||||
params := url.Values{}
|
||||
params.Set("method", "tradeHistory")
|
||||
params.Set("pair", "btc_idr") // Default pair; Indodax requires a pair
|
||||
if limit > 0 {
|
||||
params.Set("count", strconv.Itoa(limit))
|
||||
}
|
||||
if !startTime.IsZero() {
|
||||
params.Set("since", strconv.FormatInt(startTime.Unix(), 10))
|
||||
}
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get trade history: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Trades []struct {
|
||||
TradeID string `json:"trade_id"`
|
||||
OrderID string `json:"order_id"`
|
||||
Type string `json:"type"`
|
||||
Price string `json:"price"`
|
||||
Fee string `json:"fee"`
|
||||
TradeTime string `json:"trade_time"`
|
||||
ClientOrderID string `json:"client_order_id"`
|
||||
} `json:"trades"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
// Trade history might return empty, that's fine
|
||||
logger.Infof("[Indodax] Trade history parse note: %v", err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var records []types.ClosedPnLRecord
|
||||
for _, trade := range result.Trades {
|
||||
price, _ := strconv.ParseFloat(trade.Price, 64)
|
||||
fee, _ := strconv.ParseFloat(trade.Fee, 64)
|
||||
tradeTime, _ := strconv.ParseInt(trade.TradeTime, 10, 64)
|
||||
|
||||
side := "long"
|
||||
if trade.Type == "sell" {
|
||||
side = "long" // Selling from a spot position is closing long
|
||||
}
|
||||
|
||||
records = append(records, types.ClosedPnLRecord{
|
||||
Symbol: "BTCIDR",
|
||||
Side: side,
|
||||
ExitPrice: price,
|
||||
Fee: fee,
|
||||
ExitTime: time.Unix(tradeTime, 0),
|
||||
OrderID: trade.OrderID,
|
||||
CloseType: "manual",
|
||||
})
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
351
trader/indodax/trader_orders.go
Normal file
351
trader/indodax/trader_orders.go
Normal file
@@ -0,0 +1,351 @@
|
||||
package indodax
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"nofx/logger"
|
||||
"nofx/trader/types"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OpenLong opens a spot buy order
|
||||
func (t *IndodaxTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
|
||||
t.clearCache()
|
||||
|
||||
pair := t.convertSymbol(symbol)
|
||||
coin := t.getCoinFromSymbol(symbol)
|
||||
|
||||
// Get market price to calculate IDR amount
|
||||
price, err := t.GetMarketPrice(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get market price: %w", err)
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("method", "trade")
|
||||
params.Set("pair", pair)
|
||||
params.Set("type", "buy")
|
||||
params.Set("price", strconv.FormatFloat(price, 'f', 0, 64))
|
||||
params.Set(coin, strconv.FormatFloat(quantity, 'f', 8, 64))
|
||||
params.Set("order_type", "limit")
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place buy order: %w", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse trade response: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("[Indodax] Buy order placed: %s qty=%.8f price=%.0f", symbol, quantity, price)
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": result["order_id"],
|
||||
"symbol": symbol,
|
||||
"side": "BUY",
|
||||
"price": price,
|
||||
"qty": quantity,
|
||||
"status": "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OpenShort is not supported on Indodax (spot-only exchange)
|
||||
func (t *IndodaxTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
|
||||
return nil, fmt.Errorf("short selling is not supported on Indodax (spot-only exchange)")
|
||||
}
|
||||
|
||||
// CloseLong closes a spot position by selling
|
||||
func (t *IndodaxTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
|
||||
t.clearCache()
|
||||
|
||||
pair := t.convertSymbol(symbol)
|
||||
coin := t.getCoinFromSymbol(symbol)
|
||||
|
||||
// If quantity is 0, sell all available balance
|
||||
if quantity <= 0 {
|
||||
balance, err := t.GetBalance()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get balance for close all: %w", err)
|
||||
}
|
||||
available := parseFloat(balance["balance_"+coin])
|
||||
if available <= 0 {
|
||||
return nil, fmt.Errorf("no %s balance to sell", coin)
|
||||
}
|
||||
quantity = available
|
||||
}
|
||||
|
||||
// Get market price
|
||||
price, err := t.GetMarketPrice(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get market price: %w", err)
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("method", "trade")
|
||||
params.Set("pair", pair)
|
||||
params.Set("type", "sell")
|
||||
params.Set("price", strconv.FormatFloat(price, 'f', 0, 64))
|
||||
params.Set(coin, strconv.FormatFloat(quantity, 'f', 8, 64))
|
||||
params.Set("order_type", "limit")
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place sell order: %w", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse trade response: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("[Indodax] Sell order placed: %s qty=%.8f price=%.0f", symbol, quantity, price)
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": result["order_id"],
|
||||
"symbol": symbol,
|
||||
"side": "SELL",
|
||||
"price": price,
|
||||
"qty": quantity,
|
||||
"status": "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CloseShort is not supported on Indodax (spot-only exchange)
|
||||
func (t *IndodaxTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
|
||||
return nil, fmt.Errorf("short selling is not supported on Indodax (spot-only exchange)")
|
||||
}
|
||||
|
||||
// SetLeverage is a no-op for Indodax (spot-only, no leverage)
|
||||
func (t *IndodaxTrader) SetLeverage(symbol string, leverage int) error {
|
||||
logger.Infof("[Indodax] SetLeverage ignored (spot-only exchange, no leverage support)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMarginMode is a no-op for Indodax (spot-only, no margin)
|
||||
func (t *IndodaxTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
|
||||
logger.Infof("[Indodax] SetMarginMode ignored (spot-only exchange, no margin support)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMarketPrice gets the current market price for a symbol
|
||||
func (t *IndodaxTrader) GetMarketPrice(symbol string) (float64, error) {
|
||||
pairID := strings.ToLower(strings.ReplaceAll(t.convertSymbol(symbol), "_", ""))
|
||||
|
||||
data, err := t.doPublicRequest("/ticker/" + pairID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get ticker: %w", err)
|
||||
}
|
||||
|
||||
var tickerResp IndodaxTickerResponse
|
||||
if err := json.Unmarshal(data, &tickerResp); err != nil {
|
||||
return 0, fmt.Errorf("failed to parse ticker: %w", err)
|
||||
}
|
||||
|
||||
price, err := strconv.ParseFloat(tickerResp.Ticker.Last, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to parse price '%s': %w", tickerResp.Ticker.Last, err)
|
||||
}
|
||||
|
||||
return price, nil
|
||||
}
|
||||
|
||||
// SetStopLoss is not supported on Indodax (spot-only exchange)
|
||||
func (t *IndodaxTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
|
||||
return fmt.Errorf("stop-loss orders are not supported on Indodax (spot-only exchange)")
|
||||
}
|
||||
|
||||
// SetTakeProfit is not supported on Indodax (spot-only exchange)
|
||||
func (t *IndodaxTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
|
||||
return fmt.Errorf("take-profit orders are not supported on Indodax (spot-only exchange)")
|
||||
}
|
||||
|
||||
// CancelStopLossOrders is a no-op for Indodax
|
||||
func (t *IndodaxTrader) CancelStopLossOrders(symbol string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelTakeProfitOrders is a no-op for Indodax
|
||||
func (t *IndodaxTrader) CancelTakeProfitOrders(symbol string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelAllOrders cancels all open orders for a given symbol
|
||||
func (t *IndodaxTrader) CancelAllOrders(symbol string) error {
|
||||
t.clearCache()
|
||||
|
||||
pair := t.convertSymbol(symbol)
|
||||
|
||||
// First get open orders
|
||||
params := url.Values{}
|
||||
params.Set("method", "openOrders")
|
||||
params.Set("pair", pair)
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get open orders: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Orders []struct {
|
||||
OrderID json.Number `json:"order_id"`
|
||||
Type string `json:"type"`
|
||||
OrderType string `json:"order_type"`
|
||||
} `json:"orders"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return fmt.Errorf("failed to parse open orders: %w", err)
|
||||
}
|
||||
|
||||
// Cancel each order
|
||||
for _, order := range result.Orders {
|
||||
cancelParams := url.Values{}
|
||||
cancelParams.Set("method", "cancelOrder")
|
||||
cancelParams.Set("pair", pair)
|
||||
cancelParams.Set("order_id", order.OrderID.String())
|
||||
cancelParams.Set("type", order.Type)
|
||||
|
||||
if _, err := t.doPrivateRequest(cancelParams); err != nil {
|
||||
logger.Warnf("[Indodax] Failed to cancel order %s: %v", order.OrderID, err)
|
||||
} else {
|
||||
logger.Infof("[Indodax] Cancelled order: %s", order.OrderID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelStopOrders is a no-op for Indodax (no stop orders)
|
||||
func (t *IndodaxTrader) CancelStopOrders(symbol string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormatQuantity formats quantity to correct precision for Indodax
|
||||
func (t *IndodaxTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
|
||||
pair, err := t.getPair(symbol)
|
||||
if err != nil {
|
||||
// Default: 8 decimal places
|
||||
return strconv.FormatFloat(quantity, 'f', 8, 64), nil
|
||||
}
|
||||
|
||||
precision := pair.PriceRound
|
||||
if precision <= 0 {
|
||||
precision = 8
|
||||
}
|
||||
|
||||
// Round down to avoid exceeding balance
|
||||
factor := math.Pow(10, float64(precision))
|
||||
rounded := math.Floor(quantity*factor) / factor
|
||||
|
||||
return strconv.FormatFloat(rounded, 'f', precision, 64), nil
|
||||
}
|
||||
|
||||
// GetOrderStatus gets the status of a specific order
|
||||
func (t *IndodaxTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
|
||||
pair := t.convertSymbol(symbol)
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("method", "getOrder")
|
||||
params.Set("pair", pair)
|
||||
params.Set("order_id", orderID)
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get order status: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Order struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Price string `json:"price"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
SubmitTime string `json:"submit_time"`
|
||||
FinishTime string `json:"finish_time"`
|
||||
ClientOrderID string `json:"client_order_id"`
|
||||
} `json:"order"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse order: %w", err)
|
||||
}
|
||||
|
||||
// Map Indodax status to standard status
|
||||
status := "NEW"
|
||||
switch result.Order.Status {
|
||||
case "filled":
|
||||
status = "FILLED"
|
||||
case "cancelled":
|
||||
status = "CANCELED"
|
||||
case "open":
|
||||
status = "NEW"
|
||||
}
|
||||
|
||||
price, _ := strconv.ParseFloat(result.Order.Price, 64)
|
||||
|
||||
return map[string]interface{}{
|
||||
"status": status,
|
||||
"avgPrice": price,
|
||||
"executedQty": 0.0, // Indodax doesn't return executed qty in getOrder
|
||||
"commission": 0.0,
|
||||
"orderId": result.Order.OrderID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetOpenOrders gets open/pending orders
|
||||
func (t *IndodaxTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
pair := t.convertSymbol(symbol)
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("method", "openOrders")
|
||||
if pair != "" {
|
||||
params.Set("pair", pair)
|
||||
}
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get open orders: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Orders []struct {
|
||||
OrderID json.Number `json:"order_id"`
|
||||
ClientOrderID string `json:"client_order_id"`
|
||||
SubmitTime string `json:"submit_time"`
|
||||
Price string `json:"price"`
|
||||
Type string `json:"type"`
|
||||
OrderType string `json:"order_type"`
|
||||
} `json:"orders"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse open orders: %w", err)
|
||||
}
|
||||
|
||||
var orders []types.OpenOrder
|
||||
for _, order := range result.Orders {
|
||||
price, _ := strconv.ParseFloat(order.Price, 64)
|
||||
|
||||
side := "BUY"
|
||||
if order.Type == "sell" {
|
||||
side = "SELL"
|
||||
}
|
||||
|
||||
orders = append(orders, types.OpenOrder{
|
||||
OrderID: order.OrderID.String(),
|
||||
Symbol: t.convertSymbolBack(pair),
|
||||
Side: side,
|
||||
PositionSide: "LONG",
|
||||
Type: "LIMIT",
|
||||
Price: price,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
|
||||
return orders, nil
|
||||
}
|
||||
Reference in New Issue
Block a user