- puga/: cached FIO + PRUNplanner clients, market view with order-book walk, econ formulas ported from PRUNplanner (tested against its suite and live FIO), saturation model v1 (reviewed by Opus) - tools/: scan (depth-aware), price, book, chain, state sync, plan_push (dry run default, [PuGa]-prefixed plans only), legacy prun_scan/prun_cxarb - docs/: mechanics (PRUNplanner is source of truth), roadmap, decisions, saturation design, archived handoff - secrets stay in .env (gitignored); ref/ holds PRUNplanner source (ignored) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
134 lines
6.4 KiB
Python
134 lines
6.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Scan every Prosperous Universe recipe for single-step profit:
|
|
buy all inputs at exchange ask, sell all outputs at exchange bid.
|
|
|
|
Uses the public FIO REST API (rest.fnar.net), no auth needed.
|
|
|
|
Examples:
|
|
python prun_scan.py # AI1, all tiers, top 40 by ROI
|
|
python prun_scan.py --cx NC1 --tier P # Moria, pioneer-only buildings
|
|
python prun_scan.py --sort net --depth 60 --top 20
|
|
python prun_scan.py --cross # buy at cheapest CX, sell at best CX (ignores shipping)
|
|
"""
|
|
import argparse, json, urllib.request, sys
|
|
|
|
FIO = "https://rest.fnar.net"
|
|
EXCH = ["AI1", "NC1", "CI1", "IC1", "NC2", "CI2"]
|
|
TIERS = ["Pioneers", "Settlers", "Technicians", "Engineers", "Scientists"]
|
|
TIER_CODE = {"Pioneers": "P", "Settlers": "S", "Technicians": "T", "Engineers": "E", "Scientists": "Sc"}
|
|
HAB = {"Pioneers": ("HB1", 100), "Settlers": ("HB2", 75), "Technicians": ("HB3", 75),
|
|
"Engineers": ("HB4", 75), "Scientists": ("HB5", 75)}
|
|
|
|
def get(path):
|
|
with urllib.request.urlopen(f"{FIO}{path}", timeout=60) as r:
|
|
return json.load(r)
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--cx", default="AI1", help="exchange code (AI1 NC1 CI1 IC1 NC2 CI2)")
|
|
ap.add_argument("--cross", action="store_true", help="buy at cheapest ask across all CX, sell at best bid across all CX")
|
|
ap.add_argument("--tier", default=None, help="max workforce tier: P, S, T, E, Sc")
|
|
ap.add_argument("--depth", type=float, default=30, help="min days of open demand for one building's output")
|
|
ap.add_argument("--sort", default="roi", choices=["roi", "net", "area"])
|
|
ap.add_argument("--top", type=int, default=40)
|
|
ap.add_argument("--mcg", type=float, default=4.0, help="MCG per area (4 on normal rocky planets)")
|
|
ap.add_argument("--understaff", action="store_true", help="also evaluate running without some worker tiers (no housing/wages for them)")
|
|
ap.add_argument("--penalty", type=float, default=1.0, help="efficiency = (staffed headcount share) ** penalty; 1 = proportional")
|
|
a = ap.parse_args()
|
|
|
|
print("fetching FIO data...", file=sys.stderr)
|
|
recipes = get("/recipes/allrecipes")
|
|
buildings = {b["Ticker"]: b for b in get("/building/allbuildings")}
|
|
exch = get("/exchange/all")
|
|
needs = get("/global/workforceneeds")
|
|
|
|
px = {}
|
|
for e in exch:
|
|
px.setdefault(e["MaterialTicker"], {})[e["ExchangeCode"]] = e
|
|
|
|
def ask(t):
|
|
if a.cross:
|
|
v = [px[t][c]["Ask"] for c in px.get(t, {}) if px[t][c].get("Ask")]
|
|
return min(v) if v else None
|
|
return (px.get(t, {}).get(a.cx) or {}).get("Ask")
|
|
|
|
def bid(t):
|
|
if a.cross:
|
|
v = [px[t][c]["Bid"] for c in px.get(t, {}) if px[t][c].get("Bid")]
|
|
return max(v) if v else None
|
|
return (px.get(t, {}).get(a.cx) or {}).get("Bid")
|
|
|
|
def demand(t):
|
|
if a.cross:
|
|
return sum((px[t][c].get("Demand") or 0) for c in px.get(t, {}))
|
|
return (px.get(t, {}).get(a.cx) or {}).get("Demand") or 0
|
|
|
|
# wages per worker per day from consumable needs at ask
|
|
wage = {}
|
|
for w in needs:
|
|
wage[w["WorkforceType"]] = sum(n["Amount"] * (ask(n["MaterialTicker"]) or 0) for n in w["Needs"]) / 100
|
|
|
|
def bcost(tk):
|
|
b = buildings[tk]
|
|
c = sum(x["Amount"] * (ask(x["CommodityTicker"]) or 0) for x in b["BuildingCosts"])
|
|
return c + a.mcg * b["AreaCost"] * (ask("MCG") or 0)
|
|
|
|
hab_cost = {t: bcost(h) / n for t, (h, n) in HAB.items()}
|
|
hab_area = {t: buildings[h]["AreaCost"] / n for t, (h, n) in HAB.items()}
|
|
|
|
tier_rank = {"P": 0, "S": 1, "T": 2, "E": 3, "Sc": 4}
|
|
rows = []
|
|
for rec in recipes:
|
|
b = buildings.get(rec["BuildingTicker"])
|
|
if not b or not rec["Outputs"]:
|
|
continue
|
|
top = "P"
|
|
for t in TIERS:
|
|
if b[t]:
|
|
top = TIER_CODE[t]
|
|
if a.tier and tier_rank[top] > tier_rank[a.tier] and not a.understaff:
|
|
continue
|
|
ins, outs = rec["Inputs"], rec["Outputs"]
|
|
if any(ask(i["Ticker"]) is None for i in ins) or any(bid(o["Ticker"]) is None for o in outs):
|
|
continue
|
|
per_day = 24 / (rec["TimeMs"] / 3.6e6)
|
|
cin = sum(i["Amount"] * ask(i["Ticker"]) for i in ins) * per_day
|
|
cout = sum(o["Amount"] * bid(o["Ticker"]) for o in outs) * per_day
|
|
outq = sum(o["Amount"] for o in outs) * per_day
|
|
dep = min(demand(o["Ticker"]) for o in outs) / outq
|
|
if dep < a.depth:
|
|
continue
|
|
used = [t for t in TIERS if b[t]]
|
|
total_heads = sum(b[t] for t in used)
|
|
import itertools
|
|
variants = [tuple(used)]
|
|
if a.understaff and len(used) > 1:
|
|
# drop any non-empty subset of tiers, but never drop all
|
|
for k in range(1, len(used)):
|
|
for keep in itertools.combinations(used, k):
|
|
variants.append(keep)
|
|
for keep in variants:
|
|
eff = (sum(b[t] for t in keep) / total_heads) ** a.penalty
|
|
wages = sum(b[t] * wage[t.upper()[:-1] if t != "Pioneers" else "PIONEER"] for t in keep)
|
|
net = (cout - cin) * eff - wages
|
|
capex = bcost(b["Ticker"]) + sum(b[t] * hab_cost[t] for t in keep)
|
|
area = b["AreaCost"] + sum(b[t] * hab_area[t] for t in keep)
|
|
staff = "".join(TIER_CODE[t] for t in keep) + ("" if len(keep) == len(used) else "-")
|
|
if a.tier and any(tier_rank[TIER_CODE[t]] > tier_rank[a.tier] for t in keep):
|
|
continue
|
|
rows.append(dict(roi=100 * net / capex, net=net, area=net / area, capex=capex, tier=staff,
|
|
bld=b["Ticker"], exp=(b["Expertise"] or "")[:5],
|
|
ins=" ".join(f"{i['Amount']}{i['Ticker']}" for i in ins),
|
|
outs=" ".join(f"{o['Amount']}{o['Ticker']}" for o in outs),
|
|
h=rec["TimeMs"] / 3.6e6, depth=dep, eff=eff))
|
|
|
|
rows.sort(key=lambda r: r[a.sort], reverse=True)
|
|
print(f"# {'cross-CX' if a.cross else a.cx} tier<={a.tier or 'any'} depth>={a.depth}d sorted by {a.sort}")
|
|
print(f"{'ROI%':>6} {'net/d':>8} {'net/area':>8} {'capex':>8} {'staff':6} {'eff':>4} {'bld':4} {'exp':5} {'h':>5} {'depth':>6} recipe")
|
|
for r in rows[:a.top]:
|
|
print(f"{r['roi']:6.1f} {r['net']:8.0f} {r['area']:8.0f} {r['capex']:8.0f} {r['tier']:6} {r['eff']*100:4.0f} {r['bld']:4} {r['exp']:5} "
|
|
f"{r['h']:5.1f} {r['depth']:6.0f} {r['ins']} -> {r['outs']}")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|