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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -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 workforce_needs(refresh=False): return _g("/global/workforceneeds", STATIC, refresh)
|
||||||
def systems(refresh=False): return _g("/systemstars", STATIC, refresh)
|
def systems(refresh=False): return _g("/systemstars", STATIC, refresh)
|
||||||
def stations(refresh=False): return _g("/exchange/station", 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):
|
def own(path: str, ttl: int = 300, refresh: bool = False):
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user