Add CONTD contract evaluator, deadline-aware buy pricing, actpkg JSON generator
- puga/contd.py, tools/contd.py: price open CONTD contract offers against real buy-fresh cost, flagging conditions that can't be priced from the API alone. - tools/buyat.py: cheapest bid price likely to fill by a hard deadline (stockout risk), using the empirical first-passage model from history.py. - puga/actpkg.py, tools/actpkg.py: Refined PrUn XIT ACT buy-list JSON packages. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
|||||||
|
"""Refined PrUn (github.com/refined-prun/refined-prun) XIT ACT action-package JSON - the semi-auto
|
||||||
|
buy-list format: importing one pre-fills the in-game CXPO buy tile for each material, the player
|
||||||
|
still clicks the real buy button themselves. Schema confirmed from the extension's own source
|
||||||
|
(src/store/user-data.types.d.ts, src/features/XIT/ACT/actions/cx-buy/cx-buy.ts, 2026-10-17).
|
||||||
|
|
||||||
|
Gotcha (verified against the real extension, not just reading the source): `global.name` (and group/
|
||||||
|
action names) get turned directly into an in-game command string by ActionPackageList.vue - spaces
|
||||||
|
become underscores but other punctuation survives into the command literally, and APEX's command
|
||||||
|
parser then rejects it with a generic "Illegal command" error on Edit/Execute (NOT on import, so it
|
||||||
|
looks like the JSON is fine until you actually open it). sanitize_name() strips anything but
|
||||||
|
letters/digits/spaces to avoid this.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_name(name: str) -> str:
|
||||||
|
return re.sub(r"[^A-Za-z0-9 ]", "", name).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def build_package(name: str, materials: dict[str, float], exchange: str = "AI1") -> dict:
|
||||||
|
"""materials: {ticker: qty}. Quantities are rounded up to whole units (CXPO buy amounts aren't
|
||||||
|
fractional)."""
|
||||||
|
name = sanitize_name(name)
|
||||||
|
group = sanitize_name(name.lower()) or "group"
|
||||||
|
import math
|
||||||
|
mats = {tk.upper(): math.ceil(qty) for tk, qty in materials.items() if qty > 0}
|
||||||
|
return {
|
||||||
|
"global": {"name": name},
|
||||||
|
"groups": [
|
||||||
|
{"type": "Manual", "name": group, "materials": mats},
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "CX Buy",
|
||||||
|
"name": f"Buy {name}",
|
||||||
|
"group": group,
|
||||||
|
"exchange": exchange,
|
||||||
|
"priceLimits": {},
|
||||||
|
"buyPartial": False,
|
||||||
|
"allowUnfilled": False,
|
||||||
|
"useCXInv": True,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Player-to-player CONTD contracts (FIO /contract/allcontracts/{user}) - the NPC logistics-officer
|
||||||
|
offers shown in APEX's CONTD panel, not the public Local Market hauling ads in puga/contracts.py.
|
||||||
|
Only OPEN contracts are real choices - exactly one of the pool of (usually 3) offers can be accepted;
|
||||||
|
CANCELLED/REJECTED/FULFILLED/PARTIALLY_FULFILLED are history or already-committed work.
|
||||||
|
|
||||||
|
Condition vocabulary seen in this account's full history (2026-10-16): PAYMENT, REPUTATION,
|
||||||
|
PROVISION_SHIPMENT and COMEX_PURCHASE_PICKUP (a material+qty+address you must supply - these are the
|
||||||
|
only ones with a real market buy-fresh cost), PICKUP_SHIPMENT/DELIVERY_SHIPMENT (logistics legs, no
|
||||||
|
separate cost beyond the material), and open-ended ones this module deliberately does NOT price
|
||||||
|
because their real cost depends on context this API can't see (a ship already flying that route, a
|
||||||
|
planet you were about to survey anyway, a ship already damaged): EXPLORATION (Address = planet to
|
||||||
|
survey), REPAIR_SHIP, HEADQUARTERS_UPGRADE, BASE_CONSTRUCTION, CONTRIBUTION, WAIT,
|
||||||
|
INCREASE_SATISFACTION. Judging whether one of those is actually cheap right now is the caller's job.
|
||||||
|
"""
|
||||||
|
from . import fio, market
|
||||||
|
|
||||||
|
MATERIAL_TYPES = {"PROVISION_SHIPMENT", "COMEX_PURCHASE_PICKUP"}
|
||||||
|
|
||||||
|
|
||||||
|
def open_contracts():
|
||||||
|
me = fio.me()
|
||||||
|
data = fio.own(f"/contract/allcontracts/{me}", ttl=60)
|
||||||
|
return [c for c in data if c["Status"] == "OPEN"]
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate(contract: dict, cx: str) -> dict:
|
||||||
|
"""Payment, priced material requirements (buy-fresh cost at `cx` - assumes none already in free
|
||||||
|
stock, check storage for materials you may already hold), and any unpriced/contextual conditions
|
||||||
|
flagged separately for the caller to judge."""
|
||||||
|
payment = 0.0
|
||||||
|
reputation = False
|
||||||
|
requirements = [] # (ticker, qty, address)
|
||||||
|
flags = [] # (type, address) for conditions this module won't price
|
||||||
|
deadline_days = 0.0
|
||||||
|
for cond in contract["Conditions"]:
|
||||||
|
t = cond["Type"]
|
||||||
|
if t == "PAYMENT" and cond.get("Amount"):
|
||||||
|
payment += cond["Amount"]
|
||||||
|
elif t == "REPUTATION":
|
||||||
|
reputation = True
|
||||||
|
elif t in MATERIAL_TYPES and cond.get("MaterialTicker"):
|
||||||
|
requirements.append((cond["MaterialTicker"], cond["MaterialAmount"], cond.get("Address")))
|
||||||
|
elif t not in MATERIAL_TYPES | {"PAYMENT", "REPUTATION", "PICKUP_SHIPMENT", "DELIVERY_SHIPMENT"}:
|
||||||
|
flags.append((t, cond.get("Address")))
|
||||||
|
dl = cond.get("DeadlineDurationMs")
|
||||||
|
if dl:
|
||||||
|
deadline_days = max(deadline_days, dl / 86400000)
|
||||||
|
|
||||||
|
material_cost = 0.0
|
||||||
|
short = False
|
||||||
|
priced = []
|
||||||
|
for tick, qty, addr in requirements:
|
||||||
|
r = market.walk(tick, cx, qty, "buy")
|
||||||
|
priced.append({"ticker": tick, "qty": qty, "address": addr, "cost": r["total"], "short": r["short"]})
|
||||||
|
material_cost += r["total"]
|
||||||
|
short = short or r["short"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": contract["ContractLocalId"],
|
||||||
|
"partner": contract.get("PartnerName"),
|
||||||
|
"payment": payment,
|
||||||
|
"reputation": reputation,
|
||||||
|
"deadline_days": deadline_days,
|
||||||
|
"requirements": priced,
|
||||||
|
"material_cost": material_cost,
|
||||||
|
"net": payment - material_cost,
|
||||||
|
"short": short,
|
||||||
|
"flags": flags,
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Emit a Refined PrUn XIT ACT action-package JSON for a buy list - paste into the extension's
|
||||||
|
"Import Action Package" (XIT ACT -> IMPORT) to pre-fill the CXPO buy tile for each material; you
|
||||||
|
still click the real buy button yourself. See puga/actpkg.py for the schema/gotcha notes.
|
||||||
|
|
||||||
|
puga actpkg "Nike PP1 BDE buildout" BDE:3 BBH:3 BSE:4 MCG:76 PE:750
|
||||||
|
puga actpkg "Nike PP1 BDE buildout" BDE:3 BBH:3 BSE:4 MCG:76 PE:750 --cx AI1 -o nike_pp1.json
|
||||||
|
"""
|
||||||
|
import argparse, json, sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
from puga import config, actpkg
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("name", help="package name (letters/digits/spaces only - punctuation is stripped)")
|
||||||
|
ap.add_argument("materials", nargs="+", help="TICKER:QTY pairs, e.g. BDE:3 MCG:76")
|
||||||
|
ap.add_argument("--cx", default=config.DEFAULT_CX)
|
||||||
|
ap.add_argument("-o", "--out", help="write to this file instead of stdout")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
mats = {}
|
||||||
|
for m in a.materials:
|
||||||
|
tk, _, qty = m.partition(":")
|
||||||
|
if not qty:
|
||||||
|
sys.exit(f"bad material spec {m!r}, expected TICKER:QTY")
|
||||||
|
mats[tk] = float(qty)
|
||||||
|
|
||||||
|
pkg = actpkg.build_package(a.name, mats, a.cx)
|
||||||
|
text = json.dumps(pkg, indent=2)
|
||||||
|
if a.out:
|
||||||
|
Path(a.out).write_text(text)
|
||||||
|
print(f"wrote {a.out}")
|
||||||
|
else:
|
||||||
|
print(text)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Where to post a BUY order (a resting bid) when there's a hard DEADLINE - not "is patience
|
||||||
|
worth it forever" (that's tools/sell.py's problem, where foregone margin is the only cost of
|
||||||
|
waiting). Here, a bid that never fills means a production-line STOCKOUT (check tools/runway.py
|
||||||
|
for your real deadline), so the right question is: what's the cheapest price that's still likely
|
||||||
|
to fill BY that deadline, with paying the ask as the fallback if it doesn't?
|
||||||
|
|
||||||
|
puga buyat PG 150 --deadline 3 # 3 days of runway left on PG
|
||||||
|
|
||||||
|
For each candidate price between the current ask and best bid, uses puga.history.empirical_touch
|
||||||
|
with a fixed `horizon` (the deadline) to get the REAL empirical P(price actually falls to this
|
||||||
|
level within that many days) - not "ever", which is the wrong question under a deadline. Expected
|
||||||
|
cost = price * P(fill by deadline) + current_ask * (1 - P(fill by deadline)) - the fallback term
|
||||||
|
assumes paying the ask at the deadline if the bid never filled; the ask itself could move by then,
|
||||||
|
so this is an approximation, not a guarantee. Recommends the price minimizing expected cost, but
|
||||||
|
shows the whole curve - pick a more conservative (higher-P) price than the cost-minimizer if a
|
||||||
|
stockout would be expensive in ways this doesn't price in (e.g. the whole base's workforce idling).
|
||||||
|
"""
|
||||||
|
import argparse, sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
from puga import config, fio, market
|
||||||
|
from puga.history import daily_prices, full_series, empirical_touch
|
||||||
|
import math
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("ticker")
|
||||||
|
ap.add_argument("qty", type=float)
|
||||||
|
ap.add_argument("--deadline", type=float, required=True, help="days until this input runs out (tools/runway.py) - the hard constraint, not a soft discount rate")
|
||||||
|
ap.add_argument("--cx", default=config.DEFAULT_CX)
|
||||||
|
a = ap.parse_args()
|
||||||
|
t = a.ticker.upper()
|
||||||
|
ob = fio.order_book(t, a.cx)
|
||||||
|
bids = sorted(((o["ItemCost"], o["ItemCount"] or 0) for o in ob["BuyingOrders"] if o.get("ItemCost")), reverse=True)
|
||||||
|
best_bid = bids[0][0] if bids else None
|
||||||
|
best_ask = ob.get("Ask")
|
||||||
|
|
||||||
|
hit = market.walk(t, a.cx, a.qty, "buy")
|
||||||
|
ask_cost = hit["total"] if not hit["short"] else None
|
||||||
|
print(f"{t}.{a.cx} best bid {best_bid} ask {best_ask} vwap7 {ob.get('PriceAverage')} deadline {a.deadline:g}d")
|
||||||
|
if ask_cost is not None:
|
||||||
|
print(f"instant (pay the asks now): {ask_cost:,.0f} (avg {hit['avg']:.0f}) - the fallback if nothing fills in time")
|
||||||
|
else:
|
||||||
|
print("WARNING: book too thin to fill this quantity at any price right now - the 'pay the ask' fallback isn't actually available at this size")
|
||||||
|
|
||||||
|
dp = daily_prices(t, a.cx)
|
||||||
|
s0 = sorted(dp.items())[-1][1][0] if dp else (best_bid or ob.get("Bid") or 0)
|
||||||
|
series = full_series(t, a.cx)
|
||||||
|
if len(series) < 10:
|
||||||
|
print("\nnot enough price history to estimate fill probability - fall back to paying the ask")
|
||||||
|
return
|
||||||
|
|
||||||
|
candidates = sorted({p for p, _ in bids if p <= s0} | {round(s0 * m, 0) for m in (0.99, 0.97, 0.95, 0.9, 0.85, 0.8, 0.7, 0.6)})
|
||||||
|
candidates = [p for p in candidates if p > 0 and (best_bid is None or p >= best_bid * 0.5)]
|
||||||
|
|
||||||
|
print(f"\nlast traded {s0:.0f}, {len(series)}d history - P(fill) is specifically BY the {a.deadline:g}d deadline, not ever:")
|
||||||
|
print(f" {'price':>10} {'P(fill by deadline)':>20} {'cost if filled':>15} {'expected cost':>14}")
|
||||||
|
rows = []
|
||||||
|
for p in candidates:
|
||||||
|
y = math.log(p / s0) if s0 else 0.0
|
||||||
|
if y >= 0:
|
||||||
|
p_fill = 1.0
|
||||||
|
else:
|
||||||
|
p_fill = empirical_touch(series, y, 0.0, horizon=a.deadline)["touched_frac"]
|
||||||
|
cost_if_filled = p * a.qty
|
||||||
|
fallback = ask_cost if ask_cost is not None else p * a.qty * 1.5 # crude: no real fallback price known
|
||||||
|
expected = p_fill * cost_if_filled + (1 - p_fill) * fallback
|
||||||
|
rows.append((p, p_fill, cost_if_filled, expected))
|
||||||
|
|
||||||
|
best = min(rows, key=lambda r: r[3]) if rows else None
|
||||||
|
for p, p_fill, cost_if_filled, expected in rows:
|
||||||
|
flag = " <- min expected cost" if (p, p_fill, cost_if_filled, expected) == best else ""
|
||||||
|
print(f" {p:>10.0f} {100*p_fill:>19.0f}% {cost_if_filled:>15,.0f} {expected:>14,.0f}{flag}")
|
||||||
|
|
||||||
|
print("\nNOTE: 'expected cost' assumes paying today's ask as the fallback if the bid never fills by the "
|
||||||
|
"deadline - the real ask could move by then, this is an estimate. Pick a price with HIGHER P(fill) "
|
||||||
|
"than the cost-minimizer if a stockout costs more than this model sees (idle workforce, missed orders).")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""List this account's OPEN CONTD contract offers (the NPC logistics-officer pool shown in APEX's
|
||||||
|
CONTD panel - usually 3 at a time, only one acceptable) with payment, priced material requirements,
|
||||||
|
and net. Judging the actual best deal is NOT this tool's job for anything it flags as unpriced
|
||||||
|
(EXPLORATION, REPAIR_SHIP, etc. - see puga/contd.py's docstring) or for situational cheapness (a ship
|
||||||
|
already flying that route, material already in free/uncommitted storage) - it reports the breakdown,
|
||||||
|
the caller reasons about context.
|
||||||
|
|
||||||
|
puga contd
|
||||||
|
puga contd --cx AI1
|
||||||
|
"""
|
||||||
|
import argparse, sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
from puga import config, contd
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--cx", default=config.DEFAULT_CX)
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
contracts = contd.open_contracts()
|
||||||
|
if not contracts:
|
||||||
|
print("no OPEN contract offers right now")
|
||||||
|
return
|
||||||
|
|
||||||
|
for c in contracts:
|
||||||
|
r = contd.evaluate(c, a.cx)
|
||||||
|
print(f"\n{r['id']} partner={r['partner']} deadline {r['deadline_days']:.1f}d"
|
||||||
|
+ (" +reputation" if r["reputation"] else ""))
|
||||||
|
print(f" payment: {r['payment']:,.2f} AIC")
|
||||||
|
for req in r["requirements"]:
|
||||||
|
flag = " SHORT: book can't fill this qty" if req["short"] else ""
|
||||||
|
print(f" requires: {req['ticker']} x{req['qty']:g} @ {req['address']} buy-fresh cost {req['cost']:,.0f}{flag}")
|
||||||
|
for ftype, addr in r["flags"]:
|
||||||
|
print(f" UNPRICED CONDITION: {ftype}" + (f" @ {addr}" if addr else "") + " - context-dependent, judge separately")
|
||||||
|
print(f" material cost: {r['material_cost']:,.0f} -> net (payment - buy-fresh cost): {r['net']:,.0f}")
|
||||||
|
|
||||||
|
priceable = [c for c in contracts if not contd.evaluate(c, a.cx)["flags"]]
|
||||||
|
if len(priceable) == len(contracts):
|
||||||
|
best = max(contracts, key=lambda c: contd.evaluate(c, a.cx)["net"])
|
||||||
|
print(f"\nby buy-fresh net alone, best priced: {best['ContractLocalId']}"
|
||||||
|
" - but check storage for materials you may already hold before trusting this")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user