Add multi-base chain modelling, fix price-band-aware demand/supply, tranche-split sell tool
- puga/network.py + tools/network.py: model a multi-base chain (e.g. mine LST at one base, ship it, consume it at another). Simulates each base independently, nets a transferred material's producer-surplus against consumer-need, charges real freight only on what's moved, cm_free for a founding covered by a Core Module Kit. Worked example in plans/chains/. - puga/saturation.py: real price-band filtering. FIO's order_book NarrowPriceBandLow/High and WidePriceBandLow/High match APEX's own displayed Price Band exactly (verified live) - an order outside it is a stale artifact, not just uncompetitive. Added in_band() and effective_demand(); effective_supply() gained the same hard band filter alongside its existing soft vwap-proximity filter (renamed that param mult to free up `band`). Wired into tools/scan.py stage 2 in place of the raw, unfiltered demand figure. Was flagged as an unimplemented refinement in saturation-design.md since the original design review. - tools/sell.py: self-contained tranche split (aggressive tranche capped at a volume quantile, median normally or 80th pct with --tight when a payment is imminent and stockout risk outweighs margin; patient tranche priced under the next competitor tier). Instant-bid comparison moved behind --show-bid (off by default). - Docs and roadmap updated accordingly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Executable
+90
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Model a multi-base chain: several plans, each on its own planet, with materials transferred
|
||||
between bases instead of bought/sold twice at market. See puga/network.py for the method.
|
||||
|
||||
Spec (YAML): {name, bases: {key: plan-yaml-path}, transfers: [{material, src, dst, trip_cost, cargo}]}
|
||||
puga network chains/nike_deimos_lst.yaml --basis vwap30
|
||||
"""
|
||||
import argparse, sys
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from puga import ROOT, config, market, prunplanner as pp
|
||||
from puga.network import BaseNode, Transfer, combine
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import plan_push
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("spec", help="chain spec YAML")
|
||||
ap.add_argument("--cx", default=config.DEFAULT_CX)
|
||||
ap.add_argument("--basis", default="uni30", choices=["real", "uni30", "vwap30", "vwap7", "ask", "bid", "mid"])
|
||||
a = ap.parse_args()
|
||||
|
||||
net = yaml.safe_load(Path(a.spec).read_text())
|
||||
recipes, blds = pp.recipes(), pp.buildings()
|
||||
snap = market.snapshot()
|
||||
|
||||
def price(t, side="both"):
|
||||
if a.basis == "real":
|
||||
q = snap.get((t, a.cx))
|
||||
return None if not q else (q.ask if side == "buy" else (q.vwap7 or q.vwap30 or q.bid))
|
||||
if a.basis == "uni30":
|
||||
return market.uni30(snap, t) or (snap.get((t, a.cx)) or market.Quote(t, a.cx)).ask
|
||||
q = snap.get((t, a.cx))
|
||||
if not q:
|
||||
return None
|
||||
return {"vwap30": q.vwap30 or q.vwap7 or q.ask, "vwap7": q.vwap7 or q.vwap30 or q.ask, "ask": q.ask,
|
||||
"bid": q.bid, "mid": (q.ask + q.bid) / 2 if q.ask and q.bid else None}[a.basis]
|
||||
|
||||
st = yaml.safe_load(config.state_path().read_text())
|
||||
faction = st.get("company", {}).get("faction")
|
||||
perm = (st.get("permits", {}).get("used", 1), st.get("permits", {}).get("total", 2))
|
||||
|
||||
bases = {}
|
||||
for key, entry in net["bases"].items():
|
||||
entry = entry if isinstance(entry, dict) else {"plan": entry}
|
||||
spec = yaml.safe_load((ROOT / entry["plan"]).read_text())
|
||||
plan = plan_push.build_payload(spec, recipes, {b["building_ticker"] for b in blds})
|
||||
if not st.get("hq"):
|
||||
plan["plan_corphq"] = False
|
||||
planet = pp._g(f"/data/planet/{plan['planet_natural_id']}/", 3600)
|
||||
built = next((dict(b.get("buildings", {})) for b in st.get("bases", []) if b.get("planet") == plan["planet_natural_id"]), {})
|
||||
if entry.get("cm_free"): # a Core Module Kit (founding) covers the CM: don't price it
|
||||
built["CM"] = 1
|
||||
bases[key] = BaseNode(key, plan, recipes, blds, planet["resources"], planet["fertility"], price, faction, perm, built)
|
||||
|
||||
transfers = [Transfer(**t) for t in net.get("transfers", [])]
|
||||
r = combine(bases, transfers)
|
||||
|
||||
print(f"{net.get('name', a.spec)} ({a.basis} prices)\n")
|
||||
for key, res in r["results"].items():
|
||||
line = f"[{key}] area {res['area']:.0f} profit/day {res['profit']:,.0f} new capex {res['new_capex']:,.0f}"
|
||||
if res["new_capex"] > 0:
|
||||
line += f" ({100*res['profit']/res['new_capex']:.1f}%/day)"
|
||||
print(line)
|
||||
for b in res["buildings"]:
|
||||
print(f" {b['amount']:2} x {b['building']:4} eff {b['efficiency']*100:6.1f}%")
|
||||
|
||||
print("\nTRANSFERS")
|
||||
for t in r["ledger"]:
|
||||
note = f"SHORTFALL {t['shortfall']:.1f}/d bought at market by {t['dst']}" if t["shortfall"] > 0.01 else \
|
||||
(f"surplus {t['surplus']:.1f}/d sold at market by {t['src']}" if t["surplus"] > 0.01 else "fully matched")
|
||||
print(f" {t['material']:5} {t['src']} -> {t['dst']}: moved {t['moved']:7.1f}/d "
|
||||
f"({t['weight']:.0f} t, {t['volume']:.0f} m3/d, {t['trips']:.2f} trips/d) freight {t['freight']:,.0f}/d [{note}]")
|
||||
|
||||
total_new_capex = sum(res["new_capex"] for res in r["results"].values())
|
||||
print(f"\nCOMBINED profit/day {r['total_profit']:,.0f} new capex {total_new_capex:,.0f}"
|
||||
+ (f" {100*r['total_profit']/total_new_capex:.1f}%/day" if total_new_capex > 0 else ""))
|
||||
print("CAUTION: a base's 'profit/day' here is its WHOLE plan (existing buildings included), not just the new "
|
||||
"addition, so its %/day overstates the marginal return where new_capex is small relative to an existing "
|
||||
"base. For the true marginal ROI, also simulate the base's plan WITHOUT the new building/transfer and "
|
||||
"diff the profit; see docs/library.md.")
|
||||
print("NOTE: trip_cost/cargo are placeholders (default 9250 AIC, 500 t/m3, same as the AI1 route) until a real "
|
||||
"inter-base fuel model exists (docs/roadmap.md tools/route.py) -- check SFC in-game for the real cost.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+26
-8
@@ -33,6 +33,7 @@ def main():
|
||||
ap.add_argument("--no-hq", action="store_true", help="ignore HQ from state (new base without the HQ)")
|
||||
ap.add_argument("--permits-used", type=int, help="override permits used (affects faction bonus multiplier), e.g. 2 for a second base")
|
||||
ap.add_argument("--deprec", type=float, default=0, help="demolish-later mode: building value decays linearly to 0 over this many days (game: ~60); subtracts capex/deprec per day")
|
||||
ap.add_argument("--input", help="only recipes consuming this ticker as an input, e.g. --input AL for recipes built on top of your own AL chain")
|
||||
ap.add_argument("--planet", help="planet natural id: adds extraction (EXT/COL/RIG) from its resources, uses its fertility and active COGC; new base (not in state) => no HQ, permits+1")
|
||||
ap.add_argument("--own", type=int, default=1, help="how many buildings WE would run; ROI is measured at this size (default 1). --min-n is only a market-size filter")
|
||||
ap.add_argument("--skip", default="", help="tiers left unstaffed, e.g. technician (no housing/wages; efficiency = staffed headcount share)")
|
||||
@@ -115,6 +116,8 @@ def main():
|
||||
continue
|
||||
ins = {i["Ticker"]: i["Amount"] for i in rec["Inputs"]}
|
||||
outs = {o["Ticker"]: o["Amount"] for o in rec["Outputs"]}
|
||||
if a.input and a.input.upper() not in ins:
|
||||
continue
|
||||
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
|
||||
capex0 = bcost(b["Ticker"])
|
||||
@@ -170,23 +173,38 @@ def main():
|
||||
|
||||
cands.sort(key=lambda c: c["rough_roi"], reverse=True)
|
||||
rows = []
|
||||
book = {}
|
||||
book, bandcache = {}, {}
|
||||
|
||||
def raw_book(t):
|
||||
if t not in book:
|
||||
book[t] = fio.order_book(t, a.cx)
|
||||
return book[t]
|
||||
|
||||
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]
|
||||
return sorted((o["ItemCost"], (o["ItemCount"] or 0)) for o in raw_book(t)["SellingOrders"])
|
||||
|
||||
def band_of(t):
|
||||
if t not in bandcache:
|
||||
ob = raw_book(t)
|
||||
lo = ob.get("WidePriceBandLow") or ob.get("NarrowPriceBandLow")
|
||||
hi = ob.get("WidePriceBandHigh") or ob.get("NarrowPriceBandHigh")
|
||||
bandcache[t] = (lo, hi)
|
||||
return bandcache[t]
|
||||
|
||||
def demand_of(t):
|
||||
bids = ((o["ItemCost"], o["ItemCount"] or 0) for o in raw_book(t)["BuyingOrders"])
|
||||
return sat.effective_demand(bids, band_of(t))
|
||||
|
||||
for c in cands[:a.k]:
|
||||
io, mm = c["io"], c["mm"]
|
||||
# stage 2: queue penalty from competing asks near market price only
|
||||
# stage 2: queue penalty from competing asks near market price only, and demand within the tradeable band
|
||||
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))
|
||||
se = sat.effective_supply(asks_of(t), x.vwap7 or x.ask, band=band_of(t))
|
||||
n2 = min(n2, sat.n_out(sat.tref(x.traded7, x.traded30), se, demand_of(t), 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
|
||||
@@ -198,7 +216,7 @@ def main():
|
||||
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)
|
||||
p = sat.p_patient(asks_of(t), sat.tref(x.traded7, x.traded30), N * qo, x.bid, x.vwap7, x.vwap30, x.ask, band_of(t)[1])
|
||||
rev += N * qo * p
|
||||
cost = 0.0
|
||||
for t, qi in io["in"].items():
|
||||
|
||||
+36
-8
@@ -1,9 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Where to post a sell order and how long it should take to clear, vs hitting the bids now.
|
||||
Formalizes the by-hand check done before every AL/BHP sale.
|
||||
Formalizes the by-hand check done before every AL/BHP sale, including the tranche split: an
|
||||
aggressive (undercut) tranche sized to a quantile of daily volume so it's confident to clear
|
||||
today, and a patient tranche (priced just under the next competitor tier) for the rest. The
|
||||
quantile is the newsvendor critical fractile Cu/(Cu+Co): median (0.5) when the cost of running
|
||||
out of cash (Cu) and the cost of discounting unnecessarily (Co) are about equal; higher (0.8 via
|
||||
--tight) when Cu dominates, i.e. a restock/buildout payment is imminent and stockout risk (not
|
||||
having cash in time) matters more than a few points of margin.
|
||||
|
||||
puga sell BHP 8 --cx AI1
|
||||
puga sell AL 16 --undercut 5
|
||||
puga sell BHP 11 --tight # cash-tight: bias the split toward the aggressive tranche
|
||||
"""
|
||||
import argparse, datetime, statistics, sys
|
||||
from pathlib import Path
|
||||
@@ -11,6 +17,17 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from puga import config, fio, market
|
||||
|
||||
|
||||
def quantile(xs: list[float], q: float) -> float:
|
||||
return sorted(xs)[int(q * (len(xs) - 1))] if xs else 0.0
|
||||
|
||||
|
||||
def tranche_split(qty: float, trades: list[float], tight: bool) -> tuple[float, float]:
|
||||
"""(aggressive_qty, patient_qty). Aggressive is capped at qty and at the volume quantile."""
|
||||
q = 0.8 if tight else 0.5
|
||||
aggressive = min(qty, quantile(trades, q)) if trades else qty
|
||||
return aggressive, qty - aggressive
|
||||
|
||||
|
||||
def daily_traded(tk: str, cx: str, days: int = 30) -> list[float]:
|
||||
rows = [(datetime.datetime.fromtimestamp(e["DateEpochMs"] / 1000, datetime.timezone.utc).date(), e["Traded"])
|
||||
for e in fio.cxpc(tk, cx) if e.get("Interval") == "DAY_ONE" and e.get("Traded")]
|
||||
@@ -23,6 +40,8 @@ def main():
|
||||
ap.add_argument("qty", type=float)
|
||||
ap.add_argument("--cx", default=config.DEFAULT_CX)
|
||||
ap.add_argument("--undercut", type=float, default=10, help="AIC to undercut the current best ask by")
|
||||
ap.add_argument("--tight", action="store_true", help="cash-tight: bias the tranche split toward the aggressive (undercut) tranche")
|
||||
ap.add_argument("--show-bid", action="store_true", help="also show instant-bid revenue (usually worse than the tranche split; off by default)")
|
||||
a = ap.parse_args()
|
||||
t = a.ticker.upper()
|
||||
ob = fio.order_book(t, a.cx)
|
||||
@@ -31,14 +50,13 @@ def main():
|
||||
post = round((best_ask - a.undercut) if best_ask else (ob.get("Ask") or 0))
|
||||
ahead = sum(u for p, u in asks if p < post)
|
||||
|
||||
hit = market.walk(t, a.cx, a.qty, "sell")
|
||||
trades = daily_traded(t, a.cx)
|
||||
med = statistics.median(trades) if trades else 0
|
||||
lo = sorted(trades)[int(0.2 * (len(trades) - 1))] if trades else 0
|
||||
hi = sorted(trades)[int(0.8 * (len(trades) - 1))] if trades else 0
|
||||
|
||||
print(f"{t}.{a.cx} best ask {best_ask} bid {ob.get('Bid')} vwap7 {ob.get('PriceAverage')}")
|
||||
print(f"\nOPTION A: post {a.qty:g} at {post:.0f} ({ahead:.0f} units ahead of you at a lower price)")
|
||||
print(f"\npost {a.qty:g} at {post:.0f} ({ahead:.0f} units ahead of you at a lower price)")
|
||||
print(f" revenue if filled: {post * a.qty:,.0f}")
|
||||
if med:
|
||||
print(f" expected clear time (last {len(trades)}d volume): busy day {24 * a.qty / hi:.1f}h | "
|
||||
@@ -46,11 +64,21 @@ def main():
|
||||
else:
|
||||
print(" no recent trade history to estimate clear time")
|
||||
|
||||
print(f"\nOPTION B: hit the bids now (instant)")
|
||||
print(f" revenue: {hit['total']:,.0f} (avg {hit['avg']:.0f}, worst {hit['worst']:.0f}"
|
||||
+ (f", SHORT: book only fills {hit['filled']:.0f}" if hit["short"] else "") + ")")
|
||||
if a.show_bid:
|
||||
hit = market.walk(t, a.cx, a.qty, "sell")
|
||||
print(f"\ninstant (hit the bids now): {hit['total']:,.0f} (avg {hit['avg']:.0f}, worst {hit['worst']:.0f}"
|
||||
+ (f", SHORT: book only fills {hit['filled']:.0f}" if hit["short"] else "") + ")")
|
||||
print(f" posting patiently gains {post * a.qty - hit['total']:,.0f} over this, at the cost of waiting")
|
||||
|
||||
print(f"\nposting patiently gains {post * a.qty - hit['total']:,.0f} over hitting the bids, at the cost of waiting")
|
||||
aggr_qty, patient_qty = tranche_split(a.qty, trades, a.tight)
|
||||
if patient_qty > 0.01:
|
||||
tiers_above = sorted({p for p, u in asks if p > post})
|
||||
patient_price = round(tiers_above[0] - 1) if tiers_above else round(post + 2 * a.undercut)
|
||||
q_label = "80th pct (tight)" if a.tight else "median"
|
||||
print(f"\nTRANCHE SPLIT ({q_label} volume quantile, {len(trades)}d):")
|
||||
print(f" aggressive: {aggr_qty:.1f} @ {post:.0f} (confident to clear today)")
|
||||
print(f" patient: {patient_qty:.1f} @ {patient_price:.0f} (waits behind the next tier, higher margin)")
|
||||
print(f" vs posting all {a.qty:g} at {post:.0f}: gains {patient_qty*(patient_price-post):,.0f} extra if the patient tranche fills")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user