304 lines
9.0 KiB
Go
304 lines
9.0 KiB
Go
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, guadagno 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, guadagno 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
|
||
}
|