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:
2026-09-25 14:32:09 +02:00
co-authored by Claude Sonnet 5
parent e2e592cae3
commit 58db08b921
15 changed files with 434 additions and 21 deletions
+2 -1
View File
@@ -43,7 +43,8 @@ Run as `puga <tool> ...` (e.g. `puga scan --min-n 3`, `puga plan pull <uuid>`);
- `tools/scan.py` DEPTH-AWARE recipe scan (use this): buildings the market could absorb, ROI for our own size, patient prices, ask-walked inputs. Fully staffed AND understaffed variants by default (`--staffing`), freight netted out (`--trip-cost`, `--cargo`), HQ/COGC/experts/faction, `--planet ID` adds that planet's extraction, fertility and COGC (a planet not in state is a new base: no HQ, permits+1), `--deprec 60` prices demolish-later. `--min-n` is ONLY a noise filter on market capacity (use 3, ideally 10); ROI is always for our own size (`--own`, default 1 building), never at the filtered scale. Below 1 lets sub-building junk in. `--json rows.json` feeds `persistence.py`. Library: `puga/saturation.py`.
- `tools/history.py TICKER` monthly margin history of the recipe producing TICKER; `tools/persistence.py rows.json` re-prices scan rows over history (mean ROI 7/14/30/90/180d, payback, net gain over 7 and 14 days, % days profitable). ALWAYS check persistence before recommending: current margins are often a spike.
- `tools/buy.py <spec.yaml|--uuid U> --days N` cash-aware shopping list: construction gap (plan vs empire state's built buildings) plus N days of the plan's NET operating stock (via `simulate()`, so self-produced inputs like AL net against consumption), priced at real fill cost, checked against cash/reserve. Pass a plan covering the WHOLE base for restocking, not just a new addition (see `docs/library.md`).
- `tools/sell.py TICKER QTY` where to post an ask (undercutting the current best) vs hitting the bids now, plus expected hours to clear from 30-day traded-volume percentiles.
- `tools/sell.py TICKER QTY [--tight]` where to post an ask, expected hours to clear, and a self-contained tranche split: aggressive (undercut) tranche capped at the median 30d-volume quantile (or 80th pct with `--tight`, for when a payment is imminent and stockout risk outweighs a few points of margin), patient tranche priced just under the next competitor tier. `--show-bid` for the instant-bid comparison (off by default; usually worse).
- `tools/network.py <chain.yaml>` models a MULTI-BASE chain (e.g. mine LST at Nike, ship it, make BSE at Deimos): simulates each base, nets a transferred material's producer-surplus against consumer-need, charges real freight only on what's moved (`puga/network.py`). Spec and worked example in `plans/chains/`. `cm_free: true` per base for a new founding covered by a Core Module Kit. profit/day per base is the WHOLE plan, not the marginal addition - diff against the base's plan without the addition for a true marginal ROI.
- `tools/chain.py TICKER` make-vs-buy cost tree plus sourcing depth of inputs.
- `tools/price.py` prices across exchanges with VWAP, volume, fill price for a quantity; `tools/book.py` order-book ladder.
- `tools/prun_scan.py`, `tools/prun_cxarb.py` legacy top-of-book scans; overstate thin markets. Baseline only. `tools/arb.py` replacement is on the roadmap.
+21
View File
@@ -97,6 +97,27 @@ plan nets that against the smelters to ~0). This bit a first version of `tools/b
comparing it against the by-hand restock calc, not by a unit test (the pure functions were right,
the plan file chosen was wrong).
## Multi-base chains: `puga.network`
For a decision that spans two or more bases (mine LST at Nike, ship it, turn it into BSE at
Deimos), don't price the transferred material at market on both ends — that double counts a trade
that never happens. `puga.network.combine()` simulates each base independently (each base's own
`profit` is still correct market-priced), then nets any transferred material between a producer's
surplus and a consumer's need, charging only the real freight for what's actually moved; leftover
surplus is still validly sold at market by the source, leftover deficit still validly bought by the
destination — those are already right in each base's own numbers, so `combine()` only adds the one
correction neither side has. CLI: `tools/network.py <chain.yaml>`, spec format and a worked example
(Nike LST -> Deimos PP2/BSE) in `plans/chains/`.
Two gotchas specific to this: (1) each base's plan must be self-contained (see above) - Nike's plan
needs its OWN housing even though Deimos's doesn't compete for it; (2) a NEW base's plan always
prices a fresh core module unless the founding source (e.g. a Core Module Kit) actually covers it -
mark it with `cm_free: true` on that base in the chain spec (mirrors `tools/simulate.py --cm-free`),
or the phantom ~200k core-module cost craters an otherwise-fine ROI. (3) each base's reported
profit/day is for its WHOLE plan, not the marginal addition - for "is this new building worth it",
also simulate the base without the addition and diff, same as `tools/simulate.py`'s built-vs-planned
new_capex logic but applied to profit instead of capex.
## Conventions to keep
- Cache TTLs matter: market data is cached ~15 min, static game data ~24h (`puga/cache.py`).
+2
View File
@@ -11,6 +11,7 @@ Legend: [ ] todo, [x] done. Build order matters; each step is usable on its own.
6. [x] `tools/chain.py`: full bill of materials, make-vs-buy per node.
7. [ ] `tools/whatif.py`: marginal ROI of adding a building to the actual state (uses `state/company.yaml`, efficiency stack, imports).
8. [ ] `tools/planet.py`, `tools/found.py`: rank planets for a resource; founding cost including environment extras.
13b. [x] `puga/network.py` + `tools/network.py`: multi-base chain modelling (per-base simulate, net transfers against real freight, `cm_free` for kits). Worked example: `plans/chains/nike_deimos_lst.yaml` (Nike LST -> Deimos PP2/BSE). Known gap: reports whole-plan profit per base, not the marginal addition; caller must diff manually.
9. [ ] `tools/route.py`, `tools/haul.py`: jump graph, fuel, profit per trip.
9b. [x] (dry-run default, --apply after user yes) `tools/plan_push.py`: create/update `[PuGa]` plans in PRUNplanner from YAML (Api-Key auth; see decisions.md for guardrails).
10. [x] `tools/state.py sync` (sites, production efficiency, storage, ships, cash, permits): inventory, production, ships into `state/`.
@@ -25,3 +26,4 @@ Legend: [ ] todo, [x] done. Build order matters; each step is usable on its own.
- PRUNplanner auth is known (`Authorization: Api-Key <key>`). FIO REST and FIO API are separate services with separate keys (separate services with separate keys; an earlier "one API" reading was wrong). Keys are in `.env`. Verified 2026-09-18: FIO_REST_KEY works on rest.fnar.net as `Authorization: <key>` (own /sites, /storage etc. readable); FIO_API_KEY gives 401 there, host unknown. PRUNplanner key works (plans 0, empires 1, cx prefs 1 on his account). Still unknown: the OpenAPI spec at https://doc.fnar.net/api.json has no securitySchemes. Probe with read-only calls.
- (resolved) PRUNplanner `exchanges/` has vwap_daily/7d/30d and traded volume; merged into `puga/market.py`. Trend history via cxpc still todo.
- Extraction: concentration to daily rate formula.
- (resolved 2026-09-25) Real price-band filtering (`NarrowPriceBandLow/High`, `WidePriceBandLow/High`) implemented in `puga/saturation.py` (`in_band`, `effective_demand`, `effective_supply` band param) and wired into `tools/scan.py` stage 2. Was flagged as an unimplemented refinement in saturation-design.md since day one; the player noticed a stale out-of-band bid in a live screenshot and asked the right question.
+21
View File
@@ -48,3 +48,24 @@ Later refinements (not built): supply-feedback fixpoint (our N raises supply), d
## Addendum: effective supply (implemented)
Stage 1 of `tools/scan.py` uses flow + demand only; stage 2 (shortlist) fetches the ask book and counts only standing sell orders priced <= 1.25 x vwap7 (`saturation.effective_supply`). Reason: DEC at AI1 showed 2,013 units 'queue' of which 1,501 sat at 50,000 (1.5x vwap), which made the raw queue penalty call the market saturated (N=0.38) when the effective queue gives about 1.7.
## Addendum: real price-band filtering (implemented 2026-09-25)
Verified live: FIO's order_book `NarrowPriceBandLow/High` and `WidePriceBandLow/High` fields match
APEX's own displayed "Price Band" exactly (BHP.AI1: 663.00 - 16,575.00 both narrow and wide at the
time). This is the exchange's actual tradeable range (dev log #191: unrated companies +/-25%, rated
+/-75% of a 3-day average, though observed bounds here are wider than that 2019 formula predicts -
the live numbers win per source-of-truth rules). An order outside this band isn't just
uncompetitive, it's a stale artifact that cannot be freshly placed and will never fill (e.g. a
leftover 334 AIC bid on BHP.AI1 when the floor is 663 - only 19 of 1,060 standing "demand" units
were like this, so the raw demand figure was mostly fine here, but the filter is a hard correctness
fix, not a heuristic).
- `saturation.in_band(price, (lo, hi))`: the band test.
- `saturation.effective_demand(bids, band)`: hard-filters demand to in-band bids. Now used in
`tools/scan.py` stage 2 (`demand_of()`) in place of the raw, unfiltered `Quote.demand`.
- `effective_supply()` gained the same hard band pre-filter (`band=` param) alongside its existing
soft `mult x vwap` "active competition" filter - these are different concepts: band = literally
invalid/stale, mult x vwap = valid but not realistically competing right now (e.g. OCK's far top
tier). Renamed the old positional `band` (vwap multiplier) param to `mult` to free up the name.
- `p_patient()` already accepted `wide_high` as a price ceiling; `scan.py` wasn't passing it before,
now does via `band_of(t)[1]`.
+8
View File
@@ -0,0 +1,8 @@
# Multi-base chain: Nike mines LST, ships it to Deimos, Deimos turns it (+ market AL) into BSE.
# trip_cost/cargo are placeholders (see tools/network.py) until a real fuel model exists.
name: "Nike LST -> Deimos PP2 (BSE)"
bases:
nike: {plan: plans/examples/nike_lst_ext.yaml, cm_free: true} # founded via the faction expansion contract's kit
deimos: plans/examples/deimos_pp2_bse.yaml
transfers:
- {material: LST, src: nike, dst: deimos, trip_cost: 9250, cargo: 500}
+16
View File
@@ -0,0 +1,16 @@
# Deimos: the WHOLE base (EXT+SME+HWP, matching base_plus_hwp.yaml) plus a PP2 making BSE from
# AL (bought at market here; own-AL self-sufficiency is a separate refinement) and LST (received
# from Nike via the network chain, not bought). Must be the whole base, not just the PP2 addition,
# so workforce capacity (existing HB1/HB2) and AL netting resolve correctly (docs/library.md).
name: "[PuGa] Deimos base + HWP + PP2 (BSE from AL+LST)"
planet: ZV-759c
permits: 1
cogc: METALLURGY
hq: false
experts: {METALLURGY: 2}
infrastructure: {HB1: 4, HB2: 1}
buildings:
- {building: EXT, amount: 2, recipes: ["EXT#ALO"]}
- {building: SME, amount: 5, recipes: ["ALO,FLX,C,O=>AL"]}
- {building: HWP, amount: 1, recipes: ["AL,STL,HE=>BHP"]}
- {building: PP2, amount: 1, recipes: ["AL,LST=>BSE"]}
+12
View File
@@ -0,0 +1,12 @@
# Nike (ZV-194a): a new base with just the LST extractor + its pioneer housing.
# Founding assumed via the faction expansion contract's Core Module Kit; only the SEA env
# material (25, low pressure) is a real extra cost, not modelled here (see empire notes).
name: "[PuGa] Nike LST extraction"
planet: ZV-194a
permits: 1
cogc: CONSTRUCTION
hq: false
experts: {}
infrastructure: {HB1: 1}
buildings:
- {building: EXT, amount: 1, recipes: ["EXT#LST"]}
+60
View File
@@ -0,0 +1,60 @@
"""Multi-base chains: combine several independently-simulated plans into one balance sheet.
Each base is simulated on its own (puga.simulate.simulate), which prices every flow at market —
that already gives each base's own correct profit whether or not a material actually leaves the
base. For a material transferred between two bases in the SAME chain (e.g. LST mined at Nike,
consumed at Deimos), the two bases' market-priced flows cancel out in the combined total to within
the transferred quantity (base A "sells" it, base B "buys" it, at the same price basis) — so the
combined total needs only ONE correction: the real freight cost of physically moving it, which
isn't in either base's isolated numbers. Leftover surplus (more produced than a partner needs) is
still validly sold at market by the producing base; leftover deficit is still validly bought.
"""
from dataclasses import dataclass, field
from . import fio
from .simulate import simulate
@dataclass
class BaseNode:
name: str
plan: dict
recipes: list
buildings: list
resources: list
fertility: float
price: callable
faction: str | None = None
permits: tuple[float, float] = (1, 2)
built: dict = field(default_factory=dict)
@dataclass
class Transfer:
material: str
src: str # producing base's name (key in the `bases` dict)
dst: str # consuming base's name
trip_cost: float = 9250.0 # AIC per round trip; PLACEHOLDER until a real route/fuel model exists
cargo: float = 500.0 # t or m3 per trip
def combine(bases: dict[str, BaseNode], transfers: list[Transfer]) -> dict:
mat = {m["Ticker"]: m for m in fio.materials()}
results = {name: simulate(b.plan, b.recipes, b.buildings, b.resources, b.fertility, b.price, b.faction, b.permits, built=b.built)
for name, b in bases.items()}
ledger = []
total_profit = sum(r["profit"] for r in results.values())
for t in transfers:
prod = results[t.src]["flows"].get(t.material, {"out": 0.0, "inp": 0.0})
cons = results[t.dst]["flows"].get(t.material, {"out": 0.0, "inp": 0.0})
avail = prod["out"] - prod["inp"] # net surplus at the source (0 if it's a net consumer)
need = cons["inp"] - cons["out"] # net requirement at the destination
moved = max(0.0, min(avail, need))
m = mat.get(t.material)
weight, volume = moved * (m["Weight"] if m else 0), moved * (m["Volume"] if m else 0)
trips = max(weight, volume) / t.cargo if t.cargo else 0.0
freight = trips * t.trip_cost
total_profit -= freight
ledger.append(dict(material=t.material, src=t.src, dst=t.dst, moved=moved, avail=avail, need=need,
shortfall=max(0.0, need - avail), surplus=max(0.0, avail - need),
weight=weight, volume=volume, trips=trips, freight=freight))
return dict(results=results, ledger=ledger, total_profit=total_profit)
+25 -4
View File
@@ -48,9 +48,30 @@ def p_patient(asks: list[tuple[float, float]], tr: float, produced_per_day: floa
return max(bid, min(p, hi))
def effective_supply(asks: list[tuple[float, float]], ref_price: float | None, band: float = 1.25) -> float:
"""Units of standing sell orders that actually compete: price <= band x reference (vwap7 or ask).
Stale asks far above market (e.g. 1500 units at 1.5x vwap) are not competition. asks = [(price, units)]."""
def in_band(price: float, band: tuple[float | None, float | None] | None) -> bool:
"""band = (low, high), the exchange's tradeable Price Band (FIO order_book's Narrow/WidePriceBandLow/High;
verified live 2026-09-25 to match APEX's own displayed 'Price Band' field exactly). An order outside it is
not just uncompetitive, it is not currently placeable at all, and existing ones there are stale artifacts
(e.g. a leftover 334 AIC bid sitting under a 663 floor) that will never fill; not just a heuristic cutoff."""
if not band:
return True
lo, hi = band
return (lo is None or price >= lo) and (hi is None or price <= hi)
def effective_supply(asks: list[tuple[float, float]], ref_price: float | None, mult: float = 1.25,
band: tuple[float | None, float | None] | None = None) -> float:
"""Units of standing sell orders that actually compete: in the tradeable price band (hard filter, if given)
AND price <= mult x reference (vwap7 or ask; soft filter for "active competition" vs merely valid-but-stale
high asks like OCK's top tier). asks = [(price, units)]."""
asks = [(p, u) for p, u in asks if in_band(p, band)]
if not ref_price:
return sum(u for _, u in asks)
return sum(u for p, u in asks if p <= band * ref_price)
return sum(u for p, u in asks if p <= mult * ref_price)
def effective_demand(bids: list[tuple[float, float]], band: tuple[float | None, float | None] | None) -> float:
"""Units of standing buy orders that are actually tradeable: within the exchange's price band. Filters out
stale/abandoned bids placed under a different band or long since left behind by the market (e.g. a 334 AIC
bid when the floor is 663) rather than genuine, fillable demand. bids = [(price, units)]."""
return sum(u for p, u in bids if in_band(p, band))
+62
View File
@@ -0,0 +1,62 @@
"""puga.network.combine(): the transfer/freight logic, with fake simulate() results (no network calls)."""
import pytest
from puga import network
def fake_results(monkeypatch, per_base):
"""per_base: {name: flows dict} -> patches simulate() to return {"flows": ..., "profit": sum handled by caller}."""
def fake_simulate(plan, recipes, buildings, resources, fertility, price, faction=None, permits=(1, 2), built=None):
return {"flows": per_base[plan["_name"]], "profit": per_base[plan["_name"]].pop("_profit")}
monkeypatch.setattr(network, "simulate", fake_simulate)
def node(name):
return network.BaseNode(name, {"_name": name}, [], [], [], 0.0, lambda t, side="both": 100.0)
def test_transfer_fully_matched_only_charges_freight(monkeypatch):
fake_results(monkeypatch, {
"a": {"LST": {"out": 20.0, "inp": 0.0}, "_profit": 1000.0},
"b": {"LST": {"out": 0.0, "inp": 20.0}, "_profit": 500.0},
})
monkeypatch.setattr(network.fio, "materials", lambda: [{"Ticker": "LST", "Weight": 2.73, "Volume": 1.0}])
r = network.combine({"a": node("a"), "b": node("b")}, [network.Transfer("LST", "a", "b", trip_cost=1000, cargo=500)])
t = r["ledger"][0]
assert t["moved"] == 20.0 and t["shortfall"] == 0 and t["surplus"] == 0
assert r["total_profit"] == pytest.approx(1000 + 500 - t["freight"])
def test_transfer_shortfall_is_flagged_not_double_charged(monkeypatch):
fake_results(monkeypatch, {
"a": {"LST": {"out": 5.0, "inp": 0.0}, "_profit": 1000.0},
"b": {"LST": {"out": 0.0, "inp": 20.0}, "_profit": 500.0}, # needs 20, source only makes 5
})
monkeypatch.setattr(network.fio, "materials", lambda: [{"Ticker": "LST", "Weight": 2.73, "Volume": 1.0}])
r = network.combine({"a": node("a"), "b": node("b")}, [network.Transfer("LST", "a", "b", trip_cost=1000, cargo=500)])
t = r["ledger"][0]
assert t["moved"] == 5.0 and t["shortfall"] == 15.0 and t["surplus"] == 0
# b's own 'profit' already priced the missing 15 units at market (simulate()'s job) - combine() must not touch that
def test_transfer_surplus_is_flagged_and_sold_by_source(monkeypatch):
fake_results(monkeypatch, {
"a": {"LST": {"out": 40.0, "inp": 0.0}, "_profit": 1000.0},
"b": {"LST": {"out": 0.0, "inp": 9.0}, "_profit": 500.0},
})
monkeypatch.setattr(network.fio, "materials", lambda: [{"Ticker": "LST", "Weight": 2.73, "Volume": 1.0}])
r = network.combine({"a": node("a"), "b": node("b")}, [network.Transfer("LST", "a", "b", trip_cost=1000, cargo=500)])
t = r["ledger"][0]
assert t["moved"] == 9.0 and t["surplus"] == 31.0 and t["shortfall"] == 0
def test_freight_scales_with_weight_and_cargo_cap(monkeypatch):
fake_results(monkeypatch, {
"a": {"LST": {"out": 100.0, "inp": 0.0}, "_profit": 0.0},
"b": {"LST": {"out": 0.0, "inp": 100.0}, "_profit": 0.0},
})
monkeypatch.setattr(network.fio, "materials", lambda: [{"Ticker": "LST", "Weight": 2.73, "Volume": 1.0}])
r = network.combine({"a": node("a"), "b": node("b")}, [network.Transfer("LST", "a", "b", trip_cost=1000, cargo=100)])
t = r["ledger"][0]
assert t["weight"] == pytest.approx(273.0)
assert t["trips"] == pytest.approx(2.73) # 273 t / 100 t cargo cap
assert t["freight"] == pytest.approx(2730.0)
+19
View File
@@ -38,3 +38,22 @@ def test_effective_supply_ignores_stale_far_asks():
asks = [(100, 10), (110, 20), (150, 1500)]
assert s.effective_supply(asks, 100) == 30 # 1500 units at 1.5x are not competition
assert s.effective_supply(asks, None) == 1530
def test_effective_supply_hard_filters_out_of_band_asks():
asks = [(100, 10), (110, 20), (150, 1500)]
# band excludes the 150 tier outright, even though ref_price=None would otherwise count it
assert s.effective_supply(asks, None, band=(50, 120)) == 30
def test_in_band():
assert s.in_band(700, (663, 16575)) is True
assert s.in_band(334, (663, 16575)) is False
assert s.in_band(20000, (663, 16575)) is False
assert s.in_band(1, None) is True # no band given: nothing excluded
def test_effective_demand_excludes_stale_bids_below_the_band():
bids = [(4550, 8), (4200, 250), (2810, 8), (334, 19)]
assert s.effective_demand(bids, band=(663, 16575)) == 8 + 250 + 8 # the 334 bid is stale, excluded
assert s.effective_demand(bids, band=None) == 8 + 250 + 8 + 19
+34
View File
@@ -0,0 +1,34 @@
import importlib.util
from pathlib import Path
spec = importlib.util.spec_from_file_location("sell", Path(__file__).resolve().parent.parent / "tools" / "sell.py")
sell = importlib.util.module_from_spec(spec)
spec.loader.exec_module(sell)
TRADES = [5, 10, 15, 20, 25, 30, 35, 40, 45, 50] # evenly spaced: median 25, 80th pct 40
def test_quantile_median_and_80th():
assert sell.quantile(TRADES, 0.5) == 25
assert sell.quantile(TRADES, 0.8) == 40
assert sell.quantile([], 0.5) == 0.0
def test_tranche_split_caps_aggressive_at_quantile_not_full_stock():
aggr, patient = sell.tranche_split(60, TRADES, tight=False)
assert aggr == 25 and patient == 35 # median cap
def test_tranche_split_tight_uses_higher_quantile():
aggr, patient = sell.tranche_split(60, TRADES, tight=True)
assert aggr == 40 and patient == 20 # 80th pct cap, bigger aggressive tranche than non-tight
def test_tranche_split_no_split_when_stock_below_quantile():
aggr, patient = sell.tranche_split(11, TRADES, tight=False)
assert aggr == 11 and patient == 0 # whole stock fits under the median, nothing held back
def test_tranche_split_falls_back_to_full_qty_with_no_history():
aggr, patient = sell.tranche_split(8, [], tight=False)
assert aggr == 8 and patient == 0
+90
View File
@@ -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
View File
@@ -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
View File
@@ -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__":