package main import ( "context" "encoding/json" "errors" "fmt" "io" "math" "net/http" "net/url" "os" "strconv" "strings" "time" ) // ================== Decisione ================== type Decision struct { Action string ExpectedBps float64 FeeBps float64 NetBps float64 ConfidenceOK bool Reason string QuoteValidUntil time.Time // scadenza del quote usato per Fee/Net (stessa chiamata) } // decideTrade: calcola la decisione e popola QuoteValidUntil dall’expiry del quote THOR. func decideTrade(cfg Config, dsTrain, dsVal Dataset, model *LSTM, lastSeq []float64) (Decision, error) { if len(lastSeq) != cfg.Lookback { return Decision{}, fmt.Errorf("sequenza input di lunghezza errata") } // ---- inferenza movimento previsto ---- model.resetState() predZ, _ := model.forward(lastSeq) predLR := predZ*dsTrain.Std + dsTrain.Mean moveBps := (math.Exp(predLR) - 1) * 1e4 // ---- confidenza valida? ---- valMAE := 0.0 if len(dsVal.Seqs) > 0 { sum := 0.0 for i := range dsVal.Seqs { model.resetState() p, _ := model.forward(dsVal.Seqs[i]) sum += math.Abs(p - dsVal.Labels[i]) } valMAE = sum / float64(len(dsVal.Seqs)) } confOK := valMAE < cfg.ValConfZ // ---- quote THOR (fee + expiry) in entrambe le direzioni ---- ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() refFromUnits := int64(cfg.RefFrom * math.Pow10(cfg.FromUnitPow)) refToUnits := int64(cfg.RefTo * math.Pow10(cfg.ToUnitPow)) feeABbps, validAB, err := fetchFeeWithExpiry(ctx, cfg.ThorFrom, cfg.ThorTo, refFromUnits) if err != nil { return Decision{}, fmt.Errorf("impossibile stimare fee %s->%s: %v", cfg.ThorFrom, cfg.ThorTo, err) } feeBAbps, validBA, err := fetchFeeWithExpiry(ctx, cfg.ThorTo, cfg.ThorFrom, refToUnits) if err != nil { return Decision{}, fmt.Errorf("impossibile stimare fee %s->%s: %v", cfg.ThorTo, cfg.ThorFrom, err) } // sanity fee if feeABbps <= 0 || feeABbps > 2000 { return Decision{"HOLD", moveBps, feeABbps, moveBps - feeABbps, confOK, "Fee anomala, quote non affidabile", validAB}, nil } if feeBAbps <= 0 || feeBAbps > 2000 { return Decision{"HOLD", moveBps, feeBAbps, moveBps - feeBAbps, confOK, "Fee anomala, quote non affidabile", validBA}, nil } // ---- decisione con expiry coerente alla direzione ---- if moveBps >= cfg.MinMoveBps { net := moveBps - (feeABbps + cfg.SafetyBps) if confOK && net > 0 { return Decision{"SWAP_AB", moveBps, feeABbps, net, true, "Previsione rialzista, confidenza valida, vantaggio oltre fee+margine", validAB}, nil } return Decision{"HOLD", moveBps, feeABbps, moveBps - feeABbps, confOK, "Previsione rialzista ma non abbastanza sopra fee+margine o confidenza bassa", validAB}, nil } else if moveBps <= -cfg.MinMoveBps { moveAbs := -moveBps net := moveAbs - (feeBAbps + cfg.SafetyBps) if confOK && net > 0 { return Decision{"SWAP_BA", -moveAbs, feeBAbps, net, true, "Previsione ribassista per asset from, confidenza valida, vantaggio oltre fee+margine", validBA}, nil } return Decision{"HOLD", -moveAbs, feeBAbps, moveAbs - feeBAbps, confOK, "Previsione ribassista ma non abbastanza sopra fee+margine o confidenza bassa", validBA}, nil } // HOLD: scegli l’expiry più “lontano” tra i due (se entrambi zero, resta zero e verrà gestito nella compose) best := pickBestExpiry(validAB, validBA) return Decision{"HOLD", moveBps, math.NaN(), math.NaN(), confOK, "Movimento previsto troppo piccolo rispetto a soglia", best}, nil } func pickBestExpiry(a, b time.Time) time.Time { if a.IsZero() && b.IsZero() { return time.Time{} } if a.IsZero() { return b } if b.IsZero() { return a } if a.After(b) { return a } return b } // ================== Costruzione del MessageBundle (definito in messages.go) ================== func BuildMessageBundle(dec Decision, cfg Config) MessageBundle { // humanAction(dec, cfg) è definita altrove (es. helpers.go) actStr := humanAction(dec, cfg) decisionLine := fmt.Sprintf( "consiglio: azione=%s expectedBps=%.3f feeBps=%.3f netBps=%.3f confOK=%v motivo=%s", actStr, dec.ExpectedBps, dec.FeeBps, dec.NetBps, dec.ConfidenceOK, dec.Reason, ) // Usa la funzione già esistente per generare le istruzioni operative var instructions string switch dec.Action { case "SWAP_AB": instructions = printManualInstruction(cfg.ThorFrom, cfg.ThorTo, cfg.RefFrom, dec.ExpectedBps, dec.FeeBps, cfg.SafetyBps) case "SWAP_BA": instructions = printManualInstruction(cfg.ThorTo, cfg.ThorFrom, cfg.RefTo, math.Abs(dec.ExpectedBps), dec.FeeBps, cfg.SafetyBps) default: instructions = fmt.Sprintf("Suggerimento: nessuna azione (HOLD). Motivo=%s", dec.Reason) } return MessageBundle{ DecisionLine: strings.TrimSpace(decisionLine), Instructions: strings.TrimSpace(instructions), QuoteValidUntil: dec.QuoteValidUntil, // expiry reale del quote // ValidityLine/ExpiryNote: li genera Compose() se lasciati vuoti } } // ================== THOR: fee + expiry dalla STESSA chiamata ================== // fetchFeeWithExpiry usa prima fetchThorSwapFeeBpsDebug; se il meta non contiene expiry, // fa fallback a /thorchain/quote/swap per leggerlo (stesso amount). func fetchFeeWithExpiry(ctx context.Context, fromAsset, toAsset string, amountUnits int64) (feeBps float64, validUntil time.Time, err error) { fee, meta, e := fetchThorSwapFeeBpsDebug(ctx, fromAsset, toAsset, amountUnits) if e != nil { return 0, time.Time{}, e } if t, ok := parseExpiryFromMeta(meta); ok { return fee, t, nil } // Fallback: chiamata diretta all'endpoint quote exp, e2 := fetchExpiryDirect(ctx, fromAsset, toAsset, amountUnits) if e2 != nil { return fee, time.Time{}, nil // expiry zero: Compose() in messages.go farà fallback now+24h } return fee, exp, nil } func fetchExpiryDirect(ctx context.Context, fromAsset, toAsset string, amountUnits int64) (time.Time, error) { base := strings.TrimSpace(os.Getenv("THORNODE_URL")) if base == "" { base = "https://thornode.ninerealms.com" } u, _ := url.Parse(base) u.Path = "/thorchain/quote/swap" q := u.Query() q.Set("from_asset", fromAsset) q.Set("to_asset", toAsset) q.Set("amount", strconv.FormatInt(amountUnits, 10)) u.RawQuery = q.Encode() req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) resp, err := http.DefaultClient.Do(req) if err != nil { return time.Time{}, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { b, _ := io.ReadAll(resp.Body) return time.Time{}, fmt.Errorf("thor quote status=%d body=%s", resp.StatusCode, string(b)) } var payload struct { Expiry int64 `json:"expiry"` } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { return time.Time{}, err } if payload.Expiry <= 0 { return time.Time{}, errors.New("expiry assente nella risposta THOR") } return time.Unix(payload.Expiry, 0).UTC(), nil } // meta → expiry (accetta diversi formati) func parseExpiryFromMeta(meta any) (time.Time, bool) { if meta == nil { return time.Time{}, false } if t, ok := meta.(time.Time); ok && !t.IsZero() { return t.UTC(), true } if b, ok := meta.([]byte); ok { return parseExpiryFromStringOrJSON(string(b)) } switch v := meta.(type) { case int64: return epochToTime(v) case int: return epochToTime(int64(v)) case float64: return epochToTime(int64(v)) case json.Number: if i, err := v.Int64(); err == nil { return epochToTime(i) } case string: return parseExpiryFromStringOrJSON(v) case map[string]any: return parseExpiryFromMap(v) } return time.Time{}, false } func parseExpiryFromMap(m map[string]any) (time.Time, bool) { keys := []string{"expiry", "expires_at", "valid_until", "validUntil", "expiresAt"} for _, k := range keys { if v, ok := m[k]; ok { if t, ok2 := parseTimeFlexible(v); ok2 { return t.UTC(), true } } } return time.Time{}, false } func parseExpiryFromStringOrJSON(s string) (time.Time, bool) { s = strings.TrimSpace(s) if s == "" { return time.Time{}, false } if isAllDigits(s) { if i, err := strconv.ParseInt(s, 10, 64); err == nil { return epochToTime(i) } } var m map[string]any if json.Unmarshal([]byte(s), &m) == nil { if t, ok := parseExpiryFromMap(m); ok { return t, true } } if t, err := time.Parse(time.RFC3339, s); err == nil { return t.UTC(), true } return time.Time{}, false } func epochToTime(i int64) (time.Time, bool) { if i <= 0 { return time.Time{}, false } // millisecondi? if i > 1_000_000_000_000 { return time.Unix(0, i*int64(time.Millisecond)).UTC(), true } return time.Unix(i, 0).UTC(), true } func isAllDigits(s string) bool { for i := 0; i < len(s); i++ { if s[i] < '0' || s[i] > '9' { return false } } return len(s) > 0 } func parseTimeFlexible(v any) (time.Time, bool) { switch x := v.(type) { case float64: return epochToTime(int64(x)) case json.Number: if i, err := x.Int64(); err == nil { return epochToTime(i) } case int64: return epochToTime(x) case int: return epochToTime(int64(x)) case string: if t, ok := parseExpiryFromStringOrJSON(x); ok { return t, true } } return time.Time{}, false }