diff --git a/CLAUDE.md b/CLAUDE.md index eb1ff1b..5e7fedf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,8 @@ Run as `puga ...` (e.g. `puga scan --min-n 3`, `puga plan pull `); - `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 --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 ` 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. diff --git a/docs/library.md b/docs/library.md index 657fc58..103065b 100644 --- a/docs/library.md +++ b/docs/library.md @@ -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 `, 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`). diff --git a/docs/roadmap.md b/docs/roadmap.md index f617818..d076333 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 `). 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: ` (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. diff --git a/docs/saturation-design.md b/docs/saturation-design.md index 9cb069a..61e4d7a 100644 --- a/docs/saturation-design.md +++ b/docs/saturation-design.md @@ -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]`. diff --git a/plans/chains/nike_deimos_lst.yaml b/plans/chains/nike_deimos_lst.yaml new file mode 100644 index 0000000..bebb3ff --- /dev/null +++ b/plans/chains/nike_deimos_lst.yaml @@ -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} diff --git a/plans/examples/deimos_pp2_bse.yaml b/plans/examples/deimos_pp2_bse.yaml new file mode 100644 index 0000000..2d55a23 --- /dev/null +++ b/plans/examples/deimos_pp2_bse.yaml @@ -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"]} diff --git a/plans/examples/nike_lst_ext.yaml b/plans/examples/nike_lst_ext.yaml new file mode 100644 index 0000000..498cb94 --- /dev/null +++ b/plans/examples/nike_lst_ext.yaml @@ -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"]} diff --git a/puga/network.py b/puga/network.py new file mode 100644 index 0000000..d95de4c --- /dev/null +++ b/puga/network.py @@ -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) diff --git a/puga/saturation.py b/puga/saturation.py index 7914d20..661e49c 100644 --- a/puga/saturation.py +++ b/puga/saturation.py @@ -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)) diff --git a/tests/test_network.py b/tests/test_network.py new file mode 100644 index 0000000..662358f --- /dev/null +++ b/tests/test_network.py @@ -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) diff --git a/tests/test_saturation.py b/tests/test_saturation.py index 064b057..ba88b0f 100644 --- a/tests/test_saturation.py +++ b/tests/test_saturation.py @@ -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 diff --git a/tests/test_sell.py b/tests/test_sell.py new file mode 100644 index 0000000..4dd76cb --- /dev/null +++ b/tests/test_sell.py @@ -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 diff --git a/tools/network.py b/tools/network.py new file mode 100755 index 0000000..2657346 --- /dev/null +++ b/tools/network.py @@ -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() diff --git a/tools/scan.py b/tools/scan.py index 6ab0700..5211203 100755 --- a/tools/scan.py +++ b/tools/scan.py @@ -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(): diff --git a/tools/sell.py b/tools/sell.py index e9d515e..c98d33b 100755 --- a/tools/sell.py +++ b/tools/sell.py @@ -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__":