From c660559f7439896f0a6a90289ff329063822f828 Mon Sep 17 00:00:00 2001 From: Dominik Roth Date: Fri, 9 Oct 2026 13:33:04 +0200 Subject: [PATCH] Add tools/lmscan.py: live Local Market hauling-ad scan Pulls real player-posted freight jobs from FIO's /localmarket/shipping endpoints (public, no auth - distinct from the CONTD player-contract system, which isn't exposed via any API). Ranks by margin after real one-way fuel cost (puga.shipping.leg_cost), flagging ads that exceed the account's ship hold or route through an unmeasured gateway. Co-Authored-By: Claude Sonnet 5 --- puga/contracts.py | 35 ++++++++++++++++++++++++++++ puga/fio.py | 6 +++++ tools/lmscan.py | 59 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 puga/contracts.py create mode 100644 tools/lmscan.py diff --git a/puga/contracts.py b/puga/contracts.py new file mode 100644 index 0000000..ebaa586 --- /dev/null +++ b/puga/contracts.py @@ -0,0 +1,35 @@ +"""Local Market hauling-ad scan: live ads from FIO's /localmarket/shipping/{source,destination} +(public, no auth - real player-posted freight-for-hire jobs, NOT the CONTD player-to-player +custom contract system). Evaluates each against the real fuel model (puga.shipping). + +SHIP_CARGO is this account's actual ship hold (both AVI-07JFB/AVI-07JFC: 500 t, 500 m3 - confirmed +via FIO /storage/{user} SHIP_STORE). A contract whose cargo exceeds this needs multiple round +trips to fulfil in one hull; that case is flagged, not computed. + +One-way cost only: this does NOT add a return trip home, because the realistic loop is picking +up the NEXT ad near the destination, not flying back empty. That makes the margin reported here +optimistic for a one-off job with nothing lined up on the return leg. +""" +from . import fio, shipping + +SHIP_CARGO = 500.0 # t and m3, both ships + + +def scan(planet: str, direction: str, sf_price: float, ff_price: float, v0: float = 1.0, v1: float = 1.0): + """direction: 'source' (ads picking up AT `planet`) or 'destination' (ads dropping off there). + Returns a list of ads enriched with cost/margin, deduplicated by ContractNaturalId.""" + ads = fio.lm_shipping_source(planet) if direction == "source" else fio.lm_shipping_destination(planet) + seen, rows = set(), [] + for ad in ads: + cid = ad["ContractNaturalId"] + if cid in seen: + continue + seen.add(cid) + over_capacity = ad["CargoWeight"] > SHIP_CARGO or ad["CargoVolume"] > SHIP_CARGO + cost = None if over_capacity else shipping.leg_cost(ad["OriginPlanetNaturalId"], ad["DestinationPlanetNaturalId"], + sf_price, ff_price, v0, v1) + payout = ad["PayoutPrice"] if ad["PayoutCurrency"] == "AIC" else None # non-AIC payout not priced here + margin = (payout - cost["aic"]) if (cost and payout is not None) else None + rows.append({**ad, "over_capacity": over_capacity, "cost": cost, "margin_aic": margin, + "margin_per_day": margin / max(ad["DeliveryTime"], 1) if margin is not None else None}) + return rows diff --git a/puga/fio.py b/puga/fio.py index bd7035b..7cfc832 100644 --- a/puga/fio.py +++ b/puga/fio.py @@ -22,6 +22,12 @@ def planets_full(refresh=False): return _g("/planet/allplanets/full", STATI def workforce_needs(refresh=False): return _g("/global/workforceneeds", STATIC, refresh) def systems(refresh=False): return _g("/systemstars", STATIC, refresh) def stations(refresh=False): return _g("/exchange/station", STATIC, refresh) +def lm_shipping_source(planet: str, refresh=False): + """Live Local Market hauling ads where `planet` is the pickup - public, no auth needed.""" + return _g(f"/localmarket/shipping/source/{planet}", 15 * 60, refresh) +def lm_shipping_destination(planet: str, refresh=False): + """Live Local Market hauling ads where `planet` is the drop-off - public, no auth needed.""" + return _g(f"/localmarket/shipping/destination/{planet}", 15 * 60, refresh) def own(path: str, ttl: int = 300, refresh: bool = False): diff --git a/tools/lmscan.py b/tools/lmscan.py new file mode 100644 index 0000000..eb8c3e6 --- /dev/null +++ b/tools/lmscan.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Scan live Local Market hauling ads (real player-posted freight jobs, FIO +/localmarket/shipping/*) and rank by margin after real fuel cost (puga.shipping). One-way cost +only (margin assumes you pick up the next job near the destination, not an empty flight home). + + tools/lmscan.py ANT # ads picking up at Antares Station + tools/lmscan.py ANT --direction destination # ads dropping off at Antares Station + tools/lmscan.py ZV-759c --min-margin 0 # only profitable ones, from Deimos +""" +import argparse, sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from puga import config, contracts, market + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("planet", help="station/planet natural id to scan ads at, e.g. ANT, ZV-759c") + ap.add_argument("--direction", choices=["source", "destination"], default="source", + help="source = ads picking up here (default); destination = ads dropping off here") + ap.add_argument("--v0", type=float, default=1.0) + ap.add_argument("--v1", type=float, default=1.0) + ap.add_argument("--min-margin", type=float, help="only show ads with at least this AIC margin") + ap.add_argument("--cx", default=config.DEFAULT_CX) + a = ap.parse_args() + + snap = market.snapshot() + sf_px = (snap.get(("SF", a.cx)) or market.Quote("SF", a.cx)).ask or 0 + ff_px = (snap.get(("FF", a.cx)) or market.Quote("FF", a.cx)).ask or 0 + + rows = contracts.scan(a.planet, a.direction, sf_px, ff_px, a.v0, a.v1) + priced = [r for r in rows if r["margin_aic"] is not None] + unpriceable = [r for r in rows if r["margin_aic"] is None] + priced.sort(key=lambda r: r["margin_aic"], reverse=True) + if a.min_margin is not None: + priced = [r for r in priced if r["margin_aic"] >= a.min_margin] + + print(f"{len(rows)} ads ({a.direction} = {a.planet}), {len(priced)} priceable at v0={a.v0} v1={a.v1}\n") + print(f"{'route':16} {'jump':4} {'t':>6} {'m3':>6} {'payout':>9} {'fuel/fee':>9} {'margin':>9} {'margin/day':>11}") + for r in priced: + print(f"{r['OriginPlanetNaturalId']}->{r['DestinationPlanetNaturalId']:11} {r['JumpDistance']:4} " + f"{r['CargoWeight']:6.0f} {r['CargoVolume']:6.0f} {r['PayoutPrice']:9,.0f} " + f"{r['cost']['aic']:9,.0f} {r['margin_aic']:9,.0f} {r['margin_per_day']:11,.0f}") + + if unpriceable: + print(f"\n{len(unpriceable)} ad(s) not priced (over-capacity cargo, non-AIC payout, or an unmeasured gateway route):") + for r in unpriceable: + reason = "cargo exceeds 500t/500m3 (needs multiple trips, not modelled)" if r["over_capacity"] else \ + ("non-AIC payout" if r["PayoutCurrency"] != "AIC" else "no known route (unmeasured gateway?)") + print(f" {r['OriginPlanetNaturalId']}->{r['DestinationPlanetNaturalId']} {r['PayoutPrice']:,.0f} {r['PayoutCurrency']} [{reason}]") + + print("\nCAVEATS: one-way cost only, assumes a return cargo lined up at the destination (no empty flight home " + "priced in) - see puga/contracts.py. margin/day divides by the ad's DeliveryTime DEADLINE, not real " + "transit time (we don't model trip duration, only fuel) - treat it as a floor, not the real rate. " + "Fuel model itself is scoped to this account's one hull/reactor; see puga/shipping.py for open gaps.") + + +if __name__ == "__main__": + main()