Initial PuGa toolkit: data layer, econ, depth-aware scan, state sync, plan push
- 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>
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user