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 <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,7 @@ Run as `puga <tool> ...` (e.g. `puga scan --min-n 3`, `puga plan pull <uuid>`);
|
||||
- `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/whatif.py <before.yaml> [--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 <spec.yaml|--uuid U> [--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 <from planet/system> <to planet/system>` 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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
+2
-9
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user