bond.go raw

   1  package market
   2  
   3  import (
   4  	"math"
   5  	"strings"
   6  	"time"
   7  	"unicode"
   8  
   9  	"git.mleku.dev/mleku/dendrite/pkg/nostr"
  10  )
  11  
  12  // AssetMention is a reference to a financial asset found in a Nostr
  13  // event's content. Cashtags ($BTC, $ETH) and ticker symbols are the
  14  // structural surface where information meets price.
  15  type AssetMention struct {
  16  	Asset    string // normalized: "BTC", "ETH", etc.
  17  	EventID  string
  18  	Author   string // pubkey hex
  19  	Content  string // the event content containing the mention
  20  	Time     time.Time
  21  }
  22  
  23  // ExtractAssetMentions scans a Nostr event's content for cashtags
  24  // and known ticker symbols. Returns nil if no assets are mentioned.
  25  func ExtractAssetMentions(ev *nostr.Event, knownAssets map[string]bool) []AssetMention {
  26  	if ev.Content == "" {
  27  		return nil
  28  	}
  29  
  30  	var mentions []AssetMention
  31  	words := strings.Fields(ev.Content)
  32  
  33  	for _, word := range words {
  34  		asset := ""
  35  
  36  		// Cashtag: $BTC, $ETH, $DOGE
  37  		if len(word) > 1 && word[0] == '$' {
  38  			ticker := strings.TrimRightFunc(word[1:], func(r rune) bool {
  39  				return !unicode.IsLetter(r) && !unicode.IsDigit(r)
  40  			})
  41  			ticker = strings.ToUpper(ticker)
  42  			if len(ticker) >= 2 && len(ticker) <= 10 {
  43  				asset = ticker
  44  			}
  45  		}
  46  
  47  		// Known ticker without cashtag prefix.
  48  		if asset == "" {
  49  			upper := strings.ToUpper(strings.TrimRightFunc(word, func(r rune) bool {
  50  				return !unicode.IsLetter(r) && !unicode.IsDigit(r)
  51  			}))
  52  			if knownAssets[upper] {
  53  				asset = upper
  54  			}
  55  		}
  56  
  57  		if asset != "" {
  58  			mentions = append(mentions, AssetMention{
  59  				Asset:   asset,
  60  				EventID: ev.ID,
  61  				Author:  ev.Pubkey,
  62  				Content: ev.Content,
  63  				Time:    time.Unix(ev.CreatedAt, 0),
  64  			})
  65  		}
  66  	}
  67  
  68  	return mentions
  69  }
  70  
  71  // AuthorWeight computes a credibility weight for an author based on
  72  // their in-degree in the event graph. More referenced authors have
  73  // higher weight. The weight is log-scaled to prevent any single
  74  // author from dominating.
  75  //
  76  // Returns a value in [0, 1]. Zero in-degree maps to a baseline of 0.1.
  77  func AuthorWeight(pubkey string, graph *nostr.EventGraph) float64 {
  78  	refs := graph.PubkeysR[pubkey]
  79  	if len(refs) == 0 {
  80  		return 0.1 // baseline: unknown authors still contribute
  81  	}
  82  	// Log scale: 1 ref → 0.3, 10 refs → 0.6, 100 refs → 0.8
  83  	w := math.Log10(float64(len(refs))+1) / 3.0
  84  	if w > 1.0 {
  85  		w = 1.0
  86  	}
  87  	if w < 0.1 {
  88  		w = 0.1
  89  	}
  90  	return w
  91  }
  92  
  93  // SentimentSignal is a weighted directional signal from event chatter
  94  // about an asset. Positive = bullish language, negative = bearish.
  95  type SentimentSignal struct {
  96  	Asset     string
  97  	Author    string
  98  	Weight    float64 // author credibility [0, 1]
  99  	Direction float64 // [-1, +1]
 100  	EventID   string
 101  	Time      time.Time
 102  }
 103  
 104  // AggregateSentiment combines multiple sentiment signals into a single
 105  // weighted sentiment value in [-1, +1].
 106  func AggregateSentiment(signals []SentimentSignal) float64 {
 107  	if len(signals) == 0 {
 108  		return 0
 109  	}
 110  	weightedSum := 0.0
 111  	totalWeight := 0.0
 112  	for _, s := range signals {
 113  		weightedSum += s.Direction * s.Weight
 114  		totalWeight += s.Weight
 115  	}
 116  	if totalWeight == 0 {
 117  		return 0
 118  	}
 119  	return weightedSum / totalWeight
 120  }
 121  
 122  // DetectDislocation compares aggregated sentiment against price
 123  // structure and returns a dislocation if they diverge.
 124  //
 125  // The dislocation magnitude measures how far apart the information
 126  // view and the price view are. A high magnitude means the market
 127  // hasn't priced the information yet — that's the negative space
 128  // where value can be harvested.
 129  func DetectDislocation(asset string, ob *OrderBook, signals []SentimentSignal) *Dislocation {
 130  	if ob == nil || len(signals) == 0 {
 131  		return nil
 132  	}
 133  
 134  	mid := ob.MidPrice()
 135  	if mid == 0 {
 136  		return nil
 137  	}
 138  
 139  	sentiment := AggregateSentiment(signals)
 140  
 141  	// Imbalance as a proxy for price direction pressure.
 142  	imbalance := ob.DepthImbalance(5)
 143  	// Normalize imbalance to [-1, +1]: 0.5 → 0, 1.0 → +1, 0.0 → -1
 144  	priceDirection := (imbalance - 0.5) * 2
 145  
 146  	// Dislocation = divergence between sentiment and price pressure.
 147  	divergence := sentiment - priceDirection
 148  	magnitude := math.Abs(divergence) / 2 // normalize to [0, 1]
 149  	if magnitude > 1 {
 150  		magnitude = 1
 151  	}
 152  
 153  	direction := 0
 154  	if divergence > 0.1 {
 155  		direction = 1 // sentiment bullish, price not yet
 156  	} else if divergence < -0.1 {
 157  		direction = -1 // sentiment bearish, price not yet
 158  	}
 159  
 160  	return &Dislocation{
 161  		Asset:     asset,
 162  		PriceMid:  mid,
 163  		Sentiment: sentiment,
 164  		Magnitude: magnitude,
 165  		Direction: direction,
 166  		Time:      time.Now(),
 167  	}
 168  }
 169