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