mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-18 01:44:38 +08:00
feat: simplify Claw402 autopilot trading flow
This commit is contained in:
960
provider/vergex/client.go
Normal file
960
provider/vergex/client.go
Normal 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
|
||||
}
|
||||
220
provider/vergex/client_test.go
Normal file
220
provider/vergex/client_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user