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:
2026-09-18 23:00:21 +02:00
co-authored by Claude Sonnet 5
commit 7a538cb300
35 changed files with 1692 additions and 0 deletions
+39
View File
@@ -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}")