roi.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. from __future__ import annotations
  2. import dataclasses
  3. import json
  4. import typing
  5. import cache
  6. def main() -> None:
  7. recipes: list[Recipe] = cache.get('https://api.prunplanner.org/data/recipes/')
  8. buildings: dict[str, Building] = {m['building_ticker']: m for m in cache.get('https://api.prunplanner.org/data/buildings/')}
  9. materials: dict[str, Material] = {m['ticker']: m for m in cache.get('https://api.prunplanner.org/data/materials/')}
  10. raw_prices: list[RawPrice] = cache.get('https://refined-prun.github.io/refined-prices/all.json')
  11. for cx in ['AI1', 'CI1', 'IC1', 'NC1']:
  12. profits = calc_for_cx(cx, recipes, buildings, materials, raw_prices)
  13. with open(f'www/roi_{cx.lower()}.json', 'w') as f:
  14. json.dump([dataclasses.asdict(p) for p in profits], f, indent='\t')
  15. def calc_for_cx(cx: str, recipes: typing.Collection[Recipe], buildings: typing.Mapping[str, Building],
  16. materials: typing.Mapping[str, Material], raw_prices: typing.Collection[RawPrice]) -> typing.Sequence[Profit]:
  17. prices: dict[str, Price] = {
  18. p['MaterialTicker']: Price(p['VWAP7D'], p['AverageTraded7D'], p['VWAP30D']) for p in raw_prices # pyright: ignore[reportArgumentType]
  19. if p['ExchangeCode'] == cx
  20. }
  21. habitation: typing.Mapping[Worker, str] = {
  22. 'pioneers': 'HB1',
  23. 'settlers': 'HB2',
  24. 'technicians': 'HB3',
  25. 'engineers': 'HB4',
  26. 'scientists': 'HB5',
  27. }
  28. hab_area_cost: dict[Worker, float] = {}
  29. hab_capex: dict[Worker, float] = {}
  30. for worker, hab in habitation.items():
  31. hab_area_cost[worker] = buildings[hab]['area_cost'] / 100
  32. hab_capex[worker] = building_construction_cost(buildings[hab], prices) / 100
  33. profits: list[Profit] = []
  34. for recipe in recipes:
  35. if profit := calc_profit(recipe, buildings, hab_area_cost, hab_capex, materials, prices):
  36. profits.append(profit)
  37. profits.sort()
  38. return profits
  39. def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], hab_area_cost: typing.Mapping[Worker, float],
  40. hab_capex: typing.Mapping[Worker, float], materials: typing.Mapping[str, Material],
  41. prices: typing.Mapping[str, Price]) -> Profit | None:
  42. if len(recipe['outputs']) == 0:
  43. return
  44. outputs: list[MatPrice] = []
  45. revenue = 0
  46. output_prices: dict[str, PriceNonNull] = {}
  47. for output in recipe['outputs']:
  48. price = prices[output['material_ticker']]
  49. if price.vwap_7d is None or price.average_traded_7d is None:
  50. return # skip recipes with thinly traded outputs
  51. output_prices[output['material_ticker']] = typing.cast(PriceNonNull, price)
  52. outputs.append(MatPrice(output['material_ticker'], output['material_amount'], price.vwap_7d))
  53. revenue += price.vwap_7d * output['material_amount']
  54. input_costs: list[MatPrice] = []
  55. cost = 0
  56. for input in recipe['inputs']:
  57. if (input_cost := prices[input['material_ticker']].vwap_7d) is None:
  58. return # skip recipes with thinly traded inputs
  59. input_costs.append(MatPrice(input['material_ticker'], input['material_amount'], input_cost))
  60. cost += input_cost * input['material_amount']
  61. profit_per_run = revenue - cost
  62. building = buildings[recipe['building_ticker']]
  63. area = building['area_cost'] + sum(hab_area_cost[worker] * building[worker] for worker in hab_area_cost)
  64. capex = building_construction_cost(building, prices) + \
  65. sum(hab_capex[worker] * building[worker] for worker in hab_capex)
  66. runs_per_day = 24 * 60 * 60 * 1000 / recipe['time_ms'] * 1.25 # assume CoGC
  67. if building['building_ticker'] in ('FRM', 'ORC'):
  68. runs_per_day *= 1.1212 # promitor's fertility
  69. worker_consumable_daily_cost = building_daily_cost(building, prices)
  70. cost_per_day = cost * runs_per_day + worker_consumable_daily_cost
  71. # EXTREME DETAIL: We establish the "Single Source of Truth" (SSOT) here.
  72. # Previously, the frontend recalculated `break_even` and `profit_per_area` dynamically.
  73. # By performing the math here on the backend and storing it directly into the JSON data contract,
  74. # we guarantee the sorting algorithms in Python and rendering in TypeScript can never mismatch.
  75. profit_per_day = profit_per_run * runs_per_day - worker_consumable_daily_cost
  76. if profit_per_day > 0:
  77. break_even = (capex + 3 * cost_per_day) / profit_per_day
  78. else:
  79. break_even = 10000 - profit_per_day
  80. profit_per_area = profit_per_day / area
  81. lowest_liquidity = min(recipe['outputs'],
  82. key=lambda output: output['material_amount'] / output_prices[output['material_ticker']].average_traded_7d)
  83. output_per_day = lowest_liquidity['material_amount'] * runs_per_day
  84. logistics_per_area = max(
  85. sum(materials[input['material_ticker']]['weight'] * input['material_amount'] for input in recipe['inputs']),
  86. sum(materials[input['material_ticker']]['volume'] * input['material_amount'] for input in recipe['inputs']),
  87. sum(materials[output['material_ticker']]['weight'] * output['material_amount'] for output in recipe['outputs']),
  88. sum(materials[output['material_ticker']]['volume'] * output['material_amount'] for output in recipe['outputs']),
  89. ) * runs_per_day / area
  90. return Profit(outputs, recipe['recipe_name'],
  91. expertise=building['expertise'],
  92. building=building['building_ticker'],
  93. profit_per_day=profit_per_day,
  94. area=area,
  95. capex=capex,
  96. cost_per_day=cost_per_day,
  97. input_costs=input_costs,
  98. worker_consumable_cost_per_day=worker_consumable_daily_cost,
  99. runs_per_day=runs_per_day,
  100. logistics_per_area=logistics_per_area,
  101. output_per_day=output_per_day,
  102. average_traded_7d=output_prices[lowest_liquidity['material_ticker']].average_traded_7d,
  103. profit_per_area=profit_per_area,
  104. break_even=break_even)
  105. def building_construction_cost(building: Building, prices: typing.Mapping[str, Price]) -> float:
  106. cost = sum(bc['material_amount'] * prices[bc['material_ticker']].vwap_7d for bc in building['costs']) # pyright: ignore[reportOperatorIssue]
  107. # https://handbook.apex.prosperousuniverse.com/wiki/building-costs/#rocky-planets
  108. cost += building['area_cost'] * 4 * prices['MCG'].vwap_7d # pyright: ignore[reportOperatorIssue]
  109. return cost
  110. def building_daily_cost(building: Building, prices: typing.Mapping[str, Price]) -> float:
  111. consumption = {
  112. 'pioneers': [('COF', 0.5), ('DW', 4), ('RAT', 4), ('OVE', 0.5), ('PWO', 0.2)],
  113. 'settlers': [('DW', 5), ('RAT', 6), ('KOM', 1), ('EXO', 0.5), ('REP', 0.2), ('PT', 0.5)],
  114. 'technicians': [('DW', 7.5), ('RAT', 7), ('ALE', 1), ('MED', 0.5), ('SC', 0.1), ('HMS', 0.5), ('SCN', 0.1)],
  115. 'engineers': [('DW', 10), ('MED', 0.5), ('GIN', 1), ('FIM', 7), ('VG', 0.2), ('HSS', 0.2), ('PDA', 0.1)],
  116. 'scientists': [('DW', 10), ('MED', 0.5), ('WIN', 1), ('MEA', 7), ('NST', 0.1), ('LC', 0.2), ('WS', 0.05)],
  117. }
  118. cost = 0
  119. for worker, mats in consumption.items():
  120. workers = building[worker]
  121. for mat, per_100 in mats:
  122. mat_price = prices[mat]
  123. cost += (mat_price.vwap_7d or mat_price.vwap_30d) * workers * per_100 / 100
  124. return cost
  125. Worker = typing.Literal['pioneers', 'settlers', 'technicians', 'engineers', 'scientists']
  126. class Recipe(typing.TypedDict):
  127. recipe_name: str
  128. building_ticker: str
  129. inputs: list[RecipeMat]
  130. outputs: list[RecipeMat]
  131. time_ms: int
  132. class RecipeMat(typing.TypedDict):
  133. material_ticker: str
  134. material_amount: int
  135. class Building(typing.TypedDict):
  136. building_ticker: str
  137. expertise: str
  138. area_cost: int
  139. costs: list[BuildingMat]
  140. pioneers: int
  141. settlers: int
  142. technicians: int
  143. engineers: int
  144. scientists: int
  145. class BuildingMat(typing.TypedDict):
  146. material_ticker: str
  147. material_amount: int
  148. class Material(typing.TypedDict):
  149. ticker: str
  150. weight: float
  151. volume: float
  152. class RawPrice(typing.TypedDict):
  153. MaterialTicker: str
  154. ExchangeCode: str
  155. VWAP7D: float | None # volume-weighted average price over last 7 days
  156. AverageTraded7D: float | None # averaged daily traded volume over last 7 days
  157. VWAP30D: float | None
  158. @dataclasses.dataclass(eq=False, frozen=True, slots=True)
  159. class Price:
  160. vwap_7d: float | None
  161. average_traded_7d: float | None
  162. vwap_30d: float | None
  163. @dataclasses.dataclass(eq=False, frozen=True, slots=True)
  164. class PriceNonNull:
  165. vwap_7d: float
  166. average_traded_7d: float
  167. @dataclasses.dataclass(eq=False, frozen=True, slots=True)
  168. class Profit:
  169. outputs: typing.Collection[MatPrice]
  170. recipe: str
  171. expertise: str
  172. building: str
  173. profit_per_day: float
  174. area: float
  175. capex: float
  176. cost_per_day: float
  177. input_costs: typing.Collection[MatPrice]
  178. worker_consumable_cost_per_day: float
  179. runs_per_day: float
  180. logistics_per_area: float
  181. output_per_day: float
  182. average_traded_7d: float
  183. profit_per_area: float # Added derived property
  184. break_even: float # Added derived property
  185. def __lt__(self, other: Profit) -> bool:
  186. # EXTREME DETAIL: Because break_even is now pre-calculated upon instantiation,
  187. # the magic less-than comparison used for backend array sorting is simplified to a pure property read.
  188. return self.break_even < other.break_even
  189. @dataclasses.dataclass(eq=False, frozen=True, slots=True)
  190. class MatPrice:
  191. ticker: str
  192. amount: int
  193. vwap_7d: float
  194. if __name__ == '__main__':
  195. main()