feat: simplify Claw402 autopilot trading flow

This commit is contained in:
tinkle-community
2026-06-27 00:37:59 +08:00
parent 961e016d33
commit 24f6421a73
151 changed files with 7453 additions and 41117 deletions

960
provider/vergex/client.go Normal file
View File

@@ -0,0 +1,960 @@
package vergex
import (
"context"
"crypto/ecdsa"
"encoding/json"
"fmt"
"math"
"net/http"
"net/url"
"nofx/mcp"
"nofx/mcp/payment"
"nofx/provider/hyperliquid"
"os"
"sort"
"strings"
"time"
"github.com/ethereum/go-ethereum/crypto"
)
const (
DefaultBaseURL = "https://claw402.ai"
DefaultChain = "mainnet"
DefaultMarketType = "hip3_perp"
MaxSignalRankingItems = 30
SignalRankingPath = "/api/v1/vergex/signal-ranking"
SignalLabPath = "/api/v1/vergex/signal-lab"
CostLiquidationHeatmapPath = "/api/v1/vergex/cost-liquidation-heatmap"
)
type Client struct {
baseURL string
privateKey *ecdsa.PrivateKey
httpClient *http.Client
logger mcp.Logger
}
type Query struct {
MarketType string
Symbol string
Chain string
LiqBand string
Category string
}
type SignalRankingData struct {
Raw json.RawMessage `json:"raw"`
Items []SignalRankItem `json:"items"`
}
type SignalRankItem struct {
Rank int `json:"rank,omitempty"`
Symbol string `json:"symbol"`
MarketType string `json:"market_type,omitempty"`
Bias string `json:"bias,omitempty"`
Confidence float64 `json:"confidence,omitempty"`
Score float64 `json:"score,omitempty"`
Category string `json:"category,omitempty"`
Raw json.RawMessage `json:"raw,omitempty"`
}
type MarketAnalysis struct {
Symbol string `json:"symbol"`
QuerySymbol string `json:"query_symbol"`
MarketType string `json:"market_type"`
Ranking *SignalRankItem `json:"ranking,omitempty"`
SignalLab json.RawMessage `json:"signal_lab,omitempty"`
SignalLabError string `json:"signal_lab_error,omitempty"`
Heatmap json.RawMessage `json:"heatmap,omitempty"`
HeatmapError string `json:"heatmap_error,omitempty"`
}
func NewClient(baseURL, privateKeyHex string, logger mcp.Logger) (*Client, error) {
if baseURL == "" {
baseURL = DefaultBaseURL
}
baseURL = strings.TrimRight(baseURL, "/")
if privateKeyHex == "" {
privateKeyHex = os.Getenv("CLAW402_WALLET_KEY")
}
if privateKeyHex == "" {
return nil, fmt.Errorf("claw402 wallet private key not set")
}
if logger == nil {
logger = mcp.NewNoopLogger()
}
hexKey := strings.TrimPrefix(strings.TrimSpace(privateKeyHex), "0x")
pk, err := crypto.HexToECDSA(hexKey)
if err != nil {
return nil, fmt.Errorf("invalid claw402 private key: %w", err)
}
return &Client{
baseURL: baseURL,
privateKey: pk,
httpClient: &http.Client{Timeout: 30 * time.Second},
logger: logger,
}, nil
}
func (c *Client) GetSignalRanking(ctx context.Context, q Query) (*SignalRankingData, error) {
params := url.Values{}
addQueryDefaults(params, q, false)
body, err := c.doGET(ctx, SignalRankingPath, params)
if err != nil {
return nil, err
}
return ParseSignalRanking(body)
}
func (c *Client) GetSignalLab(ctx context.Context, q Query) (json.RawMessage, error) {
if strings.TrimSpace(q.MarketType) == "" || strings.TrimSpace(q.Symbol) == "" {
return nil, fmt.Errorf("marketType and symbol are required")
}
params := url.Values{}
addQueryDefaults(params, q, true)
return c.doGET(ctx, SignalLabPath, params)
}
func (c *Client) GetCostLiquidationHeatmap(ctx context.Context, q Query) (json.RawMessage, error) {
if strings.TrimSpace(q.MarketType) == "" || strings.TrimSpace(q.Symbol) == "" {
return nil, fmt.Errorf("marketType and symbol are required")
}
params := url.Values{}
addQueryDefaults(params, q, true)
return c.doGET(ctx, CostLiquidationHeatmapPath, params)
}
func addQueryDefaults(params url.Values, q Query, includeMarket bool) {
if includeMarket {
if q.MarketType != "" {
params.Set("marketType", q.MarketType)
}
if q.Symbol != "" {
params.Set("symbol", MarketSymbol(q.MarketType, q.Symbol))
}
}
if q.Chain != "" {
params.Set("chain", QueryChain(q.Chain))
}
if q.LiqBand != "" {
params.Set("liqBand", q.LiqBand)
}
}
func (c *Client) doGET(ctx context.Context, path string, params url.Values) ([]byte, error) {
if c == nil {
return nil, fmt.Errorf("vergex client is nil")
}
if ctx == nil {
ctx = context.Background()
}
fullURL := c.baseURL + path
if encoded := params.Encode(); encoded != "" {
fullURL += "?" + encoded
}
buildReq := func() (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Client-ID", "nofx")
return req, nil
}
body, err := payment.DoX402Request(
ctx,
c.httpClient,
buildReq,
payment.MakeClaw402SignFunc(c.privateKey),
"claw402-vergex",
c.logger,
)
if err != nil {
return nil, fmt.Errorf("vergex request failed (%s): %w", path, err)
}
return body, nil
}
func ParseSignalRanking(body []byte) (*SignalRankingData, error) {
raw := json.RawMessage(append([]byte(nil), body...))
var decoded any
if err := json.Unmarshal(body, &decoded); err != nil {
return nil, fmt.Errorf("failed to parse vergex signal-ranking response: %w", err)
}
rows := findObjectArray(decoded)
items := make([]SignalRankItem, 0, len(rows))
for idx, row := range rows {
obj, ok := row.(map[string]any)
if !ok {
continue
}
item, ok := parseRankItem(obj, idx+1)
if ok {
items = append(items, item)
}
}
return &SignalRankingData{Raw: raw, Items: items}, nil
}
func FilterTradFiItems(items []SignalRankItem, marketType string, limit int) []SignalRankItem {
if marketType == "" {
marketType = DefaultMarketType
}
return filterSignalRankingItems(items, marketType, limit, false)
}
func FilterSignalRankingItems(items []SignalRankItem, marketType string, limit int) []SignalRankItem {
return filterSignalRankingItems(items, marketType, limit, true)
}
func filterSignalRankingItems(items []SignalRankItem, marketType string, limit int, allowAll bool) []SignalRankItem {
requestedMarketType := marketType
normalizedMarketType := normalizeMarketType(marketType)
includeAll := allowAll && isAllMarketType(marketType)
if limit <= 0 {
limit = 5
}
if limit > MaxSignalRankingItems {
limit = MaxSignalRankingItems
}
out := make([]SignalRankItem, 0, limit)
seen := make(map[string]bool)
for _, item := range items {
base := QuerySymbol(item.Symbol)
if base == "" {
continue
}
itemMarket := normalizeMarketType(item.MarketType)
isXYZ := hyperliquid.IsXYZAsset(item.Symbol) || hyperliquid.IsXYZAsset(base)
if !includeAll {
if itemMarket != "" && normalizedMarketType != "" && itemMarket != normalizedMarketType && !isTradeFiMarketType(itemMarket) && !isXYZ {
continue
}
if itemMarket == "" && !isXYZ {
continue
}
}
item.MarketType = coalesce(item.MarketType, inferRankingMarketType(item.Symbol, base, requestedMarketType))
tradeSymbol := TradableSymbolForMarket(item.MarketType, item.Symbol)
if tradeSymbol == "" || seen[tradeSymbol] {
continue
}
item.Symbol = base
item.Category = rankingCategory(item.MarketType, base)
out = append(out, item)
seen[tradeSymbol] = true
if len(out) >= limit {
break
}
}
return out
}
func TradableSymbol(symbol string) string {
return TradableSymbolForMarket(DefaultMarketType, symbol)
}
func TradableSymbolForMarket(marketType, symbol string) string {
base := QuerySymbol(symbol)
if base == "" {
return ""
}
if isCoreMarketType(marketType) {
return base
}
if isAllMarketType(marketType) && !hyperliquid.IsXYZAsset(symbol) && !hyperliquid.IsXYZAsset(base) {
return base
}
return hyperliquid.FormatCoinForAPI("xyz:" + base)
}
func MarketSymbol(marketType, symbol string) string {
symbol = strings.TrimSpace(symbol)
if symbol == "" {
return ""
}
if strings.Contains(symbol, "/") {
parts := strings.Split(symbol, "/")
symbol = parts[len(parts)-1]
}
if strings.HasPrefix(strings.ToLower(symbol), "xyz:") {
return "xyz:" + hyperliquid.NormalizeCoinBase(strings.TrimPrefix(strings.ToUpper(symbol), "XYZ:"))
}
base := QuerySymbol(symbol)
if base == "" {
return ""
}
if normalizeMarketType(marketType) == "hip3perp" {
return "xyz:" + base
}
return base
}
func QuerySymbol(symbol string) string {
symbol = strings.TrimSpace(symbol)
if symbol == "" {
return ""
}
symbol = strings.TrimPrefix(strings.ToUpper(symbol), "XYZ:")
if strings.Contains(symbol, "/") {
parts := strings.Split(symbol, "/")
symbol = parts[len(parts)-1]
}
return hyperliquid.NormalizeCoinBase(symbol)
}
func QueryChain(chain string) string {
raw := strings.TrimSpace(chain)
normalized := strings.ToLower(raw)
switch normalized {
case "", "hyperliquid", "hl":
return DefaultChain
default:
return raw
}
}
func FormatAnalysisForAI(analysis *MarketAnalysis) string {
if analysis == nil {
return ""
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("### %s (Vergex %s/%s)\n", analysis.Symbol, analysis.MarketType, analysis.QuerySymbol))
if analysis.Ranking != nil {
sb.WriteString(fmt.Sprintf("Ranking: rank=%d bias=%s confidence=%.2f score=%.4f category=%s\n",
analysis.Ranking.Rank,
emptyDash(analysis.Ranking.Bias),
analysis.Ranking.Confidence,
analysis.Ranking.Score,
emptyDash(analysis.Ranking.Category)))
}
if len(analysis.SignalLab) > 0 {
sb.WriteString("#### Signal Lab\n")
sb.WriteString(FormatSignalLabMarkdown(analysis.SignalLab))
sb.WriteString("\n")
} else if analysis.SignalLabError != "" {
sb.WriteString("Signal Lab: unavailable (")
sb.WriteString(truncateText(analysis.SignalLabError, 360))
sb.WriteString(")\n")
}
if len(analysis.Heatmap) > 0 {
sb.WriteString("#### Cost/Liquidation Heatmap\n")
sb.WriteString(FormatHeatmapMarkdown(analysis.Heatmap))
sb.WriteString("\n")
} else if analysis.HeatmapError != "" {
sb.WriteString("Cost/Liquidation Heatmap: unavailable (")
sb.WriteString(truncateText(analysis.HeatmapError, 360))
sb.WriteString(")\n")
}
return sb.String()
}
func FormatSignalLabMarkdown(raw json.RawMessage) string {
data, ok := decodeVergexDataObject(raw)
if !ok {
return fallbackJSONBlock(raw, 2200)
}
var sb strings.Builder
writeScalarSummary(&sb, data, []string{"symbol", "marketType", "band", "bias", "confidence", "compositeZ", "score"})
dimensions := objectArray(data, "dimensions")
if len(dimensions) == 0 {
return withFallbackIfEmpty(sb.String(), raw)
}
sb.WriteString("| Family | Signal | Direction | Strength | Percentile | Detail |\n")
sb.WriteString("| --- | --- | --- | --- | ---: | --- |\n")
limit := minInt(len(dimensions), 8)
for _, row := range dimensions[:limit] {
sb.WriteString("| ")
sb.WriteString(markdownCell(firstString(row, "family")))
sb.WriteString(" | ")
sb.WriteString(markdownCell(firstString(row, "label", "key")))
sb.WriteString(" | ")
sb.WriteString(markdownCell(firstString(row, "direction")))
sb.WriteString(" | ")
sb.WriteString(markdownCell(firstString(row, "strength")))
sb.WriteString(" | ")
sb.WriteString(markdownCell(formatOptionalFloat(row, "percentile")))
sb.WriteString(" | ")
sb.WriteString(markdownCell(truncateText(firstString(row, "detail", "what"), 220)))
sb.WriteString(" |\n")
}
if len(dimensions) > limit {
sb.WriteString(fmt.Sprintf("- Additional dimensions omitted: %d\n", len(dimensions)-limit))
}
return withFallbackIfEmpty(sb.String(), raw)
}
func FormatHeatmapMarkdown(raw json.RawMessage) string {
data, ok := decodeVergexDataObject(raw)
if !ok {
return fallbackJSONBlock(raw, 2600)
}
bins := objectArray(data, "bins")
if len(bins) == 0 {
var sb strings.Builder
writeScalarSummary(&sb, data, []string{"symbol", "marketType", "band", "liqBand", "currentPrice", "price", "binStep"})
return withFallbackIfEmpty(sb.String(), raw)
}
zones := make([]heatmapZone, 0, len(bins))
var totalLongCost, totalShortCost, totalLongLiq, totalShortLiq float64
for _, bin := range bins {
zone := heatmapZone{
Start: firstFloat(bin, "bucketStartPrice", "start", "startPrice"),
End: firstFloat(bin, "bucketEndPrice", "end", "endPrice"),
PX: firstFloat(bin, "px", "price"),
LongCost: firstFloat(bin, "longCost"),
ShortCost: firstFloat(bin, "shortCost"),
LongLiq: firstFloat(bin, "longLiq", "longLiquidation"),
ShortLiq: firstFloat(bin, "shortLiq", "shortLiquidation"),
}
totalLongCost += zone.LongCost
totalShortCost += zone.ShortCost
totalLongLiq += zone.LongLiq
totalShortLiq += zone.ShortLiq
zone.Score = maxFloat(zone.LongCost, zone.ShortCost, zone.LongLiq, zone.ShortLiq)
if zone.Score > 0 {
zones = append(zones, zone)
}
}
sortHeatmapZones(zones)
var sb strings.Builder
writeScalarSummary(&sb, data, []string{"symbol", "marketType", "band", "liqBand", "currentPrice", "price", "binStep"})
sb.WriteString(fmt.Sprintf("- Total cost: long %s / short %s\n", formatUSDAmount(totalLongCost), formatUSDAmount(totalShortCost)))
sb.WriteString(fmt.Sprintf("- Total liquidation: long %s / short %s\n", formatUSDAmount(totalLongLiq), formatUSDAmount(totalShortLiq)))
sb.WriteString("| Price zone | Long cost | Short cost | Long liq | Short liq | Main cluster |\n")
sb.WriteString("| --- | ---: | ---: | ---: | ---: | --- |\n")
limit := minInt(len(zones), 10)
for _, zone := range zones[:limit] {
sb.WriteString("| ")
sb.WriteString(markdownCell(formatPriceZone(zone)))
sb.WriteString(" | ")
sb.WriteString(markdownCell(formatUSDAmount(zone.LongCost)))
sb.WriteString(" | ")
sb.WriteString(markdownCell(formatUSDAmount(zone.ShortCost)))
sb.WriteString(" | ")
sb.WriteString(markdownCell(formatUSDAmount(zone.LongLiq)))
sb.WriteString(" | ")
sb.WriteString(markdownCell(formatUSDAmount(zone.ShortLiq)))
sb.WriteString(" | ")
sb.WriteString(markdownCell(zone.MainCluster()))
sb.WriteString(" |\n")
}
if len(zones) > limit {
sb.WriteString(fmt.Sprintf("- Additional heatmap bins omitted: %d\n", len(zones)-limit))
}
return withFallbackIfEmpty(sb.String(), raw)
}
type heatmapZone struct {
Start float64
End float64
PX float64
LongCost float64
ShortCost float64
LongLiq float64
ShortLiq float64
Score float64
}
func (z heatmapZone) MainCluster() string {
maxVal := maxFloat(z.LongCost, z.ShortCost, z.LongLiq, z.ShortLiq)
switch maxVal {
case z.LongCost:
return "long cost"
case z.ShortCost:
return "short cost"
case z.LongLiq:
return "long liquidation"
case z.ShortLiq:
return "short liquidation"
default:
return "-"
}
}
func sortHeatmapZones(zones []heatmapZone) {
sort.SliceStable(zones, func(i, j int) bool {
return zones[i].Score > zones[j].Score
})
}
func decodeVergexDataObject(raw json.RawMessage) (map[string]any, bool) {
var decoded any
if err := json.Unmarshal(raw, &decoded); err != nil {
return nil, false
}
obj, ok := decoded.(map[string]any)
if !ok {
return nil, false
}
if data, ok := lookupNormalized(obj, "data"); ok {
if dataObj, ok := data.(map[string]any); ok {
return dataObj, true
}
}
return obj, true
}
func writeScalarSummary(sb *strings.Builder, obj map[string]any, keys []string) {
wrote := false
for _, key := range keys {
value, ok := lookupNormalized(obj, key)
if !ok {
continue
}
text := formatScalarValue(value)
if text == "" {
continue
}
sb.WriteString(fmt.Sprintf("- %s: %s\n", titleKey(key), text))
wrote = true
}
if wrote {
sb.WriteString("\n")
}
}
func objectArray(obj map[string]any, key string) []map[string]any {
val, ok := lookupNormalized(obj, key)
if !ok {
return nil
}
rows, ok := val.([]any)
if !ok {
return nil
}
out := make([]map[string]any, 0, len(rows))
for _, row := range rows {
if rowObj, ok := row.(map[string]any); ok {
out = append(out, rowObj)
}
}
return out
}
func formatOptionalFloat(obj map[string]any, key string) string {
val, ok := lookupNormalized(obj, key)
if !ok {
return "-"
}
num, ok := anyFloat(val)
if !ok {
return formatScalarValue(val)
}
return trimFloat(num, 1)
}
func anyFloat(val any) (float64, bool) {
switch t := val.(type) {
case float64:
return t, true
case float32:
return float64(t), true
case int:
return float64(t), true
case int64:
return float64(t), true
case json.Number:
f, err := t.Float64()
return f, err == nil
case string:
var f float64
if _, err := fmt.Sscanf(strings.TrimSpace(t), "%f", &f); err == nil {
return f, true
}
}
return 0, false
}
func formatScalarValue(val any) string {
switch t := val.(type) {
case string:
return strings.TrimSpace(t)
case bool:
return fmt.Sprintf("%t", t)
case float64:
return trimFloat(t, 4)
case json.Number:
f, err := t.Float64()
if err == nil {
return trimFloat(f, 4)
}
return t.String()
default:
if f, ok := anyFloat(val); ok {
return trimFloat(f, 4)
}
return ""
}
}
func formatPriceZone(z heatmapZone) string {
if z.Start != 0 || z.End != 0 {
return fmt.Sprintf("%s-%s", trimFloat(z.Start, 4), trimFloat(z.End, 4))
}
if z.PX != 0 {
return trimFloat(z.PX, 4)
}
return "-"
}
func formatUSDAmount(v float64) string {
abs := math.Abs(v)
sign := ""
if v < 0 {
sign = "-"
}
switch {
case abs >= 1_000_000_000:
return fmt.Sprintf("%s$%.2fB", sign, abs/1_000_000_000)
case abs >= 1_000_000:
return fmt.Sprintf("%s$%.2fM", sign, abs/1_000_000)
case abs >= 1_000:
return fmt.Sprintf("%s$%.2fK", sign, abs/1_000)
default:
return fmt.Sprintf("%s$%.2f", sign, abs)
}
}
func trimFloat(v float64, precision int) string {
text := fmt.Sprintf("%.*f", precision, v)
text = strings.TrimRight(text, "0")
text = strings.TrimRight(text, ".")
if text == "-0" {
return "0"
}
return text
}
func markdownCell(text string) string {
text = strings.ReplaceAll(strings.TrimSpace(text), "\n", " ")
text = strings.ReplaceAll(text, "|", "\\|")
if text == "" {
return "-"
}
return text
}
func titleKey(key string) string {
switch key {
case "marketType":
return "Market type"
case "liqBand":
return "Liquidation band"
case "currentPrice":
return "Current price"
case "binStep":
return "Bin step"
case "compositeZ":
return "Composite Z"
default:
if key == "" {
return ""
}
return strings.ToUpper(key[:1]) + key[1:]
}
}
func withFallbackIfEmpty(text string, raw json.RawMessage) string {
if strings.TrimSpace(text) == "" {
return fallbackJSONBlock(raw, 2200)
}
return text
}
func fallbackJSONBlock(raw json.RawMessage, maxBytes int) string {
return "```json\n" + CompactJSON(raw, maxBytes) + "\n```\n"
}
func maxFloat(values ...float64) float64 {
max := 0.0
for _, value := range values {
if value > max {
max = value
}
}
return max
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func CompactJSON(raw json.RawMessage, maxBytes int) string {
if len(raw) == 0 {
return "{}"
}
var buf any
if err := json.Unmarshal(raw, &buf); err == nil {
if compact, err := json.Marshal(buf); err == nil {
raw = compact
}
}
text := string(raw)
if maxBytes > 0 && len(text) > maxBytes {
return text[:maxBytes] + "...<truncated>"
}
return text
}
func truncateText(text string, maxBytes int) string {
text = strings.TrimSpace(text)
if maxBytes <= 0 || len(text) <= maxBytes {
return text
}
return text[:maxBytes] + "...<truncated>"
}
func parseRankItem(obj map[string]any, fallbackRank int) (SignalRankItem, bool) {
symbol := firstString(obj, "symbol", "ticker", "base", "coin", "asset", "market", "name")
if symbol == "" {
symbol = nestedMarketString(obj, "symbol", "ticker", "base", "coin", "asset", "name")
}
if symbol == "" {
return SignalRankItem{}, false
}
raw, _ := json.Marshal(obj)
rank := firstInt(obj, "rank", "ranking", "position")
if rank <= 0 {
rank = fallbackRank
}
score := firstFloat(obj, "compositeZ", "composite_z", "score", "rank_score", "z", "value")
confidence := firstFloat(obj, "confidence", "conf", "signalConfidence", "signal_confidence")
marketType := firstString(obj, "marketType", "market_type", "venue")
if marketType == "" {
marketType = nestedMarketString(obj, "marketType", "market_type", "venue", "type")
}
item := SignalRankItem{
Rank: rank,
Symbol: QuerySymbol(symbol),
MarketType: marketType,
Bias: firstString(obj, "bias", "direction", "side", "signal"),
Confidence: confidence,
Score: score,
Raw: raw,
}
if item.Symbol != "" {
item.Category = hyperliquid.XYZCategory(item.Symbol)
}
return item, item.Symbol != ""
}
func nestedMarketString(obj map[string]any, keys ...string) string {
val, ok := lookupNormalized(obj, "market")
if !ok {
return ""
}
nested, ok := val.(map[string]any)
if !ok {
return ""
}
return firstString(nested, keys...)
}
func findObjectArray(v any) []any {
switch t := v.(type) {
case []any:
if arrayLooksLikeRows(t) {
return t
}
for _, item := range t {
if rows := findObjectArray(item); len(rows) > 0 {
return rows
}
}
case map[string]any:
for _, key := range []string{"data", "items", "results", "ranking", "rankings", "rows", "markets", "signals"} {
if val, ok := lookupNormalized(t, key); ok {
if rows := findObjectArray(val); len(rows) > 0 {
return rows
}
}
}
for _, val := range t {
if rows := findObjectArray(val); len(rows) > 0 {
return rows
}
}
}
return nil
}
func arrayLooksLikeRows(rows []any) bool {
for _, row := range rows {
obj, ok := row.(map[string]any)
if !ok {
continue
}
if firstString(obj, "symbol", "ticker", "base", "coin", "asset", "market", "name") != "" {
return true
}
}
return false
}
func firstString(obj map[string]any, keys ...string) string {
for _, key := range keys {
val, ok := lookupNormalized(obj, key)
if !ok {
continue
}
switch t := val.(type) {
case string:
if strings.TrimSpace(t) != "" {
return strings.TrimSpace(t)
}
case fmt.Stringer:
if strings.TrimSpace(t.String()) != "" {
return strings.TrimSpace(t.String())
}
}
}
return ""
}
func firstFloat(obj map[string]any, keys ...string) float64 {
for _, key := range keys {
val, ok := lookupNormalized(obj, key)
if !ok {
continue
}
switch t := val.(type) {
case float64:
return t
case int:
return float64(t)
case json.Number:
f, _ := t.Float64()
return f
case string:
var f float64
if _, err := fmt.Sscanf(strings.TrimSpace(t), "%f", &f); err == nil {
return f
}
}
}
return 0
}
func firstInt(obj map[string]any, keys ...string) int {
for _, key := range keys {
val, ok := lookupNormalized(obj, key)
if !ok {
continue
}
switch t := val.(type) {
case float64:
return int(t)
case int:
return t
case json.Number:
i, _ := t.Int64()
return int(i)
case string:
var i int
if _, err := fmt.Sscanf(strings.TrimSpace(t), "%d", &i); err == nil {
return i
}
}
}
return 0
}
func lookupNormalized(obj map[string]any, key string) (any, bool) {
want := normalizeKey(key)
for k, v := range obj {
if normalizeKey(k) == want {
return v, true
}
}
return nil, false
}
func normalizeKey(key string) string {
replacer := strings.NewReplacer("_", "", "-", "", " ", "", ".", "")
return replacer.Replace(strings.ToLower(strings.TrimSpace(key)))
}
func normalizeMarketType(marketType string) string {
replacer := strings.NewReplacer("_", "", "-", "", " ", "", ".", "", "/", "")
return replacer.Replace(strings.ToLower(strings.TrimSpace(marketType)))
}
func isTradeFiMarketType(marketType string) bool {
switch normalizeMarketType(marketType) {
case "hip3perp", "hip3", "xyz", "xyzperp", "tradefi", "tradfi",
"stock", "stocks", "equity", "equities", "usequity", "usequities", "usstock", "usstocks",
"commodity", "commodities", "forex", "fx", "index", "indices", "preipo":
return true
default:
return false
}
}
func isAllMarketType(marketType string) bool {
switch normalizeMarketType(marketType) {
case "", "all", "any", "ranking", "signalranking", "claw402", "vergex":
return true
default:
return false
}
}
func isCoreMarketType(marketType string) bool {
switch normalizeMarketType(marketType) {
case "coreperp", "core", "crypto", "cryptoperp":
return true
default:
return false
}
}
func inferRankingMarketType(symbol, base, fallback string) string {
if !isAllMarketType(fallback) && strings.TrimSpace(fallback) != "" {
return fallback
}
if hyperliquid.IsXYZAsset(symbol) || hyperliquid.IsXYZAsset(base) {
return DefaultMarketType
}
return "core_perp"
}
func rankingCategory(marketType, base string) string {
if isCoreMarketType(marketType) {
return "crypto"
}
return hyperliquid.XYZCategory(base)
}
func coalesce(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func emptyDash(value string) string {
if strings.TrimSpace(value) == "" {
return "-"
}
return value
}

View File

@@ -0,0 +1,220 @@
package vergex
import (
"fmt"
"net/url"
"strings"
"testing"
)
func TestParseSignalRankingAndFilterTradFiItems(t *testing.T) {
body := []byte(`{
"data": {
"rankings": [
{"marketType":"hip3-perp","symbol":"AAPL","bias":"long","confidence":0.88,"compositeZ":1.75},
{"marketType":"stock","symbol":"NVDA","bias":"long","confidence":0.81,"compositeZ":1.25},
{"market_type":"core_perp","symbol":"BTC","bias":"short","score":0.91}
]
}
}`)
ranking, err := ParseSignalRanking(body)
if err != nil {
t.Fatalf("ParseSignalRanking returned error: %v", err)
}
if len(ranking.Items) != 3 {
t.Fatalf("items len = %d, want 3", len(ranking.Items))
}
if ranking.Items[0].Symbol != "AAPL" || ranking.Items[0].MarketType != "hip3-perp" || ranking.Items[0].Bias != "long" {
t.Fatalf("unexpected first item: %+v", ranking.Items[0])
}
items := FilterTradFiItems(ranking.Items, "hip3_perp", 5)
if len(items) != 2 {
t.Fatalf("filtered len = %d, want 2", len(items))
}
if got := TradableSymbol(items[0].Symbol); got != "xyz:AAPL" {
t.Fatalf("TradableSymbol = %q, want xyz:AAPL", got)
}
if got := TradableSymbol(items[1].Symbol); got != "xyz:NVDA" {
t.Fatalf("TradableSymbol = %q, want xyz:NVDA", got)
}
}
func TestFilterTradFiItemsAllowsFullClaw402Board(t *testing.T) {
items := make([]SignalRankItem, 0, 35)
for i := 1; i <= 35; i++ {
items = append(items, SignalRankItem{
Rank: i,
Symbol: fmt.Sprintf("xyz:STK%02d", i),
MarketType: "hip3_perp",
Bias: "bullish",
})
}
filtered := FilterTradFiItems(items, "hip3_perp", 30)
if len(filtered) != 30 {
t.Fatalf("filtered len = %d, want 30", len(filtered))
}
if filtered[0].Symbol != "STK01" || filtered[29].Symbol != "STK30" {
t.Fatalf("unexpected filtered bounds: first=%q last=%q", filtered[0].Symbol, filtered[29].Symbol)
}
capped := FilterTradFiItems(items, "hip3_perp", 35)
if len(capped) != MaxSignalRankingItems {
t.Fatalf("capped len = %d, want %d", len(capped), MaxSignalRankingItems)
}
}
func TestParseSignalRankingReadsNestedMarketShape(t *testing.T) {
body := []byte(`{
"data": {
"items": [
{"market":{"marketType":"hip3_perp","symbol":"xyz:NBIS"},"symbol":"xyz:NBIS","bias":"bullish","compositeZ":1.05,"rank":5},
{"market":{"marketType":"hip3_perp","symbol":"xyz:DRAM"},"symbol":"xyz:DRAM","bias":"bullish","compositeZ":0.47,"rank":10},
{"market":{"marketType":"core_perp","symbol":"BTC"},"symbol":"BTC","bias":"bearish","compositeZ":-0.08,"rank":12}
]
}
}`)
ranking, err := ParseSignalRanking(body)
if err != nil {
t.Fatalf("ParseSignalRanking returned error: %v", err)
}
allItems := FilterSignalRankingItems(ranking.Items, "all", 30)
if len(allItems) != 3 {
t.Fatalf("all filtered len = %d, want 3: %+v", len(allItems), allItems)
}
if allItems[2].Symbol != "BTC" || allItems[2].MarketType != "core_perp" || allItems[2].Category != "crypto" {
t.Fatalf("unexpected crypto item: %+v", allItems[2])
}
items := FilterTradFiItems(ranking.Items, "hip3_perp", 30)
if len(items) != 2 {
t.Fatalf("filtered len = %d, want 2: %+v", len(items), items)
}
if items[0].Symbol != "NBIS" || items[0].MarketType != "hip3_perp" {
t.Fatalf("unexpected first item: %+v", items[0])
}
if items[1].Symbol != "DRAM" || items[1].MarketType != "hip3_perp" {
t.Fatalf("unexpected second item: %+v", items[1])
}
}
func TestMarketSymbolPreservesHIP3XYZPrefix(t *testing.T) {
if got := MarketSymbol("hip3_perp", "INTC"); got != "xyz:INTC" {
t.Fatalf("MarketSymbol hip3_perp/INTC = %q, want xyz:INTC", got)
}
if got := MarketSymbol("hip3_perp", "xyz:skhx"); got != "xyz:SKHX" {
t.Fatalf("MarketSymbol hip3_perp/xyz:skhx = %q, want xyz:SKHX", got)
}
if got := MarketSymbol("core_perp", "BTC"); got != "BTC" {
t.Fatalf("MarketSymbol core_perp/BTC = %q, want BTC", got)
}
if got := TradableSymbolForMarket("core_perp", "BTC"); got != "BTC" {
t.Fatalf("TradableSymbolForMarket core_perp/BTC = %q, want BTC", got)
}
if got := TradableSymbolForMarket("hip3_perp", "INTC"); got != "xyz:INTC" {
t.Fatalf("TradableSymbolForMarket hip3_perp/INTC = %q, want xyz:INTC", got)
}
}
func TestAddQueryDefaultsUsesClaw402GatewayParams(t *testing.T) {
params := url.Values{}
addQueryDefaults(params, Query{
MarketType: "hip3_perp",
Symbol: "INTC",
Chain: "hyperliquid",
LiqBand: "15",
}, true)
if got := params.Get("marketType"); got != "hip3_perp" {
t.Fatalf("marketType = %q", got)
}
if got := params.Get("symbol"); got != "xyz:INTC" {
t.Fatalf("symbol = %q, want xyz:INTC", got)
}
if got := params.Get("chain"); got != "mainnet" {
t.Fatalf("chain = %q, want mainnet", got)
}
if got := params.Get("liqBand"); got != "15" {
t.Fatalf("liqBand = %q, want 15", got)
}
}
func TestQueryChainMapsHyperliquidToVergexMainnet(t *testing.T) {
if got := QueryChain("hyperliquid"); got != "mainnet" {
t.Fatalf("QueryChain hyperliquid = %q, want mainnet", got)
}
}
func TestFormatAnalysisForAIIncludesDetailErrors(t *testing.T) {
text := FormatAnalysisForAI(&MarketAnalysis{
Symbol: "xyz:NVDA",
QuerySymbol: "NVDA",
MarketType: "stock",
SignalLabError: "upstream returned status 502",
HeatmapError: "market not found",
})
if !containsAll(text, "Signal Lab: unavailable", "upstream returned status 502", "Cost/Liquidation Heatmap: unavailable", "market not found") {
t.Fatalf("formatted analysis did not include detail errors:\n%s", text)
}
}
func TestFormatAnalysisForAIFormatsVergexDetailsAsMarkdown(t *testing.T) {
text := FormatAnalysisForAI(&MarketAnalysis{
Symbol: "xyz:DRAM",
QuerySymbol: "DRAM",
MarketType: "hip3_perp",
SignalLab: []byte(`{
"data": {
"band": "15",
"bias": "bullish",
"compositeZ": 1.41,
"confidence": "Medium",
"dimensions": [
{
"family": "I Cost & Positioning",
"label": "Capital-gains overhang",
"direction": "bullish",
"strength": "medium",
"percentile": 80,
"detail": "price is above aggregate cost"
}
]
}
}`),
Heatmap: []byte(`{
"data": {
"binStep": 3.2,
"bins": [
{"bucketStartPrice": 100, "bucketEndPrice": 103.2, "longCost": 1200000, "shortCost": 1000, "longLiq": 5000, "shortLiq": 700000},
{"bucketStartPrice": 103.2, "bucketEndPrice": 106.4, "longCost": 1000, "shortCost": 2000, "longLiq": 900000, "shortLiq": 4000}
]
}
}`),
})
if !containsAll(text,
"#### Signal Lab",
"| Family | Signal | Direction | Strength | Percentile | Detail |",
"Capital-gains overhang",
"#### Cost/Liquidation Heatmap",
"| Price zone | Long cost | Short cost | Long liq | Short liq | Main cluster |",
"$1.20M",
) {
t.Fatalf("formatted analysis is not markdown enough:\n%s", text)
}
if strings.Contains(text, "Signal Lab: {") || strings.Contains(text, "Cost/Liquidation Heatmap: {") {
t.Fatalf("formatted analysis still includes raw inline JSON:\n%s", text)
}
}
func containsAll(text string, needles ...string) bool {
for _, needle := range needles {
if !strings.Contains(text, needle) {
return false
}
}
return true
}