Files
tinkle-community cb31782be4 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)
2026-03-12 12:53:57 +08:00

431 lines
11 KiB
Go

package aster
import (
"context"
"crypto/ecdsa"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math"
"math/big"
"net/http"
"net/url"
"nofx/hook"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
)
// AsterTrader Aster trading platform implementation
type AsterTrader struct {
ctx context.Context
user string // Main wallet address (ERC20)
signer string // API wallet address
privateKey *ecdsa.PrivateKey // API wallet private key
client *http.Client
baseURL string
// Cache symbol precision information
symbolPrecision map[string]SymbolPrecision
mu sync.RWMutex
}
// SymbolPrecision Symbol precision information
type SymbolPrecision struct {
PricePrecision int
QuantityPrecision int
TickSize float64 // Price tick size
StepSize float64 // Quantity step size
}
// NewAsterTrader Create Aster trader
// user: Main wallet address (login address)
// signer: API wallet address (obtained from https://www.asterdex.com/en/api-wallet)
// privateKey: API wallet private key (obtained from https://www.asterdex.com/en/api-wallet)
func NewAsterTrader(user, signer, privateKeyHex string) (*AsterTrader, error) {
// Parse private key
privKey, err := crypto.HexToECDSA(strings.TrimPrefix(privateKeyHex, "0x"))
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
client := &http.Client{
Timeout: 30 * time.Second, // Increased to 30 seconds
Transport: &http.Transport{
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
IdleConnTimeout: 90 * time.Second,
},
}
res := hook.HookExec[hook.NewAsterTraderResult](hook.NEW_ASTER_TRADER, user, client)
if res != nil && res.Error() == nil {
client = res.GetResult()
}
return &AsterTrader{
ctx: context.Background(),
user: user,
signer: signer,
privateKey: privKey,
symbolPrecision: make(map[string]SymbolPrecision),
client: client,
baseURL: "https://fapi.asterdex.com",
}, nil
}
// genNonce Generate microsecond timestamp
func (t *AsterTrader) genNonce() uint64 {
return uint64(time.Now().UnixMicro())
}
// getPrecision Get symbol precision information
func (t *AsterTrader) getPrecision(symbol string) (SymbolPrecision, error) {
t.mu.RLock()
if prec, ok := t.symbolPrecision[symbol]; ok {
t.mu.RUnlock()
return prec, nil
}
t.mu.RUnlock()
// Get exchange information
resp, err := t.client.Get(t.baseURL + "/fapi/v3/exchangeInfo")
if err != nil {
return SymbolPrecision{}, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var info struct {
Symbols []struct {
Symbol string `json:"symbol"`
PricePrecision int `json:"pricePrecision"`
QuantityPrecision int `json:"quantityPrecision"`
Filters []map[string]interface{} `json:"filters"`
} `json:"symbols"`
}
if err := json.Unmarshal(body, &info); err != nil {
return SymbolPrecision{}, err
}
// Cache precision for all symbols
t.mu.Lock()
for _, s := range info.Symbols {
prec := SymbolPrecision{
PricePrecision: s.PricePrecision,
QuantityPrecision: s.QuantityPrecision,
}
// Parse filters to get tickSize and stepSize
for _, filter := range s.Filters {
filterType, _ := filter["filterType"].(string)
switch filterType {
case "PRICE_FILTER":
if tickSizeStr, ok := filter["tickSize"].(string); ok {
prec.TickSize, _ = strconv.ParseFloat(tickSizeStr, 64)
}
case "LOT_SIZE":
if stepSizeStr, ok := filter["stepSize"].(string); ok {
prec.StepSize, _ = strconv.ParseFloat(stepSizeStr, 64)
}
}
}
t.symbolPrecision[s.Symbol] = prec
}
t.mu.Unlock()
if prec, ok := t.symbolPrecision[symbol]; ok {
return prec, nil
}
return SymbolPrecision{}, fmt.Errorf("precision information not found for symbol %s", symbol)
}
// roundToTickSize Round price/quantity to the nearest multiple of tick size/step size
func roundToTickSize(value float64, tickSize float64) float64 {
if tickSize <= 0 {
return value
}
// Calculate how many tick sizes
steps := value / tickSize
// Round to the nearest integer
roundedSteps := math.Round(steps)
// Multiply back by tick size
return roundedSteps * tickSize
}
// formatPrice Format price to correct precision and tick size
func (t *AsterTrader) formatPrice(symbol string, price float64) (float64, error) {
prec, err := t.getPrecision(symbol)
if err != nil {
return 0, err
}
// Prioritize tick size to ensure price is a multiple of tick size
if prec.TickSize > 0 {
return roundToTickSize(price, prec.TickSize), nil
}
// If no tick size, round by precision
multiplier := math.Pow10(prec.PricePrecision)
return math.Round(price*multiplier) / multiplier, nil
}
// formatQuantity Format quantity to correct precision and step size
func (t *AsterTrader) formatQuantity(symbol string, quantity float64) (float64, error) {
prec, err := t.getPrecision(symbol)
if err != nil {
return 0, err
}
// Prioritize step size to ensure quantity is a multiple of step size
if prec.StepSize > 0 {
return roundToTickSize(quantity, prec.StepSize), nil
}
// If no step size, round by precision
multiplier := math.Pow10(prec.QuantityPrecision)
return math.Round(quantity*multiplier) / multiplier, nil
}
// formatFloatWithPrecision Format float to string with specified precision (remove trailing zeros)
func (t *AsterTrader) formatFloatWithPrecision(value float64, precision int) string {
// Format with specified precision
formatted := strconv.FormatFloat(value, 'f', precision, 64)
// Remove trailing zeros and decimal point (if any)
formatted = strings.TrimRight(formatted, "0")
formatted = strings.TrimRight(formatted, ".")
return formatted
}
// normalizeAndStringify Normalize parameters and serialize to JSON string (sorted by key)
func (t *AsterTrader) normalizeAndStringify(params map[string]interface{}) (string, error) {
normalized, err := t.normalize(params)
if err != nil {
return "", err
}
bs, err := json.Marshal(normalized)
if err != nil {
return "", err
}
return string(bs), nil
}
// normalize Recursively normalize parameters (sorted by key, all values converted to strings)
func (t *AsterTrader) normalize(v interface{}) (interface{}, error) {
switch val := v.(type) {
case map[string]interface{}:
keys := make([]string, 0, len(val))
for k := range val {
keys = append(keys, k)
}
sort.Strings(keys)
newMap := make(map[string]interface{}, len(keys))
for _, k := range keys {
nv, err := t.normalize(val[k])
if err != nil {
return nil, err
}
newMap[k] = nv
}
return newMap, nil
case []interface{}:
out := make([]interface{}, 0, len(val))
for _, it := range val {
nv, err := t.normalize(it)
if err != nil {
return nil, err
}
out = append(out, nv)
}
return out, nil
case string:
return val, nil
case int:
return fmt.Sprintf("%d", val), nil
case int64:
return fmt.Sprintf("%d", val), nil
case float64:
return fmt.Sprintf("%v", val), nil
case bool:
return fmt.Sprintf("%v", val), nil
default:
// Convert other types to string
return fmt.Sprintf("%v", val), nil
}
}
// sign Sign request parameters
func (t *AsterTrader) sign(params map[string]interface{}, nonce uint64) error {
// Add timestamp and receive window
params["recvWindow"] = "50000"
params["timestamp"] = strconv.FormatInt(time.Now().UnixNano()/int64(time.Millisecond), 10)
// Normalize parameters to JSON string
jsonStr, err := t.normalizeAndStringify(params)
if err != nil {
return err
}
// ABI encoding: (string, address, address, uint256)
addrUser := common.HexToAddress(t.user)
addrSigner := common.HexToAddress(t.signer)
nonceBig := new(big.Int).SetUint64(nonce)
tString, _ := abi.NewType("string", "", nil)
tAddress, _ := abi.NewType("address", "", nil)
tUint256, _ := abi.NewType("uint256", "", nil)
arguments := abi.Arguments{
{Type: tString},
{Type: tAddress},
{Type: tAddress},
{Type: tUint256},
}
packed, err := arguments.Pack(jsonStr, addrUser, addrSigner, nonceBig)
if err != nil {
return fmt.Errorf("ABI encoding failed: %w", err)
}
// Keccak256 hash
hash := crypto.Keccak256(packed)
// Ethereum signed message prefix
prefixedMsg := fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(hash), hash)
msgHash := crypto.Keccak256Hash([]byte(prefixedMsg))
// ECDSA signature
sig, err := crypto.Sign(msgHash.Bytes(), t.privateKey)
if err != nil {
return fmt.Errorf("signature failed: %w", err)
}
// Convert v from 0/1 to 27/28
if len(sig) != 65 {
return fmt.Errorf("signature length abnormal: %d", len(sig))
}
sig[64] += 27
// Add signature parameters
params["user"] = t.user
params["signer"] = t.signer
params["signature"] = "0x" + hex.EncodeToString(sig)
params["nonce"] = nonce
return nil
}
// request Send HTTP request (with retry mechanism)
func (t *AsterTrader) request(method, endpoint string, params map[string]interface{}) ([]byte, error) {
const maxRetries = 3
var lastErr error
for attempt := 1; attempt <= maxRetries; attempt++ {
// Generate new nonce and signature for each retry
nonce := t.genNonce()
paramsCopy := make(map[string]interface{})
for k, v := range params {
paramsCopy[k] = v
}
// Sign
if err := t.sign(paramsCopy, nonce); err != nil {
return nil, err
}
body, err := t.doRequest(method, endpoint, paramsCopy)
if err == nil {
return body, nil
}
lastErr = err
// Retry if network timeout or temporary error
if strings.Contains(err.Error(), "timeout") ||
strings.Contains(err.Error(), "connection reset") ||
strings.Contains(err.Error(), "EOF") {
if attempt < maxRetries {
waitTime := time.Duration(attempt) * time.Second
time.Sleep(waitTime)
continue
}
}
// Don't retry other errors (like 400/401)
return nil, err
}
return nil, fmt.Errorf("request failed (retried %d times): %w", maxRetries, lastErr)
}
// doRequest Execute actual HTTP request
func (t *AsterTrader) doRequest(method, endpoint string, params map[string]interface{}) ([]byte, error) {
fullURL := t.baseURL + endpoint
method = strings.ToUpper(method)
switch method {
case "POST":
// POST request: parameters in form body
form := url.Values{}
for k, v := range params {
form.Set(k, fmt.Sprintf("%v", v))
}
req, err := http.NewRequest("POST", fullURL, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := t.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
return body, nil
case "GET", "DELETE":
// GET/DELETE request: parameters in querystring
q := url.Values{}
for k, v := range params {
q.Set(k, fmt.Sprintf("%v", v))
}
u, _ := url.Parse(fullURL)
u.RawQuery = q.Encode()
req, err := http.NewRequest(method, u.String(), nil)
if err != nil {
return nil, err
}
resp, err := t.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
return body, nil
default:
return nil, fmt.Errorf("unsupported HTTP method: %s", method)
}
}