package core import ( "context" "eve_haul_go/internal/esi" "fmt" "log" "sync" "time" ) const ( PrimaryCandidateLimit = 30000 PrimaryTypeLimit = 250 PrimaryStationPairLimit = 40 FallbackCandidateLimit = 80000 FallbackTypeLimit = 600 FallbackStationPairLimit = 80 WalletPollInterval = 60 * time.Second TradeConfirmationTTL = 6 * time.Hour ) type TrackedTradeConfirmation struct { Trade Trade CreatedAt time.Time ExpiresAt time.Time BuyRemaining int SellRemaining int IsBuy bool IsSell bool } func NewTrackedTradeConfirmation(trade Trade, createdAt time.Time) *TrackedTradeConfirmation { return &TrackedTradeConfirmation{ Trade: trade, CreatedAt: createdAt, ExpiresAt: createdAt.Add(TradeConfirmationTTL), BuyRemaining: trade.Quantity, SellRemaining: trade.Quantity, IsBuy: trade.Quantity > 0, IsSell: trade.Quantity > 0, } } func (t *TrackedTradeConfirmation) IsComplete() bool { return t.BuyRemaining <= 0 && t.SellRemaining <= 0 } func (t *TrackedTradeConfirmation) IsExpired(now time.Time) bool { return now.After(t.ExpiresAt) || now.Equal(t.ExpiresAt) } type Manager struct { mu sync.Mutex Ship Ship Graph *Graph Market *Market Route []RouteStep RouteInfo *RouteInfo RoutePath []int RoutePathOffsets []int ActiveRouteTrades []Trade ActiveConfirmations []*TrackedTradeConfirmation PendingConfirmations []*TrackedTradeConfirmation UpdatingOrders bool Log []string Client *esi.EsiClient LastWalletPollAt time.Time LastSeenWalletTxID int WalletScopeErr bool } func NewManager(client *esi.EsiClient, graph *Graph) *Manager { return &Manager{ Ship: *graph.Ship, Graph: graph, Market: _newMarketIgnore(nil, client), Client: client, Log: []string{"Manager initialized."}, Route: []RouteStep{}, RouteInfo: &RouteInfo{}, } } func (m *Manager) CreateRoute(ctx context.Context) error { m.mu.Lock() defer m.mu.Unlock() m.Route = []RouteStep{} m.RouteInfo = &RouteInfo{} m.ActiveRouteTrades = []Trade{} m.ActiveConfirmations = []*TrackedTradeConfirmation{} m.Log = append([]string{"Creating new route..."}, m.Log...) log.Printf("Starting primary pass...") m.Log = append([]string{"Starting primary pass..."}, m.Log...) shortlist := ShortlistConfig{ CandidateLimit: PrimaryCandidateLimit, TypeLimit: PrimaryTypeLimit, StationPairLimit: PrimaryStationPairLimit, RankMode: "and", } matches, err := Arbitrage(m.Market, int(m.Ship.Cargo), m.Ship.TaxRate, m.Ship.ProfitThreshold, m.Ship.MaxCapital, &shortlist) if err != nil { return err } if len(matches) == 0 { m.Log = append([]string{"Primary pass found no matches, starting fallback pass..."}, m.Log...) log.Printf("Starting fallback pass...") shortlist = ShortlistConfig{ CandidateLimit: FallbackCandidateLimit, TypeLimit: FallbackTypeLimit, StationPairLimit: FallbackStationPairLimit, RankMode: "and", } matches, err = Arbitrage(m.Market, int(m.Ship.Cargo), m.Ship.TaxRate, m.Ship.ProfitThreshold, m.Ship.MaxCapital, &shortlist) if err != nil { return err } } if len(matches) == 0 { m.Log = append([]string{"No profitable trades found."}, m.Log...) return nil } log.Printf("Pathfinder evaluating matches...") filteredMatches := Pathfinder(matches, m.Graph, m.Ship.Location) if len(filteredMatches) == 0 { m.Log = append([]string{"No paths found for trades."}, m.Log...) return nil } legMetrics := BuildTradeLegMetrics(m.Graph, m.Ship.Location, filteredMatches) log.Printf("Route Optimizer evaluating %d matches...", len(filteredMatches)) plan := RouteOptimizer(filteredMatches, m.Graph, m.Ship, legMetrics) if plan == nil || len(plan.Route) == 0 { m.Log = append([]string{"Optimal route generation failed."}, m.Log...) return nil } m.Route = plan.Route m.RouteInfo = plan.Info m.RoutePath = plan.Path m.RoutePathOffsets = plan.RawOffsets m.ActiveRouteTrades = plan.OptimizedTrades now := time.Now() for _, t := range m.ActiveRouteTrades { m.ActiveConfirmations = append(m.ActiveConfirmations, NewTrackedTradeConfirmation(t, now)) } m.Log = append([]string{fmt.Sprintf("Route created with %d steps.", len(m.Route))}, m.Log...) return nil } func (m *Manager) ReconcileTradeConfirmations(ctx context.Context) { now := time.Now() m.mu.Lock() if now.Sub(m.LastWalletPollAt) < WalletPollInterval { m.mu.Unlock() return } m.mu.Unlock() transactions, err := m.Client.GetWalletTransactions(nil) if err != nil { m.mu.Lock() if !m.WalletScopeErr { m.Log = append([]string{fmt.Sprintf("Wallet Error: %v", err)}, m.Log...) m.WalletScopeErr = true } m.mu.Unlock() return } m.mu.Lock() m.LastWalletPollAt = now orderAdjustments := make(map[int]int) for i := len(transactions) - 1; i >= 0; i-- { tx := transactions[i] if tx.TransactionID <= m.LastSeenWalletTxID { continue } m.LastSeenWalletTxID = tx.TransactionID m.applyWalletTransaction(tx, tx.Date, orderAdjustments) } if len(orderAdjustments) > 0 { err = m.Market.ApplyOrderVolumeAdjustments(orderAdjustments) if err != nil { log.Printf("Error applying volume adjustments: %v", err) } else { m.Log = append([]string{fmt.Sprintf("Applied wallet transactions. Updated %d orders.", len(orderAdjustments))}, m.Log...) } } m.pruneTradeConfirmations(now) m.mu.Unlock() } func (m *Manager) applyWalletTransaction(tx esi.WalletTransaction, txDate time.Time, adjustments map[int]int) { for _, conf := range m.PendingConfirmations { m.matchAndDeduct(conf, tx, txDate, adjustments) } for _, conf := range m.ActiveConfirmations { m.matchAndDeduct(conf, tx, txDate, adjustments) } } func (m *Manager) matchAndDeduct(conf *TrackedTradeConfirmation, tx esi.WalletTransaction, txDate time.Time, adjustments map[int]int) { if conf.IsComplete() { return } if conf.Trade.TypeID != int(tx.TypeID) { return } tolPriceCheck := func(expected, actual float64) bool { diff := expected - actual if diff < 0 { diff = -diff } allowed := expected * 0.002 if 0.01 > allowed { allowed = 0.01 } return diff <= allowed } if tx.IsBuy && conf.BuyRemaining > 0 && tx.LocationID == conf.Trade.FromStation && tolPriceCheck(conf.Trade.FromPrice, tx.UnitPrice) { deduct := int(tx.Quantity) if deduct > conf.BuyRemaining { deduct = conf.BuyRemaining } conf.BuyRemaining -= deduct adjustments[conf.Trade.FromOrderID] += deduct } if !tx.IsBuy && conf.SellRemaining > 0 && tx.LocationID == conf.Trade.ToStation && tolPriceCheck(conf.Trade.ToPrice, tx.UnitPrice) { deduct := int(tx.Quantity) if deduct > conf.SellRemaining { deduct = conf.SellRemaining } conf.SellRemaining -= deduct adjustments[conf.Trade.ToOrderID] += deduct } } func (m *Manager) pruneTradeConfirmations(now time.Time) { var active []*TrackedTradeConfirmation for _, c := range m.ActiveConfirmations { if c.IsComplete() { continue } if c.IsExpired(now) { m.Log = append([]string{fmt.Sprintf("WARN: Expired active trade: %d", c.Trade.TypeID)}, m.Log...) continue } active = append(active, c) } m.ActiveConfirmations = active var pending []*TrackedTradeConfirmation for _, c := range m.PendingConfirmations { if c.IsComplete() { continue } if c.IsExpired(now) { m.Log = append([]string{fmt.Sprintf("WARN: Expired pending trade: %d", c.Trade.TypeID)}, m.Log...) continue } pending = append(pending, c) } m.PendingConfirmations = pending } func (m *Manager) Update(ctx context.Context) error { m.ReconcileTradeConfirmations(ctx) loc, err := m.Client.GetLocation() if err != nil { return err } m.mu.Lock() if loc.StationID != 0 { m.Ship.Location = int(loc.StationID) } else { m.Ship.Location = int(loc.SolarSystemID) } m.mu.Unlock() m.mu.Lock() if !m.UpdatingOrders { m.UpdatingOrders = true m.mu.Unlock() go func() { defer func() { m.mu.Lock() m.UpdatingOrders = false m.mu.Unlock() }() m.Market.UpdateOrders() }() } else { m.mu.Unlock() } return nil } func _newMarketIgnore(items map[int]*Item, esiClient *esi.EsiClient) *Market { m, _ := NewMarket(items, esiClient) return m } func (m *Manager) SetWaypoints() error { m.mu.Lock() defer m.mu.Unlock() if len(m.RoutePath) == 0 { return fmt.Errorf("no route path available") } _, err := m.Client.SetWaypoints(m.RoutePath) return err }