From 1b59961ce2385252e3346ea6107b15a2c1feafe6 Mon Sep 17 00:00:00 2001 From: kboz Date: Sun, 27 Sep 2026 22:36:34 +0200 Subject: 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. --- backend-go/internal/core/arbitrage.go | 259 ++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 backend-go/internal/core/arbitrage.go (limited to 'backend-go/internal/core/arbitrage.go') 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 +} -- cgit v1.3