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:
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Order-book ladder for one material at one exchange: price levels with cumulative units and value.
|
||||
tools/book.py BHP --cx AI1 --levels 8
|
||||
"""
|
||||
import argparse, sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from puga import config, fio
|
||||
|
||||
|
||||
def ladder(orders, reverse):
|
||||
lv = defaultdict(int)
|
||||
for o in orders:
|
||||
if o.get("ItemCount") != 0:
|
||||
lv[o["ItemCost"]] += 10**9 if o.get("ItemCount") is None else o["ItemCount"] # 10**9 = market maker, unlimited
|
||||
cum, out = 0, []
|
||||
for price in sorted(lv, reverse=reverse):
|
||||
cum += lv[price]
|
||||
out.append((price, lv[price], cum))
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("ticker")
|
||||
ap.add_argument("--cx", default=config.DEFAULT_CX)
|
||||
ap.add_argument("--levels", type=int, default=8)
|
||||
ap.add_argument("--refresh", action="store_true")
|
||||
a = ap.parse_args()
|
||||
t = a.ticker.upper()
|
||||
ob = fio.order_book(t, a.cx, a.refresh)
|
||||
asks, bids = ladder(ob["SellingOrders"], False), ladder(ob["BuyingOrders"], True)
|
||||
print(f"{t}.{a.cx} ask {ob.get('Ask')} bid {ob.get('Bid')} supply {ob.get('Supply')} demand {ob.get('Demand')} "
|
||||
f"MMBuy {ob.get('MMBuy')} MMSell {ob.get('MMSell')}")
|
||||
print(f"{'ASKS':>8} {'units':>7} {'cum':>7} | {'BIDS':>8} {'units':>7} {'cum':>7}")
|
||||
for i in range(a.levels):
|
||||
l = f"{asks[i][0]:8.0f} {asks[i][1]:7d} {asks[i][2]:7d}" if i < len(asks) else " " * 24
|
||||
r = f"{bids[i][0]:8.0f} {bids[i][1]:7d} {bids[i][2]:7d}" if i < len(bids) else ""
|
||||
print(f"{l} | {r}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+100
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Make-vs-buy cost tree for a material at one exchange, plus sourcing depth for every raw input.
|
||||
Cost to make = inputs at min(buy, make) per output unit; wages/capex excluded unless --wages.
|
||||
tools/chain.py DEC # AI1
|
||||
tools/chain.py BHP --depth 3 --qty 13
|
||||
"""
|
||||
import argparse, sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from puga import config, econ, fio, market, saturation as sat
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("ticker")
|
||||
ap.add_argument("--cx", default=config.DEFAULT_CX)
|
||||
ap.add_argument("--depth", type=int, default=2, help="levels of make-vs-buy expansion")
|
||||
ap.add_argument("--qty", type=float, default=1.0, help="units/day you would make (sourcing check)")
|
||||
ap.add_argument("--wages", action="store_true", help="add wage cost per output unit (both luxuries at ask)")
|
||||
ap.add_argument("--cogc")
|
||||
a = ap.parse_args()
|
||||
|
||||
snap = market.snapshot()
|
||||
Q = lambda t: snap.get((t, a.cx))
|
||||
blds = {b["Ticker"]: b for b in fio.buildings()}
|
||||
by_out = {}
|
||||
for r in fio.recipes():
|
||||
for o in r["Outputs"]:
|
||||
by_out.setdefault(o["Ticker"], []).append(r)
|
||||
|
||||
def wage_per_day(b):
|
||||
tot = 0.0
|
||||
for t in econ.TIERS:
|
||||
n = b[t.capitalize() + "s"]
|
||||
if n:
|
||||
tot += n * sum(need / 100 * (Q(tk).ask if Q(tk) and Q(tk).ask else 0) for tk, need, _, _ in econ.CONSUMPTION[t])
|
||||
return tot
|
||||
|
||||
def buy(t):
|
||||
q = Q(t)
|
||||
return q.ask if q and q.ask else None
|
||||
|
||||
memo = {}
|
||||
|
||||
def make_cost(t, depth, stack=()):
|
||||
"""(best unit cost, how). Compares buy vs best recipe."""
|
||||
key = (t, depth)
|
||||
if key in memo:
|
||||
return memo[key]
|
||||
b = buy(t)
|
||||
best = (b if b is not None else float("inf"), "buy" if b is not None else "n/a", None)
|
||||
if depth > 0 and t not in stack:
|
||||
for r in by_out.get(t, []):
|
||||
bld = blds.get(r["BuildingTicker"])
|
||||
if not bld:
|
||||
continue
|
||||
cost = 0.0
|
||||
for i in r["Inputs"]:
|
||||
c, _, _ = make_cost(i["Ticker"], depth - 1, stack + (t,))
|
||||
cost += i["Amount"] * c
|
||||
outs = sum(o["Amount"] for o in r["Outputs"] if o["Ticker"] == t)
|
||||
if outs == 0:
|
||||
continue
|
||||
if a.wages:
|
||||
eff, _ = econ.building_efficiency({x.lower() + "s": bld[x + "s"] for x in ("Pioneer", "Settler", "Technician", "Engineer", "Scientist")},
|
||||
{x: 1.0 for x in econ.TIERS}, expertise=bld["Expertise"] or None, cogc=a.cogc)
|
||||
cost += wage_per_day(bld) * (r["TimeMs"] / 86400e3) / eff
|
||||
unit = cost / outs
|
||||
if unit < best[0]:
|
||||
best = (unit, f"make {r['BuildingTicker']} {r['TimeMs']/3.6e6:.1f}h", r)
|
||||
memo[key] = best
|
||||
return best
|
||||
|
||||
def show(t, depth, qty, indent=0):
|
||||
c, how, r = make_cost(t, depth)
|
||||
q = Q(t)
|
||||
pad = " " * indent
|
||||
mk = f"ask {q.ask:.0f} bid {q.bid:.0f} sup {q.supply:.0f} dem {q.demand:.0f} flow {sat.tref(q.traded7, q.traded30):.1f}/d" if q and q.ask and q.bid else "no market"
|
||||
print(f"{pad}{t:5} x{qty:<8.1f} best {c:9.0f}/u via {how:18} | {mk}")
|
||||
if r and how.startswith("make") and depth > 0:
|
||||
outs = sum(o["Amount"] for o in r["Outputs"] if o["Ticker"] == t)
|
||||
for i in r["Inputs"]:
|
||||
show(i["Ticker"], depth - 1, qty * i["Amount"] / outs, indent + 1)
|
||||
|
||||
show(a.ticker.upper(), a.depth, a.qty)
|
||||
print("\n# sourcing check for buy-leaves at", a.qty, "output units/day: ask-walk price for one day of inputs")
|
||||
q0 = Q(a.ticker.upper())
|
||||
_, _, r = make_cost(a.ticker.upper(), a.depth)
|
||||
if r:
|
||||
outs = sum(o["Amount"] for o in r["Outputs"] if o["Ticker"] == a.ticker.upper())
|
||||
for i in r["Inputs"]:
|
||||
need = a.qty * i["Amount"] / outs
|
||||
w = market.walk(i["Ticker"], a.cx, need, "buy")
|
||||
x = Q(i["Ticker"])
|
||||
days = x.supply / need if x and need else 0
|
||||
print(f" {i['Ticker']:5} need {need:8.1f}/d ask {x.ask if x else None} walked avg {w['avg'] and round(w['avg'])} short={w['short']} standing supply = {days:.0f} days of your use, market flow {sat.tref(x.traded7, x.traded30):.1f}/d")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Turn a YAML plan spec (plans/*.yaml) into a PRUNplanner plan, via the Api-Key API.
|
||||
DEFAULT IS DRY RUN. Guardrails (docs/decisions.md): only plans named '[PuGa] ...' are created/updated; never delete;
|
||||
--update refuses if the existing plan's name does not start with '[PuGa]'. Show the dry-run to Dominik and get a yes before --apply.
|
||||
|
||||
tools/plan_push.py list
|
||||
tools/plan_push.py plans/deimos_bhp.yaml # dry run: validated payload summary
|
||||
tools/plan_push.py plans/deimos_bhp.yaml --json # full JSON payload
|
||||
tools/plan_push.py plans/deimos_bhp.yaml --apply # create (after user says yes)
|
||||
tools/plan_push.py plans/deimos_bhp.yaml --apply --update <uuid>
|
||||
|
||||
Spec: name, planet (natural id), permits, cogc (e.g. METALLURGY or null), hq, experts {METALLURGY: 2}, lux {pioneer: [true,true]},
|
||||
infrastructure {HB1: 4, HB2: 1}, buildings: [{building: SME, amount: 5, recipes: ["ALO,FLX,C,O=>4AL"|"AL,STL,HE=>BHP"|"EXT#ALO"]}]
|
||||
A recipe is an exact PRUNplanner recipe_id, an extraction id like EXT#ALO, or 'IN,IN=>OUT' (ticker sets, matched within the building).
|
||||
"""
|
||||
import argparse, json, sys
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from puga import prunplanner as pp
|
||||
|
||||
EXPERTS = ["Agriculture", "Chemistry", "Construction", "Electronics", "Food_Industries", "Fuel_Refining",
|
||||
"Manufacturing", "Metallurgy", "Resource_Extraction"]
|
||||
COGC = {"AGRICULTURE", "CHEMISTRY", "CONSTRUCTION", "ELECTRONICS", "FOOD_INDUSTRIES", "FUEL_REFINING", "MANUFACTURING",
|
||||
"METALLURGY", "RESOURCE_EXTRACTION", "PIONEERS", "SETTLERS", "TECHNICIANS", "ENGINEERS", "SCIENTISTS"}
|
||||
TIERS = ["pioneer", "settler", "technician", "engineer", "scientist"]
|
||||
PREFIX = "[PuGa]"
|
||||
|
||||
|
||||
def resolve_recipe(spec: str, building: str, recipes: list[dict]) -> str:
|
||||
rs = [r for r in recipes if r["building_ticker"] == building]
|
||||
if any(r["recipe_id"] == spec for r in rs) or spec.startswith(building + "#") and "=>" not in spec:
|
||||
return spec # exact id, or extraction id (EXT#ALO)
|
||||
if "=>" not in spec:
|
||||
raise ValueError(f"cannot parse recipe '{spec}'")
|
||||
left, right = spec.split("=>")
|
||||
want_in = {x.strip().split("x")[-1] if x.strip()[0].isdigit() and "x" in x else x.strip() for x in left.split(",") if x.strip()}
|
||||
want_out = {x.strip().split("x")[-1] if x.strip()[0].isdigit() and "x" in x else x.strip() for x in right.split(",") if x.strip()}
|
||||
# tolerate amount prefixes like '4AL' / '4xAL'
|
||||
strip = lambda s: {"".join(ch for ch in x.lstrip("0123456789x")) for x in s}
|
||||
want_in, want_out = strip(want_in), strip(want_out)
|
||||
hits = [r for r in rs if {i["material_ticker"] for i in r["inputs"]} == want_in and {o["material_ticker"] for o in r["outputs"]} == want_out]
|
||||
if len(hits) != 1:
|
||||
raise ValueError(f"recipe '{spec}' at {building}: {len(hits)} matches" + (": " + ", ".join(h["recipe_id"] for h in hits) if hits else ""))
|
||||
return hits[0]["recipe_id"]
|
||||
|
||||
|
||||
def build_payload(spec: dict, recipes: list[dict], building_tickers: set[str]) -> dict:
|
||||
name = spec["name"]
|
||||
if not name.startswith(PREFIX):
|
||||
raise ValueError(f"plan name must start with '{PREFIX}' (guardrail); got '{name}'")
|
||||
planet = spec["planet"]
|
||||
if len(planet) != 7:
|
||||
raise ValueError("planet natural id must be 7 chars, e.g. ZV-759c")
|
||||
permits = int(spec.get("permits", 1))
|
||||
if not 0 <= permits <= 3:
|
||||
raise ValueError("permits must be 0..3")
|
||||
cogc = spec.get("cogc")
|
||||
if cogc is not None and cogc.upper() not in COGC:
|
||||
raise ValueError(f"cogc '{cogc}' not one of {sorted(COGC)}")
|
||||
experts = {k.upper(): v for k, v in (spec.get("experts") or {}).items()}
|
||||
lux = spec.get("lux") or {}
|
||||
buildings = []
|
||||
for b in spec["buildings"]:
|
||||
tk = b["building"]
|
||||
if tk not in building_tickers:
|
||||
raise ValueError(f"unknown building {tk}")
|
||||
recs = [{"recipeid": resolve_recipe(r, tk, recipes), "amount": 1} for r in b.get("recipes", [])]
|
||||
buildings.append({"name": tk, "amount": int(b["amount"]), "active_recipes": recs})
|
||||
infra = [{"building": k, "amount": int(v)} for k, v in (spec.get("infrastructure") or {}).items()]
|
||||
return {
|
||||
"plan_name": name, "planet_natural_id": planet, "plan_permits_used": permits,
|
||||
"plan_cogc": cogc.upper() if cogc else None, "plan_corphq": bool(spec.get("hq", False)),
|
||||
"plan_data": {
|
||||
"experts": [{"type": e, "amount": int(experts.get(e.upper(), 0))} for e in EXPERTS],
|
||||
"workforce": [{"type": t, "lux1": bool(lux.get(t, [True, True])[0]), "lux2": bool(lux.get(t, [True, True])[1])} for t in TIERS],
|
||||
"infrastructure": infra, "buildings": buildings}}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("spec", help="plans/*.yaml or 'list'")
|
||||
ap.add_argument("--apply", action="store_true", help="actually write to PRUNplanner (creates a new plan)")
|
||||
ap.add_argument("--update", metavar="UUID", help="with --apply: update this existing [PuGa] plan instead of creating")
|
||||
ap.add_argument("--json", action="store_true")
|
||||
a = ap.parse_args()
|
||||
|
||||
if a.spec == "list":
|
||||
for p in pp.request("GET", "/planning/plan/"):
|
||||
print(p["uuid"], p.get("plan_name"), p.get("planet_natural_id"))
|
||||
return
|
||||
spec = yaml.safe_load(Path(a.spec).read_text())
|
||||
payload = build_payload(spec, pp.recipes(), {b["building_ticker"] for b in pp.buildings()})
|
||||
d = payload["plan_data"]
|
||||
print(f"PLAN {payload['plan_name']} planet {payload['planet_natural_id']} permits {payload['plan_permits_used']} cogc {payload['plan_cogc']} hq {payload['plan_corphq']}")
|
||||
print("experts:", {e["type"]: e["amount"] for e in d["experts"] if e["amount"]} or "-")
|
||||
print("infrastructure:", {i["building"]: i["amount"] for i in d["infrastructure"]} or "-")
|
||||
for b in d["buildings"]:
|
||||
print(f" {b['amount']:>3} x {b['name']:4}", [r["recipeid"] for r in b["active_recipes"]])
|
||||
if a.json:
|
||||
print(json.dumps(payload, indent=1))
|
||||
if not a.apply:
|
||||
print("\nDRY RUN: nothing sent. Re-run with --apply after Dominik confirms.")
|
||||
return
|
||||
if a.update:
|
||||
cur = pp.request("GET", f"/planning/plan/{a.update}/")
|
||||
if not str(cur.get("plan_name", "")).startswith(PREFIX):
|
||||
sys.exit(f"REFUSED: existing plan '{cur.get('plan_name')}' is not a {PREFIX} plan")
|
||||
out = pp.request("PUT", f"/planning/plan/{a.update}/", payload)
|
||||
else:
|
||||
out = pp.request("POST", "/planning/plan/", payload)
|
||||
print("WRITTEN:", out.get("uuid") if isinstance(out, dict) else out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prices for materials across all exchanges, with VWAP and daily traded volume; optional fill price for a quantity.
|
||||
tools/price.py BHP STL # all CX
|
||||
tools/price.py BHP --cx AI1 --qty 200 # avg fill price to buy/sell 200 at AI1 (walks the live book)
|
||||
"""
|
||||
import argparse, sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from puga import config, market
|
||||
|
||||
|
||||
def f(x, w=7):
|
||||
return f"{x:{w}.0f}" if x is not None else f"{'-':>{w}}"
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("tickers", nargs="+")
|
||||
ap.add_argument("--cx", help="limit to one exchange")
|
||||
ap.add_argument("--qty", type=float, help="show avg fill price for this quantity (needs --cx or uses default CX)")
|
||||
ap.add_argument("--refresh", action="store_true")
|
||||
a = ap.parse_args()
|
||||
snap = market.snapshot(a.refresh)
|
||||
print(f"{'mat':5} {'cx':4} {'bid':>7} {'ask':>7} {'vwap7':>7} {'vwap30':>7} {'trd/d7':>7} {'trd/d30':>7} {'demand':>7} {'supply':>7}")
|
||||
for t in map(str.upper, a.tickers):
|
||||
for cx in market.CXS:
|
||||
if a.cx and cx != a.cx:
|
||||
continue
|
||||
q = snap.get((t, cx))
|
||||
if q:
|
||||
print(f"{t:5} {cx:4} {f(q.bid)} {f(q.ask)} {f(q.vwap7)} {f(q.vwap30)} {q.traded7:7.1f} {q.traded30:7.1f} {q.demand:7.0f} {q.supply:7.0f}")
|
||||
if a.qty:
|
||||
cx = a.cx or config.DEFAULT_CX
|
||||
b, s = market.walk(t, cx, a.qty, "buy"), market.walk(t, cx, a.qty, "sell")
|
||||
print(f" fill {a.qty:.0f} @ {cx}: buy avg {f(b['avg'])} (worst {f(b['worst'])}, filled {b['filled']:.0f}) | "
|
||||
f"sell avg {f(s['avg'])} (worst {f(s['worst'])}, filled {s['filled']:.0f})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pure exchange arbitrage: for each material, cheapest ask at any CX vs best bid at any other CX.
|
||||
Reports spread per unit, per ton and per m3 of cargo, and how many units the thinner side can absorb.
|
||||
python prun_cxarb.py --from AI1 # only routes buying at AI1
|
||||
python prun_cxarb.py --to AI1 # only routes selling at AI1
|
||||
python prun_cxarb.py --minqty 200 --sort ton
|
||||
"""
|
||||
import argparse, json, urllib.request
|
||||
FIO="https://rest.fnar.net"
|
||||
def get(p):
|
||||
with urllib.request.urlopen(FIO+p,timeout=60) as r: return json.load(r)
|
||||
ap=argparse.ArgumentParser()
|
||||
ap.add_argument("--from",dest="src",default=None); ap.add_argument("--to",dest="dst",default=None)
|
||||
ap.add_argument("--minqty",type=float,default=100,help="min units both sides can absorb")
|
||||
ap.add_argument("--sort",default="ton",choices=["unit","ton","m3","total","pct"]); ap.add_argument("--top",type=int,default=30)
|
||||
a=ap.parse_args()
|
||||
mats={m["Ticker"]:m for m in get("/material/allmaterials")}
|
||||
px={}
|
||||
for e in get("/exchange/all"): px.setdefault(e["MaterialTicker"],{})[e["ExchangeCode"]]=e
|
||||
rows=[]
|
||||
for t,cxs in px.items():
|
||||
for s,es in cxs.items():
|
||||
if a.src and s!=a.src: continue
|
||||
if not es.get("Ask"): continue
|
||||
for d,ed in cxs.items():
|
||||
if d==s or (a.dst and d!=a.dst) or not ed.get("Bid"): continue
|
||||
# depth: units available at ask side / wanted at bid side (order book totals)
|
||||
# /exchange/all has only book totals; use min(supply at source, demand at destination) as depth proxy
|
||||
qty=min(es.get("Supply") or 0, ed.get("Demand") or 0)
|
||||
if qty<a.minqty: continue
|
||||
spread=ed["Bid"]-es["Ask"]
|
||||
if spread<=0: continue
|
||||
w=mats[t]["Weight"]; v=mats[t]["Volume"]
|
||||
rows.append(dict(t=t,src=s,dst=d,ask=es["Ask"],bid=ed["Bid"],unit=spread,pct=100*spread/es["Ask"],ton=spread/w if w else 0,m3=spread/v if v else 0,qty=qty,total=spread*qty))
|
||||
rows.sort(key=lambda r:r[a.sort],reverse=True)
|
||||
print(f"{'mat':4} {'buy@':4} {'sell@':5} {'ask':>8} {'bid':>8} {'spread':>7} {'%':>5} {'/ton':>7} {'/m3':>7} {'qty':>7} {'total':>9}")
|
||||
for r in rows[:a.top]:
|
||||
print(f"{r['t']:4} {r['src']:4} {r['dst']:5} {r['ask']:8.0f} {r['bid']:8.0f} {r['unit']:7.0f} {r['pct']:5.0f} {r['ton']:7.0f} {r['m3']:7.0f} {r['qty']:7.0f} {r['total']:9.0f}")
|
||||
|
||||
@@ -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()
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
# Re-pull PRUNplanner source into ref/ (gitignored). Their code is the source of truth for game mechanics.
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
rm -rf ref/frontend ref/backend
|
||||
git clone -q --depth 1 https://github.com/PRUNplanner/frontend.git ref/frontend
|
||||
git clone -q --depth 1 https://github.com/PRUNplanner/backend.git ref/backend
|
||||
rm -rf ref/frontend/.git ref/backend/.git
|
||||
echo "refs updated $(date -I)"
|
||||
Executable
+196
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Depth-aware single-step recipe scan at one exchange (saturation model v1, see docs/saturation-design.md).
|
||||
For each recipe: buildings the market can absorb (N*), and ROI/day at N=1 and N* using a patient sell price
|
||||
(not top-of-book) and ask-walked inputs. Excludes thin markets by default.
|
||||
|
||||
tools/scan.py # AI1, all tiers, top 30 by ROI at N*
|
||||
tools/scan.py --cogc METALLURGY --tier S # Deimos-like: metallurgy COGC, up to settlers
|
||||
tools/scan.py --sort total --min-n 3 # rank by absorbable profit/day
|
||||
"""
|
||||
import argparse, sys
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from puga import ROOT, config, econ, fio, market, saturation as sat
|
||||
|
||||
TIER_CODE = {"pioneer": "P", "settler": "S", "technician": "T", "engineer": "E", "scientist": "Sc"}
|
||||
TIER_RANK = {"P": 0, "S": 1, "T": 2, "E": 3, "Sc": 4}
|
||||
|
||||
|
||||
def load_state():
|
||||
p = ROOT / "state" / "company.yaml"
|
||||
return yaml.safe_load(p.read_text()) if p.exists() else {}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--cx", default=config.DEFAULT_CX)
|
||||
ap.add_argument("--tier", help="max workforce tier: P S T E Sc")
|
||||
ap.add_argument("--cogc", help="COGC programme on the base, e.g. METALLURGY, or SETTLERS")
|
||||
ap.add_argument("--skip", default="", help="tiers left unstaffed, e.g. technician (no housing/wages; efficiency = staffed headcount share)")
|
||||
ap.add_argument("--experts", default="", help="expert counts, e.g. METALLURGY=2 (bonus x1.0306..1.284 for 1..5)")
|
||||
ap.add_argument("--hq", action="store_true", help="corp HQ bonus x1.1")
|
||||
ap.add_argument("--no-faction", action="store_true", help="ignore faction bonus from state/company.yaml")
|
||||
ap.add_argument("--min-n", type=float, default=1.0, help="min buildings the market absorbs (default 1)")
|
||||
ap.add_argument("--max-n", type=int, default=50, help="cap N* (capital/attention limit)")
|
||||
ap.add_argument("--budget", type=float, help="capital limit in AIC: caps N and drops recipes whose single-building capex exceeds it")
|
||||
ap.add_argument("--mcg", type=float, default=4.0, help="MCG per area (4 on normal planets)")
|
||||
ap.add_argument("--sort", default="roi", choices=["roi", "total", "roi1"])
|
||||
ap.add_argument("--top", type=int, default=30)
|
||||
ap.add_argument("--k", type=int, default=80, help="candidates refined with order books")
|
||||
ap.add_argument("--show-thin", action="store_true")
|
||||
a = ap.parse_args()
|
||||
|
||||
snap = market.snapshot()
|
||||
Q = lambda t: snap.get((t, a.cx))
|
||||
ask = lambda t: (Q(t).ask if Q(t) else None)
|
||||
st = load_state()
|
||||
faction = None if a.no_faction else (st.get("company") or {}).get("faction")
|
||||
pu, pt = (st.get("permits") or {}).get("used", 1), (st.get("permits") or {}).get("total", 2)
|
||||
|
||||
blds = {b["Ticker"]: b for b in fio.buildings()}
|
||||
mcg = ask("MCG") or 0
|
||||
|
||||
def bcost(tk):
|
||||
b = blds[tk]
|
||||
parts = [(x["Amount"], ask(x["CommodityTicker"])) for x in b["BuildingCosts"]]
|
||||
if any(p is None for _, p in parts):
|
||||
return None
|
||||
return sum(n * p for n, p in parts) + a.mcg * b["AreaCost"] * mcg
|
||||
|
||||
hab_head = {}
|
||||
for tier, hab in (("pioneer", "HB1"), ("settler", "HB2"), ("technician", "HB3"), ("engineer", "HB4"), ("scientist", "HB5")):
|
||||
c = bcost(hab)
|
||||
hab_head[tier] = None if c is None else c / econ.HAB_CAP[hab][tier]
|
||||
|
||||
def wage(tier): # per worker per day, both luxuries supplied, at ask
|
||||
tot = 0.0
|
||||
for tk, need, _, _ in econ.CONSUMPTION[tier]:
|
||||
p = ask(tk)
|
||||
if p is None:
|
||||
return None
|
||||
tot += need / 100 * p
|
||||
return tot
|
||||
|
||||
experts = {k.strip().upper(): int(v) for k, v in (x.split("=") for x in a.experts.split(",") if "=" in x)}
|
||||
skip = {x.strip() for x in a.skip.split(",") if x.strip()}
|
||||
wages = {t: wage(t) for t in econ.TIERS}
|
||||
cands = []
|
||||
for rec in fio.recipes():
|
||||
b = blds.get(rec["BuildingTicker"])
|
||||
if not b or not rec["Outputs"] or b["Ticker"].startswith("HB"):
|
||||
continue
|
||||
heads = {t: b[t.capitalize() + "s"] for t in econ.TIERS}
|
||||
used = [t for t in econ.TIERS if heads[t] and t not in skip] # staffed tiers
|
||||
if not used:
|
||||
continue
|
||||
top = TIER_CODE[used[-1]]
|
||||
if a.tier and TIER_RANK[top] > TIER_RANK[a.tier]:
|
||||
continue
|
||||
if any(hab_head[t] is None or wages[t] is None for t in used):
|
||||
continue
|
||||
ins = {i["Ticker"]: i["Amount"] for i in rec["Inputs"]}
|
||||
outs = {o["Ticker"]: o["Amount"] for o in rec["Outputs"]}
|
||||
if any(not Q(t) or not Q(t).ask for t in ins) or any(not Q(t) or not Q(t).bid for t in outs):
|
||||
continue
|
||||
bd = {t + "s": heads[t] for t in econ.TIERS}
|
||||
eff, _ = econ.building_efficiency(bd, {t: (0.0 if t in skip else 1.0) for t in econ.TIERS}, expertise=b["Expertise"] or None, cogc=a.cogc,
|
||||
hq=a.hq, experts=experts, faction=faction, permits_used=pu, permits_total=pt)
|
||||
io = econ.production_io([dict(time_ms=rec["TimeMs"], inputs=ins, outputs=outs)], eff, 1)
|
||||
# saturation per output
|
||||
n_lim, lim, thin, mm = float("inf"), "", False, {}
|
||||
for t, qo in io["out"].items():
|
||||
x = Q(t)
|
||||
tr = sat.tref(x.traded7, x.traded30)
|
||||
if x.mm_buy and x.bid and x.mm_buy >= 0.9 * x.bid:
|
||||
mm[t] = x.mm_buy # market maker floor: unlimited depth at mm_buy
|
||||
continue
|
||||
thin |= sat.is_thin(tr, x.demand, qo)
|
||||
n = sat.n_out(tr, 0, x.demand, qo) # stage 1: flow + demand only; queue penalty applied in stage 2 with the book
|
||||
if n < n_lim:
|
||||
n_lim, lim = n, t
|
||||
if n_lim == float("inf"):
|
||||
n_lim, lim = float(a.max_n), "MM"
|
||||
if (thin or n_lim < a.min_n) and not a.show_thin:
|
||||
continue
|
||||
capex = bcost(b["Ticker"])
|
||||
if capex is None:
|
||||
continue
|
||||
capex += sum(heads[t] * hab_head[t] for t in used)
|
||||
wcost = sum(heads[t] * wages[t] for t in used)
|
||||
price0 = {t: mm.get(t) or min(x for x in (Q(t).vwap7, Q(t).vwap30, Q(t).ask) if x) if (Q(t).vwap7 or Q(t).vwap30 or Q(t).ask) else Q(t).bid
|
||||
for t in io["out"]}
|
||||
rough = sum(io["out"][t] * price0[t] for t in io["out"]) - sum(io["in"][t] * ask(t) for t in io["in"]) - wcost
|
||||
cands.append(dict(rec=rec, b=b, io=io, eff=eff, capex=capex, wcost=wcost, n_lim=n_lim, lim=lim, thin=thin, mm=mm,
|
||||
rough_roi=100 * rough / capex, top=top, ins=ins, outs=outs))
|
||||
|
||||
cands.sort(key=lambda c: c["rough_roi"], reverse=True)
|
||||
rows = []
|
||||
book = {}
|
||||
|
||||
def asks_of(t):
|
||||
if t not in book:
|
||||
book[t] = sorted((o["ItemCost"], (o["ItemCount"] or 0)) for o in fio.order_book(t, a.cx)["SellingOrders"])
|
||||
return book[t]
|
||||
|
||||
for c in cands[:a.k]:
|
||||
io, mm = c["io"], c["mm"]
|
||||
# stage 2: queue penalty from competing asks near market price only
|
||||
n2 = float("inf")
|
||||
for t, qo in io["out"].items():
|
||||
if t in mm:
|
||||
continue
|
||||
x = Q(t)
|
||||
se = sat.effective_supply(asks_of(t), x.vwap7 or x.ask)
|
||||
n2 = min(n2, sat.n_out(sat.tref(x.traded7, x.traded30), se, x.demand, qo))
|
||||
c["n_lim"] = float(a.max_n) if n2 == float("inf") else n2
|
||||
if c["n_lim"] < a.min_n and not a.show_thin:
|
||||
continue
|
||||
|
||||
def evaluate(N):
|
||||
rev = 0.0
|
||||
for t, qo in io["out"].items():
|
||||
x = Q(t)
|
||||
if t in mm:
|
||||
p = mm[t]
|
||||
else:
|
||||
p = sat.p_patient(asks_of(t), sat.tref(x.traded7, x.traded30), N * qo, x.bid, x.vwap7, x.vwap30, x.ask)
|
||||
rev += N * qo * p
|
||||
cost = 0.0
|
||||
for t, qi in io["in"].items():
|
||||
w = market.walk(t, a.cx, N * qi, "buy")
|
||||
if w["short"]:
|
||||
return None
|
||||
cost += w["total"]
|
||||
return rev - cost - N * c["wcost"]
|
||||
|
||||
if a.budget and c["capex"] > a.budget:
|
||||
continue
|
||||
n_star = max(1, min(int(c["n_lim"]), a.max_n))
|
||||
if a.budget:
|
||||
n_star = max(1, min(n_star, int(a.budget // c["capex"])))
|
||||
net1 = evaluate(1)
|
||||
if net1 is None:
|
||||
continue
|
||||
netN = evaluate(n_star)
|
||||
while netN is None and n_star > 1:
|
||||
n_star = max(1, n_star // 2)
|
||||
netN = evaluate(n_star)
|
||||
if netN is None:
|
||||
continue
|
||||
rows.append(dict(roi1=100 * net1 / c["capex"], roiN=100 * netN / n_star / c["capex"], total=netN, n=n_star,
|
||||
n_lim=c["n_lim"], lim=c["lim"], capex=c["capex"] * n_star, eff=c["eff"], bld=c["b"]["Ticker"],
|
||||
top=c["top"], h=c["rec"]["TimeMs"] / 3.6e6, thin=c["thin"],
|
||||
rec=" ".join(f"{v:g}{k}" for k, v in c["ins"].items()) + " -> " + " ".join(f"{v:g}{k}" for k, v in c["outs"].items())))
|
||||
key = {"roi": "roiN", "roi1": "roi1", "total": "total"}[a.sort]
|
||||
rows.sort(key=lambda r: r[key], reverse=True)
|
||||
print(f"# {a.cx} tier<={a.tier or 'any'} cogc={a.cogc or '-'} faction={faction or '-'} ({pu}/{pt} permits) min-n>={a.min_n} sorted by {a.sort}")
|
||||
print("# price: patient asks near VWAP (clamped), inputs ask-walked; N* = market-absorbable buildings (cap %d); all estimates" % a.max_n)
|
||||
print(f"{'ROI@1':>6} {'ROI@N*':>7} {'N*':>4} {'lim':4} {'profit/d@N*':>11} {'capex@N*':>9} {'eff':>4} {'bld':4} {'tier':4} {'h':>5} recipe")
|
||||
for r in rows[:a.top]:
|
||||
print(f"{r['roi1']:6.1f} {r['roiN']:7.1f} {r['n']:4d} {r['lim']:4} {r['total']:11.0f} {r['capex']:9.0f} {r['eff']*100:4.0f} {r['bld']:4} {r['top']:4} "
|
||||
f"{r['h']:5.1f} {r['rec']}{' [thin]' if r['thin'] else ''}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sync state/company.yaml from live FIO (own data via FIO_REST_KEY) and show it.
|
||||
tools/state.py sync # overwrite live fields (buildings, production efficiency, storage, ships, cash, permits)
|
||||
tools/state.py show
|
||||
Requires the FIO extension to have uploaded recently; check `as_of`. Manual keys (company, notes) are preserved."""
|
||||
import sys, datetime
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from puga import ROOT, fio
|
||||
|
||||
P = ROOT / "state" / "company.yaml"
|
||||
|
||||
|
||||
def sync():
|
||||
u = fio.me()
|
||||
st = yaml.safe_load(P.read_text()) if P.exists() else {}
|
||||
company = fio.private(f"/company/code/{(st.get('company') or {}).get('ticker', 'GBI')}", ttl=0)
|
||||
for stale in ("cash_aic",):
|
||||
st.pop(stale, None)
|
||||
st["as_of"] = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%MZ")
|
||||
st["cash"] = {b["Currency"]: b["Amount"] for b in company["Balances"] if b["Amount"]}
|
||||
sites = fio.private(f"/sites/{u}", ttl=0)
|
||||
prod = fio.private(f"/production/{u}", ttl=0)
|
||||
stores = fio.private(f"/storage/{u}", ttl=0)
|
||||
ships = fio.private(f"/ship/ships/{u}", ttl=0)
|
||||
st["permits"] = {"used": sites[0]["InvestedPermits"] if sites else 0, "total": sites[0]["MaximumPermits"] if sites else 0}
|
||||
old = {b["planet"]: b for b in st.get("bases", [])}
|
||||
bases = []
|
||||
for s in sites:
|
||||
b = old.get(s["PlanetIdentifier"], {})
|
||||
b.update(name=s["PlanetName"], planet=s["PlanetIdentifier"], buildings=dict(Counter(x["BuildingTicker"] for x in s["Buildings"])),
|
||||
avg_condition=round(sum(x["Condition"] for x in s["Buildings"]) / len(s["Buildings"]), 4))
|
||||
b["production"] = [dict(type=l["Type"], count=l["Capacity"], efficiency=round(l["Efficiency"], 4), queued_orders=len(l["Orders"]))
|
||||
for l in prod if l["SiteId"] == s["SiteId"]]
|
||||
bases.append(b)
|
||||
st["bases"] = bases
|
||||
st["storage"] = [dict(type=x["Type"], name=x["Name"], weight=f"{x['WeightLoad']:.0f}/{x['WeightCapacity']:.0f}t",
|
||||
items={i["MaterialTicker"]: i["MaterialAmount"] for i in x["StorageItems"] if i.get("MaterialTicker")})
|
||||
for x in stores if x["StorageItems"] or x["Type"] == "SHIP_STORE"]
|
||||
st["ships"] = [dict(name=x["Name"], reg=x["Registration"], mass=x["Mass"], in_flight=bool(x.get("FlightId"))) for x in ships]
|
||||
P.write_text(yaml.safe_dump(st, sort_keys=False, default_flow_style=None, width=140))
|
||||
print("synced", st["as_of"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "show"
|
||||
if cmd == "sync":
|
||||
sync()
|
||||
print(P.read_text())
|
||||
Reference in New Issue
Block a user