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:
@@ -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.
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]`.
|
||||
|
||||
@@ -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}
|
||||
@@ -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"]}
|
||||
@@ -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"]}
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
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