diff options
| author | kboz <kutayboz@gmail.com> | 2026-09-27 22:36:34 +0200 |
|---|---|---|
| committer | kboz <kutayboz@gmail.com> | 2026-09-27 22:36:34 +0200 |
| commit | 1b59961ce2385252e3346ea6107b15a2c1feafe6 (patch) | |
| tree | fbe9e0cb968c47cff353e937e4dc835a64058c60 /backend-go/internal/core | |
| parent | 29c4b57db9a34e0f815d93cac6a2cae543778a93 (diff) | |
| download | haul-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')
| -rw-r--r-- | backend-go/internal/core/arbitrage.go | 259 | ||||
| -rw-r--r-- | backend-go/internal/core/graph.go | 191 | ||||
| -rw-r--r-- | backend-go/internal/core/graph_route.go | 82 | ||||
| -rw-r--r-- | backend-go/internal/core/loader.go | 286 | ||||
| -rw-r--r-- | backend-go/internal/core/manager.go | 343 | ||||
| -rw-r--r-- | backend-go/internal/core/market.go | 267 | ||||
| -rw-r--r-- | backend-go/internal/core/pathfinder.go | 112 | ||||
| -rw-r--r-- | backend-go/internal/core/pathfinder_aux.go | 85 | ||||
| -rw-r--r-- | backend-go/internal/core/route.go | 434 |
9 files changed, 2059 insertions, 0 deletions
diff --git a/backend-go/internal/core/arbitrage.go b/backend-go/internal/core/arbitrage.go new file mode 100644 index 0000000..9cb26bb --- /dev/null +++ b/backend-go/internal/core/arbitrage.go @@ -0,0 +1,259 @@ +package core + +import ( + "fmt" + "log" +) + +const ( + ArbitrageCandidateLimit = 30000 + ArbitrageTypeLimit = 250 + ArbitrageStationPairLimit = 40 +) + +type ShortlistConfig struct { + CandidateLimit int + TypeLimit int + StationPairLimit int + RankMode string +} + +func DefaultShortlistConfig() *ShortlistConfig { + return &ShortlistConfig{ + CandidateLimit: ArbitrageCandidateLimit, + TypeLimit: ArbitrageTypeLimit, + StationPairLimit: ArbitrageStationPairLimit, + RankMode: "and", + } +} + +// Arbitrage calculates profitable trade pairs between stations. +func Arbitrage( + market *Market, + cargoSpace int, + taxRate float64, + profitThreshold float64, + maxCapital float64, + shortlist *ShortlistConfig, +) ([]Trade, error) { + taxRate = taxRate / 100.0 + if shortlist == nil { + shortlist = DefaultShortlistConfig() + } + + if shortlist.RankMode != "and" && shortlist.RankMode != "or" { + return nil, fmt.Errorf("unsupported shortlist rank mode: %s", shortlist.RankMode) + } + + rankOperator := "AND" + if shortlist.RankMode == "or" { + rankOperator = "OR" + } + + query := fmt.Sprintf(` + WITH buy_orders AS ( + SELECT * FROM orders WHERE is_buy_order = 1 + ), + sell_orders AS ( + SELECT * FROM orders WHERE is_buy_order = 0 + ), + raw_trades AS ( + SELECT + s.location_id as from_station, + b.location_id as to_station, + s.order_id as from_order_id, + b.order_id as to_order_id, + s.item_name, + s.type_id, + s.item_cargo_volume, + s.price as from_price, + b.price as to_price, + MIN( + s.volume_remain, + b.volume_remain, + CAST(%d / NULLIF(s.item_cargo_volume, 0) AS INTEGER), + CAST(%f / NULLIF(s.price, 0) AS INTEGER), + CAST(%f / NULLIF(b.price, 0) AS INTEGER) + ) as quantity + FROM sell_orders s + JOIN buy_orders b ON s.type_id = b.type_id + WHERE + b.price * (1 - %f) >= s.price + AND s.item_cargo_volume > 0 + AND s.price > 0 AND b.price > 0 + ), + candidate_trades AS ( + SELECT + *, + quantity * (to_price * (1 - %f) - from_price) as gross_profit, + quantity * item_cargo_volume as cargo, + (quantity * (to_price * (1 - %f) - from_price)) / NULLIF(quantity * item_cargo_volume, 0) as profit_density, + (quantity * (to_price * (1 - %f) - from_price)) / NULLIF(quantity * from_price, 0) as roi + FROM raw_trades + WHERE quantity > 0 AND quantity * (to_price * (1 - %f) - from_price) >= %f + ), + ranked_trades AS ( + SELECT + *, + ROW_NUMBER() OVER ( + PARTITION BY type_id + ORDER BY gross_profit DESC, roi DESC, profit_density DESC + ) as type_rank, + ROW_NUMBER() OVER ( + PARTITION BY from_station, to_station + ORDER BY gross_profit DESC, roi DESC, profit_density DESC + ) as station_pair_rank + FROM candidate_trades + ) + SELECT + from_station, + to_station, + from_order_id, + to_order_id, + item_name, + type_id, + item_cargo_volume, + from_price, + to_price, + quantity, + cargo, + gross_profit + FROM ranked_trades + WHERE (type_rank <= %d + %s station_pair_rank <= %d) + ORDER BY gross_profit DESC, roi DESC, profit_density DESC + LIMIT %d + `, + cargoSpace, maxCapital, maxCapital, taxRate, + taxRate, taxRate, taxRate, taxRate, profitThreshold, + shortlist.TypeLimit, rankOperator, shortlist.StationPairLimit, shortlist.CandidateLimit, + ) + + rows, err := market.DB.Query(query) + if err != nil { + return nil, fmt.Errorf("arbitrage query error: %v", err) + } + defer rows.Close() + + var trades []Trade + for rows.Next() { + var t Trade + err := rows.Scan( + &t.FromStation, + &t.ToStation, + &t.FromOrderID, + &t.ToOrderID, + &t.ItemName, + &t.TypeID, + &t.ItemCargoVolume, + &t.FromPrice, + &t.ToPrice, + &t.Quantity, + &t.Cargo, + &t.GrossProfit, + ) + if err != nil { + return nil, fmt.Errorf("arbitrage scan error: %v", err) + } + + if t.Quantity > 0 { + trades = append(trades, t) + } + } + + log.Printf("Generated %d arbitrage candidates after shortlist (limit=%d, rank_mode=%s)", len(trades), shortlist.CandidateLimit, shortlist.RankMode) + return trades, nil +} + +// Snipe looks for immediate sell orders inside the current region that can be immediately flipped to buy orders. +func Snipe( + market *Market, + cargoSpace int, + currentRegion int, + taxRate float64, + maxCapital float64, + snipeProfitThreshold float64, +) (*Trade, error) { + taxRate = taxRate / 100.0 + query := fmt.Sprintf(` + WITH buy_orders AS ( + SELECT * FROM orders WHERE is_buy_order = 1 + ), + sell_orders AS ( + SELECT * FROM orders WHERE is_buy_order = 0 AND region_id = %d + ) + SELECT + s.location_id as from_station, + b.location_id as to_station, + s.order_id as from_order_id, + b.order_id as to_order_id, + s.item_name, + s.type_id, + s.item_cargo_volume, + s.price as from_price, + b.price as to_price, + MIN( + s.volume_remain, + b.volume_remain, + CAST(%d / NULLIF(s.item_cargo_volume, 0) AS INTEGER), + CAST(%f / NULLIF(s.price, 0) AS INTEGER), + CAST(%f / NULLIF(b.price, 0) AS INTEGER) + ) as quantity + FROM sell_orders s + JOIN buy_orders b ON s.type_id = b.type_id + WHERE + b.price * (1 - %f) >= s.price + AND s.item_cargo_volume > 0 + AND s.price > 0 AND b.price > 0 + `, currentRegion, cargoSpace, maxCapital, maxCapital, taxRate) + + rows, err := market.DB.Query(query) + if err != nil { + return nil, fmt.Errorf("snipe query error: %v", err) + } + defer rows.Close() + + var bestTrade *Trade + maxProfit := 0.0 + + for rows.Next() { + var t Trade + err := rows.Scan( + &t.FromStation, + &t.ToStation, + &t.FromOrderID, + &t.ToOrderID, + &t.ItemName, + &t.TypeID, + &t.ItemCargoVolume, + &t.FromPrice, + &t.ToPrice, + &t.Quantity, + ) + if err != nil { + return nil, fmt.Errorf("snipe scan error: %v", err) + } + + if t.Quantity <= 0 { + continue + } + + effectiveBuyPrice := t.ToPrice * (1.0 - taxRate) + grossProfit := float64(t.Quantity) * (effectiveBuyPrice - t.FromPrice) + + if grossProfit > maxProfit { + t.Cargo = float64(t.Quantity) * t.ItemCargoVolume + t.GrossProfit = grossProfit + + tradeCopy := t // copy + bestTrade = &tradeCopy + maxProfit = grossProfit + } + } + + if maxProfit > snipeProfitThreshold && bestTrade != nil { + return bestTrade, nil + } + + return nil, nil +} diff --git a/backend-go/internal/core/graph.go b/backend-go/internal/core/graph.go new file mode 100644 index 0000000..f0c77a9 --- /dev/null +++ b/backend-go/internal/core/graph.go @@ -0,0 +1,191 @@ +package core + +import ( + "math" +) + +const ( + AuInM = 149597870700.0 +) + + +type Edge struct { + To int + Time float64 + Risk float64 + Weight float64 +} + +type Graph struct { + Stations map[int]*Station + SolarSystems map[int]*SolarSystem + Ship *Ship + Adj map[int][]Edge +} + +func NewGraph(stations map[int]*Station, systems map[int]*SolarSystem, ship *Ship) *Graph { + g := &Graph{ + Stations: stations, + SolarSystems: systems, + Ship: ship, + Adj: make(map[int][]Edge), + } + return g +} + +func (g *Graph) AddEdge(u, v int) { + // Add only structural adjacency initially + g.Adj[u] = append(g.Adj[u], Edge{To: v}) + g.Adj[v] = append(g.Adj[v], Edge{To: u}) +} + +// Build compiles all edges based on stations and stargates +func (g *Graph) Build(connections [][2]int) { + // 1. Intra-system connections + stationsBySystem := make(map[int][]int) + for id, station := range g.Stations { + sysID := station.SolarSystemID + stationsBySystem[sysID] = append(stationsBySystem[sysID], id) + } + + for sysID, sysStations := range stationsBySystem { + for _, stationID := range sysStations { + g.AddEdge(sysID, stationID) + } + // Direct station to station within same system + for i := 0; i < len(sysStations); i++ { + for j := i + 1; j < len(sysStations); j++ { + g.AddEdge(sysStations[i], sysStations[j]) + } + } + } + + // 2. Inter-system connections (Stargates) + for _, conn := range connections { + from, to := conn[0], conn[1] + if _, hasFrom := g.Stations[from]; hasFrom { + if _, hasTo := g.Stations[to]; hasTo { + g.AddEdge(from, to) + } + } + } + + // 3. Compute time and risk + g.calculateWeights() +} + +func (g *Graph) getPosition(nodeID int) Position { + if st, ok := g.Stations[nodeID]; ok { + return st.Position + } + return Position{X: 0, Y: 0, Z: 0} +} + +func (g *Graph) calculateWeights() { + playerCost := g.Ship.PlayerCost + riskCost := g.Ship.RiskCost + + // We used a simple slice append above, which might have duplicates if A->B and B->A were added twice. + // But AddEdge adds undirected, so we need to deduplicate or just overwrite weights carefully. + // For simplicity, we just rebuild Adj with weights. + newAdj := make(map[int][]Edge) + + for u, neighbors := range g.Adj { + visited := make(map[int]bool) + var uniqueEdges []Edge + + for _, edge := range neighbors { + v := edge.To + if visited[v] { + continue + } + visited[v] = true + + time := g.calculateTime(u, v) + risk := g.calculateRisk(v) + weight := time*playerCost + riskCost*risk + + uniqueEdges = append(uniqueEdges, Edge{ + To: v, + Time: time, + Risk: risk, + Weight: weight, + }) + } + newAdj[u] = uniqueEdges + } + g.Adj = newAdj +} + +func (g *Graph) calculateTime(fromNode, toNode int) float64 { + stFrom, isStFrom := g.Stations[fromNode] + stTo, isStTo := g.Stations[toNode] + + if isStFrom && isStTo && stFrom.SolarSystemID != stTo.SolarSystemID { + return 9.0 // Fixed stargate time + } + + posFrom := g.getPosition(fromNode) + posTo := g.getPosition(toNode) + + dx := posFrom.X - posTo.X + dy := posFrom.Y - posTo.Y + dz := posFrom.Z - posTo.Z + warpDist := math.Sqrt(dx*dx + dy*dy + dz*dz) + + return g.calculateTimeInWarp(warpDist) + 2.0 +} + +func (g *Graph) calculateTimeInWarp(warpDist float64) float64 { + kAccel := g.Ship.MaxWarpSpeed + kDecel := math.Min(g.Ship.MaxWarpSpeed/3.0, 2.0) + warpDropout := math.Min(g.Ship.MaxSubwarpSpeed/2.0, 100.0) + maxMsWarpSpeed := g.Ship.MaxWarpSpeed * AuInM + + accelDist := AuInM + decelDist := maxMsWarpSpeed / kDecel + minDist := accelDist + decelDist + + cruiseTime := 0.0 + if minDist > warpDist { + maxMsWarpSpeed = warpDist * kAccel * kDecel / (kAccel + kDecel) + } else { + cruiseTime = (warpDist - minDist) / maxMsWarpSpeed + } + + accelTime := math.Log(maxMsWarpSpeed/kAccel) / kAccel + decelTime := math.Log(maxMsWarpSpeed/warpDropout) / kDecel + + return cruiseTime + accelTime + decelTime +} + +func (g *Graph) calculateRisk(toNode int) float64 { + security := 1.0 + sysName := "" + + if st, ok := g.Stations[toNode]; ok { + security = st.Security + sysName = st.SolarSystemName + } else if sys, ok := g.SolarSystems[toNode]; ok { + // Wait, some hubs don't have security field inside SolarSystem in the python code. + // Python fallbacks to 1.0 if not found, since SolarSystem doesn't actually store security directly in Loader. + // If we need it, we can fallback to 1.0 + security = 1.0 + sysName = sys.ItemName + } + + risk := 0.00004 + (1.0-security)*0.0004 + if security < 0.5 { + risk += 0.005 + } + if security < 0.1 { + risk += 0.5 + } + + if g.Ship.GankersAreas != nil { + if penalty, exists := g.Ship.GankersAreas[sysName]; exists { + risk += penalty + } + } + return risk +} diff --git a/backend-go/internal/core/graph_route.go b/backend-go/internal/core/graph_route.go new file mode 100644 index 0000000..c779c40 --- /dev/null +++ b/backend-go/internal/core/graph_route.go @@ -0,0 +1,82 @@ +package core + +import "fmt" + +func (g *Graph) ShortestPath(source, target int) []int { + if source == target { + return []int{source} + } + res := g.SingleSourceDijkstra(source) + if pr, ok := res[target]; ok { + return pr.Path + } + return nil +} + +func (g *Graph) FormattedRouteWithOffsets(path []int) ([]RouteStep, []int) { + if len(path) == 0 { + return nil, nil + } + + var combined []int + var rawOffsets []int + + for i := 0; i < len(path)-1; i++ { + currID := path[i] + nextID := path[i+1] + + currStation, currOk := g.Stations[currID] + nextStation, nextOk := g.Stations[nextID] + + if currOk && currStation.IsStation { + combined = append(combined, currID) + rawOffsets = append(rawOffsets, i) + } else if currOk && nextOk && !currStation.IsStation && !nextStation.IsStation && currStation.SolarSystemID == nextStation.SolarSystemID { + combined = append(combined, currStation.SolarSystemID) + rawOffsets = append(rawOffsets, i) + } + } + + lastID := path[len(path)-1] + lastStation, lastOk := g.Stations[lastID] + if lastOk && lastStation.IsStation { + combined = append(combined, lastID) + rawOffsets = append(rawOffsets, len(path)-1) + } + + var steps []RouteStep + var filteredOffsets []int + + for i, locID := range combined { + if st, ok := g.Stations[locID]; ok { + steps = append(steps, RouteStep{ + ID: i, + LocationID: locID, + Location: fmt.Sprintf("%s - %s - %s", st.RegionName, st.SolarSystemName, st.ItemName), + LocationType: "station", + SystemID: st.SolarSystemID, + SystemName: st.SolarSystemName, + StationName: st.ItemName, + Security: st.Security, + RegionID: st.RegionID, + Actions: make([]Action, 0), // initialize empty for JSON formatting + }) + filteredOffsets = append(filteredOffsets, rawOffsets[i]) + } else if sys, ok := g.SolarSystems[locID]; ok { + steps = append(steps, RouteStep{ + ID: i, + LocationID: locID, + Location: fmt.Sprintf("%s - %s", sys.RegionName, sys.ItemName), + LocationType: "system", + SystemID: locID, + SystemName: sys.ItemName, + StationName: fmt.Sprintf("%s - %s", sys.RegionName, sys.ItemName), // Using Region/System name like Python code formatting does + RegionID: sys.RegionID, + Actions: make([]Action, 0), + }) + filteredOffsets = append(filteredOffsets, rawOffsets[i]) + } + } + + return steps, filteredOffsets +}
\ No newline at end of file diff --git a/backend-go/internal/core/loader.go b/backend-go/internal/core/loader.go new file mode 100644 index 0000000..8e451ef --- /dev/null +++ b/backend-go/internal/core/loader.go @@ -0,0 +1,286 @@ +package core + +import ( + "encoding/csv" + "os" + "path/filepath" + "strconv" +) + +const ( + TypeIDSolarSystem = 5 + GroupIDStation = 15 + GroupIDStargate = 10 +) + +var AllowedRegionIDs = map[int]bool{ + 10000002: true, // The Forge + 10000016: true, // Lonetrek + 10000030: true, // Heimatar + 10000033: true, // The Citadel + 10000032: true, // Sinq Laison + 10000042: true, // Metropolis + 10000064: true, // Essence + 10000037: true, // Everyshore + 10000043: true, // Domain + 10000036: true, // Devoid + 10000052: true, // Kador + 10000067: true, // Genesis + 10000068: true, // Verge Vendor + 10000001: true, // Derelik + 10000020: true, // Tash-Murkon +} + +type Position struct { + X float64 `json:"x"` + Y float64 `json:"y"` + Z float64 `json:"z"` +} + +type Station struct { + IsStation bool `json:"is_station"` + ItemName string `json:"item_name"` + Security float64 `json:"security"` + Position Position `json:"position"` + SolarSystemID int `json:"solar_system_id"` + RegionID int `json:"region_id"` + RegionName string `json:"region_name"` + SolarSystemName string `json:"solar_system_name"` + SolarSystemPosition Position `json:"solar_system_position"` + Jumps []int `json:"jumps"` +} + +type SolarSystem struct { + ItemName string `json:"item_name"` + Position Position `json:"position"` + RegionID int `json:"region_id"` + RegionName string `json:"region_name"` +} + +type Item struct { + TypeID int `json:"type_id"` + GroupID int `json:"group_id"` + TypeName string `json:"type_name"` + Description string `json:"description"` + Mass *float64 `json:"mass"` + Volume *float64 `json:"volume"` + Capacity *float64 `json:"capacity"` +} + +// DataLoader handles loading data from CSV files +type DataLoader struct { + BaseDir string +} + +func NewDataLoader(baseDir string) *DataLoader { + return &DataLoader{BaseDir: baseDir} +} + +func (dl *DataLoader) ReadRegions() (map[int]string, error) { + file, err := os.Open(filepath.Join(dl.BaseDir, "mapRegions.csv")) + if err != nil { + return nil, err + } + defer file.Close() + + reader := csv.NewReader(file) + records, err := reader.ReadAll() + if err != nil { + return nil, err + } + + regions := make(map[int]string) + if len(records) > 0 { + records = records[1:] // Skip header + } + + for _, row := range records { + regionID, err := strconv.Atoi(row[0]) + if err != nil { + continue + } + regions[regionID] = row[1] + } + return regions, nil +} + +func (dl *DataLoader) LoadMapData() (map[int]*Station, map[int]*SolarSystem, error) { + regions, err := dl.ReadRegions() + if err != nil { + return nil, nil, err + } + + file, err := os.Open(filepath.Join(dl.BaseDir, "mapDenormalize.csv")) + if err != nil { + return nil, nil, err + } + defer file.Close() + + reader := csv.NewReader(file) + // Some fields might be mismatched or have commas in strings, standard csv covers it + records, err := reader.ReadAll() + if err != nil { + return nil, nil, err + } + + stations := make(map[int]*Station) + solarSystems := make(map[int]*SolarSystem) + + if len(records) > 0 { + records = records[1:] + } + + for _, row := range records { + if len(row) < 15 { + continue + } + + regionIDStr := row[5] + if regionIDStr == "None" || regionIDStr == "" { + continue + } + regionID, err := strconv.Atoi(regionIDStr) + if err != nil || !AllowedRegionIDs[regionID] { + continue + } + + itemID, _ := strconv.Atoi(row[0]) + typeID, _ := strconv.Atoi(row[1]) + groupID, _ := strconv.Atoi(row[2]) + + solarSystemID := 0 + if row[3] != "None" && row[3] != "" { + solarSystemID, _ = strconv.Atoi(row[3]) + } + + x, _ := strconv.ParseFloat(row[7], 64) + y, _ := strconv.ParseFloat(row[8], 64) + z, _ := strconv.ParseFloat(row[9], 64) + + itemName := row[11] + security, _ := strconv.ParseFloat(row[12], 64) + + regionName := regions[regionID] + if regionName == "" { + regionName = "Unknown" + } + + if typeID == TypeIDSolarSystem { + solarSystems[itemID] = &SolarSystem{ + ItemName: itemName, + Position: Position{X: x, Y: y, Z: z}, + RegionID: regionID, + RegionName: regionName, + } + } else if groupID == GroupIDStation || groupID == GroupIDStargate { + stations[itemID] = &Station{ + IsStation: groupID == GroupIDStation, + ItemName: itemName, + Security: security, + Position: Position{X: x, Y: y, Z: z}, + SolarSystemID: solarSystemID, + RegionID: regionID, + Jumps: []int{}, + } + } + } + + // Update station data + for _, station := range stations { + if ss, ok := solarSystems[station.SolarSystemID]; ok { + station.SolarSystemName = ss.ItemName + station.SolarSystemPosition = ss.Position + station.RegionID = ss.RegionID + if name, exists := regions[ss.RegionID]; exists { + station.RegionName = name + } else { + station.RegionName = "Unknown" + } + } + } + + return stations, solarSystems, nil +} + +func parseOptionalFloat(val string) *float64 { + if val == "None" || val == "" { + return nil + } + f, err := strconv.ParseFloat(val, 64) + if err != nil { + return nil + } + return &f +} + +func (dl *DataLoader) LoadItems() (map[int]*Item, error) { + file, err := os.Open(filepath.Join(dl.BaseDir, "invTypes.csv")) + if err != nil { + return nil, err + } + defer file.Close() + + reader := csv.NewReader(file) + records, err := reader.ReadAll() + if err != nil { + return nil, err + } + + items := make(map[int]*Item) + if len(records) > 0 { + records = records[1:] + } + + for _, row := range records { + if len(row) < 7 { + continue + } + typeID, err := strconv.Atoi(row[0]) + if err != nil { + continue + } + groupID, _ := strconv.Atoi(row[1]) + + items[typeID] = &Item{ + TypeID: typeID, + GroupID: groupID, + TypeName: row[2], + Description: row[3], + Mass: parseOptionalFloat(row[4]), + Volume: parseOptionalFloat(row[5]), + Capacity: parseOptionalFloat(row[6]), + } + } + return items, nil +} + +func (dl *DataLoader) LoadStarGateConnections() ([][2]int, error) { + file, err := os.Open(filepath.Join(dl.BaseDir, "mapJumps.csv")) + if err != nil { + return nil, err + } + defer file.Close() + + reader := csv.NewReader(file) + records, err := reader.ReadAll() + if err != nil { + return nil, err + } + + var connections [][2]int + if len(records) > 0 { + records = records[1:] + } + + for _, row := range records { + if len(row) < 2 { + continue + } + from, err1 := strconv.Atoi(row[0]) + to, err2 := strconv.Atoi(row[1]) + if err1 == nil && err2 == nil { + connections = append(connections, [2]int{from, to}) + } + } + return connections, nil +} 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 +} diff --git a/backend-go/internal/core/market.go b/backend-go/internal/core/market.go new file mode 100644 index 0000000..1cc12d5 --- /dev/null +++ b/backend-go/internal/core/market.go @@ -0,0 +1,267 @@ +package core + +import ( + "database/sql" + "fmt" + "log" + "sync" + "time" + + _ "github.com/mattn/go-sqlite3" + + "eve_haul_go/internal/esi" +) + +var RegionIDs = []int{ + 10000002, // The Forge + 10000016, // Lonetrek + 10000030, // Heimatar + 10000033, // The Citadel + 10000032, // Sinq Laison + 10000042, // Metropolis + 10000064, // Essence + 10000037, // Everyshore + 10000043, // Domain + 10000036, // Devoid + 10000052, // Kador + 10000067, // Genesis + 10000068, // Verge Vendor + 10000001, // Derelik + 10000020, // Tash-Murkon +} + +type Order struct { + OrderID int `json:"order_id"` + RegionID int `json:"region_id"` + LocationID int `json:"location_id"` + TypeID int `json:"type_id"` + IsBuyOrder bool `json:"is_buy_order"` + Price float64 `json:"price"` + Issued time.Time `json:"issued"` + VolumeRemain int `json:"volume_remain"` + ItemName string `json:"item_name"` + ItemCargoVolume float64 `json:"item_cargo_volume"` +} + +type Trade struct { + FromStation int `json:"from_station"` + ToStation int `json:"to_station"` + FromOrderID int `json:"from_order_id"` + ToOrderID int `json:"to_order_id"` + ItemName string `json:"item_name"` + TypeID int `json:"type_id"` + ItemCargoVolume float64 `json:"item_cargo_volume"` + FromPrice float64 `json:"from_price"` + ToPrice float64 `json:"to_price"` + Quantity int `json:"quantity"` + Cargo float64 `json:"cargo"` + GrossProfit float64 `json:"gross_profit"` + NetProfit *float64 `json:"net_profit,omitempty"` +} + +type Market struct { + DB *sql.DB + ExpiresTimes map[int]time.Time + Items map[int]*Item + EsiClient *esi.EsiClient + Mutex sync.RWMutex +} + +func NewMarket(items map[int]*Item, esiClient *esi.EsiClient) (*Market, error) { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + return nil, err + } + + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS orders ( + order_id INTEGER PRIMARY KEY, + region_id INTEGER, + location_id INTEGER, + type_id INTEGER, + is_buy_order BOOLEAN, + price REAL, + issued TEXT, + volume_remain INTEGER, + item_name TEXT, + item_cargo_volume REAL + ) + `) + if err != nil { + return nil, err + } + + _, err = db.Exec(`CREATE INDEX IF NOT EXISTS idx_arbitrage ON orders(type_id, is_buy_order, region_id)`) + if err != nil { + return nil, err + } + + return &Market{ + DB: db, + ExpiresTimes: make(map[int]time.Time), + Items: items, + EsiClient: esiClient, + }, nil +} + +func (m *Market) UpdateOrders() error { + m.Mutex.Lock() + defer m.Mutex.Unlock() + + var wg sync.WaitGroup + errChan := make(chan error, len(RegionIDs)) + ordersChan := make(chan []Order, len(RegionIDs)) + + for _, regionID := range RegionIDs { + expiryTime, exists := m.ExpiresTimes[regionID] + if !exists || time.Now().UTC().After(expiryTime) { + wg.Add(1) + go func(rID int) { + defer wg.Done() + orders, err := m.downloadOrders(rID) + if err != nil { + errChan <- err + return + } + ordersChan <- orders + }(regionID) + } + } + + wg.Wait() + close(errChan) + close(ordersChan) + + var allErrors []error + for err := range errChan { + allErrors = append(allErrors, err) + } + + if len(allErrors) > 0 { + return fmt.Errorf("errors downloading orders: %v", allErrors) + } + + for regionOrders := range ordersChan { + if len(regionOrders) == 0 { + continue + } + rID := regionOrders[0].RegionID + + tx, err := m.DB.Begin() + if err != nil { + return err + } + + _, err = tx.Exec("DELETE FROM orders WHERE region_id = ?", rID) + if err != nil { + tx.Rollback() + return err + } + + stmt, err := tx.Prepare(`INSERT OR REPLACE INTO orders + (order_id, region_id, location_id, type_id, is_buy_order, price, issued, volume_remain, item_name, item_cargo_volume) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + if err != nil { + tx.Rollback() + return err + } + + for _, o := range regionOrders { + _, err = stmt.Exec(o.OrderID, o.RegionID, o.LocationID, o.TypeID, o.IsBuyOrder, o.Price, o.Issued.Format(time.RFC3339), o.VolumeRemain, o.ItemName, o.ItemCargoVolume) + if err != nil { + stmt.Close() + tx.Rollback() + return err + } + } + stmt.Close() + tx.Commit() + } + + return nil +} + +func (m *Market) downloadOrders(regionID int) ([]Order, error) { + page := 1 + var rawOrders []map[string]interface{} + var lastExpiry string + + for { + pageOrders, expiry, totalPages, err := m.EsiClient.GetRegionOrders(regionID, page) + if err != nil { + log.Printf("Failed to get region orders %d page %d: %v", regionID, page, err) + break + } + if len(pageOrders) == 0 { + break + } + rawOrders = append(rawOrders, pageOrders...) + lastExpiry = expiry + + if page >= totalPages { + break + } + page++ + } + + if lastExpiry != "" { + if parsed, err := time.Parse(time.RFC1123, lastExpiry); err == nil { + m.ExpiresTimes[regionID] = parsed + } else { + m.ExpiresTimes[regionID] = time.Now().UTC() + } + } else { + m.ExpiresTimes[regionID] = time.Now().UTC() + } + + var formatted []Order + for _, o := range rawOrders { + typeID := int(o["type_id"].(float64)) + item, exists := m.Items[typeID] + if !exists { + continue + } + + cargoVol := item.Volume + if cargoVol == nil || *cargoVol <= 0 { + continue + } + + issuedStr, _ := o["issued"].(string) + issuedTime, _ := time.Parse("2006-01-02T15:04:05Z", issuedStr) + + isBuyOrder, _ := o["is_buy_order"].(bool) + orderIDFloat, _ := o["order_id"].(float64) + locIDFloat, _ := o["location_id"].(float64) + price, _ := o["price"].(float64) + volRemainFloat, _ := o["volume_remain"].(float64) + + formatted = append(formatted, Order{ + OrderID: int(orderIDFloat), + RegionID: regionID, + LocationID: int(locIDFloat), + TypeID: typeID, + IsBuyOrder: isBuyOrder, + Price: price, + Issued: issuedTime, + VolumeRemain: int(volRemainFloat), + ItemName: item.TypeName, + ItemCargoVolume: *cargoVol, + }) + } + return formatted, nil +} + +func (m *Market) ApplyOrderVolumeAdjustments(deductions map[int]int) error { +m.Mutex.Lock() +defer m.Mutex.Unlock() + +for orderID, volumeDeducted := range deductions { +log.Printf("Deducting %d volume from order %d", volumeDeducted, orderID) +_, err := m.DB.Exec("UPDATE orders SET volume_remain = volume_remain - ? WHERE order_id = ?", volumeDeducted, orderID) +if err != nil { +return err +} +} +return nil +} diff --git a/backend-go/internal/core/pathfinder.go b/backend-go/internal/core/pathfinder.go new file mode 100644 index 0000000..62849b0 --- /dev/null +++ b/backend-go/internal/core/pathfinder.go @@ -0,0 +1,112 @@ +package core + +import ( + "container/heap" +) + +type pqItem struct { + node int + weight float64 + index int +} + +type priorityQueue []*pqItem + +func (pq priorityQueue) Len() int { return len(pq) } +func (pq priorityQueue) Less(i, j int) bool { + return pq[i].weight < pq[j].weight +} +func (pq priorityQueue) Swap(i, j int) { + pq[i], pq[j] = pq[j], pq[i] + pq[i].index = i + pq[j].index = j +} +func (pq *priorityQueue) Push(x any) { + n := len(*pq) + item := x.(*pqItem) + item.index = n + *pq = append(*pq, item) +} +func (pq *priorityQueue) Pop() any { + old := *pq + n := len(old) + item := old[n-1] + old[n-1] = nil + item.index = -1 + *pq = old[0 : n-1] + return item +} + +type PathResult struct { + TotalWeight float64 + TotalTime float64 + TotalRisk float64 + Path []int +} + +// SingleSourceDijkstra computes shortest paths from source to all reachable nodes +func (g *Graph) SingleSourceDijkstra(source int) map[int]*PathResult { + dist := make(map[int]float64) + timeDist := make(map[int]float64) + riskDist := make(map[int]float64) + prev := make(map[int]int) + + pq := make(priorityQueue, 0) + heap.Init(&pq) + + dist[source] = 0.0 + heap.Push(&pq, &pqItem{node: source, weight: 0.0}) + + for pq.Len() > 0 { + item := heap.Pop(&pq).(*pqItem) + u := item.node + w := item.weight + + // Outdated element + if d, ok := dist[u]; ok && w > d { + continue + } + + for _, edge := range g.Adj[u] { + v := edge.To + alt := w + edge.Weight + if d, ok := dist[v]; !ok || alt < d { + dist[v] = alt + timeDist[v] = timeDist[u] + edge.Time + riskDist[v] = riskDist[u] + edge.Risk + prev[v] = u + heap.Push(&pq, &pqItem{node: v, weight: alt}) + } + } + } + + results := make(map[int]*PathResult) + for node, w := range dist { + // Reconstruct path + var path []int + curr := node + for { + path = append(path, curr) + p, hasPrev := prev[curr] + if !hasPrev { + break + } + curr = p + } + + // Reverse path + for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 { + path[i], path[j] = path[j], path[i] + } + + results[node] = &PathResult{ + TotalWeight: w, + TotalTime: timeDist[node], + TotalRisk: riskDist[node], + Path: path, + } + } + + return results +} + diff --git a/backend-go/internal/core/pathfinder_aux.go b/backend-go/internal/core/pathfinder_aux.go new file mode 100644 index 0000000..171ffe8 --- /dev/null +++ b/backend-go/internal/core/pathfinder_aux.go @@ -0,0 +1,85 @@ +package core + +type LegMetrics struct { + Weight float64 + Time float64 + Risk float64 +} + +// BuildTradeLegMetrics computes travel metrics for all legs needed by trades +func BuildTradeLegMetrics(graph *Graph, shipLocation int, trades []Trade) map[[2]int]*LegMetrics { + sourceTargets := make(map[int]map[int]bool) + + sourceTargets[shipLocation] = make(map[int]bool) + for _, t := range trades { + if _, ok := sourceTargets[shipLocation]; !ok { + sourceTargets[shipLocation] = make(map[int]bool) + } + sourceTargets[shipLocation][t.FromStation] = true + + if _, ok := sourceTargets[t.FromStation]; !ok { + sourceTargets[t.FromStation] = make(map[int]bool) + } + sourceTargets[t.FromStation][t.ToStation] = true + } + + metrics := make(map[[2]int]*LegMetrics) + + for source, targets := range sourceTargets { + dijkstraRes := graph.SingleSourceDijkstra(source) + + for target := range targets { + if source == target { + metrics[[2]int{source, target}] = &LegMetrics{Weight: 0, Time: 0, Risk: 0} + continue + } + + if res, ok := dijkstraRes[target]; ok { + metrics[[2]int{source, target}] = &LegMetrics{ + Weight: res.TotalWeight, + Time: res.TotalTime, + Risk: res.TotalRisk, + } + } else { + metrics[[2]int{source, target}] = nil + } + } + } + return metrics +} + +// Pathfinder filters trades that lack paths and calculates NetProfit +func Pathfinder(trades []Trade, graph *Graph, shipLocation int) []Trade { + var validTrades []Trade + for _, t := range trades { + _, hasFrom := graph.Adj[t.FromStation] + _, hasTo := graph.Adj[t.ToStation] + if hasFrom && hasTo { + validTrades = append(validTrades, t) + } + } + + if len(validTrades) == 0 { + return validTrades + } + + metrics := BuildTradeLegMetrics(graph, shipLocation, validTrades) + + var profitable []Trade + for i := range validTrades { + sourceToFrom, ok1 := metrics[[2]int{shipLocation, validTrades[i].FromStation}] + fromToTo, ok2 := metrics[[2]int{validTrades[i].FromStation, validTrades[i].ToStation}] + + if ok1 && ok2 && sourceToFrom != nil && fromToTo != nil { + totalCost := sourceToFrom.Weight + fromToTo.Weight + netProfit := validTrades[i].GrossProfit - totalCost + + if netProfit > 0 { + validTrades[i].NetProfit = &netProfit + profitable = append(profitable, validTrades[i]) + } + } + } + + return profitable +}
\ No newline at end of file diff --git a/backend-go/internal/core/route.go b/backend-go/internal/core/route.go new file mode 100644 index 0000000..1a8b862 --- /dev/null +++ b/backend-go/internal/core/route.go @@ -0,0 +1,434 @@ +package core + +import ( + "container/heap" + "log" + "sort" +) + +const ( + MaxCapital = 100000000000.0 // 100B + MaxTradesToConsider = 200 // Keep bound so we don't evaluate forever, python doesn't effectively bound it +) + +type Action struct { + ActionType string `json:"action_type"` // "buy" or "sell" + Item string `json:"item"` + TypeID int `json:"type_id"` + Quantity int `json:"quantity"` + Price float64 `json:"price"` +} + +type RouteStep struct { + ID int `json:"id"` + LocationID int `json:"location_id"` + Location string `json:"location"` + LocationType string `json:"location_type"` + SystemID int `json:"system_id,omitempty"` + SystemName string `json:"system_name,omitempty"` + StationName string `json:"station_name,omitempty"` + Security float64 `json:"security,omitempty"` + RegionID int `json:"region_id,omitempty"` + Actions []Action `json:"actions"` +} + +type RouteInfo struct { + ProfitRate float64 `json:"profit_rate"` + Risk float64 `json:"risk"` + Capital float64 `json:"capital"` + TransportTime float64 `json:"transport_time"` + GrossProfit float64 `json:"gross_profit"` + NetProfit float64 `json:"net_profit"` +} + +type RoutePlan struct { + Route []RouteStep + Info *RouteInfo + OptimizedTrades []Trade + Path []int + RawOffsets []int +} + +type Ship struct { +TaxRate float64 `json:"tax_rate"` +ProfitThreshold float64 `json:"profit_threshold"` +MaxCapital float64 `json:"max_capital"` +SnipeThreshold float64 `json:"snipe_profit_threshold"` +Location int `json:"location"` +Cargo float64 `json:"cargo"` +MaxWarpSpeed float64 `json:"max_warp_speed"` +MaxSubwarpSpeed float64 `json:"max_subwarp_speed"` +GankersAreas map[string]float64 `json:"gankers_areas"` +PlayerCost float64 `json:"player_cost"` +RiskCost float64 `json:"risk_cost"` +ShipCost float64 `json:"ship_cost"` +} + +type tradeDensityHeap struct { + trades []Trade +} +func (h tradeDensityHeap) Len() int { return len(h.trades) } +func (h tradeDensityHeap) Less(i, j int) bool { + // Reverse sorted by profit density + di := h.trades[i].GrossProfit / h.trades[i].Cargo + dj := h.trades[j].GrossProfit / h.trades[j].Cargo + return di > dj +} +func (h tradeDensityHeap) Swap(i, j int) { h.trades[i], h.trades[j] = h.trades[j], h.trades[i] } +func (h *tradeDensityHeap) Push(x any) { h.trades = append(h.trades, x.(Trade)) } +func (h *tradeDensityHeap) Pop() any { + old := h.trades + n := len(old) + x := old[n-1] + h.trades = old[0 : n-1] + return x +} + +func tradeCapitalEfficiency(t Trade) float64 { + invested := t.FromPrice * float64(t.Quantity) + if invested > 0 { + return t.GrossProfit / invested + } + return 0.0 +} + +func minInt(vars ...int) int { + m := vars[0] + for _, v := range vars[1:] { + if v < m { + m = v + } + } + return m +} + +func maxFloat(vars ...float64) float64 { + m := vars[0] + for _, v := range vars[1:] { + if v > m { + m = v + } + } + return m +} + +func buildRankedTriplets(trades []Trade, shipLoc int) [][3]int { + // Sort by capital efficiency descending + sorted := make([]Trade, len(trades)) + copy(sorted, trades) + sort.Slice(sorted, func(i, j int) bool { + return tradeCapitalEfficiency(sorted[i]) > tradeCapitalEfficiency(sorted[j]) + }) + + var ranked [][3]int + seen := make(map[[3]int]bool) + + for _, t := range sorted { + trip := [3]int{shipLoc, t.FromStation, t.ToStation} + if !seen[trip] { + seen[trip] = true + ranked = append(ranked, trip) + } + } + return ranked +} + +func buildStationPairIndex(trades []Trade) map[[2]int][]Trade { + index := make(map[[2]int][]Trade) + for _, t := range trades { + pair := [2]int{t.FromStation, t.ToStation} + index[pair] = append(index[pair], t) + } + for k := range index { + sort.Slice(index[k], func(i, j int) bool { + di := index[k][i].GrossProfit / index[k][i].Cargo + dj := index[k][j].GrossProfit / index[k][j].Cargo + return di > dj + }) + } + return index +} + +func selectTrades(routeStations [3]int, tradesByPair map[[2]int][]Trade, ship Ship) []Trade { + var candidatePairs [][2]int + for i, fromStation := range routeStations { + for _, toStation := range routeStations[i+1:] { + pair := [2]int{fromStation, toStation} + + // ensure unique pairs + exists := false + for _, cp := range candidatePairs { + if cp == pair { + exists = true + break + } + } + if !exists { + candidatePairs = append(candidatePairs, pair) + } + } + } + + // merge candidates into max heap based on profit density + h := &tradeDensityHeap{} + heap.Init(h) + for _, pair := range candidatePairs { + if ts, ok := tradesByPair[pair]; ok { + for _, t := range ts { + heap.Push(h, t) + } + } + } + + usedOrders := make(map[int]int) + var optimized []Trade + var cargo, capital float64 + + for h.Len() > 0 { + t := heap.Pop(h).(Trade) + + fromRemaining, ok := usedOrders[t.FromOrderID] + if !ok { + fromRemaining = t.Quantity + } + toRemaining, ok := usedOrders[t.ToOrderID] + if !ok { + toRemaining = t.Quantity + } + + maxQtyCargo := int((ship.Cargo - cargo) / (t.Cargo / float64(t.Quantity))) // rough item volume + itemVol := t.Cargo / float64(t.Quantity) + if itemVol == 0 { + itemVol = 0.01 // avoid divide by zero just in case + maxQtyCargo = t.Quantity + } else { + maxQtyCargo = int((ship.Cargo - cargo) / itemVol) + } + + maxQtyCapital := int((MaxCapital - capital) / t.FromPrice) + + maxQty := minInt(t.Quantity, fromRemaining, toRemaining, maxQtyCargo, maxQtyCapital) + + if maxQty <= 0 { + continue + } + + cargo += itemVol * float64(maxQty) + capital += t.FromPrice * float64(maxQty) + + usedOrders[t.FromOrderID] = fromRemaining - maxQty + usedOrders[t.ToOrderID] = toRemaining - maxQty + + updated := t + updated.Quantity = maxQty + updated.Cargo = itemVol * float64(maxQty) + if t.Quantity > 0 { + updated.GrossProfit = t.GrossProfit * (float64(maxQty) / float64(t.Quantity)) + } else { + updated.GrossProfit = 0 + } + + optimized = append(optimized, updated) + + if cargo >= ship.Cargo || capital >= MaxCapital { + break + } + } + return optimized +} + +func calculatePeakCapital(locationIDs []int, trades []Trade, shipCost float64) float64 { + if len(trades) == 0 { + return shipCost + } + + remainingLocs := make(map[int]bool) + for _, l := range locationIDs { + remainingLocs[l] = true + } + + committed := shipCost + for _, t := range trades { + if !remainingLocs[t.FromStation] { + committed += t.FromPrice * float64(t.Quantity) + } + } + + peak := committed + for _, loc := range locationIDs { + // sell + for _, t := range trades { + if t.ToStation == loc { + committed -= t.FromPrice * float64(t.Quantity) + } + } + committed = maxFloat(shipCost, committed) + + // buy + for _, t := range trades { + if t.FromStation == loc { + committed += t.FromPrice * float64(t.Quantity) + } + } + peak = maxFloat(peak, committed) + } + + return peak +} + +func buildRouteInfo(locationIDs []int, trades []Trade, ship Ship, time, risk float64) *RouteInfo { + if len(trades) == 0 { + return nil + } + + capital := calculatePeakCapital(locationIDs, trades, ship.ShipCost) + + var gross float64 + for _, t := range trades { + gross += t.GrossProfit + } + + net := gross - risk*capital + rate := 0.0 + if time > 0 { + rate = net / time + } + + return &RouteInfo{ + ProfitRate: rate, + Risk: risk, + Capital: capital, + TransportTime: time, + GrossProfit: gross, + NetProfit: net, + } +} + +func RouteOptimizer(trades []Trade, graph *Graph, ship Ship, legMetrics map[[2]int]*LegMetrics) *RoutePlan { + if len(trades) == 0 { + return &RoutePlan{Route: []RouteStep{}, OptimizedTrades: []Trade{}, Path: []int{}, RawOffsets: []int{}} + } + + rankedTriplets := buildRankedTriplets(trades, ship.Location) + tradesByPair := buildStationPairIndex(trades) + + if legMetrics == nil { + legMetrics = BuildTradeLegMetrics(graph, ship.Location, trades) + } + + var bestRate float64 + var bestInfo *RouteInfo + var bestStations []int + var bestTrades []Trade + + limit := MaxTradesToConsider + if len(rankedTriplets) < limit { + limit = len(rankedTriplets) + } + + for _, trip := range rankedTriplets[:limit] { + optimized := selectTrades(trip, tradesByPair, ship) + if len(optimized) == 0 { + continue + } + + sLeg, sOk := legMetrics[[2]int{trip[0], trip[1]}] + dLeg, dOk := legMetrics[[2]int{trip[1], trip[2]}] + if !sOk || !dOk || sLeg == nil || dLeg == nil { + continue + } + + risk := sLeg.Risk + dLeg.Risk + time := sLeg.Time + dLeg.Time + + locs := []int{trip[0], trip[1], trip[2]} + info := buildRouteInfo(locs, optimized, ship, time, risk) + if info == nil { + continue + } + + if info.ProfitRate > bestRate { + bestRate = info.ProfitRate + bestInfo = info + bestStations = locs + bestTrades = optimized + log.Printf("New best profit rate: %.2f/hr", bestRate*3600) + } + } + + if bestStations == nil || bestTrades == nil { + return &RoutePlan{Route: []RouteStep{}, OptimizedTrades: []Trade{}, Path: []int{}, RawOffsets: []int{}} + } + + // Reconstruct path + sRes := graph.ShortestPath(bestStations[0], bestStations[1]) + dRes := graph.ShortestPath(bestStations[1], bestStations[2]) + + var path []int + if len(sRes) > 0 { + path = append(path, sRes[:len(sRes)-1]...) + path = append(path, dRes...) + } else { + // fallback + path = append(path, dRes...) + } + + // Formatted route (this needs more detailed graph metadata merging, we'll build a simplified version) + formatted, offsets := graph.FormattedRouteWithOffsets(path) + + // Set Actions + actionsRoute := setActions(formatted, bestTrades) + + return &RoutePlan{ + Route: actionsRoute, + Info: bestInfo, + OptimizedTrades: bestTrades, + Path: path, + RawOffsets: offsets, + } +} + +func setActions(route []RouteStep, trades []Trade) []RouteStep { + bought := make(map[int]bool) + for _, t := range trades { + for i := range route { + if t.FromStation == route[i].LocationID { + route[i].Actions = append(route[i].Actions, Action{ + ActionType: "buy", + Item: t.ItemName, + TypeID: t.TypeID, + Quantity: t.Quantity, + Price: t.FromPrice, + }) + bought[t.TypeID] = true + } + + if t.ToStation == route[i].LocationID && bought[t.TypeID] { + route[i].Actions = append(route[i].Actions, Action{ + ActionType: "sell", + Item: t.ItemName, + TypeID: t.TypeID, + Quantity: t.Quantity, + Price: t.ToPrice, + }) + } + } + } + + // Sort actions + for i := range route { + sort.Slice(route[i].Actions, func(a, b int) bool { + al := route[i].Actions[a] + bl := route[i].Actions[b] + if al.ActionType != bl.ActionType { + return al.ActionType == "sell" // sell comes before buy (0 vs 1 in python logic) + } + if al.Item != bl.Item { + return al.Item < bl.Item + } + return al.Price < bl.Price + }) + } + + return route +} |
