-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathanalysis.py
More file actions
318 lines (283 loc) · 16.9 KB
/
Copy pathanalysis.py
File metadata and controls
318 lines (283 loc) · 16.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
"""
SpaceTraders market analysis — supply-chain trade routing.
The sustained money in SpaceTraders v2 is the SUPPLY CHAIN, not random arbitrage.
A market EXPORTS a good (type=="EXPORT") when it produces it: supply runs
HIGH/ABUNDANT, the purchase price is cheap, and it REFILLS every cycle. Another
market IMPORTS that good (type=="IMPORT") when it consumes it: supply runs SCARCE,
the sell price is dear, and the demand refills every cycle too. Pairing those —
buy where it's exported, sell where it's imported — is a route that keeps paying.
Random buy-low/sell-high on arbitrary goods looks great on paper but saturates
fast: every trade moves the price (capped by tradeVolume), so a 50% spread dies in
two trades while a 10% export->import route refills forever. So we DON'T rank by raw
spread — we rank by margin × tradeVolume (per-cycle throughput): a 10% route moving
60 units beats a 50% route capped at 5.
"""
# PEP 604 unions (`dict | None`) in the signatures below are evaluated at def time,
# so without this future import the module fails to IMPORT on Python 3.9 — and since
# tools.py/fleet.py/__init__.py all import from here, that takes the whole plugin down.
from __future__ import annotations
# Supply tiers, ordered cheapest-to-buy / most-saturated-to-sell-into.
_SUPPLY_ORDER = {"SCARCE": 0, "LIMITED": 1, "MODERATE": 2, "HIGH": 3, "ABUNDANT": 4}
def _supply_tier(supply) -> int:
"""Numeric supply tier (0=SCARCE … 4=ABUNDANT); unknown sorts as MODERATE."""
return _SUPPLY_ORDER.get((supply or "").upper(), 2)
# ── time-aware lane scoring (v2.7) — net profit per SECOND, not per round trip ────────────
# margin × volume is throughput per VISIT, but a visit to a lane 400 units away costs ~6×
# the time of one 60 units away — the fleet's real budget is ship-time, so the research
# round's first finding was that a fat-but-far lane can starve credits/hr while a thinner
# near lane compounds. With waypoint coordinates the score becomes net-per-travel-second.
# First-order CRUISE model (the API's own shape), constants documented so the strategist
# can reason about them:
# * fuel burned ≈ distance (1 fuel per distance unit on CRUISE), costing about
# _FUEL_CR_PER_DIST credits per unit at typical FUEL market prices;
# * travel seconds ≈ distance × 25 / engine speed — scored at a NOMINAL hauler speed
# (_NOMINAL_SPEED, a light hauler's drive) since ranking is relative, not per-hull;
# * plus a fixed _STOP_OVERHEAD_S per stop (dock/undock/orbit + transaction pacing),
# _ROUTE_STOPS stops per leg (the buy end and the sell end).
# With no coordinate map at all the score falls back to the time-blind margin × volume —
# existing callers (and the price-map-only paths) rank exactly as before.
_NOMINAL_SPEED = 30.0 # nominal hauler engine speed (ION_DRIVE_II class)
_STOP_OVERHEAD_S = 15.0 # fixed seconds per stop (dock/undock + market round trip)
_ROUTE_STOPS = 2 # a lane has two ends — one stop overhead at each
_FUEL_CR_PER_DIST = 1.0 # ≈ credits of CRUISE fuel per distance unit
# Distance assumed for a route with a coordinate-UNKNOWN endpoint inside a time-aware
# ranking. The trap this guards: raw margin × volume is credits per VISIT (thousands)
# while the time-aware score is credits per SECOND (< ~100), so letting an unmapped
# route keep its raw base in a mixed ranking made ANY coord-unknown lane structurally
# dominate EVERY mapped one — the ranking systematically preferred unknown-distance
# lanes, the exact inversion of the feature. Instead the unmapped route is scored AS IF
# it were this far out: pessimistic, because an unproven endpoint can't claim to be
# near (the same doctrine as ride_along_pick's coordinate-proven sinks), but finite —
# a monster-throughput unmapped lane can still win, and the moment a probe maps the
# endpoint the real distance takes over. Falling back to the raw time-blind base is
# reserved for a WHOLE ranking without coordinates, where every score shares that unit.
_UNMAPPED_DIST = 400.0 # ≈ a far cross-system lane (the fat-but-far case from research)
def _lane_score(margin: int, volume: int, buy_wp: str, sell_wp: str,
coords: dict | None) -> float:
"""One route's ranking key. Time-blind (margin × volume) when the ranking carries no
coordinate map at all; net-per-second for EVERY route otherwise — the real distance
when both endpoints are mapped, the pessimistic ``_UNMAPPED_DIST`` when either is
not, so all scores in one ranking share one unit (see the constants above). Pure."""
base = margin * max(volume, 1)
if not coords:
return base
b, s = coords.get(buy_wp), coords.get(sell_wp)
if b is None or s is None:
dist = _UNMAPPED_DIST
else:
dist = ((b[0] - s[0]) ** 2 + (b[1] - s[1]) ** 2) ** 0.5
net = base - dist * _FUEL_CR_PER_DIST
secs = dist * 25.0 / _NOMINAL_SPEED + _ROUTE_STOPS * _STOP_OVERHEAD_S
return net / secs
def rank_routes(markets: list[dict], min_margin: int = 1,
sink_supply_cutoff: str = "ABUNDANT",
coords: dict | None = None) -> list[dict]:
"""All profitable supply-chain routes, ranked best-first (same route shape as
``best_route``). ``sink_supply_cutoff`` is the saturation guard: an importer whose
supply is already AT OR ABOVE this tier is skipped as a sell target — it's saturated,
won't pay the premium, and dumping into it just craters the price further (the #1
documented way an unattended bot crashes its own market). Default ABUNDANT skips only
the most saturated sinks; the strategist can tighten it to HIGH/MODERATE via st_tune.
Returns ``[]`` if nothing clears ``min_margin``. Used by the engine to diversify
several haulers across the top-N routes instead of stacking them all on one (which
would saturate it); ``best_route`` returns just the top entry.
``coords`` is an optional ``{waypoint: (x, y)}`` map — when passed, EVERY route is
scored time-aware net-per-second (``_lane_score``): the real distance where both
endpoints are mapped, the pessimistic ``_UNMAPPED_DIST`` where either is not, so
the whole ranking sorts in one unit. With no map at all every route keeps the
time-blind margin × volume score — existing callers rank exactly as before.
"""
if not markets:
return []
cutoff = _supply_tier(sink_supply_cutoff)
exports: dict[str, list] = {} # good -> [(purchasePrice, waypoint, tradeVolume, supply)]
imports: dict[str, list] = {} # good -> [(sellPrice, waypoint, supply, tradeVolume)]
exchanges: dict[str, list] = {} # good -> [(purchasePrice, sellPrice, waypoint, tradeVolume)]
for market in markets:
waypoint = market.get("waypointSymbol")
if not waypoint:
continue
for good in (market.get("tradeGoods") or []):
symbol = good.get("symbol")
if not symbol:
continue
gtype = good.get("type")
bp = good.get("purchasePrice")
sp = good.get("sellPrice")
vol = good.get("tradeVolume") or 0
supply = good.get("supply")
if gtype == "EXPORT":
if bp is None:
continue
exports.setdefault(symbol, []).append((bp, waypoint, vol, supply))
elif gtype == "IMPORT":
if sp is None or _supply_tier(supply) >= cutoff: # saturation guard
continue
imports.setdefault(symbol, []).append((sp, waypoint, supply, vol))
elif gtype == "EXCHANGE":
if bp is None or sp is None:
continue
exchanges.setdefault(symbol, []).append((bp, sp, waypoint, vol))
primary: list[dict] = []
for good in set(exports) & set(imports):
buy_price, buy_wp, buy_vol, _ = min(exports[good], key=lambda x: x[0])
sell_price, sell_wp, _, sink_vol = max(imports[good], key=lambda x: x[0])
if buy_wp == sell_wp:
continue
margin = int(sell_price - buy_price)
if margin < min_margin:
continue
primary.append({"good": good, "buy_at": buy_wp, "sell_at": sell_wp,
"profit_per_unit": margin, "volume": buy_vol,
"buy_price": buy_price, "sell_price": sell_price,
"sink_volume": sink_vol, "kind": "export→import",
"score": _lane_score(margin, buy_vol, buy_wp, sell_wp, coords)})
if primary:
return sorted(primary, key=lambda r: r["score"], reverse=True)
# FALLBACK: cross-market EXCHANGE spreads (no refilling supply chain available).
fallback: list[dict] = []
for good, listings in exchanges.items():
for buy_price, _, buy_wp, buy_vol in listings:
for _, sell_price, sell_wp, sell_vol in listings:
if buy_wp == sell_wp:
continue
margin = int(sell_price - buy_price)
if margin < min_margin:
continue
fallback.append({"good": good, "buy_at": buy_wp, "sell_at": sell_wp,
"profit_per_unit": margin, "volume": buy_vol,
"buy_price": buy_price, "sell_price": sell_price,
# the SINK is the sell waypoint — size the saturation cap
# against ITS tradeVolume, not the buy leg's.
"sink_volume": sell_vol, "kind": "exchange",
"score": _lane_score(margin, buy_vol, buy_wp, sell_wp,
coords)})
return sorted(fallback, key=lambda r: r["score"], reverse=True)
def best_route(markets: list[dict], min_margin: int = 1,
sink_supply_cutoff: str = "ABUNDANT",
coords: dict | None = None) -> dict:
"""
Find the best SUPPLY-CHAIN trade route across a list of markets.
Args:
markets: List of market dicts, each of the form:
{
'waypointSymbol': str,
'tradeGoods': [
{
'symbol': str,
'type': str, # EXPORT | IMPORT | EXCHANGE
'supply': str, # SCARCE | LIMITED | MODERATE | HIGH | ABUNDANT
'tradeVolume': int, # units per trade before the price moves
'purchasePrice': int, # what a ship PAYS to buy here
'sellPrice': int # what the market PAYS a ship to sell here
},
...
]
}
min_margin: cr/unit floor below which a route isn't worth the fuel.
Returns:
The best route, or {} if none:
{
'good': str,
'buy_at': str, # waypoint to buy at (an EXPORT, cheap)
'sell_at': str, # waypoint to sell at (an IMPORT, dear)
'profit_per_unit': int, # sellPrice - purchasePrice
'volume': int, # tradeVolume at the buy leg (throughput)
'kind': str, # "export→import" | "exchange"
'score': int # margin * max(volume, 1) — the ranking key
}
Strategy:
PRIMARY — the supply chain. For each good that is EXPORTED somewhere and
IMPORTED somewhere else, buy at the cheapest exporter and sell at the dearest
importer. These legs refill every cycle, so the route sustains. Rank by
margin × tradeVolume (throughput), NOT raw spread.
FALLBACK — only when no export->import pair exists at all: cross-market
EXCHANGE spreads (buy an exchange good at one waypoint, sell it at another).
These don't refill the way an export/import pair does, so they're a last resort.
``sink_supply_cutoff`` skips importers already saturated at/above that supply
tier (see ``rank_routes``). ``coords`` (``{waypoint: (x, y)}``) opts the WHOLE
ranking into the time-aware net-per-second score — unmapped endpoints score at
the pessimistic ``_UNMAPPED_DIST``, never the incomparable raw base. This is the
single best of the ranked routes; the engine uses ``rank_routes`` directly to
spread haulers across the top-N.
"""
routes = rank_routes(markets, min_margin=min_margin,
sink_supply_cutoff=sink_supply_cutoff, coords=coords)
return routes[0] if routes else {}
# Backward-compat alias — callers should prefer best_route.
best_arbitrage = best_route
def ride_along_pick(origin_goods: list, markets: list, deliver_wp: str, *,
min_margin: int = 1, exclude: str | None = None,
coords: dict | None = None, max_detour: float = 40.0) -> dict | None:
"""Pick a RIDE-ALONG good for a contract leg: something the CURRENT (docked) market
EXPORTS that the contract's DELIVERY stop imports, so the hold's spare room earns on
a trip the contract is paying for anyway (the research round's "never fly half-empty").
``origin_goods`` is the current market's live ``tradeGoods`` (the ship is docked, so
prices are real); only EXPORT listings with a purchase price qualify as buys — the
same cheap-and-refilling doctrine as the route ranker. Sinks come from the recorded
price map (``markets``, ``price_map`` shape): the delivery waypoint itself, plus any
market within ``max_detour`` of it when BOTH coordinates are known — the contract leg
must never grow a real detour, and an unmapped market can't prove it's nearby. Only
IMPORT listings with a recorded sell price qualify as sinks (no blind sells — the
engine's confirmed-sink rule). ``exclude`` drops the contract good itself: extra
units of it in the hold would blend into the delivery count and over-deliver.
Returns ``{good, sell_at, buy_price, sell_price, margin}`` for the best margin at or
above ``min_margin``, else None. Pure — no I/O, host-free testable.
"""
sinks: dict[str, tuple] = {} # good → (best sellPrice, waypoint)
dc = (coords or {}).get(deliver_wp)
for m in markets or []:
wp = m.get("waypointSymbol")
if not wp:
continue
if wp != deliver_wp:
c = (coords or {}).get(wp)
if dc is None or c is None:
continue # can't PROVE it's nearby → not a sink
if ((c[0] - dc[0]) ** 2 + (c[1] - dc[1]) ** 2) ** 0.5 > max_detour:
continue
for g in m.get("tradeGoods") or []:
if g.get("type") != "IMPORT" or g.get("sellPrice") is None:
continue
cur = sinks.get(g.get("symbol"))
if cur is None or g["sellPrice"] > cur[0]:
sinks[g["symbol"]] = (g["sellPrice"], wp)
best = None
for g in origin_goods or []:
sym = g.get("symbol")
if not sym or sym == exclude:
continue
if g.get("type") != "EXPORT" or g.get("purchasePrice") is None:
continue
hit = sinks.get(sym)
if hit is None:
continue
margin = int(hit[0] - g["purchasePrice"])
if margin < min_margin:
continue
if best is None or margin > best["margin"]:
best = {"good": sym, "sell_at": hit[1], "buy_price": g["purchasePrice"],
"sell_price": hit[0], "margin": margin}
return best
def affordable_units(credits: int, unit_price, room: int, vol_cap: int,
*, max_spend_frac: float = 0.5) -> int:
"""How many units to buy in ONE trade without draining the treasury.
Sizes a purchase by the smaller of three caps: cargo ``room``, the saturation cap
``vol_cap`` (≈ one tier-step of the sink's tradeVolume), and — the working-capital guard
the engine was missing — the cash we'll commit, at most ``max_spend_frac`` of current
``credits``. Without the cash cap, a single high-value buy (a full hold of ASSAULT_RIFLES
at ~80k against a 109k treasury) spends almost everything into cargo, and a sell leg that
doesn't fully realize then craters the agent (the documented crash). Capping per-trade
spend to a fraction of cash means even a failed trade leaves most of the treasury intact,
while small trades still proceed at low credits — no deadlock. ``max_spend_frac >= 1``
disables the cash cap. Returns 0 when nothing is affordable (caller skips the buy).
Pure: no I/O, host-free testable.
"""
room = max(0, room)
vol_cap = max(0, vol_cap)
if not unit_price or unit_price <= 0: # price unknown → fall back to size caps only
return min(room, vol_cap)
if max_spend_frac >= 1: # cash cap disabled
return min(room, vol_cap)
budget = max(0, int(max_spend_frac * max(int(credits), 0)))
return max(0, min(room, vol_cap, budget // int(unit_price)))