summaryrefslogtreecommitdiff
path: root/backend-go/internal
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
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')
-rw-r--r--backend-go/internal/api/setup.go231
-rw-r--r--backend-go/internal/core/arbitrage.go259
-rw-r--r--backend-go/internal/core/graph.go191
-rw-r--r--backend-go/internal/core/graph_route.go82
-rw-r--r--backend-go/internal/core/loader.go286
-rw-r--r--backend-go/internal/core/manager.go343
-rw-r--r--backend-go/internal/core/market.go267
-rw-r--r--backend-go/internal/core/pathfinder.go112
-rw-r--r--backend-go/internal/core/pathfinder_aux.go85
-rw-r--r--backend-go/internal/core/route.go434
-rw-r--r--backend-go/internal/esi/api.go215
-rw-r--r--backend-go/internal/esi/client.go203
12 files changed, 2708 insertions, 0 deletions
diff --git a/backend-go/internal/api/setup.go b/backend-go/internal/api/setup.go
new file mode 100644
index 0000000..738deea
--- /dev/null
+++ b/backend-go/internal/api/setup.go
@@ -0,0 +1,231 @@
+package api
+
+import (
+"bytes"
+"compress/bzip2"
+"encoding/base64"
+"encoding/json"
+"fmt"
+"io"
+"net/http"
+"net/url"
+"os"
+"path/filepath"
+"strings"
+"time"
+)
+
+const (
+baseURL = "https://www.fuzzwork.co.uk/dump/latest"
+tokenURL = "https://login.eveonline.com/v2/oauth/token"
+authURL = "https://login.eveonline.com/v2/oauth/authorize"
+redirectURI = "https://localhost/callback/"
+dataDirPath = "../data"
+authFilePath = "./cache/auth.json"
+envFilePath = "../../.env"
+)
+
+var scopes = []string{
+"esi-assets.read_assets.v1",
+"esi-location.read_location.v1",
+"esi-location.read_online.v1",
+"esi-wallet.read_character_wallet.v1",
+"esi-ui.open_window.v1",
+"esi-ui.write_waypoint.v1",
+}
+
+var requiredFiles = map[string]string{
+"mapDenormalize.csv": fmt.Sprintf("%s/mapDenormalize.csv.bz2", baseURL),
+"mapJumps.csv": fmt.Sprintf("%s/mapJumps.csv.bz2", baseURL),
+"invTypes.csv": fmt.Sprintf("%s/invTypes.csv.bz2", baseURL),
+"mapRegions.csv": fmt.Sprintf("%s/mapRegions.csv.bz2", baseURL),
+}
+
+func StatusHandler(w http.ResponseWriter, r *http.Request) {
+ // Check if data is ready
+ dataReady := true
+ for fileName := range requiredFiles {
+ destPath := filepath.Join(dataDirPath, fileName)
+ if _, err := os.Stat(destPath); err != nil {
+ dataReady = false
+ break
+ }
+ }
+
+ // Check if auth is ready
+ authReady := false
+ if _, err := os.Stat(authFilePath); err == nil {
+ authReady = true
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]bool{
+ "data_ready": dataReady,
+ "auth_ready": authReady,
+ })
+}
+
+func DownloadDataHandler(w http.ResponseWriter, r *http.Request) {
+if err := os.MkdirAll(dataDirPath, os.ModePerm); err != nil {
+http.Error(w, fmt.Sprintf("Failed to create data dir: %v", err), http.StatusInternalServerError)
+return
+}
+
+for fileName, downloadURL := range requiredFiles {
+destPath := filepath.Join(dataDirPath, fileName)
+if _, err := os.Stat(destPath); err == nil {
+fmt.Printf("Skipping %s; already exists.\n", fileName)
+continue
+}
+
+fmt.Printf("Downloading %s from %s\n", fileName, downloadURL)
+resp, err := http.Get(downloadURL)
+if err != nil || resp.StatusCode != 200 {
+http.Error(w, fmt.Sprintf("Failed to download %s", fileName), http.StatusInternalServerError)
+if resp != nil {
+resp.Body.Close()
+}
+return
+}
+
+out, err := os.Create(destPath)
+if err != nil {
+http.Error(w, "Failed to create destination file", http.StatusInternalServerError)
+resp.Body.Close()
+return
+}
+
+bzReader := bzip2.NewReader(resp.Body)
+if _, err := io.Copy(out, bzReader); err != nil {
+http.Error(w, "Decompression failed", http.StatusInternalServerError)
+out.Close()
+resp.Body.Close()
+return
+}
+
+out.Close()
+resp.Body.Close()
+fmt.Printf("Wrote %s\n", destPath)
+}
+
+w.Header().Set("Content-Type", "application/json")
+w.WriteHeader(http.StatusOK)
+w.Write([]byte(`{"message": "EVE static data is ready."}`))
+}
+
+func GetAuthURLHandler(w http.ResponseWriter, r *http.Request) {
+clientID := os.Getenv("CLIENT_ID")
+if clientID == "" {
+http.Error(w, "CLIENT_ID not set in environment.", http.StatusInternalServerError)
+return
+}
+
+queryParams := url.Values{}
+queryParams.Add("response_type", "code")
+queryParams.Add("redirect_uri", redirectURI)
+queryParams.Add("client_id", clientID)
+queryParams.Add("state", "haul-local")
+queryParams.Add("scope", strings.Join(scopes, " "))
+
+fullURL := fmt.Sprintf("%s?%s", authURL, queryParams.Encode())
+w.Header().Set("Content-Type", "application/json")
+json.NewEncoder(w).Encode(map[string]string{"url": fullURL})
+}
+
+func CompleteAuthHandler(w http.ResponseWriter, r *http.Request) {
+var request struct {
+Code string `json:"code"`
+}
+if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.Code == "" {
+http.Error(w, "Valid 'code' is required.", http.StatusBadRequest)
+return
+}
+
+clientID := os.Getenv("CLIENT_ID")
+secretKey := os.Getenv("SECRET_KEY")
+
+if clientID == "" || secretKey == "" {
+http.Error(w, "Missing CLIENT_ID or SECRET_KEY", http.StatusInternalServerError)
+return
+}
+
+formValues := url.Values{}
+formValues.Set("grant_type", "authorization_code")
+formValues.Set("code", request.Code)
+
+req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formValues.Encode()))
+if err != nil {
+http.Error(w, "Failed to create request.", http.StatusInternalServerError)
+return
+}
+
+userPass := clientID + ":" + secretKey
+encodedAuth := base64.StdEncoding.EncodeToString([]byte(userPass))
+req.Header.Add("Authorization", fmt.Sprintf("Basic %s", encodedAuth))
+req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+req.Header.Add("Host", "login.eveonline.com")
+
+client := &http.Client{Timeout: 30 * time.Second}
+resp, err := client.Do(req)
+if err != nil || resp.StatusCode >= 400 {
+http.Error(w, "Failed to swap token", http.StatusBadGateway)
+return
+}
+defer resp.Body.Close()
+
+var authData map[string]interface{}
+if err := json.NewDecoder(resp.Body).Decode(&authData); err != nil {
+http.Error(w, "Failed to decode ESI response", http.StatusInternalServerError)
+return
+}
+
+expiresIn := authData["expires_in"].(float64)
+authData["expires_at"] = time.Now().UTC().Unix() + int64(expiresIn)
+
+os.MkdirAll(filepath.Dir(authFilePath), os.ModePerm)
+cacheData, _ := json.MarshalIndent(authData, "", " ")
+os.WriteFile(authFilePath, cacheData, 0644)
+
+tokenStr, _ := authData["access_token"].(string)
+charInfo := extractJWTSubject(tokenStr)
+appendEnvValue("character_id", charInfo)
+
+w.Header().Set("Content-Type", "application/json")
+w.WriteHeader(http.StatusOK)
+w.Write([]byte(`{"message": "Auth successful, cache built."}`))
+}
+
+func extractJWTSubject(token string) string {
+parts := strings.Split(token, ".")
+if len(parts) < 2 {
+return "unknown"
+}
+payload, err := base64.RawURLEncoding.DecodeString(parts[1])
+if err != nil {
+return "unknown"
+}
+
+var claims map[string]interface{}
+if err := json.Unmarshal(payload, &claims); err != nil {
+return "unknown"
+}
+
+if sub, ok := claims["sub"].(string); ok {
+subParts := strings.Split(sub, ":")
+return subParts[len(subParts)-1]
+}
+return "unknown"
+}
+
+func appendEnvValue(key, value string) {
+content, err := os.ReadFile(envFilePath)
+if err != nil {
+content = []byte{}
+}
+
+if !bytes.Contains(content, []byte(key+"=")) {
+f, _ := os.OpenFile(envFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
+defer f.Close()
+f.WriteString(fmt.Sprintf("\n%s=%s\n", key, value))
+}
+}
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
+}
diff --git a/backend-go/internal/esi/api.go b/backend-go/internal/esi/api.go
new file mode 100644
index 0000000..706ea32
--- /dev/null
+++ b/backend-go/internal/esi/api.go
@@ -0,0 +1,215 @@
+package esi
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "strconv"
+ "time"
+)
+
+type WaypointResult struct {
+ RequestedCount int `json:"requested_count"`
+ SuccessCount int `json:"success_count"`
+ AppliedWaypoints []int `json:"applied_waypoints"`
+ FailedWaypoints []int `json:"failed_waypoints"`
+ Errors []string `json:"errors"`
+ Success bool `json:"success"`
+}
+
+type WalletTransaction struct {
+ TransactionID int `json:"transaction_id"`
+ Date time.Time `json:"date"`
+ LocationID int `json:"location_id"`
+ TypeID int `json:"type_id"`
+ UnitPrice float64 `json:"unit_price"`
+ Quantity int `json:"quantity"`
+ ClientID int `json:"client_id"`
+ IsBuy bool `json:"is_buy"`
+ IsPersonal bool `json:"is_personal"`
+ JournalRefID int `json:"journal_ref_id"`
+}
+
+type LocationData struct {
+ SolarSystemID int `json:"solar_system_id"`
+ StationID int `json:"station_id"`
+}
+
+func (c *EsiClient) GetLocation() (*LocationData, error) {
+ characterIDStr := os.Getenv("character_id")
+ if characterIDStr == "" {
+ return nil, fmt.Errorf("character_id not set in environment variables")
+ }
+ characterID, _ := strconv.Atoi(characterIDStr)
+
+ endpoint := fmt.Sprintf("/characters/%d/location/?datasource=tranquility", characterID)
+ resp, err := c.doRequest("GET", endpoint, nil, true)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("failed to get location, status: %d body: %s", resp.StatusCode, string(body))
+ }
+
+var data LocationData
+ if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
+ return nil, err
+ }
+
+ return &data, nil
+}
+
+func (c *EsiClient) SetWaypoints(waypoints []int) (*WaypointResult, error) {
+ res := &WaypointResult{
+ RequestedCount: len(waypoints),
+ Success: true,
+ }
+
+ for i, wp := range waypoints {
+ clearOther := "false"
+ if i == 0 {
+ clearOther = "true"
+ }
+
+ endpoint := fmt.Sprintf("/ui/autopilot/waypoint/?add_to_beginning=false&clear_other_waypoints=%s&destination_id=%d&datasource=tranquility", clearOther, wp)
+
+ resp, err := c.doRequest("POST", endpoint, nil, true)
+ if err != nil {
+ res.FailedWaypoints = append(res.FailedWaypoints, wp)
+ res.Errors = append(res.Errors, err.Error())
+ res.Success = false
+ continue
+ }
+
+ if resp.StatusCode >= 200 && resp.StatusCode < 300 {
+ res.SuccessCount++
+ res.AppliedWaypoints = append(res.AppliedWaypoints, wp)
+ } else {
+ body, _ := io.ReadAll(resp.Body)
+ res.Errors = append(res.Errors, fmt.Sprintf("status %d body %s", resp.StatusCode, string(body)))
+ res.FailedWaypoints = append(res.FailedWaypoints, wp)
+ res.Success = false
+ }
+ resp.Body.Close()
+ time.Sleep(50 * time.Millisecond) // Rate limiting
+ }
+
+ res.Success = len(res.FailedWaypoints) == 0
+ return res, nil
+}
+
+func (c *EsiClient) GetWalletTransactions(fromID *int) ([]WalletTransaction, error) {
+ characterIDStr := os.Getenv("character_id")
+ if characterIDStr == "" {
+ return nil, fmt.Errorf("character_id not set in environment variables")
+ }
+ characterID, _ := strconv.Atoi(characterIDStr)
+ endpoint := fmt.Sprintf("/characters/%d/wallet/transactions/?datasource=tranquility", characterID)
+ if fromID != nil {
+ endpoint += fmt.Sprintf("&from_id=%d", *fromID)
+ }
+
+ resp, err := c.doRequest("GET", endpoint, nil, true)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("failed to get transactions, status: %d body: %s", resp.StatusCode, string(body))
+ }
+
+ var baseTx []struct {
+ TransactionID int `json:"transaction_id"`
+ Date string `json:"date"`
+ LocationID int `json:"location_id"`
+ TypeID int `json:"type_id"`
+ UnitPrice float64 `json:"unit_price"`
+ Quantity int `json:"quantity"`
+ ClientID int `json:"client_id"`
+ IsBuy bool `json:"is_buy"`
+ IsPersonal bool `json:"is_personal"`
+ JournalRefID int `json:"journal_ref_id"`
+ }
+
+ if err := json.NewDecoder(resp.Body).Decode(&baseTx); err != nil {
+ return nil, err
+ }
+
+ var results []WalletTransaction
+ for _, bt := range baseTx {
+ // ESI dates look like `2026-04-26T01:23:45Z`
+ t, _ := time.Parse(time.RFC3339, bt.Date)
+ results = append(results, WalletTransaction{
+ TransactionID: bt.TransactionID,
+ Date: t,
+ LocationID: bt.LocationID,
+ TypeID: bt.TypeID,
+ UnitPrice: bt.UnitPrice,
+ Quantity: bt.Quantity,
+ ClientID: bt.ClientID,
+ IsBuy: bt.IsBuy,
+ IsPersonal: bt.IsPersonal,
+ JournalRefID: bt.JournalRefID,
+ })
+ }
+ return results, nil
+}
+
+func (c *EsiClient) OpenMarketWindow(typeID int) error {
+ endpoint := fmt.Sprintf("/ui/openwindow/marketdetails/?datasource=tranquility&type_id=%d", typeID)
+ resp, err := c.doRequest("POST", endpoint, nil, true)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode >= 200 && resp.StatusCode < 300 {
+ return nil
+ }
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("failed to open market window, status: %d body: %s", resp.StatusCode, string(body))
+}
+
+func (c *EsiClient) GetRegionOrders(regionID int, page int) ([]map[string]interface{}, string, int, error) {
+ endpoint := fmt.Sprintf("/markets/%d/orders/?datasource=tranquility&order_type=all&page=%d", regionID, page)
+ resp, err := c.doRequest("GET", endpoint, nil, true)
+ if err != nil {
+ return nil, "", 0, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, "", 0, fmt.Errorf("failed to get region orders, status: %d body: %s", resp.StatusCode, string(body))
+ }
+
+ var orders []map[string]interface{}
+ if err := json.NewDecoder(resp.Body).Decode(&orders); err != nil {
+ return nil, "", 0, err
+ }
+
+ expires := resp.Header.Get("Expires")
+ totalPagesStr := resp.Header.Get("X-Pages")
+ totalPages, _ := strconv.Atoi(totalPagesStr)
+ if totalPages == 0 {
+ totalPages = 1
+ }
+
+ // Add RegionID and drop 0 volume
+ var filtered []map[string]interface{}
+ for _, o := range orders {
+ volRemain, _ := o["volume_remain"].(float64)
+ if volRemain > 0 {
+ o["region_id"] = regionID
+ filtered = append(filtered, o)
+ }
+ }
+
+ return filtered, expires, totalPages, nil
+}
diff --git a/backend-go/internal/esi/client.go b/backend-go/internal/esi/client.go
new file mode 100644
index 0000000..6a3b5fd
--- /dev/null
+++ b/backend-go/internal/esi/client.go
@@ -0,0 +1,203 @@
+package esi
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+const (
+ EsiBaseURL = "https://esi.evetech.net/latest"
+ UserAgent = "Haul (Hyperspace Asset Unloading and Loading) - Go Port"
+ ErrorLimitThresh = 50
+)
+
+type AuthData struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ ExpiresAt float64 `json:"expires_at"`
+}
+
+type EsiClient struct {
+ HttpClient *http.Client
+ AuthFilePath string
+ TokenMutex sync.Mutex
+ ClientID string
+ SecretKey string
+}
+
+func NewEsiClient(authFilePath string) *EsiClient {
+ clientID := os.Getenv("CLIENT_ID")
+ secretKey := os.Getenv("SECRET_KEY")
+
+ // Default to generic timeout
+ client := &http.Client{
+ Timeout: 10 * time.Second,
+ }
+
+ return &EsiClient{
+ HttpClient: client,
+ AuthFilePath: authFilePath,
+ ClientID: clientID,
+ SecretKey: secretKey,
+ }
+}
+
+// GetAccessToken checks if the token is valid, refreshing if needed or forced.
+func (c *EsiClient) GetAccessToken(forceRefresh bool) (string, error) {
+ c.TokenMutex.Lock()
+ defer c.TokenMutex.Unlock()
+
+ data, err := os.ReadFile(c.AuthFilePath)
+ if err != nil {
+ return "", fmt.Errorf("failed to read auth file: %w", err)
+ }
+
+ var auth AuthData
+ if err := json.Unmarshal(data, &auth); err != nil {
+ return "", fmt.Errorf("failed to parse auth file: %w", err)
+ }
+
+ now := float64(time.Now().Unix())
+ // Refresh if expired or soon to expire (within 20 mins)
+ if forceRefresh || auth.ExpiresAt < now+1200 {
+ return c.refreshAccessToken(auth.RefreshToken)
+ }
+
+ return auth.AccessToken, nil
+}
+
+func (c *EsiClient) refreshAccessToken(refreshToken string) (string, error) {
+ if c.ClientID == "" || c.SecretKey == "" {
+ return "", errors.New("CLIENT_ID and SECRET_KEY environment variables not set")
+ }
+
+ tokenURL := "https://login.eveonline.com/v2/oauth/token"
+ data := url.Values{}
+ data.Set("grant_type", "refresh_token")
+ data.Set("refresh_token", refreshToken)
+
+ req, err := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode()))
+ if err != nil {
+ return "", err
+ }
+
+ authStr := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", c.ClientID, c.SecretKey)))
+ req.Header.Add("Authorization", "Basic "+authStr)
+ req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Add("Host", "login.eveonline.com")
+
+ resp, err := c.HttpClient.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ bodyBytes, _ := io.ReadAll(resp.Body)
+ return "", fmt.Errorf("failed to refresh token, status %d: %s", resp.StatusCode, string(bodyBytes))
+ }
+
+ var respData struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ ExpiresIn int64 `json:"expires_in"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&respData); err != nil {
+ return "", err
+ }
+
+ // Update auth file
+ newAuth := AuthData{
+ AccessToken: respData.AccessToken,
+ RefreshToken: respData.RefreshToken,
+ ExpiresAt: float64(time.Now().Unix() + respData.ExpiresIn),
+ }
+
+ // Ensure cache dir exists
+ os.MkdirAll(filepath.Dir(c.AuthFilePath), 0755)
+
+ outBytes, err := json.MarshalIndent(newAuth, "", " ")
+ if err != nil {
+ return "", err
+ }
+ if err := os.WriteFile(c.AuthFilePath, outBytes, 0644); err != nil {
+ return "", err
+ }
+
+ return newAuth.AccessToken, nil
+}
+
+func (c *EsiClient) doRequest(method, endpoint string, body []byte, allowRetry bool) (*http.Response, error) {
+ url := fmt.Sprintf("%s%s", EsiBaseURL, endpoint)
+
+ for attempt := 1; attempt <= 3; attempt++ {
+ token, err := c.GetAccessToken(false)
+ if err != nil {
+ return nil, err
+ }
+
+ var reqBody io.Reader
+ if body != nil {
+ reqBody = bytes.NewReader(body)
+ }
+
+ req, err := http.NewRequest(method, url, reqBody)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Cache-Control", "no-cache")
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("User-Agent", UserAgent)
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ resp, err := c.HttpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ // Check ESI Error Limit
+ if remainStr := resp.Header.Get("x-esi-error-limit-remain"); remainStr != "" {
+ if remain, err := strconv.Atoi(remainStr); err == nil && remain < ErrorLimitThresh {
+ resp.Body.Close()
+ return nil, fmt.Errorf("ESI error limit threshold reached: %d remaining", remain)
+ }
+ }
+
+ if resp.StatusCode == 401 && allowRetry && attempt == 1 {
+ resp.Body.Close()
+ _, _ = c.GetAccessToken(true) // Force refresh
+ continue
+ }
+
+ if resp.StatusCode == 429 && allowRetry {
+ resp.Body.Close()
+ time.Sleep(time.Duration(2*attempt) * time.Second)
+ continue
+ }
+
+ if resp.StatusCode == 403 {
+ resp.Body.Close()
+ return nil, errors.New("permission denied (403). Check ESI scopes")
+ }
+
+ return resp, nil
+ }
+
+ return nil, errors.New("max retries exceeded")
+} \ No newline at end of file