#!/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()