diff options
Diffstat (limited to 'backend-go/internal/core/manager.go')
| -rw-r--r-- | backend-go/internal/core/manager.go | 343 |
1 files changed, 343 insertions, 0 deletions
diff --git a/backend-go/internal/core/manager.go b/backend-go/internal/core/manager.go new file mode 100644 index 0000000..a2c7bd5 --- /dev/null +++ b/backend-go/internal/core/manager.go @@ -0,0 +1,343 @@ +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 +} |
