roi.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. from __future__ import annotations
  2. import dataclasses
  3. import typing
  4. import cache
  5. def main() -> None:
  6. recipes: list[Recipe] = cache.get('https://api.prunplanner.org/data/recipes')
  7. buildings: dict[str, Building] = {m['Ticker']: m for m in cache.get('https://api.prunplanner.org/data/buildings')}
  8. materials: dict[str, Material] = {m['Ticker']: m for m in cache.get('https://api.prunplanner.org/data/materials')}
  9. raw_prices: list[RawPrice] = cache.get('https://refined-prun.github.io/refined-prices/all.json')
  10. prices: dict[str, Price] = {
  11. p['MaterialTicker']: Price(p['VWAP7D'], p['AverageTraded7D']) for p in raw_prices # pyright: ignore[reportArgumentType]
  12. if p['ExchangeCode'] == 'IC1' and p['VWAP7D'] is not None
  13. }
  14. profits = []
  15. for recipe in recipes:
  16. if profit := calc_profit(recipe, buildings, materials, prices):
  17. profits.append(profit)
  18. profits.sort(reverse=True)
  19. print('\033[1mwrought \033[0;32mdaily profit/area\033[31m')
  20. print('\033[30mrecipe \033[0mexpertise \033[33mcapex \033[35mdaily opex\033[0m')
  21. for p in profits:
  22. print(f'\033[53;1m{p.output:5} \033[53;32m{p.profit_per_area: 10,.0f} ', end='')
  23. warnings = []
  24. if p.low_volume:
  25. warnings.append('low volume')
  26. if p.heavy_logistics:
  27. warnings.append('heavy logistics')
  28. if p.high_opex:
  29. warnings.append('high opex')
  30. if len(warnings) > 0:
  31. print(' \033[31m' + ' '.join(warnings).rjust(36), end='')
  32. else:
  33. print(' ' * 14 + '\033[0;53m' + ' ' * 37, end='')
  34. print(f'\n\033[0;30m{p.recipe:30} \033[0m{p.expertise:19} \033[33m{p.capex:7,.0f} \033[35m{p.cost_per_day:9,.0f}\033[0m')
  35. def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], materials: typing.Mapping[str, Material],
  36. prices: typing.Mapping[str, Price]) -> Profit | None:
  37. try:
  38. (output,) = recipe['Outputs']
  39. except ValueError: # skip recipes that don't have exactly 1 output
  40. return
  41. try:
  42. output_price = prices[output['Ticker']]
  43. cost = sum(prices[input['Ticker']].vwap * input['Amount'] for input in recipe['Inputs'])
  44. except KeyError: # skip recipes with thinly traded materials
  45. return
  46. revenue = output_price.vwap * output['Amount']
  47. building = buildings[recipe['BuildingTicker']]
  48. capex = sum(bm['Amount'] * prices[bm['CommodityTicker']].vwap
  49. for bm in building['BuildingCosts'])
  50. profit_per_run = revenue - cost
  51. runs_per_day = 24 * 60 * 60 * 1000 / recipe['TimeMs']
  52. worker_consumable_daily_cost = building_daily_cost(building, prices)
  53. cost_per_day = cost * runs_per_day + worker_consumable_daily_cost
  54. output_per_day = output['Amount'] * runs_per_day
  55. logistics_per_day = max(
  56. sum(materials[input['Ticker']]['Weight'] * input['Amount'] for input in recipe['Inputs']),
  57. sum(materials[input['Ticker']]['Volume'] * input['Amount'] for input in recipe['Inputs']),
  58. materials[output['Ticker']]['Weight'] * output['Amount'],
  59. materials[output['Ticker']]['Volume'] * output['Amount'],
  60. ) * runs_per_day
  61. return Profit(output['Ticker'], recipe['RecipeName'],
  62. expertise=building['Expertise'].replace('_', ' ').lower(),
  63. profit_per_area=(profit_per_run * runs_per_day - worker_consumable_daily_cost) / building['AreaCost'],
  64. capex=capex,
  65. cost_per_day=cost_per_day,
  66. low_volume=output_price.average_traded < output_per_day * 20,
  67. heavy_logistics=logistics_per_day > 100)
  68. def building_daily_cost(building: Building, prices: typing.Mapping[str, Price]) -> float:
  69. consumption = {
  70. 'Pioneers': [('COF', 0.5), ('DW', 4), ('RAT', 4), ('OVE', 0.5), ('PWO', 0.2)],
  71. 'Settlers': [('DW', 5), ('RAT', 6), ('KOM', 1), ('EXO', 0.5), ('REP', 0.2), ('PT', 0.5)],
  72. 'Technicians': [('DW', 7.5), ('RAT', 7), ('ALE', 1), ('MED', 0.5), ('SC', 0.1), ('HMS', 0.5), ('SCN', 0.1)],
  73. 'Engineers': [('DW', 10), ('MED', 0.5), ('GIN', 1), ('FIM', 7), ('VG', 0.2), ('HSS', 0.2), ('PDA', 0.1)],
  74. 'Scientists': [('DW', 10), ('MED', 0.5), ('WIN', 1), ('MEA', 7), ('NST', 0.1), ('LC', 0.2), ('WS', 0.1)],
  75. }
  76. cost = 0
  77. for worker, mats in consumption.items():
  78. workers = building[worker]
  79. for mat, per_100 in mats:
  80. cost += prices[mat].vwap * workers * per_100 / 100
  81. return cost
  82. class Recipe(typing.TypedDict):
  83. RecipeName: str
  84. BuildingTicker: str
  85. Inputs: list[RecipeMat]
  86. Outputs: list[RecipeMat]
  87. TimeMs: int
  88. class RecipeMat(typing.TypedDict):
  89. Ticker: str
  90. Amount: int
  91. class Building(typing.TypedDict):
  92. Ticker: str
  93. Expertise: str
  94. AreaCost: int
  95. BuildingCosts: list[BuildingMat]
  96. Pioneers: int
  97. Settlers: int
  98. Technicians: int
  99. Engineers: int
  100. Scientists: int
  101. class BuildingMat(typing.TypedDict):
  102. CommodityTicker: str
  103. Amount: int
  104. class Material(typing.TypedDict):
  105. Ticker: str
  106. Weight: float
  107. Volume: float
  108. class RawPrice(typing.TypedDict):
  109. MaterialTicker: str
  110. ExchangeCode: str
  111. PriceAverage: int
  112. VWAP7D: float | None # volume-weighted average price over last 7 days
  113. AverageTraded7D: float | None # averaged daily traded volume over last 7 days
  114. @dataclasses.dataclass(eq=False, frozen=True, slots=True)
  115. class Price:
  116. vwap: float
  117. average_traded: float
  118. @dataclasses.dataclass(eq=False, slots=True)
  119. class Profit:
  120. output: str
  121. recipe: str
  122. expertise: str
  123. profit_per_area: float
  124. capex: float
  125. cost_per_day: float
  126. low_volume: bool
  127. heavy_logistics: bool
  128. high_opex: bool = dataclasses.field(init=False)
  129. score: float = dataclasses.field(init=False)
  130. def __post_init__(self) -> None:
  131. self.high_opex = self.cost_per_day > 50_000
  132. self.score = self.profit_per_area
  133. if self.low_volume:
  134. self.score *= 0.2
  135. def __lt__(self, other: Profit) -> bool:
  136. return self.score < other.score
  137. if __name__ == '__main__':
  138. main()