From c4178fe779c8a9abbc214aa5205a3a0c8b56d1f3 Mon Sep 17 00:00:00 2001 From: Dominik Roth Date: Fri, 9 Oct 2026 10:28:34 +0200 Subject: [PATCH] Add tools/undervalued.py: trading strategy, goods priced below production cost Finds manufactured goods currently selling below their implied production cost (live input cost vs output price) - candidates to buy and hold for a price-reversion bounce, the mirror of the "is this worth building" question saturation.py already answers. Backtested first (326 output tickers, 287 deduplicated dip-onset episodes across 112 distinct tickers): +3mo forward return mean +21.3%/median +8.9%/73.2% hit rate at >=10 units/day volume - real signal, not noise, but only ~0.10-0.24%/day, far below the 14-29%/day marginal ROI this session's production builds found. Documented prominently in the tool's own docstring and CLAUDE.md so it doesn't get mistaken for a primary strategy - it's for excess idle capital once better-yielding builds are exhausted, not before. Promoted daily_prices() out of tools/history.py into puga/history.py so both tools share it, per the self-improve rule. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 1 + docs/library.md | 5 ++++ docs/roadmap.md | 1 + puga/history.py | 13 ++++++++ puga/undervalued.py | 70 ++++++++++++++++++++++++++++++++++++++++++++ tools/history.py | 11 ++----- tools/undervalued.py | 41 ++++++++++++++++++++++++++ 7 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 puga/history.py create mode 100644 puga/undervalued.py create mode 100644 tools/undervalued.py diff --git a/CLAUDE.md b/CLAUDE.md index e1e3ab1..0ed2eea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,7 @@ Run as `puga ...` (e.g. `puga scan --min-n 3`, `puga plan pull `); - `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/whatif.py [--add BUILDING:AMOUNT[:RECIPE]] [--infra HB1:+1] [--after after.yaml]` marginal ROI of adding to a base you already run (whole-plan diff, same rule as buy/runway - pass the WHOLE base, not just the addition). `--add` mutates a copy of `before` in-memory (reuses an existing building's recipe if omitted, e.g. `--add POL:1`); `--after` takes a second full spec instead. Flags any workforce tier the addition newly overloads (vs. a pre-existing one, which it won't flag) - this is what caught the PP2/pioneer-housing and 2nd-CHP/pioneer-housing surprises by hand before this tool existed. - `tools/runway.py [--no-inbound]` days of runway per material before a base stalls: real NET consumption via `simulate()` (self-produced inputs excluded), base storage, PLUS (by default) any ship currently flying to that planet, auto-detected from live flight data (`tools/state.py` tags each ship with its flight origin/destination). Pass the WHOLE base plan (see `docs/library.md`). +- `tools/undervalued.py [--cx AI1] [--min-vol 5] [--top 20]` manufactured goods currently priced below implied production cost - buy-and-hold-for-reversion candidates, not build candidates. **Expected ROI is much lower than production builds** (~0.10-0.24%/day backtested, vs 14-29%/day for the builds this toolkit recommends) - only worth it for excess idle capital once better-yielding builds are exhausted. Library: `puga/undervalued.py`. - `tools/chain.py TICKER` make-vs-buy cost tree plus sourcing depth of inputs. `--consumers` flips it: what recipes EAT this ticker as an input, with each output's market depth - for "is there a deeper downstream product worth chaining into" questions (e.g. checking BOR/BSE/BBH for further processing). - `tools/planet.py RESOURCE [--min-factor X] [--from PLANET] [--top N]` ranks planets for a raw resource by extraction factor, with founding cost straight from FIO's own `BuildRequirements` (real environment-extras materials, not guessed temperature/pressure thresholds - PRUNplanner's own calculations don't model those either) and optional jump distance via `tools/route.py`. Pure-factor ranking alone is misleading - the best-factor planets are often thousands of ly away; always pass `--from` an existing base. Library: `puga/planet.py`. - `tools/route.py ` jump-distance route (not a PRUNplanner feature - built from FIO's `/systemstars` graph, Dijkstra by straight-line ly since that's what FTL fuel/time scale with, jump count reported alongside). Accepts planet natural IDs, system natural IDs, or names. Library: `puga/route.py`. diff --git a/docs/library.md b/docs/library.md index d4d7248..d345334 100644 --- a/docs/library.md +++ b/docs/library.md @@ -49,6 +49,11 @@ EOF -> 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.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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 7d70feb..70778f5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -23,6 +23,7 @@ Legend: [ ] todo, [x] done. Build order matters; each step is usable on its own. 15. [x] `tools/runway.py`: days-of-runway per material from simulate()'s net consumption, base storage plus auto-detected inbound ship cargo (tools/state.py now tags each ship with its live flight origin/destination, and each STORE with its planet, needed once a 2nd base existed). 12. [ ] `tools/portfolio.py`: choose the set of 1-building opportunities for one base that maximises total profit given shared whole-building housing (HB1/HB2/...), capex, permits and (for demolish-later) 60-day value decay. Persistence check exists (`tools/persistence.py`); wire it into the ranking. +14. [x] `tools/undervalued.py`: trading (not building) strategy - manufactured goods priced below implied production cost, candidates for a buy-and-hold-for-reversion bet. Backtested 2026-10-09 (326 output tickers, 287 deduplicated dip-onset episodes, 112 distinct tickers): +3mo forward return mean +21.3%/median +8.9%/73.2% hit rate at >=10 units/day volume - roughly 0.10-0.24%/day, far below the 14-29%/day marginal ROI on production builds found this session. Only worth excess idle capital, not a primary strategy. ## Open questions - 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. diff --git a/puga/history.py b/puga/history.py new file mode 100644 index 0000000..74f6042 --- /dev/null +++ b/puga/history.py @@ -0,0 +1,13 @@ +"""Daily exchange price/volume series from FIO's cxpc candles, shared by tools/history.py and +tools/undervalued.py.""" +import datetime +from . import fio + + +def daily_prices(tk: str, cx: str) -> dict: + """date -> (VWAP price, units traded) for days with trades, from FIO cxpc DAY_ONE candles.""" + out = {} + for e in fio.cxpc(tk, cx): + if e.get("Interval") == "DAY_ONE" and e.get("Traded"): + out[datetime.datetime.fromtimestamp(e["DateEpochMs"] / 1000, datetime.timezone.utc).date()] = (e["Volume"] / e["Traded"], e["Traded"]) + return out diff --git a/puga/undervalued.py b/puga/undervalued.py new file mode 100644 index 0000000..0fabfff --- /dev/null +++ b/puga/undervalued.py @@ -0,0 +1,70 @@ +"""Finds manufactured goods currently selling below their implied production cost (output price +minus input cost, from live daily VWAP candles) - the mirror image of saturation.py's "is this +worth building" question: here we're asking "is this worth buying and holding for a price- +reversion bounce." + +Backtested 2026-10-09 across 326 output tickers / 112 distinct tickers that ever showed the +signal, 287 independent dip-onset episodes (deduplicated so a persisting dip only counts once, +at onset, not once per month it continues): at >=10 units/day traded volume, +3 months forward +price return was mean +21.3%, median +8.9%, 73.2% of episodes positive (see empire/docs or the +session that built this for the raw numbers). That is ~0.10-0.24%/day - MUCH lower than this +project's production-building marginal ROI (14-29%/day seen this session). Only worth deploying +*excess* capital into once better-yielding builds are exhausted, not as a primary strategy, and +it's a gross-return backtest with no spread/freight/storage-fee/capital-lockup cost subtracted. +""" +from . import fio +from .history import daily_prices + + +def manufacturing_candidates(): + """One representative recipe per output ticker, excluding pure-extraction buildings (no + material inputs to compute an implied cost from).""" + seen = {} + for r in fio.recipes(): + if r["BuildingTicker"] in ("EXT", "COL", "RIG") or not r["Inputs"]: + continue + for o in r["Outputs"]: + seen.setdefault(o["Ticker"], r) + return seen + + +def current_margin(ticker: str, recipe: dict, cx: str, window_days: int = 35, recent_days: int = 14): + """Mean margin%/price/volume over the most recent `recent_days` of overlapping price history + for this recipe's inputs+output, within a `window_days` lookback. None if not enough history.""" + ins = {i["Ticker"]: i["Amount"] for i in recipe["Inputs"]} + outs = {o["Ticker"]: o["Amount"] for o in recipe["Outputs"]} + series = {m: daily_prices(m, cx) for m in list(ins) + list(outs)} + if any(not s for s in series.values()): + return None + days = sorted(set().union(*[set(s) for s in series.values()]))[-window_days:] + last = {} + rows = [] + for d in days: + for m, s in series.items(): + if d in s: + last[m] = s[d][0] + if len(last) == len(series): + rev = sum(last[m] * n for m, n in outs.items()) + cost = sum(last[m] * n for m, n in ins.items()) + rows.append((rev, cost, last[ticker], series[ticker].get(d, (0, 0))[1])) + recent = rows[-recent_days:] + if len(recent) < recent_days // 2: + return None + rev_m = sum(x[0] for x in recent) / len(recent) + cost_m = sum(x[1] for x in recent) / len(recent) + px_m = sum(x[2] for x in recent) / len(recent) + vol_m = sum(x[3] for x in recent) / len(recent) + margin_pct = 100 * (rev_m - cost_m) / cost_m if cost_m else 0.0 + return {"margin_pct": margin_pct, "price": px_m, "volume": vol_m, "building": recipe["BuildingTicker"]} + + +def scan_current(cx: str, min_vol: float = 5.0): + """Every manufactured good currently priced below its implied production cost, with enough + volume to actually trade, sorted most-undervalued first.""" + out = [] + for t, r in manufacturing_candidates().items(): + m = current_margin(t, r, cx) + if m and m["margin_pct"] < 0 and m["volume"] >= min_vol: + out.append({"ticker": t, **m}) + out.sort(key=lambda x: x["margin_pct"]) + return out diff --git a/tools/history.py b/tools/history.py index 545ed87..9ea4337 100755 --- a/tools/history.py +++ b/tools/history.py @@ -4,18 +4,11 @@ Daily price = value traded / units traded (VWAP), forward-filled over days witho tools/history.py KV # AI1, the recipe producing KV tools/history.py BHP --cx AI1 --eff 1.18 --overhead 3200 --capex 94000 --months 12 Building/day = batches/day at --eff; profit/day = batches/day * margin_per_batch - overhead (wages etc, --overhead).""" -import argparse, datetime, statistics, sys +import argparse, statistics, sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from puga import config, fio - - -def daily_prices(tk, cx): - out = {} - for e in fio.cxpc(tk, cx): - if e.get("Interval") == "DAY_ONE" and e.get("Traded"): - out[datetime.datetime.fromtimestamp(e["DateEpochMs"] / 1000, datetime.timezone.utc).date()] = (e["Volume"] / e["Traded"], e["Traded"]) - return out +from puga.history import daily_prices def main(): diff --git a/tools/undervalued.py b/tools/undervalued.py new file mode 100644 index 0000000..18538e9 --- /dev/null +++ b/tools/undervalued.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Manufactured goods currently selling below their implied production cost (output price minus +live input cost) - candidates to buy and hold for a price-reversion bounce, not to build. + +EXPECTED ROI IS MUCH LOWER THAN THIS PROJECT'S PRODUCTION BUILDS. Backtest (2026-10-09, 326 +output tickers, 287 independent dip-onset episodes across 112 distinct tickers, >=10 units/day +volume): +3 months forward price return mean +21.3%, median +8.9%, 73.2% of episodes positive. +That's ~0.10-0.24%/day, or very roughly ~41%/year if you could always immediately redeploy into +a new dip (optimistic) - versus 14-29%/day marginal ROI on the production buildings this toolkit +has been recommending. Gross price return only, no spread/freight/storage-fee/capital-lockup cost +subtracted. Only deploy EXCESS capital here once better-yielding builds are exhausted - this is +not a primary strategy. + + tools/undervalued.py # AI1, min 5 units/day traded + tools/undervalued.py --cx AI1 --min-vol 10 --top 10 +""" +import argparse, sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from puga import config, undervalued + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--cx", default=config.DEFAULT_CX) + ap.add_argument("--min-vol", type=float, default=5.0, help="min units/day traded (noise filter, thin markets won't revert reliably)") + ap.add_argument("--top", type=int, default=20) + a = ap.parse_args() + + rows = undervalued.scan_current(a.cx, a.min_vol) + if not rows: + print(f"nothing currently priced below production cost at {a.cx} with volume >= {a.min_vol}/day") + return + print(f"# {a.cx} min-vol>={a.min_vol}/day (gross price return only - see docstring for backtest caveats)") + print(f"{'ticker':6} {'margin%':>8} {'price':>10} {'vol/d':>7} bld") + for r in rows[:a.top]: + print(f"{r['ticker']:6} {r['margin_pct']:8.1f} {r['price']:10.1f} {r['volume']:7.1f} {r['building']}") + + +if __name__ == "__main__": + main()