Files
PuGa/docs/library.md
T
dodoxandClaude Sonnet 5 12d26c5d68 Docs: register today's fixes, prune completed roadmap items
CLAUDE.md/docs/library.md updated to match the sell.py/parkcash.py/
scan.py changes in the preceding commits. docs/decisions.md gets the
full reasoning trail: the Black-Scholes closed form marked superseded,
the actual backtest entry (BHP/AL touch-probability table) that both
sell.py and library.md already cited but was never written, the
discretization-bias caveat on that backtest, the price-impact-cap and
demolition-value decisions. docs/roadmap.md pruned back to open items
only - finished work is discoverable from CLAUDE.md/library.md/
decisions.md instead of staying listed as a done backlog entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-10 23:07:02 +02:00

199 lines
14 KiB
Markdown

# Using `puga` as a library (not the CLI)
The `tools/*.py` scripts cover the common questions. For anything more involved — a custom
what-if, a one-off cost breakdown with wages and capex, a market check the CLI doesn't expose —
import the modules directly and write a short script inline. This is normal; most answers in
this project's history came from a `python - <<EOF ... EOF` block, not a tool flag.
```sh
. .venv/bin/activate # or use .venv/bin/python directly
python - <<'EOF'
from puga import market, econ, fio, prunplanner as pp, saturation as sat
...
EOF
```
## Modules and what they give you
- **`puga.fio`** — public FIO REST, cached. `exchange_all()`, `order_book(mat, cx)` (full book,
`BuyingOrders`/`SellingOrders`), `recipes()`, `buildings()`, `materials()`, `planets_full()`,
`workforce_needs()`. `own(path, ttl=0)` for the player's authenticated data
(`/sites/{u}`, `/storage/{u}`, `/production/{u}` — has live `Efficiency`/`Condition` per
building, `/workforce/{u}/{planet}`, `/ship/ships/{u}`, `/ship/flights/{u}`). `me()` gives the
configured username.
- **`puga.prunplanner`** — `exchanges()` (has vwap_daily/7d/30d, traded volume — richer than FIO's
book totals), `recipes()`/`buildings()` (different field names than `fio`'s: `building_ticker`,
`material_ticker`, snake_case), `_g(path, ttl)` for any other `/data/...` GET (e.g.
`_g(f"/data/planet/{id}/", 3600)` for fertility, resources, COGC, build requirements).
`request(method, path, body)` for authenticated calls (`Api-Key`) — reads are always fine;
writes must go through `tools/plan_push.py`'s guardrails, don't call `request("POST"/"PUT"/"DELETE", "/planning/plan/...")` directly.
- **`puga.market`** — `snapshot(refresh=False)` returns `{(ticker, cx): Quote}` merging FIO book
totals with PRUNplanner's VWAP/volume; this is the one-stop source for prices, use it instead
of calling `fio`/`prunplanner` separately. `walk(mat, cx, qty, side, refresh=False)` walks the
live order book for a real fill price (`side="buy"` hits asks, `"sell"` hits bids; handles
market-maker orders with `ItemCount: None` as unlimited). `uni30(snap, tk)` = PRUNplanner's
"Universe 30D" price basis (volume-weighted 30d VWAP across all CX).
- **`puga.econ`** — pure formulas, no I/O, ported from PRUNplanner and tested against its own
suite plus live FIO: `tier_efficiency`, `workforce_consumption`, `building_efficiency`,
`production_io`, `daily_extraction`/`extraction_cycle`, `optimize_habs`, `CONSUMPTION` (the
per-tier need table), `HAB_CAP`/`HAB_AREA`, `TIERS`. This is what you use for "what if I
understaff this building" or "what's the real wage bill for N heads" — the CLI tools only
expose fixed slices of this.
- **`puga.saturation`** — `tref` (cheap, Poisson-bounded, for stage-1 screening), `tref_precise`
(real daily-volume variance, for a bounded candidate set), `n_out` (flow x queue, NO demand
term - thin resting-bid books don't disqualify a market sold into via patient asks), `p_patient`,
`effective_supply`, `is_thin` (demand still checked here, independently), `max_units_at_impact`
(real live bid-book depth cap - how many units before realized price degrades past a threshold;
replaced a flat 25%-of-flow guess after a naive price-vs-volume elasticity regression on real
data came back near-zero/wrong-signed, see `docs/decisions.md`): the depth/persistence
primitives behind `scan.py`. Reuse these instead of re-deriving a "how many buildings can the
market take" estimate by hand.
- **`puga.simulate`** — `simulate(plan, recipes, buildings, resources, fertility, price, ...)`:
the PRUNplanner-simulator replica. Reuse this for anything plan-shaped rather than reimplementing
efficiency/workforce/flows again.
- **`puga.route`** — `load_graph()`, `resolve_system(query)` (planet/system natural id or name
-> SystemId), `shortest_path(src, dst, graph=None)` (Dijkstra by straight-line ly over FIO's
`/systemstars` jump graph; returns jumps, distance_ly, path). Not a PRUNplanner feature, built
for this project since travel isn't modelled anywhere else. CLI: `tools/route.py`.
- **`puga.shipping`** — `cruise_leg_fuel(v0)`, `jump_fuel(v1)`, `charge_fuel(v1)`,
`gateway_hop_cost(lock_fee_amount, lock_fee_currency)`, `landing_fuel(planet_natural_id)` (lookup
only, `None` if unmeasured), `trip_fuel(...)` (one-way total, shared by the CLIs),
`leg_cost(src, dst, sf_price, ff_price, v0=1.0, v1=1.0)` (one-way AIC cost; uses
`KNOWN_GATEWAY_ROUTES` when the pair has a measured gateway chain, else the direct-jump graph;
`None` if neither applies), `round_trip_cost(...)` (there-and-back via two `leg_cost` calls,
`None` if either leg is unpriceable or carries a non-AIC fee - used by `tools/network.py`'s
freight cost): real ship flight fuel model, linear in the fuel-usage (v0) / reactor-usage (v1)
sliders, additive/no cross-term. Scoped to this account's one hull/reactor (BP-STRT-0000, both
ships confirmed identical); see the module
docstring for confirmed-vs-open caveats before trusting a number. CLIs: `tools/shiptrip.py`
(ship+from+to, auto-computes everything via `puga.route`), `tools/shipcost.py` (explicit
jumps/sliders/gateway-fees, for when you already know the breakdown). Raw calibration data:
`empire/data/shipping_calibration/`.
- **`puga.parkcash`** — `scan(cx="AI1", min_spread_pct=20.0, days=30, rate=0.05)`: every ticker
with a live quote and a big bid/ask spread, scored on real trading liquidity
(`puga.history.trading_days()`/`trade_gaps_days()`) and an EMPIRICAL round-trip estimate
(`puga.history.empirical_touch()`, two sequential legs: dip to bid, recover to ask) - no
random-walk assumption. CLI: `tools/parkcash.py`. See the module docstring for open gaps (no fee
data; the two legs are treated as independent, an approximation).
- **`puga.history`** — `daily_prices(tk, cx)`, `trading_days(tk, cx, days)` (real calendar-window
trading days, not "last N rows" - a sparse ticker needs this distinction), `trade_gaps_days(dates)`,
`full_series(tk, cx)` (all available history, ascending), `empirical_touch(series, y, rate)`
(non-parametric first-passage stats for a target log-move `y` - hit_factor/touched_frac/
median_days, estimated directly from how often/fast the ticker's own real history has made
moves of that size, by default only drawing on historical days in a similar state to today
(trailing-60d z-score within +/-0.5, `condition_on_state=True`) since mean reversion's effect
depends on where price is now, not just the move size. Optional `horizon` (days, off by
default): bounds both eligibility and the search window to a fixed number of days so every
start-day sample gets the same fair window, instead of recent start days being biased by
running out of data to search. Replaced an earlier driftless-random-walk formula that
backtested wrong, see `docs/decisions.md`). Shared by `tools/history.py`, `tools/sell.py`, `tools/undervalued.py`,
`puga.parkcash`.
- **`puga.contracts`** — `scan(planet, direction, sf_price, ff_price, v0=1.0, v1=1.0)`: live
Local Market hauling ads (FIO `/localmarket/shipping/{source,destination}`), each enriched with
real one-way fuel cost (`puga.shipping.leg_cost`) and margin. CLI: `tools/lmscan.py`.
- **`puga.undervalued`** — `manufacturing_candidates()`, `current_margin(ticker, recipe, cx)`,
`scan_current(cx, min_vol=5.0)`: manufactured goods priced below implied production cost, for
a buy-and-hold-for-reversion trade, not a build decision. Backtested expected return is
~0.10-0.24%/day - far below production's 14-29%/day, only worth excess idle capital. CLI:
`tools/undervalued.py`. Shares `puga.history.daily_prices` with `tools/history.py`.
- **`puga.planet`** — `candidates(resource_ticker, min_factor=0.0)`: every planet with that
resource, sorted by extraction factor, each with temperature/pressure/gravity/fertility/faction
and `build_requirements` (FIO's own founding-cost materials for that specific planet — real
data, not guessed environment thresholds). CLI: `tools/planet.py`, pass `--from` an existing
base or pure-factor ranking is misleading (best-factor planets are often thousands of ly away).
- **`puga.config`** — `get(key)` reads `.env`; `state_path()` returns the real
`empire/state/company.yaml` if synced, else the tracked example.
## Patterns that come up a lot
**Price a basket at real fill cost** (construction lists, working stock, a chain's inputs):
```python
from puga import market, fio
mat = {m["Ticker"]: m for m in fio.materials()}
def price(items, cx="AI1"):
tot = wt = 0
for t, q in items.items():
w = market.walk(t, cx, q, "buy"); tot += w["total"]; wt += q * mat[t]["Weight"]
return tot, wt
```
**Building construction cost incl. MCG**, using either `fio.buildings()` (`Ticker`,
`BuildingCosts`, `AreaCost`) or `prunplanner.buildings()` (`building_ticker`, `costs`,
`area_cost`) — pick one client and stay consistent within a script, the field names differ.
**Efficiency of a hypothetical building**, e.g. staffing/COGC/expert what-ifs:
```python
from puga import econ
eff, elements = econ.building_efficiency(
dict(pioneers=40, settlers=10), {"pioneer": 1.0, "settler": 1.0},
expertise="METALLURGY", cogc="METALLURGY", experts={"METALLURGY": 2},
faction="ANTARES", permits_used=1, permits_total=2)
```
**Live production truth** (skip the model, read the game's own number):
```python
from puga import fio
for line in fio.own(f"/production/{fio.me()}", ttl=0):
print(line["Type"], line["Efficiency"], line["Condition"])
```
**A recipe's daily flows for N buildings**:
```python
io = econ.production_io([dict(time_ms=r["TimeMs"], inputs=..., outputs=...)], eff, n_buildings)
```
## `puga.simulate.simulate()` only knows what the plan tells it
`simulate()` (and anything built on it, e.g. `tools/buy.py`) computes material flows from the
plan_data's own building list — it has no idea what else exists at the base beyond the `built`
dict used for the construction-gap check. Pass a plan covering everything whose production or
consumption matters, not just the piece you're adding, or a self-sufficient material will look
like something you need to buy (a standalone HWP plan says "buy 42.8 AL/day"; the full base+HWP
plan nets that against the smelters to ~0). This bit a first version of `tools/buy.py`; caught by
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.
## Multi-base state: storage and ships are now tagged by location
Once a 2nd base exists, `empire/state/company.yaml`'s `storage` list has multiple `STORE` entries
(one per base) and must be filtered by `planet` (a field `tools/state.py` now adds, from FIO's
`AddressableId` matching a site's `SiteId`) - summing all `STORE` entries blindly (as `tools/buy.py`
did briefly) mixes bases' inventories together. Similarly each entry in `ships` now carries a
`flight` dict (`origin`, `destination`, `eta_ms`) from live `/ship/flights`, keyed to the ship via
`ShipId` (not `StlFuelStoreId`, despite that looking like a plausible match - verified 2026-09-28).
`tools/runway.py`'s `inbound_ships()`/`on_hand()` are the reference implementation for scoping to
one base correctly; reuse them rather than re-deriving this.
## Conventions to keep
- Cache TTLs matter: market data is cached ~15 min, static game data ~24h (`puga/cache.py`).
Pass `refresh=True` if you just changed something in-game and need it now.
- `fio` uses PascalCase ticker/field names (`MaterialTicker`, `TimeMs`); `prunplanner` uses
snake_case (`material_ticker`, `time_ms`). Don't mix them in the same dict without converting.
- Never call `prunplanner.request` with a write verb outside `tools/plan_push.py` — the `[PuGa]`
name guard and dry-run default live there, not in the client.
- One-off scripts like these are fine to throw away; if a calculation gets reused more than once
or two, promote it into a function in the relevant module instead of re-deriving it in a shell
heredoc each time.