package market import ( "fmt" "os" "sync" "time" ) // VenueGraph tracks order books across multiple venues for the same asset. // It detects cross-venue dislocations — prices that exist on one venue // but not another, or spreads that shouldn't exist. type VenueGraph struct { mu sync.RWMutex books map[string]map[Venue]*OrderBook // asset → venue → latest book } // NewVenueGraph creates an empty cross-venue graph. func NewVenueGraph() *VenueGraph { return &VenueGraph{ books: make(map[string]map[Venue]*OrderBook), } } // Update records the latest order book for an asset/venue pair. func (vg *VenueGraph) Update(ob *OrderBook) { vg.mu.Lock() defer vg.mu.Unlock() if vg.books[ob.Asset] == nil { vg.books[ob.Asset] = make(map[Venue]*OrderBook) } vg.books[ob.Asset][ob.Venue] = ob } // DetectDislocations scans all tracked assets for cross-venue spread // opportunities. Returns dislocations where the spread exceeds the // given cost threshold. func (vg *VenueGraph) DetectDislocations(costThreshold float64) []CrossSpread { vg.mu.RLock() defer vg.mu.RUnlock() var dislocations []CrossSpread for _, venues := range vg.books { // Compare all pairs of venues for this asset. venueList := make([]Venue, 0, len(venues)) for v := range venues { venueList = append(venueList, v) } for i := 0; i < len(venueList); i++ { for j := i + 1; j < len(venueList); j++ { bookA := venues[venueList[i]] bookB := venues[venueList[j]] cs := CompareBooksForSpread(bookA, bookB) spread, _, _ := cs.BestSpread() if spread > costThreshold { dislocations = append(dislocations, *cs) } } } } return dislocations } // Assets returns the list of tracked assets. func (vg *VenueGraph) Assets() []string { vg.mu.RLock() defer vg.mu.RUnlock() assets := make([]string, 0, len(vg.books)) for a := range vg.books { assets = append(assets, a) } return assets } // VenuesForAsset returns the venues tracking a specific asset. func (vg *VenueGraph) VenuesForAsset(asset string) []Venue { vg.mu.RLock() defer vg.mu.RUnlock() venues := make([]Venue, 0, len(vg.books[asset])) for v := range vg.books[asset] { venues = append(venues, v) } return venues } // Book returns the latest order book for an asset/venue pair. func (vg *VenueGraph) Book(asset string, venue Venue) *OrderBook { vg.mu.RLock() defer vg.mu.RUnlock() if vg.books[asset] == nil { return nil } return vg.books[asset][venue] } // DislocationLog records detected cross-venue dislocations over time. type DislocationLog struct { mu sync.Mutex entries []DislocationEntry path string } // DislocationEntry is a single recorded dislocation event. type DislocationEntry struct { Time time.Time Asset string VenueA Venue VenueB Venue Spread float64 BuyVenue Venue SellVenue Venue } // NewDislocationLog creates a log that writes to the given file path. func NewDislocationLog(path string) *DislocationLog { return &DislocationLog{path: path} } // Record adds a dislocation entry and appends it to the log file. func (dl *DislocationLog) Record(cs *CrossSpread) { spread, buyV, sellV := cs.BestSpread() entry := DislocationEntry{ Time: time.Now(), Asset: cs.Asset, VenueA: cs.VenueA, VenueB: cs.VenueB, Spread: spread, BuyVenue: buyV, SellVenue: sellV, } dl.mu.Lock() dl.entries = append(dl.entries, entry) dl.mu.Unlock() // Append to file. if dl.path != "" { f, err := os.OpenFile(dl.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err == nil { fmt.Fprintf(f, "%s %s buy@%s sell@%s spread=%.2f\n", entry.Time.Format(time.RFC3339), entry.Asset, entry.BuyVenue, entry.SellVenue, entry.Spread) f.Close() } } } // Entries returns all recorded dislocations. func (dl *DislocationLog) Entries() []DislocationEntry { dl.mu.Lock() defer dl.mu.Unlock() out := make([]DislocationEntry, len(dl.entries)) copy(out, dl.entries) return out } // Count returns the number of recorded dislocations. func (dl *DislocationLog) Count() int { dl.mu.Lock() defer dl.mu.Unlock() return len(dl.entries) }