tradedangerous 11.5.2__py3-none-any.whl → 12.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of tradedangerous might be problematic. Click here for more details.

Files changed (39) hide show
  1. tradedangerous/cache.py +567 -395
  2. tradedangerous/cli.py +2 -2
  3. tradedangerous/commands/TEMPLATE.py +25 -26
  4. tradedangerous/commands/__init__.py +8 -16
  5. tradedangerous/commands/buildcache_cmd.py +40 -10
  6. tradedangerous/commands/buy_cmd.py +57 -46
  7. tradedangerous/commands/commandenv.py +0 -2
  8. tradedangerous/commands/export_cmd.py +78 -50
  9. tradedangerous/commands/import_cmd.py +70 -34
  10. tradedangerous/commands/market_cmd.py +52 -19
  11. tradedangerous/commands/olddata_cmd.py +120 -107
  12. tradedangerous/commands/rares_cmd.py +122 -110
  13. tradedangerous/commands/run_cmd.py +118 -66
  14. tradedangerous/commands/sell_cmd.py +52 -45
  15. tradedangerous/commands/shipvendor_cmd.py +49 -234
  16. tradedangerous/commands/station_cmd.py +55 -485
  17. tradedangerous/commands/update_cmd.py +56 -420
  18. tradedangerous/csvexport.py +173 -162
  19. tradedangerous/gui.py +2 -2
  20. tradedangerous/plugins/eddblink_plug.py +389 -252
  21. tradedangerous/plugins/spansh_plug.py +2488 -821
  22. tradedangerous/prices.py +124 -142
  23. tradedangerous/templates/TradeDangerous.sql +6 -6
  24. tradedangerous/tradecalc.py +1227 -1109
  25. tradedangerous/tradedb.py +533 -384
  26. tradedangerous/tradeenv.py +12 -1
  27. tradedangerous/version.py +1 -1
  28. {tradedangerous-11.5.2.dist-info → tradedangerous-12.0.0.dist-info}/METADATA +17 -4
  29. {tradedangerous-11.5.2.dist-info → tradedangerous-12.0.0.dist-info}/RECORD +33 -39
  30. {tradedangerous-11.5.2.dist-info → tradedangerous-12.0.0.dist-info}/WHEEL +1 -1
  31. tradedangerous/commands/update_gui.py +0 -721
  32. tradedangerous/jsonprices.py +0 -254
  33. tradedangerous/plugins/edapi_plug.py +0 -1071
  34. tradedangerous/plugins/journal_plug.py +0 -537
  35. tradedangerous/plugins/netlog_plug.py +0 -316
  36. tradedangerous/templates/database_changes.json +0 -6
  37. {tradedangerous-11.5.2.dist-info → tradedangerous-12.0.0.dist-info}/entry_points.txt +0 -0
  38. {tradedangerous-11.5.2.dist-info → tradedangerous-12.0.0.dist-info/licenses}/LICENSE +0 -0
  39. {tradedangerous-11.5.2.dist-info → tradedangerous-12.0.0.dist-info}/top_level.txt +0 -0
@@ -1,1109 +1,1227 @@
1
- # --------------------------------------------------------------------
2
- # Copyright (C) Oliver 'kfsone' Smith 2014 <oliver@kfs.org>:
3
- # Copyright (C) Bernd 'Gazelle' Gollesch 2016, 2017
4
- # Copyright (C) Jonathan 'eyeonus' Jones 2018, 2019
5
- #
6
- # You are free to use, redistribute, or even print and eat a copy of
7
- # this software so long as you include this copyright notice.
8
- # I guarantee there is at least one bug neither of us knew about.
9
- # --------------------------------------------------------------------
10
- # TradeDangerous :: Modules :: Profit Calculator
11
-
12
- """
13
- TradeCalc provides a class for calculating trade loads, hops or
14
- routes, along with some amount of state.
15
-
16
- The intent was for it to carry a larger amount of state but
17
- much of that got moved into TradeEnv, so right now TradeCalc
18
- looks a little odd.
19
-
20
- Significant Functions:
21
-
22
- Tradecalc.getBestHops
23
- Finds the best "next hop"s given a set of routes.
24
-
25
- Classes:
26
-
27
- TradeCalc
28
- Encapsulates the calculation functions and item-trades,
29
-
30
- Route
31
- Describes a sequence of trade hops.
32
-
33
- TradeLoad
34
- Describe a cargo load to be carried on a hop.
35
- """
36
-
37
- ######################################################################
38
- # Imports
39
-
40
- from collections import defaultdict
41
- from collections import namedtuple
42
- from .tradedb import System, Station, Trade, describeAge
43
- from .tradedb import Destination
44
- from .tradeexcept import TradeException
45
-
46
- import datetime
47
- import locale
48
- import os
49
- from .misc import progress as pbar
50
- import re
51
- import sys
52
- import time
53
-
54
- locale.setlocale(locale.LC_ALL, '')
55
-
56
- ######################################################################
57
- # Exceptions
58
-
59
-
60
- class BadTimestampError(TradeException):
61
-
62
- def __init__(
63
- self,
64
- tdb,
65
- stationID, itemID,
66
- modified
67
- ):
68
- self.station = tdb.stationByID[stationID]
69
- self.item = tdb.itemByID[itemID]
70
- self.modified = modified
71
-
72
- def __str__(self):
73
- return (
74
- "Error loading price data from the local db:\n"
75
- "{} has a StationItem entry for \"{}\" with an invalid "
76
- "modified timestamp: '{}'.".format(
77
- self.station.name(),
78
- self.item.name(),
79
- str(self.modified),
80
- )
81
- )
82
-
83
-
84
- class NoHopsError(TradeException):
85
- pass
86
-
87
- ######################################################################
88
- # Stuff that passes for classes (but isn't)
89
-
90
-
91
- class TradeLoad(namedtuple('TradeLoad', (
92
- 'items', 'gainCr', 'costCr', 'units'
93
- ))):
94
- """
95
- Describes the manifest of items to be exchanged in a
96
- trade.
97
-
98
- Public attributes:
99
- items
100
- A list of (item,qty) tuples tracking the load.
101
- gainCr
102
- The predicted total gain in credits
103
- costCr
104
- How much this load was bought for
105
- units
106
- Total of all the qty values in the items list.
107
- """
108
-
109
- def __bool__(self):
110
- return self.units > 0
111
-
112
- def __lt__(self, rhs):
113
- if self.gainCr < rhs.gainCr:
114
- return True
115
- if rhs.gainCr < self.gainCr:
116
- return False
117
- if self.units < rhs.units:
118
- return True
119
- if rhs.units < self.units:
120
- return False
121
- return self.costCr < rhs.costCr
122
-
123
- @property
124
- def gpt(self):
125
- return self.gainCr / self.units if self.units else 0
126
-
127
-
128
- emptyLoad = TradeLoad((), 0, 0, 0)
129
-
130
- ######################################################################
131
- # Classes
132
-
133
-
134
- class Route:
135
- """
136
- Describes a series of hops where a TradeLoad is picked up at
137
- one station, the player travels via 0 or more hyperspace
138
- jumps and docks at a second station where they unload.
139
- E.g. 10 Algae + 5 Hydrogen at Station A, jump to System2,
140
- jump to System3, dock at Station B, sell everything, buy gold,
141
- jump to system4 and sell everything at Station X.
142
- """
143
- __slots__ = ('route', 'hops', 'startCr', 'gainCr', 'jumps', 'score')
144
-
145
- def __init__(self, stations, hops, startCr, gainCr, jumps, score):
146
- assert stations
147
- self.route = stations
148
- self.hops = hops
149
- self.startCr = startCr
150
- self.gainCr = gainCr
151
- self.jumps = jumps
152
- self.score = score
153
-
154
- @property
155
- def firstStation(self):
156
- """ Returns the first station in the route. """
157
- return self.route[0]
158
-
159
- @property
160
- def firstSystem(self):
161
- """ Returns the first system in the route. """
162
- return self.route[0].system
163
-
164
- @property
165
- def lastStation(self):
166
- """ Returns the last station in the route. """
167
- return self.route[-1]
168
-
169
- @property
170
- def lastSystem(self):
171
- """ Returns the last system in the route. """
172
- return self.route[-1].system
173
-
174
- @property
175
- def avggpt(self):
176
- if self.hops:
177
- return sum(hop.gpt for hop in self.hops) // len(self.hops)
178
- return 0
179
-
180
- @property
181
- def gpt(self):
182
- if self.hops:
183
- return (
184
- sum(hop.gainCr for hop in self.hops) //
185
- sum(hop.units for hop in self.hops)
186
- )
187
- return 0
188
-
189
- def plus(self, dst, hop, jumps, score):
190
- """
191
- Returns a new route describing the sum of this route plus a new hop.
192
- """
193
- return Route(
194
- self.route + (dst,),
195
- self.hops + (hop,),
196
- self.startCr,
197
- self.gainCr + hop[1],
198
- self.jumps + (jumps,),
199
- self.score + score,
200
- )
201
-
202
- def __lt__(self, rhs):
203
- # One route is less than the other if it has a higher score,
204
- # or the scores are even and the number of jumps are shorter.
205
- if self.score == rhs.score:
206
- return len(self.jumps) < len(rhs.jumps)
207
- return self.score > rhs.score
208
-
209
- def __eq__(self, rhs):
210
- return self.score == rhs.score and len(self.jumps) == len(rhs.jumps)
211
-
212
- def text(self, colorize) -> str:
213
- return "%s -> %s" % (colorize("cyan", self.firstStation.name()), colorize("blue", self.lastStation.name()))
214
-
215
- def detail(self, tdenv):
216
- """
217
- Return a string describing this route to a given level of detail.
218
- """
219
-
220
- detail, goalSystem = tdenv.detail, tdenv.goalSystem
221
-
222
- colorize = tdenv.colorize if tdenv.color else lambda x, y: y
223
-
224
- credits = self.startCr + (tdenv.insurance or 0) # pylint: disable=redefined-builtin
225
- gainCr = 0
226
- route = self.route
227
-
228
- hops = self.hops
229
-
230
- # TODO: Write as a comprehension, just can't wrap my head
231
- # around it this morning.
232
- def genSubValues():
233
- for hop in hops:
234
- for tr, _ in hop[0]:
235
- yield len(tr.name(detail))
236
-
237
- longestNameLen = max(genSubValues())
238
-
239
- text = self.text(colorize)
240
- if detail >= 1:
241
- text += " (score: {:f})".format(self.score)
242
- text += "\n"
243
- jumpsFmt = " Jump {jumps}\n"
244
- cruiseFmt = " Supercruise to {stn}\n"
245
- distFmt = None
246
- if detail > 1:
247
- if detail > 2:
248
- text += self.summary() + "\n"
249
- if tdenv.maxJumpsPer > 1:
250
- distFmt = " Direct: {dist:0.2f}ly, Trip: {trav:0.2f}ly\n"
251
- hopFmt = (
252
- " Load from "
253
- +colorize("cyan", "{station}") +
254
- ":\n{purchases}"
255
- )
256
- hopStepFmt = (
257
- colorize("lightYellow", " {qty:>4}") +
258
- " x "
259
- +colorize("yellow", "{item:<{longestName}} ") +
260
- "{eacost:>8n}cr vs {easell:>8n}cr, "
261
- "{age}"
262
- )
263
- if detail > 2:
264
- hopStepFmt += ", total: {ttlcost:>10n}cr"
265
- hopStepFmt += "\n"
266
- if not tdenv.summary:
267
- dockFmt = (
268
- " Unload at "
269
- +colorize("lightBlue", "{station}") +
270
- " => Gain {gain:n}cr "
271
- "({tongain:n}cr/ton) => {credits:n}cr\n"
272
- )
273
- else:
274
- jumpsFmt = re.sub(" ", " ", jumpsFmt, re.M)
275
- cruiseFmt = re.sub(" ", " ", cruiseFmt, re.M)
276
- if distFmt:
277
- distFmt = re.sub(" ", " ", distFmt, re.M)
278
- hopFmt = "\n" + hopFmt
279
- dockFmt = " Expect to gain {gain:n}cr ({tongain:n}cr/ton)\n"
280
- footer = ' ' + '-' * 76 + "\n"
281
- endFmt = (
282
- "Finish at "
283
- +colorize("blue", "{station} ") +
284
- "gaining {gain:n}cr ({tongain:n}cr/ton) "
285
- "=> est {credits:n}cr total\n"
286
- )
287
- elif detail:
288
- hopFmt = (
289
- " Load from "
290
- +colorize("cyan", "{station}") +
291
- ":{purchases}\n"
292
- )
293
- hopStepFmt = (
294
- colorize("lightYellow", " {qty}") +
295
- " x "
296
- +colorize("yellow", "{item}") +
297
- " (@{eacost}cr),")
298
- footer = None
299
- dockFmt = (
300
- " Dock at " +
301
- colorize("lightBlue", "{station}\n")
302
- )
303
- endFmt = (
304
- " Finish "
305
- +colorize("blue", "{station} ") +
306
- "+ {gain:n}cr ({tongain:n}cr/ton)"
307
- "=> {credits:n}cr\n"
308
- )
309
- else:
310
- hopFmt = colorize("cyan", " {station}:{purchases}\n")
311
- hopStepFmt = (
312
- colorize("lightYellow", " {qty}") +
313
- " x "
314
- +colorize("yellow", "{item}") +
315
- ","
316
- )
317
- footer = None
318
- dockFmt = None
319
- endFmt = (
320
- colorize("blue", " {station}") +
321
- " +{gain:n}cr ({tongain:n}/ton)"
322
- )
323
-
324
- def jumpList(jumps):
325
- text, last = "", None
326
- travelled = 0.
327
- for jump in jumps:
328
- if last:
329
- dist = last.distanceTo(jump)
330
- if dist:
331
- if tdenv.detail:
332
- text += ", {:.2f}ly -> ".format(dist)
333
- else:
334
- text += " -> "
335
- else:
336
- text += " >>> "
337
- travelled += dist
338
- text += jump.name()
339
- last = jump
340
- return travelled, text
341
-
342
- if detail > 1:
343
-
344
- def decorateStation(station):
345
- details = []
346
- if station.lsFromStar:
347
- details.append(station.distFromStar(True))
348
- if station.blackMarket != '?':
349
- details.append('BMk:' + station.blackMarket)
350
- if station.maxPadSize != '?':
351
- details.append('Pad:' + station.maxPadSize)
352
- if station.planetary != '?':
353
- details.append('Plt:' + station.planetary)
354
- if station.fleet != '?':
355
- details.append('Flc:' + station.fleet)
356
- if station.odyssey != '?':
357
- details.append('Ody:' + station.odyssey)
358
- if station.shipyard != '?':
359
- details.append('Shp:' + station.shipyard)
360
- if station.outfitting != '?':
361
- details.append('Out:' + station.outfitting)
362
- if station.refuel != '?':
363
- details.append('Ref:' + station.refuel)
364
- details = "{} ({})".format(
365
- station.name(),
366
- ", ".join(details or ["no details"])
367
- )
368
- return details
369
-
370
- else:
371
-
372
- def decorateStation(station):
373
- return station.name()
374
-
375
- if detail and goalSystem:
376
-
377
- def goalDistance(station):
378
- return " [Distance to {}: {:.2f} ly]\n".format(
379
- goalSystem.name(),
380
- station.system.distanceTo(goalSystem),
381
- )
382
-
383
- else:
384
-
385
- def goalDistance(station):
386
- return ""
387
-
388
- for i, hop in enumerate(hops):
389
- hopGainCr, hopTonnes = hop[1], 0
390
- purchases = ""
391
- for (trade, qty) in sorted(
392
- hop[0],
393
- key = lambda tradeOpt: tradeOpt[1] * tradeOpt[0].gainCr,
394
- reverse = True
395
- ):
396
- # Are they within 30 minutes of each other?
397
- if abs(trade.srcAge - trade.dstAge) <= (30 * 60):
398
- age = max(trade.srcAge, trade.dstAge)
399
- age = describeAge(age)
400
- else:
401
- srcAge = describeAge(trade.srcAge)
402
- dstAge = describeAge(trade.dstAge)
403
- age = "{} vs {}".format(srcAge, dstAge)
404
- purchases += hopStepFmt.format(
405
- qty = qty, item = trade.name(detail),
406
- eacost = trade.costCr,
407
- easell = trade.costCr + trade.gainCr,
408
- ttlcost = trade.costCr * qty,
409
- longestName = longestNameLen,
410
- age = age,
411
- )
412
- hopTonnes += qty
413
- text += goalDistance(route[i])
414
- text += hopFmt.format(
415
- station = decorateStation(route[i]),
416
- purchases = purchases
417
- )
418
- if tdenv.showJumps and jumpsFmt and self.jumps[i]:
419
- startStn = route[i]
420
- endStn = route[i + 1]
421
- if startStn.system is not endStn.system:
422
- fmt = jumpsFmt
423
- travelled, jumps = jumpList(self.jumps[i])
424
- else:
425
- fmt = cruiseFmt
426
- travelled, jumps = 0., "{start} >>> {stop}".format(
427
- start = startStn.name(), stop = endStn.name()
428
- )
429
- text += fmt.format(
430
- jumps = jumps,
431
- gain = hopGainCr,
432
- tongain = hopGainCr / hopTonnes,
433
- credits = credits + gainCr + hopGainCr,
434
- stn = route[i + 1].dbname
435
- )
436
- if travelled and distFmt and len(self.jumps[i]) > 2:
437
- text += distFmt.format(
438
- dist = startStn.system.distanceTo(endStn.system),
439
- trav = travelled,
440
- )
441
- if dockFmt:
442
- stn = route[i + 1]
443
- text += dockFmt.format(
444
- station = decorateStation(stn),
445
- gain = hopGainCr,
446
- tongain = hopGainCr / hopTonnes,
447
- credits = credits + gainCr + hopGainCr
448
- )
449
-
450
- gainCr += hopGainCr
451
-
452
- lastStation = self.lastStation
453
- if lastStation.system is not goalSystem:
454
- text += goalDistance(lastStation)
455
- text += footer or ""
456
- text += endFmt.format(
457
- station = decorateStation(lastStation),
458
- gain = gainCr,
459
- credits = credits + gainCr,
460
- tongain = self.gpt
461
- )
462
-
463
- return text
464
-
465
- def summary(self):
466
- """
467
- Returns a string giving a short summary of this route.
468
- """
469
-
470
- credits, hops, jumps = self.startCr, self.hops, self.jumps # pylint: disable=redefined-builtin
471
- ttlGainCr = sum(hop[1] for hop in hops)
472
- numJumps = sum(
473
- len(hopJumps) - 1
474
- for hopJumps in jumps
475
- if hopJumps # don't include in-system hops
476
- )
477
- return (
478
- "Start CR: {start:10n}\n"
479
- "Hops : {hops:10n}\n"
480
- "Jumps : {jumps:10n}\n"
481
- "Gain CR : {gain:10n}\n"
482
- "Gain/Hop: {hopgain:10n}\n"
483
- "Final CR: {final:10n}\n" . format(
484
- start = credits,
485
- hops = len(hops),
486
- jumps = numJumps,
487
- gain = ttlGainCr,
488
- hopgain = ttlGainCr // len(hops),
489
- final = credits + ttlGainCr
490
- )
491
- )
492
-
493
-
494
- class TradeCalc:
495
- """
496
- Container for accessing trade calculations with common properties.
497
- """
498
-
499
- def __init__(self, tdb, tdenv = None, fit = None, items = None):
500
- """
501
- Constructs the TradeCalc object and loads sell/buy data.
502
-
503
- Parameters:
504
- tdb
505
- The TradeDB() object to use to access data,
506
- tdenv [optional]
507
- TradeEnv() that controls behavior,
508
- fit [optional]
509
- Lets you specify a fitting function,
510
- items [optional]
511
- Iterable [itemID or Item()] that restricts loading,
512
-
513
- TradeEnv options:
514
- tdenv.avoidItems
515
- Iterable of [Item] that prevents items being loaded
516
- tdenv.maxAge
517
- Maximum age in days of data that gets loaded
518
- tdenv.supply
519
- Require at least this much supply to load an item
520
- tdenv.demand
521
- Require at least this much demand to load an item
522
- """
523
- if not tdenv:
524
- tdenv = tdb.tdenv
525
- self.tdb = tdb
526
- self.tdenv = tdenv
527
- self.defaultFit = fit or self.simpleFit
528
- if "BRUTE_FIT" in os.environ:
529
- self.defaultFit = self.bruteForceFit
530
- minSupply = self.tdenv.supply or 0
531
- minDemand = self.tdenv.demand or 0
532
-
533
- db = tdb.getDB()
534
-
535
- wheres, binds = [], []
536
- if tdenv.maxAge:
537
- maxDays = datetime.timedelta(days = tdenv.maxAge)
538
- cutoff = datetime.datetime.now() - maxDays
539
- wheres.append("(modified >= ?)")
540
- binds.append(str(cutoff.replace(microsecond = 0)))
541
-
542
- if tdenv.avoidItems or items:
543
- avoidItemIDs = set(item.ID for item in tdenv.avoidItems)
544
- loadItems = items or tdb.itemByID.values()
545
- loadItemSet = set()
546
- for item in loadItems:
547
- ID = item if isinstance(item, int) else item.ID
548
- if ID not in avoidItemIDs:
549
- loadItemSet.add(str(ID))
550
- if not loadItemSet:
551
- raise TradeException("No items to load.")
552
- load_ids = ",".join(str(ID) for ID in loadItemSet)
553
- wheres.append(f"(item_id IN ({load_ids}))")
554
-
555
- demand = self.stationsBuying = defaultdict(list)
556
- supply = self.stationsSelling = defaultdict(list)
557
-
558
- whereClause = " AND ".join(wheres) or "1"
559
-
560
- lastStnID = 0
561
- dmdCount, supCount = 0, 0
562
- stmt = """
563
- SELECT station_id, item_id,
564
- strftime('%s', modified),
565
- demand_price, demand_units, demand_level,
566
- supply_price, supply_units, supply_level
567
- FROM StationItem
568
- WHERE {where}
569
- """.format(where = whereClause)
570
- tdenv.DEBUG1("TradeCalc loading StationItem values")
571
- tdenv.DEBUG2("sql: {}, binds: {}", stmt, binds)
572
- cur = db.execute(stmt, binds)
573
- now = int(time.time())
574
- for (stnID, itmID,
575
- timestamp,
576
- dmdCr, dmdUnits, dmdLevel,
577
- supCr, supUnits, supLevel) in cur:
578
- if stnID != lastStnID:
579
- dmdAppend = demand[stnID].append
580
- supAppend = supply[stnID].append
581
- lastStnID = stnID
582
- try:
583
- ageS = now - int(timestamp)
584
- except TypeError:
585
- raise BadTimestampError(
586
- self.tdb,
587
- stnID, itmID, timestamp
588
- ) from None
589
- if dmdCr > 0:
590
- if not minDemand or dmdUnits >= minDemand:
591
- dmdAppend((itmID, dmdCr, dmdUnits, dmdLevel, ageS))
592
- dmdCount += 1
593
- if supCr > 0 and supUnits:
594
- if not minSupply or supUnits >= minSupply:
595
- supAppend((itmID, supCr, supUnits, supLevel, ageS))
596
- supCount += 1
597
-
598
- tdenv.DEBUG0("Loaded {} buys, {} sells".format(dmdCount, supCount))
599
-
600
- def bruteForceFit(self, items, credits, capacity, maxUnits): # pylint: disable=redefined-builtin
601
- """
602
- Brute-force generation of all possible combinations of items.
603
- This is provided to make it easy to validate the results of future
604
- variants or optimizations of the fit algorithm.
605
- """
606
-
607
- def _fitCombos(offset, cr, cap, level = 1):
608
- if cr <= 0 or cap <= 0:
609
- return emptyLoad
610
- while True:
611
- if offset >= len(items):
612
- return emptyLoad
613
- item = items[offset]
614
- offset += 1
615
-
616
- itemCost = item.costCr
617
- maxQty = min(maxUnits, cap, cr // itemCost)
618
-
619
- if item.supply < maxQty and item.supply > 0: # -1 = unknown
620
- maxQty = min(maxQty, item.supply)
621
-
622
- if maxQty > 0:
623
- break
624
-
625
- # find items that don't include us
626
- bestLoad = _fitCombos(offset, cr, cap, level + 1)
627
- itemGain = item.gainCr
628
-
629
- for qty in range(1, maxQty + 1):
630
- loadGain, loadCost = itemGain * qty, itemCost * qty
631
- load = TradeLoad(((item, qty),), loadGain, loadCost, qty)
632
- subLoad = _fitCombos(
633
- offset, cr - loadCost, cap - qty, level + 1
634
- )
635
- combGain = loadGain + subLoad.gainCr
636
- if combGain < bestLoad.gainCr:
637
- continue
638
- combCost = loadCost + subLoad.costCr
639
- combUnits = qty + subLoad.units
640
- if combGain == bestLoad.gainCr:
641
- if combUnits > bestLoad.units:
642
- continue
643
- if combUnits == bestLoad.units:
644
- if combCost >= bestLoad.costCr:
645
- continue
646
- bestLoad = TradeLoad(
647
- load.items + subLoad.items,
648
- combGain, combCost, combUnits
649
- )
650
-
651
- return bestLoad
652
-
653
- return _fitCombos(0, credits, capacity)
654
-
655
- def fastFit(self, items, credits, capacity, maxUnits): # pylint: disable=redefined-builtin
656
- """
657
- Best load calculator using a recursive knapsack-like
658
- algorithm to find multiple loads and return the best.
659
- [eyeonus] Left in for the masochists, as this becomes
660
- horribly slow at stations with many items for sale.
661
- As in iooks-like-the-program-has-frozen slow.
662
- """
663
-
664
- def _fitCombos(offset, cr, cap):
665
- """
666
- Starting from offset, consider a scenario where we
667
- would purchase the maximum number of each item
668
- given the cr+cap limitations. Then, assuming that
669
- load, solve for the remaining cr+cap from the next
670
- value of offset.
671
-
672
- The "best fit" is not always the most profitable,
673
- so we yield all the results and leave the caller
674
- to determine which is actually most profitable.
675
- """
676
-
677
- bestGainCr = -1
678
- bestItem = None
679
- bestQty = 0
680
- bestCostCr = 0
681
- bestSub = None
682
-
683
- qtyCeil = min(maxUnits, cap)
684
-
685
- for iNo in range(offset, len(items)):
686
- item = items[iNo]
687
- itemCostCr = item.costCr
688
- maxQty = min(qtyCeil, cr // itemCostCr)
689
-
690
- if maxQty <= 0:
691
- continue
692
-
693
- supply = item.supply
694
- if supply <= 0:
695
- continue
696
-
697
- maxQty = min(maxQty, supply)
698
-
699
- itemGainCr = item.gainCr
700
- if maxQty == cap:
701
- # full load
702
- gain = itemGainCr * maxQty
703
- if gain > bestGainCr:
704
- cost = itemCostCr * maxQty
705
- # list is sorted by gain DESC, cost ASC
706
- bestGainCr = gain
707
- bestItem = item
708
- bestQty = maxQty
709
- bestCostCr = cost
710
- bestSub = None
711
- break
712
-
713
- loadCostCr = maxQty * itemCostCr
714
- loadGainCr = maxQty * itemGainCr
715
- if loadGainCr > bestGainCr:
716
- bestGainCr = loadGainCr
717
- bestCostCr = loadCostCr
718
- bestItem = item
719
- bestQty = maxQty
720
- bestSub = None
721
-
722
- crLeft, capLeft = cr - loadCostCr, cap - maxQty
723
- if crLeft > 0 and capLeft > 0:
724
- # Solve for the remaining credits and capacity with what
725
- # is left in items after the item we just checked.
726
- subLoad = _fitCombos(iNo + 1, crLeft, capLeft)
727
- if subLoad is emptyLoad:
728
- continue
729
- ttlGain = loadGainCr + subLoad.gainCr
730
- if ttlGain < bestGainCr:
731
- continue
732
- ttlCost = loadCostCr + subLoad.costCr
733
- if ttlGain == bestGainCr and ttlCost >= bestCostCr:
734
- continue
735
- bestGainCr = ttlGain
736
- bestItem = item
737
- bestQty = maxQty
738
- bestCostCr = ttlCost
739
- bestSub = subLoad
740
-
741
- if not bestItem:
742
- return emptyLoad
743
-
744
- bestLoad = ((bestItem, bestQty),)
745
- if bestSub:
746
- bestLoad = bestLoad + bestSub.items
747
- bestQty += bestSub.units
748
- return TradeLoad(bestLoad, bestGainCr, bestCostCr, bestQty)
749
-
750
- return _fitCombos(0, credits, capacity)
751
-
752
- # Mark's test run, to spare searching back through the forum posts for it.
753
- # python trade.py run --fr="Orang/Bessel Gateway" --cap=720 --cr=11b --ly=24.73 --empty=37.61 --pad=L --hops=2 --jum=3 --loop --summary -vv --progress
754
- def simpleFit(self, items, credits, capacity, maxUnits): # pylint: disable=redefined-builtin
755
- """
756
- Simplistic load calculator:
757
- (The item list is sorted with highest profit margin items in front.)
758
- Step 1: Fill hold with as much of item1 as possible based on the limiting
759
- factors of hold size, supply amount, and available credits.
760
-
761
- Step 2: If there is space in the hold and money available, repeat Step 1
762
- with item2, item3, etc. until either the hold is filled
763
- or the commander is too poor to buy more.
764
-
765
- When amount of credits isn't a limiting factor, this should produce
766
- the most profitable route ~99.7% of the time, and still be very
767
- close to the most profitable the rest of the time.
768
- (Very close = not enough less profit that anyone should care,
769
- especially since this thing doesn't suffer slowdowns like fastFit.)
770
- """
771
-
772
- n = 0
773
- load = ()
774
- gainCr = 0
775
- costCr = 0
776
- qty = 0
777
- while n < len(items) and credits > 0 and capacity > 0:
778
- qtyCeil = min(maxUnits, capacity)
779
-
780
- item = items[n]
781
- maxQty = min(qtyCeil, credits // item.costCr)
782
-
783
- if maxQty > 0 and item.supply > 0:
784
- maxQty = min(maxQty, item.supply)
785
-
786
- loadCostCr = maxQty * item.costCr
787
- loadGainCr = maxQty * item.gainCr
788
-
789
- load = load + ((item, maxQty),)
790
- qty += maxQty
791
- capacity -= maxQty
792
-
793
- gainCr += loadGainCr
794
- costCr += loadCostCr
795
- credits -= loadCostCr
796
-
797
- n += 1
798
-
799
- return TradeLoad(load, gainCr, costCr, qty)
800
-
801
- def getTrades(self, srcStation, dstStation, srcSelling = None):
802
- """
803
- Returns the most profitable trading options from
804
- one station to another (uni-directional).
805
- """
806
- if not srcSelling:
807
- srcSelling = self.stationsSelling.get(srcStation.ID, None)
808
- if not srcSelling:
809
- return None
810
- dstBuying = self.stationsBuying.get(dstStation.ID, None)
811
- if not dstBuying:
812
- return None
813
-
814
- trading = []
815
- itemIdx = self.tdb.itemByID
816
- minGainCr = max(1, self.tdenv.minGainPerTon or 1)
817
- maxGainCr = max(minGainCr, self.tdenv.maxGainPerTon or sys.maxsize)
818
- getBuy = {buy[0]: buy for buy in dstBuying}.get
819
- addTrade = trading.append
820
- for sell in srcSelling: # should be the smaller list
821
- buy = getBuy(sell[0], None)
822
- if buy:
823
- gainCr = buy[1] - sell[1]
824
- if minGainCr <= gainCr <= maxGainCr:
825
- addTrade(Trade(
826
- itemIdx[sell[0]],
827
- sell[1], gainCr,
828
- sell[2], sell[3],
829
- buy[2], buy[3],
830
- sell[4], buy[4],
831
- ))
832
-
833
- # SORT BY profit DESC, cost ASC
834
- # So if two items have the same profit, the cheapest will come first.
835
- trading.sort(key = lambda trade: trade.costCr)
836
- trading.sort(key = lambda trade: trade.gainCr, reverse = True)
837
-
838
- return trading
839
-
840
- def getBestHops(self, routes, restrictTo = None):
841
- """
842
- Given a list of routes, try all available next hops from each
843
- route.
844
-
845
- Store the results by destination so that we pick the
846
- best route-to-point for each destination at each step.
847
-
848
- If we have two routes: A->B->D, A->C->D and A->B->D produces
849
- more profit, there's no point continuing the A->C->D path.
850
- """
851
-
852
- tdb = self.tdb
853
- tdenv = self.tdenv
854
- avoidPlaces = getattr(tdenv, 'avoidPlaces', None) or ()
855
- assert not restrictTo or isinstance(restrictTo, set)
856
- maxJumpsPer = tdenv.maxJumpsPer
857
- maxLyPer = tdenv.maxLyPer
858
- maxPadSize = tdenv.padSize
859
- planetary = tdenv.planetary
860
- fleet = tdenv.fleet
861
- odyssey = tdenv.odyssey
862
- noPlanet = tdenv.noPlanet
863
- maxLsFromStar = tdenv.maxLs or float('inf')
864
- reqBlackMarket = getattr(tdenv, 'blackMarket', False) or False
865
- maxAge = getattr(tdenv, 'maxAge') or 0
866
- credits = tdenv.credits - (getattr(tdenv, 'insurance', 0) or 0) # pylint: disable=redefined-builtin
867
- fitFunction = self.defaultFit
868
- capacity = tdenv.capacity
869
- maxUnits = getattr(tdenv, 'limit') or capacity
870
-
871
- bestToDest = {}
872
- safetyMargin = 1.0 - tdenv.margin
873
- unique = tdenv.unique
874
- loopInt = getattr(tdenv, 'loopInt', 0) or None
875
-
876
- # Penalty is expressed as percentage, reduce it to a multiplier
877
- if tdenv.lsPenalty:
878
- lsPenalty = max(min(tdenv.lsPenalty / 100, 1), 0)
879
- else:
880
- lsPenalty = 0
881
-
882
- goalSystem = tdenv.goalSystem
883
- uniquePath = None
884
-
885
- restrictStations = set()
886
- if restrictTo:
887
- for place in restrictTo:
888
- if isinstance(place, Station):
889
- restrictStations.add(place)
890
- elif isinstance(place, System) and place.stations:
891
- restrictStations.update(place.stations)
892
-
893
- # Are we doing direct routes?
894
- if tdenv.direct:
895
- if goalSystem and not restrictTo:
896
- restrictTo = (goalSystem,)
897
- restrictStations = set(goalSystem.stations)
898
- if avoidPlaces:
899
- restrictStations = set(
900
- stn for stn in restrictStations
901
- if stn not in avoidPlaces and
902
- stn.system not in avoidPlaces
903
- )
904
-
905
- def station_iterator(srcStation):
906
- srcSys = srcStation.system
907
- srcDist = srcSys.distanceTo
908
- for stn in restrictStations:
909
- stnSys = stn.system
910
- yield Destination(
911
- stnSys, stn,
912
- (srcSys, stnSys),
913
- srcDist(stnSys)
914
- )
915
-
916
- else:
917
- getDestinations = tdb.getDestinations
918
-
919
- def station_iterator(srcStation):
920
- yield from getDestinations(
921
- srcStation,
922
- maxJumps = maxJumpsPer,
923
- maxLyPer = maxLyPer,
924
- avoidPlaces = avoidPlaces,
925
- maxPadSize = maxPadSize,
926
- maxLsFromStar = maxLsFromStar,
927
- noPlanet = noPlanet,
928
- planetary = planetary,
929
- fleet = fleet,
930
- odyssey = odyssey,
931
- )
932
-
933
- with pbar.Progress(max_value=len(routes), width=25, show=tdenv.progress) as prog:
934
- connections = 0
935
- getSelling = self.stationsSelling.get
936
- for route_no, route in enumerate(routes):
937
- prog.increment(progress=route_no)
938
- tdenv.DEBUG1("Route = {}", route.text(lambda x, y: y))
939
-
940
- srcStation = route.lastStation
941
- startCr = credits + int(route.gainCr * safetyMargin)
942
-
943
- srcSelling = getSelling(srcStation.ID, None)
944
- srcSelling = tuple(
945
- values for values in srcSelling
946
- if values[1] <= startCr
947
- )
948
- if not srcSelling:
949
- tdenv.DEBUG1("Nothing sold/affordable - next.")
950
- continue
951
-
952
- if goalSystem:
953
- origSystem = route.firstSystem
954
- srcSystem = srcStation.system
955
- srcDistTo = srcSystem.distanceTo
956
- goalDistTo = goalSystem.distanceTo
957
- origDistTo = origSystem.distanceTo
958
- srcGoalDist = srcDistTo(goalSystem)
959
- srcOrigDist = srcDistTo(origSystem)
960
- origGoalDist = origDistTo(goalSystem)
961
-
962
- if unique:
963
- uniquePath = route.route
964
- elif loopInt:
965
- pos_from_end = 0 - loopInt
966
- uniquePath = route.route[pos_from_end:-1]
967
-
968
- stations = (d for d in station_iterator(srcStation)
969
- if (d.station != srcStation) and
970
- (d.station.blackMarket == 'Y' if reqBlackMarket else True) and
971
- (d.station not in uniquePath if uniquePath else True) and
972
- (d.station in restrictStations if restrictStations else True) and
973
- (d.station.dataAge and d.station.dataAge <= maxAge if maxAge else True) and
974
- (((d.system is not srcSystem) if bool(tdenv.unique) else (d.system is goalSystem or d.distLy < srcGoalDist)) if goalSystem else True)
975
- )
976
-
977
- if tdenv.debug >= 1:
978
-
979
- def annotate(dest):
980
- tdenv.DEBUG1(
981
- "destSys {}, destStn {}, jumps {}, distLy {}",
982
- dest.system.dbname,
983
- dest.station.dbname,
984
- "->".join(jump.text() for jump in dest.via),
985
- dest.distLy
986
- )
987
- return True
988
-
989
- stations = (d for d in stations if annotate(d))
990
-
991
- for dest in stations:
992
- dstStation = dest.station
993
-
994
- connections += 1
995
- items = self.getTrades(srcStation, dstStation, srcSelling)
996
- if not items:
997
- continue
998
- trade = fitFunction(items, startCr, capacity, maxUnits)
999
-
1000
- multiplier = 1.0
1001
- # Calculate total K-lightseconds supercruise time.
1002
- # This will amortize for the start/end stations
1003
- dstSys = dest.system
1004
- if goalSystem and dstSys is not goalSystem:
1005
- dstGoalDist = goalDistTo(dstSys)
1006
- # Biggest reward for shortening distance to goal
1007
- score = 5000 * origGoalDist / dstGoalDist
1008
- # bias towards bigger reductions
1009
- score += 50 * srcGoalDist / dstGoalDist
1010
- # discourage moving back towards origin
1011
- if dstSys is not origSystem:
1012
- score += 10 * (origDistTo(dstSys) - srcOrigDist)
1013
- # Gain per unit pays a small part
1014
- score += (trade.gainCr / trade.units) / 25
1015
- else:
1016
- score = trade.gainCr
1017
- if lsPenalty:
1018
- # [kfsone] Only want 1dp
1019
-
1020
- cruiseKls = int(dstStation.lsFromStar / 100) / 10
1021
- # Produce a curve that favors distances under 1kls
1022
- # positively, starts to penalize distances over 1k,
1023
- # and after 4kls starts to penalize aggressively
1024
- # http://goo.gl/Otj2XP
1025
-
1026
- # [eyeonus] As aadler pointed out, this goes into negative
1027
- # numbers, which causes problems.
1028
- # penalty = ((cruiseKls ** 2) - cruiseKls) / 3
1029
- # penalty *= lsPenalty
1030
- # multiplier *= (1 - penalty)
1031
-
1032
- # [eyeonus]:
1033
- # (Keep in mind all this ignores values of x<0.)
1034
- # The sigmoid: (1-(25(x-1))/(1+abs(25(x-1))))/4
1035
- # ranges between 0.5 and 0 with a drop around x=1,
1036
- # which makes it great for giving a boost to distances < 1Kls.
1037
- #
1038
- # The sigmoid: (-1-(50(x-4))/(1+abs(50(x-4))))/4
1039
- # ranges between 0 and -0.5 with a drop around x=4,
1040
- # making it great for penalizing distances > 4Kls.
1041
- #
1042
- # The curve: (-1+1/(x+1)^((x+1)/4))/2
1043
- # ranges between 0 and -0.5 in a smooth arc,
1044
- # which will be used for making distances
1045
- # closer to 4Kls get a slightly higher penalty
1046
- # then distances closer to 1Kls.
1047
- #
1048
- # Adding the three together creates a doubly-kinked curve
1049
- # that ranges from ~0.5 to -1.0, with drops around x=1 and x=4,
1050
- # which closely matches ksfone's intention without going into
1051
- # negative numbers and causing problems when we add it to
1052
- # the multiplier variable. ( 1 + -1 = 0 )
1053
- #
1054
- # You can see a graph of the formula here:
1055
- # https://goo.gl/sn1PqQ
1056
- # NOTE: The black curve is at a penalty of 0%,
1057
- # the red curve at a penalty of 100%, with intermediates at
1058
- # 25%, 50%, and 75%.
1059
- # The other colored lines show the penalty curves individually
1060
- # and the teal composite of all three.
1061
-
1062
- def sigmoid(x):
1063
- return x / (1 + abs(x))
1064
-
1065
- boost = (1 - sigmoid(25 * (cruiseKls - 1))) / 4
1066
- drop = (-1 - sigmoid(50 * (cruiseKls - 4))) / 4
1067
- try:
1068
- penalty = (-1 + 1 / (cruiseKls + 1) ** ((cruiseKls + 1) / 4)) / 2
1069
- except OverflowError:
1070
- penalty = -0.5
1071
-
1072
- multiplier += (penalty + boost + drop) * lsPenalty
1073
-
1074
- score *= multiplier
1075
-
1076
- dstID = dstStation.ID
1077
- try:
1078
- # See if there is already a candidate for this destination
1079
- btd = bestToDest[dstID]
1080
- except KeyError:
1081
- # No existing candidate, we win by default
1082
- pass
1083
- else:
1084
- bestRoute = btd[1]
1085
- bestScore = btd[5]
1086
- # Check if it is a better option than we just produced
1087
- bestTradeScore = bestRoute.score + bestScore
1088
- newTradeScore = route.score + score
1089
- if bestTradeScore > newTradeScore:
1090
- continue
1091
- if bestTradeScore == newTradeScore:
1092
- bestLy = btd[4]
1093
- if bestLy <= dest.distLy:
1094
- continue
1095
-
1096
- bestToDest[dstID] = (
1097
- dstStation, route, trade, dest.via, dest.distLy, score
1098
- )
1099
-
1100
- if connections == 0:
1101
- raise NoHopsError(
1102
- "No destinations could be reached within the constraints."
1103
- )
1104
-
1105
- result = []
1106
- for (dst, route, trade, jumps, _, score) in bestToDest.values():
1107
- result.append(route.plus(dst, trade, jumps, score))
1108
-
1109
- return result
1
+ # --------------------------------------------------------------------
2
+ # Copyright (C) Oliver 'kfsone' Smith 2014 <oliver@kfs.org>:
3
+ # Copyright (C) Bernd 'Gazelle' Gollesch 2016, 2017
4
+ # Copyright (C) Stefan 'Tromador' Morrell 2025
5
+ # Copyright (C) Jonathan 'eyeonus' Jones 2018 - 2025
6
+ #
7
+ # You are free to use, redistribute, or even print and eat a copy of
8
+ # this software so long as you include this copyright notice.
9
+ # I guarantee there is at least one bug neither of us knew about.
10
+ # --------------------------------------------------------------------
11
+ # TradeDangerous :: Modules :: Profit Calculator
12
+ #
13
+ # This module has been refactored from legacy SQLite raw SQL access
14
+ # to use SQLAlchemy ORM sessions. It retains the same API surface
15
+ # expected by other modules (mimicking legacy behaviour), but
16
+ # now queries ORM models instead of sqlite3 cursors.
17
+
18
+ """
19
+ TradeCalc provides a class for calculating trade loads, hops or
20
+ routes, along with some amount of state.
21
+
22
+ The intent was for it to carry a larger amount of state but
23
+ much of that got moved into TradeEnv, so right now TradeCalc
24
+ looks a little odd.
25
+
26
+ Significant Functions:
27
+
28
+ Tradecalc.getBestHops
29
+ Finds the best "next hop"s given a set of routes.
30
+
31
+ Classes:
32
+
33
+ TradeCalc
34
+ Encapsulates the calculation functions and item-trades,
35
+
36
+ Route
37
+ Describes a sequence of trade hops.
38
+
39
+ TradeLoad
40
+ Describe a cargo load to be carried on a hop.
41
+ """
42
+
43
+ ######################################################################
44
+ # Imports
45
+
46
+ from collections import defaultdict, namedtuple
47
+ import datetime
48
+ import locale
49
+ import os
50
+ import re
51
+ import sys
52
+ import time
53
+
54
+ from sqlalchemy import select
55
+
56
+ from .tradeexcept import TradeException
57
+
58
+ # ORM models (SQLAlchemy)
59
+ from tradedangerous.db.orm_models import StationItem, Station, System, Item
60
+ from tradedangerous.db.utils import parse_ts # replaces legacy strftime('%s', modified)
61
+
62
+ # Legacy-style helpers (these remain expected by other modules)
63
+ from .tradedb import Trade, Destination, describeAge
64
+
65
+ locale.setlocale(locale.LC_ALL, '')
66
+
67
+ ######################################################################
68
+ # Exceptions
69
+
70
+
71
+ class BadTimestampError(TradeException):
72
+ """
73
+ Raised when a StationItem row has an invalid or unparsable timestamp.
74
+ """
75
+
76
+ def __init__(self, tdb, stationID, itemID, modified):
77
+ self.station = tdb.stationByID[stationID]
78
+ self.item = tdb.itemByID[itemID]
79
+ self.modified = modified
80
+
81
+ def __str__(self):
82
+ return (
83
+ "Error loading price data from the local db:\n"
84
+ f"{self.station.name()} has a StationItem entry for "
85
+ f"\"{self.item.name()}\" with an invalid modified timestamp: "
86
+ f"'{self.modified}'."
87
+ )
88
+
89
+
90
+ class NoHopsError(TradeException):
91
+ """Raised when no possible hops can be generated within constraints."""
92
+ pass
93
+
94
+
95
+ ######################################################################
96
+ # TradeLoad (namedtuple wrapper)
97
+
98
+
99
+ class TradeLoad(namedtuple("TradeLoad", ("items", "gainCr", "costCr", "units"))):
100
+ """
101
+ Describes the manifest of items to be exchanged in a trade.
102
+
103
+ Attributes:
104
+ items : list of (item, qty) tuples tracking the load
105
+ gainCr : predicted total gain in credits
106
+ costCr : how much this load was bought for
107
+ units : total number of units across all items
108
+ """
109
+
110
+ def __bool__(self):
111
+ return self.units > 0
112
+
113
+ def __lt__(self, rhs):
114
+ if self.gainCr < rhs.gainCr:
115
+ return True
116
+ if rhs.gainCr < self.gainCr:
117
+ return False
118
+ if self.units < rhs.units:
119
+ return True
120
+ if rhs.units < self.units:
121
+ return False
122
+ return self.costCr < rhs.costCr
123
+
124
+ @property
125
+ def gpt(self):
126
+ """Gain per ton (credits per unit)."""
127
+ return self.gainCr / self.units if self.units else 0
128
+
129
+
130
+ # A convenience empty load (used as sentinel in fitting algorithms).
131
+ emptyLoad = TradeLoad((), 0, 0, 0)
132
+
133
+ ######################################################################
134
+ # Classes
135
+
136
+
137
+ class Route:
138
+ """
139
+ Describes a series of hops where a TradeLoad is picked up at
140
+ one station, the player travels via 0 or more hyperspace
141
+ jumps and docks at a second station where they unload.
142
+
143
+ Example:
144
+ 10 Algae + 5 Hydrogen at Station A,
145
+ jump to System2, jump to System3,
146
+ dock at Station B, sell everything, buy gold,
147
+ jump to System4 and sell everything at Station X.
148
+ """
149
+
150
+ __slots__ = ("route", "hops", "startCr", "gainCr", "jumps", "score")
151
+
152
+ def __init__(self, stations, hops, startCr, gainCr, jumps, score):
153
+ assert stations
154
+ self.route = stations
155
+ self.hops = hops
156
+ self.startCr = startCr
157
+ self.gainCr = gainCr
158
+ self.jumps = jumps
159
+ self.score = score
160
+
161
+ @property
162
+ def firstStation(self):
163
+ return self.route[0]
164
+
165
+ @property
166
+ def firstSystem(self):
167
+ return self.route[0].system
168
+
169
+ @property
170
+ def lastStation(self):
171
+ return self.route[-1]
172
+
173
+ @property
174
+ def lastSystem(self):
175
+ return self.route[-1].system
176
+
177
+ @property
178
+ def avggpt(self):
179
+ if self.hops:
180
+ return sum(hop.gpt for hop in self.hops) // len(self.hops)
181
+ return 0
182
+
183
+ @property
184
+ def gpt(self):
185
+ if self.hops:
186
+ return (
187
+ sum(hop.gainCr for hop in self.hops)
188
+ // sum(hop.units for hop in self.hops)
189
+ )
190
+ return 0
191
+
192
+ def plus(self, dst, hop, jumps, score):
193
+ return Route(
194
+ self.route + (dst,),
195
+ self.hops + (hop,),
196
+ self.startCr,
197
+ self.gainCr + hop[1],
198
+ self.jumps + (jumps,),
199
+ self.score + score,
200
+ )
201
+
202
+ def __lt__(self, rhs):
203
+ if self.score == rhs.score:
204
+ return len(self.jumps) < len(rhs.jumps)
205
+ return self.score > rhs.score
206
+
207
+ def __eq__(self, rhs):
208
+ return self.score == rhs.score and len(self.jumps) == len(rhs.jumps)
209
+
210
+ def text(self, colorize) -> str:
211
+ return "%s -> %s" % (
212
+ colorize("cyan", self.firstStation.name()),
213
+ colorize("blue", self.lastStation.name()),
214
+ )
215
+
216
+ def detail(self, tdenv):
217
+ """
218
+ Legacy helper used by run_cmd.render().
219
+ Renders this route using cmdenv/tdenv display settings.
220
+
221
+ Honors TD_NO_COLOR and tdenv.noColor to disable ANSI color codes.
222
+ """
223
+ import os
224
+
225
+ # TD_NO_COLOR disables color if set to anything truthy (except 0/false/no/off/"")
226
+ env_val = os.getenv("TD_NO_COLOR", "")
227
+ env_no_color = bool(env_val) and env_val.strip().lower() not in ("0", "", "false", "no", "off")
228
+
229
+ no_color = env_no_color or bool(getattr(tdenv, "noColor", False))
230
+
231
+ if no_color:
232
+ def colorize(_c, s):
233
+ return s
234
+ else:
235
+ _cz = getattr(tdenv, "colorize", None)
236
+ if callable(_cz):
237
+ def colorize(c, s):
238
+ return _cz(c, s)
239
+ else:
240
+ def colorize(_c, s):
241
+ return s
242
+
243
+ detail = int(getattr(tdenv, "detail", 0) or 0)
244
+ goalSystem = getattr(tdenv, "goalSystem", None)
245
+ credits = int(getattr(tdenv, "credits", 0) or 0)
246
+
247
+ return self.render(colorize, tdenv, detail=detail, goalSystem=goalSystem, credits=credits)
248
+
249
+
250
+ def render(self, colorize, tdenv, detail=0, goalSystem=None, credits=0):
251
+ """
252
+ Produce a formatted string representation of this route.
253
+ """
254
+
255
+ def genSubValues():
256
+ for hop in self.hops:
257
+ for tr, _ in hop[0]:
258
+ yield len(tr.name(detail))
259
+
260
+ longestNameLen = max(genSubValues(), default=0)
261
+
262
+ text = self.text(colorize)
263
+ if detail >= 1:
264
+ text += f" (score: {self.score:f})"
265
+ text += "\n"
266
+
267
+ jumpsFmt = " Jump {jumps}\n"
268
+ cruiseFmt = " Supercruise to {stn}\n"
269
+ distFmt = None
270
+
271
+ if detail > 1:
272
+ if detail > 2:
273
+ text += self.summary() + "\n"
274
+ if tdenv.maxJumpsPer > 1:
275
+ distFmt = " Direct: {dist:0.2f}ly, Trip: {trav:0.2f}ly\n"
276
+
277
+ hopFmt = (
278
+ " Load from " + colorize("cyan", "{station}") + ":\n{purchases}"
279
+ )
280
+ hopStepFmt = (
281
+ colorize("lightYellow", " {qty:>4}")
282
+ + " x "
283
+ + colorize("yellow", "{item:<{longestName}} ")
284
+ + "{eacost:>8n}cr vs {easell:>8n}cr, "
285
+ "{age}"
286
+ )
287
+ if detail > 2:
288
+ hopStepFmt += ", total: {ttlcost:>10n}cr"
289
+ hopStepFmt += "\n"
290
+
291
+ if not tdenv.summary:
292
+ dockFmt = (
293
+ " Unload at "
294
+ + colorize("lightBlue", "{station}")
295
+ + " => Gain {gain:n}cr "
296
+ "({tongain:n}cr/ton) => {credits:n}cr\n"
297
+ )
298
+ else:
299
+ jumpsFmt = re.sub(" ", " ", jumpsFmt, re.M)
300
+ cruiseFmt = re.sub(" ", " ", cruiseFmt, re.M)
301
+ if distFmt:
302
+ distFmt = re.sub(" ", " ", distFmt, re.M)
303
+ hopFmt = "\n" + hopFmt
304
+ dockFmt = " Expect to gain {gain:n}cr ({tongain:n}cr/ton)\n"
305
+
306
+ footer = " " + "-" * 76 + "\n"
307
+ endFmt = (
308
+ "Finish at "
309
+ + colorize("blue", "{station} ")
310
+ + "gaining {gain:n}cr ({tongain:n}cr/ton) "
311
+ "=> est {credits:n}cr total\n"
312
+ )
313
+
314
+ elif detail:
315
+ hopFmt = " Load from " + colorize("cyan", "{station}") + ":{purchases}\n"
316
+ hopStepFmt = (
317
+ colorize("lightYellow", " {qty}")
318
+ + " x "
319
+ + colorize("yellow", "{item}")
320
+ + " (@{eacost}cr),"
321
+ )
322
+ footer = None
323
+ dockFmt = " Dock at " + colorize("lightBlue", "{station}\n")
324
+ endFmt = (
325
+ " Finish "
326
+ + colorize("blue", "{station} ")
327
+ + "+ {gain:n}cr ({tongain:n}cr/ton)"
328
+ "=> {credits:n}cr\n"
329
+ )
330
+
331
+ else:
332
+ hopFmt = colorize("cyan", " {station}:{purchases}\n")
333
+ hopStepFmt = (
334
+ colorize("lightYellow", " {qty}")
335
+ + " x "
336
+ + colorize("yellow", "{item}")
337
+ + ","
338
+ )
339
+ footer = None
340
+ dockFmt = None
341
+ endFmt = colorize("blue", " {station}") + " +{gain:n}cr ({tongain:n}/ton)"
342
+
343
+ def jumpList(jumps):
344
+ text, last = "", None
345
+ travelled = 0.0
346
+ for jump in jumps:
347
+ if last:
348
+ dist = last.distanceTo(jump)
349
+ if dist:
350
+ if tdenv.detail:
351
+ text += f", {dist:.2f}ly -> "
352
+ else:
353
+ text += " -> "
354
+ else:
355
+ text += " >>> "
356
+ travelled += dist
357
+ text += jump.name()
358
+ last = jump
359
+ return travelled, text
360
+
361
+ if detail > 1:
362
+
363
+ def decorateStation(station):
364
+ details = []
365
+ if station.lsFromStar:
366
+ details.append(station.distFromStar(True))
367
+ if station.blackMarket != "?":
368
+ details.append("BMk:" + station.blackMarket)
369
+ if station.maxPadSize != "?":
370
+ details.append("Pad:" + station.maxPadSize)
371
+ if station.planetary != "?":
372
+ details.append("Plt:" + station.planetary)
373
+ if station.fleet != "?":
374
+ details.append("Flc:" + station.fleet)
375
+ if station.odyssey != "?":
376
+ details.append("Ody:" + station.odyssey)
377
+ if station.shipyard != "?":
378
+ details.append("Shp:" + station.shipyard)
379
+ if station.outfitting != "?":
380
+ details.append("Out:" + station.outfitting)
381
+ if station.refuel != "?":
382
+ details.append("Ref:" + station.refuel)
383
+ details = "{} ({})".format(
384
+ station.name(), ", ".join(details or ["no details"])
385
+ )
386
+ return details
387
+
388
+ else:
389
+
390
+ def decorateStation(station):
391
+ return station.name()
392
+
393
+ if detail and goalSystem:
394
+
395
+ def goalDistance(station):
396
+ return (
397
+ f" [Distance to {goalSystem.name()}: "
398
+ f"{station.system.distanceTo(goalSystem):.2f} ly]\n"
399
+ )
400
+
401
+ else:
402
+
403
+ def goalDistance(station):
404
+ return ""
405
+
406
+ gainCr = 0
407
+ for i, hop in enumerate(self.hops):
408
+ hopGainCr, hopTonnes = hop[1], 0
409
+ purchases = ""
410
+ for (trade, qty) in sorted(
411
+ hop[0],
412
+ key=lambda tradeOpt: tradeOpt[1] * tradeOpt[0].gainCr,
413
+ reverse=True,
414
+ ):
415
+ if abs(trade.srcAge - trade.dstAge) <= (30 * 60):
416
+ age = max(trade.srcAge, trade.dstAge)
417
+ age = describeAge(age)
418
+ else:
419
+ srcAge = describeAge(trade.srcAge)
420
+ dstAge = describeAge(trade.dstAge)
421
+ age = f"{srcAge} vs {dstAge}"
422
+
423
+ purchases += hopStepFmt.format(
424
+ qty=qty,
425
+ item=trade.name(detail),
426
+ eacost=trade.costCr,
427
+ easell=trade.costCr + trade.gainCr,
428
+ ttlcost=trade.costCr * qty,
429
+ longestName=longestNameLen,
430
+ age=age,
431
+ )
432
+ hopTonnes += qty
433
+
434
+ text += goalDistance(self.route[i])
435
+ text += hopFmt.format(station=decorateStation(self.route[i]), purchases=purchases)
436
+
437
+ if tdenv.showJumps and jumpsFmt and self.jumps[i]:
438
+ startStn = self.route[i]
439
+ endStn = self.route[i + 1]
440
+ if startStn.system is not endStn.system:
441
+ fmt = jumpsFmt
442
+ travelled, jumps = jumpList(self.jumps[i])
443
+ else:
444
+ fmt = cruiseFmt
445
+ travelled, jumps = 0.0, f"{startStn.name()} >>> {endStn.name()}"
446
+
447
+ text += fmt.format(
448
+ jumps=jumps,
449
+ gain=hopGainCr,
450
+ tongain=hopGainCr / hopTonnes,
451
+ credits=credits + gainCr + hopGainCr,
452
+ stn=self.route[i + 1].dbname,
453
+ )
454
+
455
+ if travelled and distFmt and len(self.jumps[i]) > 2:
456
+ text += distFmt.format(
457
+ dist=startStn.system.distanceTo(endStn.system), trav=travelled
458
+ )
459
+
460
+ if dockFmt:
461
+ stn = self.route[i + 1]
462
+ text += dockFmt.format(
463
+ station=decorateStation(stn),
464
+ gain=hopGainCr,
465
+ tongain=hopGainCr / hopTonnes,
466
+ credits=credits + gainCr + hopGainCr,
467
+ )
468
+
469
+ gainCr += hopGainCr
470
+
471
+ lastStation = self.lastStation
472
+ if lastStation.system is not goalSystem:
473
+ text += goalDistance(lastStation)
474
+ text += footer or ""
475
+ text += endFmt.format(
476
+ station=decorateStation(lastStation),
477
+ gain=gainCr,
478
+ credits=credits + gainCr,
479
+ tongain=self.gpt,
480
+ )
481
+
482
+ return text
483
+
484
+ def summary(self):
485
+ credits, hops, jumps = self.startCr, self.hops, self.jumps
486
+ ttlGainCr = sum(hop[1] for hop in hops)
487
+ numJumps = sum(
488
+ len(hopJumps) - 1 for hopJumps in jumps if hopJumps
489
+ )
490
+ return (
491
+ "Start CR: {start:10n}\n"
492
+ "Hops : {hops:10n}\n"
493
+ "Jumps : {jumps:10n}\n"
494
+ "Gain CR : {gain:10n}\n"
495
+ "Gain/Hop: {hopgain:10n}\n"
496
+ "Final CR: {final:10n}\n".format(
497
+ start=credits,
498
+ hops=len(hops),
499
+ jumps=numJumps,
500
+ gain=ttlGainCr,
501
+ hopgain=ttlGainCr // len(hops),
502
+ final=credits + ttlGainCr,
503
+ )
504
+ )
505
+
506
+ class TradeCalc:
507
+ """
508
+ Container for accessing trade calculations with common properties.
509
+ """
510
+
511
+ def __init__(self, tdb, tdenv=None, fit=None, items=None):
512
+ """
513
+ Constructs the TradeCalc object and loads sell/buy data.
514
+
515
+ RAM remediation (per brief):
516
+ - Preload via SQLAlchemy Core/Engine tuples (no ORM entities, no Session).
517
+ - Select only the 9 legacy columns needed for the two maps.
518
+ - Apply ONLY age cutoff + optional item filter.
519
+ - No station reachability gating here.
520
+ - Build stationsSelling/Buying with legacy keep rules & tuple shapes.
521
+ - Compute ageS in Python from parse_ts(modified).
522
+ """
523
+ if not tdenv:
524
+ tdenv = tdb.tdenv
525
+ self.tdb = tdb
526
+ self.tdenv = tdenv
527
+ self.defaultFit = fit or self.simpleFit
528
+ if "BRUTE_FIT" in os.environ:
529
+ self.defaultFit = self.bruteForceFit
530
+
531
+ minSupply = self.tdenv.supply or 0
532
+ minDemand = self.tdenv.demand or 0
533
+
534
+ # ---------- Build optional item filter (avoidItems + specific items) ----------
535
+ itemFilter = None
536
+ if tdenv.avoidItems or items:
537
+ avoidItemIDs = {item.ID for item in tdenv.avoidItems}
538
+ loadItems = items or tdb.itemByID.values()
539
+ loadIDs = []
540
+ for item in loadItems:
541
+ ID = item if isinstance(item, int) else item.ID
542
+ if ID not in avoidItemIDs:
543
+ loadIDs.append(ID)
544
+ if not loadIDs:
545
+ raise TradeException("No items to load.")
546
+ itemFilter = loadIDs
547
+
548
+ # ---------- Maps and counters ----------
549
+ demand = self.stationsBuying = defaultdict(list)
550
+ supply = self.stationsSelling = defaultdict(list)
551
+ dmdCount = supCount = 0
552
+ nowS = int(time.time())
553
+
554
+ # ---------- Progress heartbeat (only with --progress) ----------
555
+ showProgress = bool(getattr(tdenv, "progress", False))
556
+ hb_interval = 0.5
557
+ last_hb = 0.0
558
+ spinner = ("|", "/", "-", "\\")
559
+ spin_i = 0
560
+ rows_seen = 0
561
+
562
+ def heartbeat():
563
+ nonlocal last_hb, spin_i
564
+ if not showProgress:
565
+ return
566
+ now = time.time()
567
+ if (now - last_hb) < hb_interval:
568
+ return
569
+ last_hb = now
570
+ s = spinner[spin_i]
571
+ spin_i = (spin_i + 1) % len(spinner)
572
+ sys.stdout.write(
573
+ f"\r{s} Scanning market data… rows {rows_seen:n} kept: buys {dmdCount:n}, sells {supCount:n}"
574
+ )
575
+ sys.stdout.flush()
576
+
577
+ # ---------- Core/Engine path (NO Session; NO ORM entities) ----------
578
+ columns = (
579
+ "station_id, item_id, "
580
+ "demand_price, demand_units, demand_level, "
581
+ "supply_price, supply_units, supply_level, "
582
+ "modified"
583
+ )
584
+
585
+ where_clauses = []
586
+ params = {}
587
+
588
+ if tdenv.maxAge:
589
+ cutoffS = nowS - (tdenv.maxAge * 60 * 60)
590
+ if tdb.engine.dialect.name == "sqlite":
591
+ where_clauses.append("CAST(strftime('%s', modified) AS INTEGER) >= :cutoffS")
592
+ else:
593
+ where_clauses.append("UNIX_TIMESTAMP(modified) >= :cutoffS")
594
+ params["cutoffS"] = cutoffS
595
+
596
+ if itemFilter:
597
+ where_clauses.append("item_id IN :item_ids")
598
+ params["item_ids"] = tuple(itemFilter)
599
+
600
+ sql = f"SELECT {columns} FROM StationItem"
601
+ if where_clauses:
602
+ sql += " WHERE " + " AND ".join(where_clauses)
603
+
604
+ from sqlalchemy import text as _sa_text
605
+ with tdb.engine.connect() as conn:
606
+ result = conn.execute(_sa_text(sql), params)
607
+
608
+ for (
609
+ stnID,
610
+ itmID,
611
+ d_price, d_units, d_level,
612
+ s_price, s_units, s_level,
613
+ modified,
614
+ ) in result:
615
+ rows_seen += 1
616
+ # Compute legacy ageS from modified using parse_ts(...)
617
+ mod_dt = parse_ts(modified)
618
+ if not mod_dt:
619
+ # Finish the line before raising.
620
+ if showProgress:
621
+ sys.stdout.write("\n"); sys.stdout.flush()
622
+ raise BadTimestampError(tdb, stnID, itmID, modified)
623
+ ageS = nowS - int(mod_dt.timestamp())
624
+
625
+ # Buying map (demand side)
626
+ if d_price and d_price > 0 and d_units:
627
+ if not minDemand or d_units >= minDemand:
628
+ demand[stnID].append((itmID, d_price, d_units, d_level, ageS))
629
+ dmdCount += 1
630
+
631
+ # Selling map (supply side)
632
+ if s_price and s_price > 0 and s_units:
633
+ if not minSupply or s_units >= minSupply:
634
+ supply[stnID].append((itmID, s_price, s_units, s_level, ageS))
635
+ supCount += 1
636
+
637
+ heartbeat()
638
+
639
+ # Complete heartbeat line neatly.
640
+ if showProgress:
641
+ sys.stdout.write("\n")
642
+ sys.stdout.flush()
643
+
644
+ # --------- One-time station-ID sets for O(1) membership tests ----------
645
+ self._buying_ids = set(self.stationsBuying.keys())
646
+ self._selling_ids = set(self.stationsSelling.keys())
647
+ self.eligible_station_ids = self._buying_ids & self._selling_ids
648
+
649
+ # --------- Tiny caches valid for the lifetime of this TradeCalc ----------
650
+ self._dst_buy_map = {}
651
+
652
+ tdenv.DEBUG1(
653
+ "Preload used Engine/Core (no ORM identity map). Rows kept: buys={}, sells={}",
654
+ dmdCount, supCount,
655
+ )
656
+
657
+
658
+ # ------------------------------------------------------------------
659
+ # Cargo fitting algorithms
660
+ # ------------------------------------------------------------------
661
+
662
+ def bruteForceFit(self, items, credits, capacity, maxUnits): # pylint: disable=redefined-builtin
663
+ """
664
+ Brute-force generation of all possible combinations of items.
665
+ This is provided to make it easy to validate the results of future
666
+ variants or optimizations of the fit algorithm.
667
+ """
668
+
669
+ def _fitCombos(offset, cr, cap, level=1):
670
+ if cr <= 0 or cap <= 0:
671
+ return emptyLoad
672
+ while True:
673
+ if offset >= len(items):
674
+ return emptyLoad
675
+ item = items[offset]
676
+ offset += 1
677
+
678
+ itemCost = item.costCr
679
+ maxQty = min(maxUnits, cap, cr // itemCost)
680
+
681
+ if item.supply < maxQty and item.supply > 0:
682
+ maxQty = min(maxQty, item.supply)
683
+
684
+ if maxQty > 0:
685
+ break
686
+
687
+ bestLoad = _fitCombos(offset, cr, cap, level + 1)
688
+ itemGain = item.gainCr
689
+
690
+ for qty in range(1, maxQty + 1):
691
+ loadGain, loadCost = itemGain * qty, itemCost * qty
692
+ load = TradeLoad(((item, qty),), loadGain, loadCost, qty)
693
+ subLoad = _fitCombos(offset, cr - loadCost, cap - qty, level + 1)
694
+ combGain = loadGain + subLoad.gainCr
695
+ if combGain < bestLoad.gainCr:
696
+ continue
697
+ combCost = loadCost + subLoad.costCr
698
+ combUnits = qty + subLoad.units
699
+ if combGain == bestLoad.gainCr:
700
+ if combUnits > bestLoad.units:
701
+ continue
702
+ if combUnits == bestLoad.units:
703
+ if combCost >= bestLoad.costCr:
704
+ continue
705
+ bestLoad = TradeLoad(
706
+ load.items + subLoad.items, combGain, combCost, combUnits
707
+ )
708
+
709
+ return bestLoad
710
+
711
+ return _fitCombos(0, credits, capacity)
712
+
713
+ def fastFit(self, items, credits, capacity, maxUnits): # pylint: disable=redefined-builtin
714
+ """
715
+ Best load calculator using a recursive knapsack-like
716
+ algorithm to find multiple loads and return the best.
717
+ [eyeonus] Left in for the masochists, as this becomes
718
+ horribly slow at stations with many items for sale.
719
+ As in iooks-like-the-program-has-frozen slow.
720
+ """
721
+
722
+ def _fitCombos(offset, cr, cap):
723
+ """
724
+ Starting from offset, consider a scenario where we
725
+ would purchase the maximum number of each item
726
+ given the cr+cap limitations. Then, assuming that
727
+ load, solve for the remaining cr+cap from the next
728
+ value of offset.
729
+
730
+ The "best fit" is not always the most profitable,
731
+ so we yield all the results and leave the caller
732
+ to determine which is actually most profitable.
733
+ """
734
+ bestGainCr = -1
735
+ bestItem = None
736
+ bestQty = 0
737
+ bestCostCr = 0
738
+ bestSub = None
739
+
740
+ qtyCeil = min(maxUnits, cap)
741
+
742
+ for iNo in range(offset, len(items)):
743
+ item = items[iNo]
744
+ itemCostCr = item.costCr
745
+ maxQty = min(qtyCeil, cr // itemCostCr)
746
+
747
+ if maxQty <= 0:
748
+ continue
749
+
750
+ supply = item.supply
751
+ if supply <= 0:
752
+ continue
753
+
754
+ maxQty = min(maxQty, supply)
755
+
756
+ itemGainCr = item.gainCr
757
+ if maxQty == cap:
758
+ gain = itemGainCr * maxQty
759
+ if gain > bestGainCr:
760
+ cost = itemCostCr * maxQty
761
+ bestGainCr = gain
762
+ bestItem = item
763
+ bestQty = maxQty
764
+ bestCostCr = cost
765
+ bestSub = None
766
+ break
767
+
768
+ loadCostCr = maxQty * itemCostCr
769
+ loadGainCr = maxQty * itemGainCr
770
+ if loadGainCr > bestGainCr:
771
+ bestGainCr = loadGainCr
772
+ bestCostCr = loadCostCr
773
+ bestItem = item
774
+ bestQty = maxQty
775
+ bestSub = None
776
+
777
+ crLeft, capLeft = cr - loadCostCr, cap - maxQty
778
+ if crLeft > 0 and capLeft > 0:
779
+ subLoad = _fitCombos(iNo + 1, crLeft, capLeft)
780
+ if subLoad is emptyLoad:
781
+ continue
782
+ ttlGain = loadGainCr + subLoad.gainCr
783
+ if ttlGain < bestGainCr:
784
+ continue
785
+ ttlCost = loadCostCr + subLoad.costCr
786
+ if ttlGain == bestGainCr and ttlCost >= bestCostCr:
787
+ continue
788
+ bestGainCr = ttlGain
789
+ bestItem = item
790
+ bestQty = maxQty
791
+ bestCostCr = ttlCost
792
+ bestSub = subLoad
793
+
794
+ if not bestItem:
795
+ return emptyLoad
796
+
797
+ bestLoad = ((bestItem, bestQty),)
798
+ if bestSub:
799
+ bestLoad = bestLoad + bestSub.items
800
+ bestQty += bestSub.units
801
+ return TradeLoad(bestLoad, bestGainCr, bestCostCr, bestQty)
802
+
803
+ return _fitCombos(0, credits, capacity)
804
+
805
+
806
+ # Mark's test run, to spare searching back through the forum posts for it.
807
+ # python trade.py run --fr="Orang/Bessel Gateway" --cap=720 --cr=11b --ly=24.73 --empty=37.61 --pad=L --hops=2 --jum=3 --loop --summary -vv --progress
808
+
809
+ def simpleFit(self, items, credits, capacity, maxUnits): # pylint: disable=redefined-builtin
810
+ """
811
+ Simplistic load calculator:
812
+ (The item list is sorted with highest profit margin items in front.)
813
+ Step 1: Fill hold with as much of item1 as possible based on the limiting
814
+ factors of hold size, supply amount, and available credits.
815
+
816
+ Step 2: If there is space in the hold and money available, repeat Step 1
817
+ with item2, item3, etc. until either the hold is filled
818
+ or the commander is too poor to buy more.
819
+
820
+ When amount of credits isn't a limiting factor, this should produce
821
+ the most profitable route ~99.7% of the time, and still be very
822
+ close to the most profitable the rest of the time.
823
+ (Very close = not enough less profit that anyone should care,
824
+ especially since this thing doesn't suffer slowdowns like fastFit.)
825
+ """
826
+
827
+ n = 0
828
+ load = ()
829
+ gainCr = 0
830
+ costCr = 0
831
+ qty = 0
832
+ while n < len(items) and credits > 0 and capacity > 0:
833
+ qtyCeil = min(maxUnits, capacity)
834
+
835
+ item = items[n]
836
+ maxQty = min(qtyCeil, credits // item.costCr)
837
+
838
+ if maxQty > 0 and item.supply > 0:
839
+ maxQty = min(maxQty, item.supply)
840
+
841
+ loadCostCr = maxQty * item.costCr
842
+ loadGainCr = maxQty * item.gainCr
843
+
844
+ load = load + ((item, maxQty),)
845
+ qty += maxQty
846
+ capacity -= maxQty
847
+
848
+ gainCr += loadGainCr
849
+ costCr += loadCostCr
850
+ credits -= loadCostCr
851
+
852
+ n += 1
853
+
854
+ return TradeLoad(load, gainCr, costCr, qty)
855
+
856
+ # ------------------------------------------------------------------
857
+ # Trading methods
858
+ # ------------------------------------------------------------------
859
+
860
+ def getTrades(self, srcStation, dstStation, srcSelling=None):
861
+ """
862
+ Returns the most profitable trading options from one station to another.
863
+ """
864
+ if not srcSelling:
865
+ srcSelling = self.stationsSelling.get(srcStation.ID, None)
866
+ if not srcSelling:
867
+ return None
868
+
869
+ dstBuying = self.stationsBuying.get(dstStation.ID, None)
870
+ if not dstBuying:
871
+ return None
872
+
873
+ minGainCr = max(1, self.tdenv.minGainPerTon or 1)
874
+ maxGainCr = max(minGainCr, self.tdenv.maxGainPerTon or sys.maxsize)
875
+
876
+ # ---- per-destination buy map cache (item_id -> buy tuple) ----
877
+ buy_map = self._dst_buy_map.get(dstStation.ID)
878
+ if buy_map is None:
879
+ # list -> dict once, re-used across many src comparisons
880
+ buy_map = {buy[0]: buy for buy in dstBuying}
881
+ self._dst_buy_map[dstStation.ID] = buy_map
882
+ getBuy = buy_map.get
883
+
884
+ itemIdx = self.tdb.itemByID
885
+ trading = []
886
+ append_trade = trading.append
887
+
888
+ for sell in srcSelling:
889
+ buy = getBuy(sell[0])
890
+ if not buy:
891
+ continue
892
+ gainCr = buy[1] - sell[1]
893
+ if minGainCr <= gainCr <= maxGainCr:
894
+ append_trade(
895
+ Trade(
896
+ itemIdx[sell[0]],
897
+ sell[1],
898
+ gainCr,
899
+ sell[2],
900
+ sell[3],
901
+ buy[2],
902
+ buy[3],
903
+ sell[4],
904
+ buy[4],
905
+ )
906
+ )
907
+
908
+ # Same final ordering as two successive sorts:
909
+ # primary: gainCr desc, tiebreak: costCr asc
910
+ trading.sort(key=lambda t: (-t.gainCr, t.costCr))
911
+
912
+ return trading
913
+
914
+ def getBestHops(self, routes, restrictTo=None):
915
+ """
916
+ Given a list of routes, try all available next hops from each route.
917
+ Keeps only the best candidate per destination station for this hop.
918
+ """
919
+
920
+ tdb = self.tdb
921
+ tdenv = self.tdenv
922
+ avoidPlaces = getattr(tdenv, "avoidPlaces", None) or ()
923
+ assert not restrictTo or isinstance(restrictTo, set)
924
+ maxJumpsPer = tdenv.maxJumpsPer
925
+ maxLyPer = tdenv.maxLyPer
926
+ maxPadSize = tdenv.padSize
927
+ planetary = tdenv.planetary
928
+ fleet = tdenv.fleet
929
+ odyssey = tdenv.odyssey
930
+ noPlanet = tdenv.noPlanet
931
+ maxLsFromStar = tdenv.maxLs or float("inf")
932
+ reqBlackMarket = getattr(tdenv, "blackMarket", False) or False
933
+ maxAge = getattr(tdenv, "maxAge") or 0
934
+ credits = tdenv.credits - (getattr(tdenv, "insurance", 0) or 0)
935
+ fitFunction = self.defaultFit
936
+ capacity = tdenv.capacity
937
+ maxUnits = getattr(tdenv, "limit") or capacity
938
+
939
+ buying_ids = self._buying_ids
940
+
941
+ bestToDest = {}
942
+ safetyMargin = 1.0 - tdenv.margin
943
+ unique = tdenv.unique
944
+ loopInt = getattr(tdenv, "loopInt", 0) or None
945
+
946
+ if tdenv.lsPenalty:
947
+ lsPenalty = max(min(tdenv.lsPenalty / 100, 1), 0)
948
+ else:
949
+ lsPenalty = 0
950
+
951
+ goalSystem = tdenv.goalSystem
952
+ uniquePath = None
953
+
954
+ restrictStations = set()
955
+ if restrictTo:
956
+ for place in restrictTo:
957
+ if isinstance(place, Station):
958
+ restrictStations.add(place)
959
+ elif isinstance(place, System) and place.stations:
960
+ restrictStations.update(place.stations)
961
+
962
+ # -----------------------
963
+ # Spinner (stderr; only with --progress)
964
+ # -----------------------
965
+ heartbeat_enabled = bool(getattr(tdenv, "progress", False))
966
+ hb_interval = 0.5
967
+ last_hb = 0.0
968
+ spinner = ("|", "/", "-", "\\")
969
+ spin_i = 0
970
+ total_origins = len(routes)
971
+ best_seen_score = -1 # hop-global best hop score, nearest int
972
+
973
+ def heartbeat(origin_idx, dests_checked):
974
+ nonlocal last_hb, spin_i
975
+ if not heartbeat_enabled:
976
+ return
977
+ now = time.time()
978
+ if now - last_hb < hb_interval:
979
+ return
980
+ last_hb = now
981
+ s = spinner[spin_i]
982
+ spin_i = (spin_i + 1) % len(spinner)
983
+ sys.stderr.write(
984
+ f"\r{s} origin {origin_idx}/{total_origins} destinations checked: {dests_checked:n} best score: {max(0, best_seen_score):n}"
985
+ )
986
+ sys.stderr.flush()
987
+
988
+ if tdenv.direct:
989
+ if goalSystem and not restrictTo:
990
+ restrictTo = (goalSystem,)
991
+ restrictStations = set(goalSystem.stations)
992
+ if avoidPlaces:
993
+ restrictStations = set(
994
+ stn
995
+ for stn in restrictStations
996
+ if stn not in avoidPlaces and stn.system not in avoidPlaces
997
+ )
998
+
999
+ def station_iterator(srcStation, origin_idx):
1000
+ srcSys = srcStation.system
1001
+ srcDist = srcSys.distanceTo
1002
+ dests_seen = 0
1003
+ for stn in restrictStations:
1004
+ stnSys = stn.system
1005
+ if stn.ID not in buying_ids:
1006
+ continue
1007
+ dests_seen += 1
1008
+ heartbeat(origin_idx, dests_seen)
1009
+ yield Destination(stnSys, stn, (srcSys, stnSys), srcDist(stnSys))
1010
+
1011
+ else:
1012
+ getDestinations = tdb.getDestinations
1013
+
1014
+ def station_iterator(srcStation, origin_idx):
1015
+ dests_seen = 0
1016
+ for d in getDestinations(
1017
+ srcStation,
1018
+ maxJumps=maxJumpsPer,
1019
+ maxLyPer=maxLyPer,
1020
+ avoidPlaces=avoidPlaces,
1021
+ maxPadSize=maxPadSize,
1022
+ maxLsFromStar=maxLsFromStar,
1023
+ noPlanet=noPlanet,
1024
+ planetary=planetary,
1025
+ fleet=fleet,
1026
+ odyssey=odyssey,
1027
+ ):
1028
+ dests_seen += 1
1029
+ heartbeat(origin_idx, dests_seen)
1030
+ if d.station.ID in buying_ids:
1031
+ yield d
1032
+
1033
+ connections = 0
1034
+ getSelling = self.stationsSelling.get
1035
+
1036
+ for route_no, route in enumerate(routes):
1037
+ tdenv.DEBUG1("Route = {}", route.text(lambda x, y: y))
1038
+
1039
+ srcStation = route.lastStation
1040
+ startCr = credits + int(route.gainCr * safetyMargin)
1041
+
1042
+ srcSelling = getSelling(srcStation.ID, None)
1043
+ if not srcSelling:
1044
+ tdenv.DEBUG1("Nothing sold at source - next.")
1045
+ heartbeat(route_no + 1, 0)
1046
+ continue
1047
+
1048
+ srcSelling = tuple(values for values in srcSelling if values[1] <= startCr)
1049
+ if not srcSelling:
1050
+ tdenv.DEBUG1("Nothing affordable - next.")
1051
+ heartbeat(route_no + 1, 0)
1052
+ continue
1053
+
1054
+ if goalSystem:
1055
+ origSystem = route.firstSystem
1056
+ srcSystem = srcStation.system
1057
+ srcDistTo = srcSystem.distanceTo
1058
+ goalDistTo = goalSystem.distanceTo
1059
+ origDistTo = origSystem.distanceTo
1060
+ srcGoalDist = srcDistTo(goalSystem)
1061
+ srcOrigDist = srcDistTo(origSystem)
1062
+ origGoalDist = origDistTo(goalSystem)
1063
+
1064
+ if unique:
1065
+ uniquePath = route.route
1066
+ elif loopInt:
1067
+ pos_from_end = 0 - loopInt
1068
+ uniquePath = route.route[pos_from_end:-1]
1069
+
1070
+ stations = (
1071
+ d
1072
+ for d in station_iterator(srcStation, route_no + 1)
1073
+ if (d.station != srcStation)
1074
+ and (d.station.blackMarket == "Y" if reqBlackMarket else True)
1075
+ and (d.station not in uniquePath if uniquePath else True)
1076
+ and (d.station in restrictStations if restrictStations else True)
1077
+ and (d.station.dataAge and d.station.dataAge <= maxAge if maxAge else True)
1078
+ and (
1079
+ (
1080
+ (d.system is not srcSystem)
1081
+ if bool(tdenv.unique)
1082
+ else (d.system is goalSystem or d.distLy < srcGoalDist)
1083
+ )
1084
+ if goalSystem
1085
+ else True
1086
+ )
1087
+ )
1088
+
1089
+ if tdenv.debug >= 1:
1090
+ def annotate(dest):
1091
+ tdenv.DEBUG1(
1092
+ "destSys {}, destStn {}, jumps {}, distLy {}",
1093
+ dest.system.dbname,
1094
+ dest.station.dbname,
1095
+ "->".join(jump.text() for jump in dest.via),
1096
+ dest.distLy,
1097
+ )
1098
+ return True
1099
+ stations = (d for d in stations if annotate(d))
1100
+
1101
+ for dest in stations:
1102
+ dstStation = dest.station
1103
+ connections += 1
1104
+
1105
+ items = self.getTrades(srcStation, dstStation, srcSelling)
1106
+ if not items:
1107
+ continue
1108
+ trade = fitFunction(items, startCr, capacity, maxUnits)
1109
+
1110
+ multiplier = 1.0
1111
+ # Calculate total K-lightseconds supercruise time.
1112
+ # This will amortize for the start/end stations
1113
+ dstSys = dest.system
1114
+ if goalSystem and dstSys is not goalSystem:
1115
+ # Biggest reward for shortening distance to goal
1116
+ dstGoalDist = goalDistTo(dstSys)
1117
+ # bias towards bigger reductions
1118
+ score = 5000 * origGoalDist / dstGoalDist
1119
+ # discourage moving back towards origin
1120
+ score += 50 * srcGoalDist / dstGoalDist
1121
+ # Gain per unit pays a small part
1122
+ if dstSys is not origSystem:
1123
+ score += 10 * (origDistTo(dstSys) - srcOrigDist)
1124
+ score += (trade.gainCr / trade.units) / 25
1125
+ else:
1126
+ score = trade.gainCr
1127
+
1128
+ if lsPenalty:
1129
+
1130
+ def sigmoid(x):
1131
+ # [eyeonus]:
1132
+ # (Keep in mind all this ignores values of x<0.)
1133
+ # The sigmoid: (1-(25(x-1))/(1+abs(25(x-1))))/4
1134
+ # ranges between 0.5 and 0 with a drop around x=1,
1135
+ # which makes it great for giving a boost to distances < 1Kls.
1136
+ #
1137
+ # The sigmoid: (-1-(50(x-4))/(1+abs(50(x-4))))/4
1138
+ # ranges between 0 and -0.5 with a drop around x=4,
1139
+ # making it great for penalizing distances > 4Kls.
1140
+ #
1141
+ # The curve: (-1+1/(x+1)^((x+1)/4))/2
1142
+ # ranges between 0 and -0.5 in a smooth arc,
1143
+ # which will be used for making distances
1144
+ # closer to 4Kls get a slightly higher penalty
1145
+ # then distances closer to 1Kls.
1146
+ #
1147
+ # Adding the three together creates a doubly-kinked curve
1148
+ # that ranges from ~0.5 to -1.0, with drops around x=1 and x=4,
1149
+ # which closely matches ksfone's intention without going into
1150
+ # negative numbers and causing problems when we add it to
1151
+ # the multiplier variable. ( 1 + -1 = 0 )
1152
+ #
1153
+ # You can see a graph of the formula here:
1154
+ # https://goo.gl/sn1PqQ
1155
+ # NOTE: The black curve is at a penalty of 0%,
1156
+ # the red curve at a penalty of 100%, with intermediates at
1157
+ # 25%, 50%, and 75%.
1158
+ # The other colored lines show the penalty curves individually
1159
+ # and the teal composite of all three.
1160
+ return x / (1 + abs(x))
1161
+ # [kfsone] Only want 1dp
1162
+ # Produce a curve that favors distances under 1kls
1163
+ # positively, starts to penalize distances over 1k,
1164
+ # and after 4kls starts to penalize aggressively
1165
+ # http://goo.gl/Otj2XP
1166
+
1167
+ # [eyeonus] As aadler pointed out, this goes into negative
1168
+ # numbers, which causes problems.
1169
+ # penalty = ((cruiseKls ** 2) - cruiseKls) / 3
1170
+ # penalty *= lsPenalty
1171
+ # multiplier *= (1 - penalty)
1172
+ cruiseKls = int(dstStation.lsFromStar / 100) / 10
1173
+ boost = (1 - sigmoid(25 * (cruiseKls - 1))) / 4
1174
+ drop = (-1 - sigmoid(50 * (cruiseKls - 4))) / 4
1175
+ try:
1176
+ penalty = (-1 + 1 / (cruiseKls + 1) ** ((cruiseKls + 1) / 4)) / 2
1177
+ except OverflowError:
1178
+ penalty = -0.5
1179
+ multiplier += (penalty + boost + drop) * lsPenalty
1180
+
1181
+ score *= multiplier
1182
+
1183
+ # update hop-global best score (nearest int)
1184
+ try:
1185
+ si = int(round(score))
1186
+ except Exception:
1187
+ si = int(score)
1188
+ if si > best_seen_score:
1189
+ best_seen_score = si
1190
+
1191
+ dstID = dstStation.ID
1192
+ try:
1193
+ btd = bestToDest[dstID]
1194
+ except KeyError:
1195
+ pass
1196
+ else:
1197
+ bestRoute = btd[1]
1198
+ bestScore = btd[5]
1199
+ bestTradeScore = bestRoute.score + bestScore
1200
+ newTradeScore = route.score + score
1201
+ if bestTradeScore > newTradeScore:
1202
+ continue
1203
+ if bestTradeScore == newTradeScore:
1204
+ bestLy = btd[4]
1205
+ if bestLy <= dest.distLy:
1206
+ continue
1207
+
1208
+ bestToDest[dstID] = (
1209
+ dstStation,
1210
+ route,
1211
+ trade,
1212
+ dest.via,
1213
+ dest.distLy,
1214
+ score,
1215
+ )
1216
+
1217
+ if heartbeat_enabled:
1218
+ sys.stderr.write("\n"); sys.stderr.flush()
1219
+
1220
+ if connections == 0:
1221
+ raise NoHopsError("No destinations could be reached within the constraints.")
1222
+
1223
+ result = []
1224
+ for (dst, route, trade, jumps, _, score) in bestToDest.values():
1225
+ result.append(route.plus(dst, trade, jumps, score))
1226
+
1227
+ return result