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