summaryrefslogtreecommitdiff
path: root/backend-go/internal/core/manager.go
diff options
context:
space:
mode:
authorkboz <kutayboz@gmail.com>2026-09-27 22:36:34 +0200
committerkboz <kutayboz@gmail.com>2026-09-27 22:36:34 +0200
commit1b59961ce2385252e3346ea6107b15a2c1feafe6 (patch)
treefbe9e0cb968c47cff353e937e4dc835a64058c60 /backend-go/internal/core/manager.go
parent29c4b57db9a34e0f815d93cac6a2cae543778a93 (diff)
downloadhaul-1b59961ce2385252e3346ea6107b15a2c1feafe6.tar.gz
feat(backend): port route-planning backend to Go
Add a Go implementation mirroring the Python pipeline (loader, graph, pathfinder, arbitrage, market, route, manager) under internal/core, with ESI client/API integration and setup endpoints. Reuses backend/src as the working directory so existing data/cache paths keep working.
Diffstat (limited to 'backend-go/internal/core/manager.go')
-rw-r--r--backend-go/internal/core/manager.go343
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
+}