money/decision.go

304 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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 dallexpiry 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 lexpiry 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
}