roi.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  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. # EXTREME DETAIL: We execute the "Open Source Heist" here. We pull the static JSON
  12. # directly from PRUNplanner's GitHub repository.
  13. hq_levels_raw = cache.get('https://raw.githubusercontent.com/prunplanner/prunplanner/main/frontend/src/features/hq_upgrade_calculator/hq_levels.json')
  14. for cx in ['AI1', 'CI1', 'IC1', 'NC1']:
  15. profits = calc_for_cx(cx, recipes, buildings, materials, raw_prices, hq_levels_raw)
  16. with open(f'www/roi_{cx.lower()}.json', 'w') as f:
  17. json.dump([dataclasses.asdict(p) for p in profits], f, indent='\t')
  18. def calc_for_cx(cx: str, recipes: typing.Collection[Recipe], buildings: typing.Mapping[str, Building],
  19. materials: typing.Mapping[str, Material], raw_prices: typing.Collection[RawPrice],
  20. hq_levels_raw: dict) -> typing.Sequence[Profit]:
  21. prices: dict[str, Price] = {
  22. p['MaterialTicker']: Price(p['VWAP7D'], p['AverageTraded7D'], p['VWAP30D'], p['Bid'], p['Ask']) for p in raw_prices # pyright: ignore[reportArgumentType]
  23. if p['ExchangeCode'] == cx
  24. }
  25. # EXTREME DETAIL: We pre-calculate the VWAP, Bid, and Ask prices for every HQ level.
  26. # This ensures the frontend doesn't have to do any heavy lifting, and the HQ CapEx
  27. # naturally inherits the user's active price-metric permutation from the dropdowns!
  28. hq_costs: dict[str, dict[str, float]] = {}
  29. for level_str, mats in hq_levels_raw.items():
  30. cost = {'vwap': 0.0, 'bid': 0.0, 'ask': 0.0}
  31. for mat in mats:
  32. if mat['ticker'] in prices:
  33. m_metrics = get_metrics(mat['amount'], prices[mat['ticker']])
  34. for k in cost: cost[k] += m_metrics[k]
  35. hq_costs[level_str] = cost
  36. habitation: typing.Mapping[Worker, str] = {
  37. 'pioneers': 'HB1',
  38. 'settlers': 'HB2',
  39. 'technicians': 'HB3',
  40. 'engineers': 'HB4',
  41. 'scientists': 'HB5',
  42. }
  43. hab_area_cost: dict[Worker, float] = {}
  44. hab_capex: dict[Worker, dict[str, float]] = {}
  45. for worker, hab in habitation.items():
  46. hab_area_cost[worker] = buildings[hab]['area_cost'] / 100
  47. base_capex = building_construction_cost(buildings[hab], prices)
  48. hab_capex[worker] = {k: v / 100 for k, v in base_capex.items()}
  49. profits: list[Profit] = []
  50. for recipe in recipes:
  51. if profit := calc_profit(recipe, buildings, hab_area_cost, hab_capex, materials, prices, hq_costs):
  52. profits.append(profit)
  53. profits.sort()
  54. return profits
  55. def get_metrics(amount: float, price: Price) -> dict[str, float]:
  56. v = price.vwap_7d or price.vwap_30d or 0.0
  57. b = price.bid if price.bid is not None else v
  58. a = price.ask if price.ask is not None else v
  59. return {'vwap': amount * v, 'bid': amount * b, 'ask': amount * a}
  60. def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], hab_area_cost: typing.Mapping[Worker, float],
  61. hab_capex: typing.Mapping[Worker, dict[str, float]], materials: typing.Mapping[str, Material],
  62. prices: typing.Mapping[str, Price], hq_costs: dict[str, dict[str, float]]) -> Profit | None:
  63. if len(recipe['outputs']) == 0:
  64. return
  65. building = buildings[recipe['building_ticker']]
  66. area = building['area_cost'] + sum(hab_area_cost[worker] * building[worker] for worker in hab_area_cost)
  67. runs_per_day = 24 * 60 * 60 * 1000 / recipe['time_ms'] * 1.25 # assume CoGC
  68. if building['building_ticker'] in ('FRM', 'ORC'):
  69. runs_per_day *= 1.1212 # promitor's fertility
  70. outputs: list[MatPrice] = []
  71. revenue = {'vwap': 0.0, 'bid': 0.0, 'ask': 0.0}
  72. output_prices: dict[str, PriceNonNull] = {}
  73. for output in recipe['outputs']:
  74. price = prices[output['material_ticker']]
  75. if price.vwap_7d is None or price.average_traded_7d is None:
  76. return # skip recipes with thinly traded outputs
  77. output_prices[output['material_ticker']] = typing.cast(PriceNonNull, price)
  78. m = get_metrics(output['material_amount'] * runs_per_day, price)
  79. for k in revenue: revenue[k] += m[k]
  80. outputs.append(MatPrice(output['material_ticker'], output['material_amount'], price.vwap_7d, price.bid, price.ask))
  81. input_costs: list[MatPrice] = []
  82. opex = {'vwap': 0.0, 'bid': 0.0, 'ask': 0.0}
  83. for input in recipe['inputs']:
  84. price = prices[input['material_ticker']]
  85. if price.vwap_7d is None:
  86. return # skip recipes with thinly traded inputs
  87. m = get_metrics(input['material_amount'] * runs_per_day, price)
  88. for k in opex: opex[k] += m[k]
  89. input_costs.append(MatPrice(input['material_ticker'], input['material_amount'], price.vwap_7d, price.bid, price.ask))
  90. worker_consumable = building_daily_cost(building, prices)
  91. for k in opex: opex[k] += worker_consumable[k]
  92. capex = building_construction_cost(building, prices)
  93. for worker, hab_cost in hab_capex.items():
  94. workers = building[worker]
  95. if workers > 0:
  96. for k in capex: capex[k] += hab_cost[k] * workers
  97. lowest_liquidity = min(recipe['outputs'],
  98. key=lambda output: output['material_amount'] / output_prices[output['material_ticker']].average_traded_7d)
  99. output_per_day = lowest_liquidity['material_amount'] * runs_per_day
  100. average_traded_7d = output_prices[lowest_liquidity['material_ticker']].average_traded_7d
  101. output_per_base = output_per_day / (area / 500)
  102. market_capacity_base = average_traded_7d / output_per_base
  103. in_w = sum(materials[input['material_ticker']]['weight'] * input['material_amount'] for input in recipe['inputs'])
  104. in_v = sum(materials[input['material_ticker']]['volume'] * input['material_amount'] for input in recipe['inputs'])
  105. out_w = sum(materials[output['material_ticker']]['weight'] * output['material_amount'] for output in recipe['outputs'])
  106. out_v = sum(materials[output['material_ticker']]['volume'] * output['material_amount'] for output in recipe['outputs'])
  107. runs_per_base = runs_per_day / (area / 500)
  108. ships_needed_per_base = max(in_w / 3000, in_v / 1000, out_w / 3000, out_v / 1000) * runs_per_base
  109. ship_capex_per_base = ships_needed_per_base * 800_000
  110. bottlenecks = [
  111. (in_w, 't (I)'),
  112. (in_v, 'm³ (I)'),
  113. (out_w, 't (O)'),
  114. (out_v, 'm³ (O)')
  115. ]
  116. max_logistics, logistics_bottleneck = max(bottlenecks, key=lambda x: x[0])
  117. logistics_per_base = max_logistics * runs_per_base
  118. return Profit(outputs, recipe['recipe_name'],
  119. expertise=building['expertise'],
  120. building=building['building_ticker'],
  121. area=area,
  122. capex=capex,
  123. opex=opex,
  124. revenue=revenue,
  125. input_costs=input_costs,
  126. runs_per_day=runs_per_day,
  127. logistics_per_base=logistics_per_base,
  128. logistics_bottleneck=logistics_bottleneck,
  129. output_per_day=output_per_day,
  130. average_traded_7d=average_traded_7d,
  131. market_capacity_base=market_capacity_base,
  132. ship_capex_per_base=ship_capex_per_base,
  133. hq_costs=hq_costs)
  134. def building_construction_cost(building: Building, prices: typing.Mapping[str, Price]) -> dict[str, float]:
  135. cost = {'vwap': 0.0, 'bid': 0.0, 'ask': 0.0}
  136. for bc in building['costs']:
  137. m = get_metrics(bc['material_amount'], prices[bc['material_ticker']])
  138. for k in cost: cost[k] += m[k]
  139. # https://handbook.apex.prosperousuniverse.com/wiki/building-costs/#rocky-planets
  140. mcg = get_metrics(building['area_cost'] * 4, prices['MCG'])
  141. for k in cost: cost[k] += mcg[k]
  142. return cost
  143. def building_daily_cost(building: Building, prices: typing.Mapping[str, Price]) -> dict[str, float]:
  144. consumption = {
  145. 'pioneers': [('COF', 0.5), ('DW', 4), ('RAT', 4), ('OVE', 0.5), ('PWO', 0.2)],
  146. 'settlers': [('DW', 5), ('RAT', 6), ('KOM', 1), ('EXO', 0.5), ('REP', 0.2), ('PT', 0.5)],
  147. 'technicians': [('DW', 7.5), ('RAT', 7), ('ALE', 1), ('MED', 0.5), ('SC', 0.1), ('HMS', 0.5), ('SCN', 0.1)],
  148. 'engineers': [('DW', 10), ('MED', 0.5), ('GIN', 1), ('FIM', 7), ('VG', 0.2), ('HSS', 0.2), ('PDA', 0.1)],
  149. 'scientists': [('DW', 10), ('MED', 0.5), ('WIN', 1), ('MEA', 7), ('NST', 0.1), ('LC', 0.2), ('WS', 0.05)],
  150. }
  151. cost = {'vwap': 0.0, 'bid': 0.0, 'ask': 0.0}
  152. for worker, mats in consumption.items():
  153. workers = building[worker]
  154. for mat, per_100 in mats:
  155. m = get_metrics(workers * per_100 / 100, prices[mat])
  156. for k in cost: cost[k] += m[k]
  157. return cost
  158. Worker = typing.Literal['pioneers', 'settlers', 'technicians', 'engineers', 'scientists']
  159. class Recipe(typing.TypedDict):
  160. recipe_name: str
  161. building_ticker: str
  162. inputs: list[RecipeMat]
  163. outputs: list[RecipeMat]
  164. time_ms: int
  165. class RecipeMat(typing.TypedDict):
  166. material_ticker: str
  167. material_amount: int
  168. class Building(typing.TypedDict):
  169. building_ticker: str
  170. expertise: str
  171. area_cost: int
  172. costs: list[BuildingMat]
  173. pioneers: int
  174. settlers: int
  175. technicians: int
  176. engineers: int
  177. scientists: int
  178. class BuildingMat(typing.TypedDict):
  179. material_ticker: str
  180. material_amount: int
  181. class Material(typing.TypedDict):
  182. ticker: str
  183. weight: float
  184. volume: float
  185. class RawPrice(typing.TypedDict):
  186. MaterialTicker: str
  187. ExchangeCode: str
  188. VWAP7D: float | None
  189. AverageTraded7D: float | None
  190. VWAP30D: float | None
  191. Bid: float | None
  192. Ask: float | None
  193. @dataclasses.dataclass(eq=False, frozen=True, slots=True)
  194. class Price:
  195. vwap_7d: float | None
  196. average_traded_7d: float | None
  197. vwap_30d: float | None
  198. bid: float | None
  199. ask: float | None
  200. @dataclasses.dataclass(eq=False, frozen=True, slots=True)
  201. class PriceNonNull:
  202. vwap_7d: float
  203. average_traded_7d: float
  204. @dataclasses.dataclass(eq=False, frozen=True, slots=True)
  205. class Profit:
  206. outputs: typing.Collection[MatPrice]
  207. recipe: str
  208. expertise: str
  209. building: str
  210. area: float
  211. capex: dict[str, float]
  212. opex: dict[str, float]
  213. revenue: dict[str, float]
  214. input_costs: typing.Collection[MatPrice]
  215. runs_per_day: float
  216. logistics_per_base: float
  217. logistics_bottleneck: str
  218. output_per_day: float
  219. average_traded_7d: float
  220. market_capacity_base: float
  221. ship_capex_per_base: float
  222. hq_costs: dict[str, dict[str, float]] # Added the HQ pricing dictionary
  223. def __lt__(self, other: Profit) -> bool:
  224. bases_a = self.area / 500
  225. p_a = (self.revenue['vwap'] - self.opex['vwap']) / bases_a
  226. c_a = self.capex['vwap'] / bases_a
  227. o_a = self.opex['vwap'] / bases_a
  228. be_a = (c_a + 3 * o_a) / p_a if p_a > 0 else 10000 - p_a
  229. bases_b = other.area / 500
  230. p_b = (other.revenue['vwap'] - other.opex['vwap']) / bases_b
  231. c_b = other.capex['vwap'] / bases_b
  232. o_b = other.opex['vwap'] / bases_b
  233. be_b = (c_b + 3 * o_b) / p_b if p_b > 0 else 10000 - p_b
  234. return be_a < be_b
  235. @dataclasses.dataclass(eq=False, frozen=True, slots=True)
  236. class MatPrice:
  237. ticker: str
  238. amount: int
  239. vwap_7d: float
  240. bid: float | None
  241. ask: float | None
  242. if __name__ == '__main__':
  243. main()