roi.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  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. # EXTREME DETAIL: We extract 'Bid' and 'Ask' directly from the raw_prices API payload.
  18. prices: dict[str, Price] = {
  19. p['MaterialTicker']: Price(p['VWAP7D'], p['AverageTraded7D'], p['VWAP30D'], p['Bid'], p['Ask']) for p in raw_prices # pyright: ignore[reportArgumentType]
  20. if p['ExchangeCode'] == cx
  21. }
  22. habitation: typing.Mapping[Worker, str] = {
  23. 'pioneers': 'HB1',
  24. 'settlers': 'HB2',
  25. 'technicians': 'HB3',
  26. 'engineers': 'HB4',
  27. 'scientists': 'HB5',
  28. }
  29. hab_area_cost: dict[Worker, float] = {}
  30. hab_capex: dict[Worker, dict[str, float]] = {}
  31. for worker, hab in habitation.items():
  32. hab_area_cost[worker] = buildings[hab]['area_cost'] / 100
  33. base_capex = building_construction_cost(buildings[hab], prices)
  34. hab_capex[worker] = {k: v / 100 for k, v in base_capex.items()}
  35. profits: list[Profit] = []
  36. for recipe in recipes:
  37. if profit := calc_profit(recipe, buildings, hab_area_cost, hab_capex, materials, prices):
  38. profits.append(profit)
  39. profits.sort()
  40. return profits
  41. def get_metrics(amount: float, price: Price) -> dict[str, float]:
  42. # EXTREME DETAIL: Helper function to generate a 3-part dictionary (VWAP, Bid, Ask) for any item quantity.
  43. # If Bid or Ask data is absent from the exchange, we gracefully fall back to the VWAP to prevent math crashes.
  44. v = price.vwap_7d or price.vwap_30d or 0.0
  45. b = price.bid if price.bid is not None else v
  46. a = price.ask if price.ask is not None else v
  47. return {'vwap': amount * v, 'bid': amount * b, 'ask': amount * a}
  48. def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], hab_area_cost: typing.Mapping[Worker, float],
  49. hab_capex: typing.Mapping[Worker, dict[str, float]], materials: typing.Mapping[str, Material],
  50. prices: typing.Mapping[str, Price]) -> Profit | None:
  51. if len(recipe['outputs']) == 0:
  52. return
  53. building = buildings[recipe['building_ticker']]
  54. area = building['area_cost'] + sum(hab_area_cost[worker] * building[worker] for worker in hab_area_cost)
  55. runs_per_day = 24 * 60 * 60 * 1000 / recipe['time_ms'] * 1.25 # assume CoGC
  56. if building['building_ticker'] in ('FRM', 'ORC'):
  57. runs_per_day *= 1.1212 # promitor's fertility
  58. outputs: list[MatPrice] = []
  59. revenue = {'vwap': 0.0, 'bid': 0.0, 'ask': 0.0}
  60. output_prices: dict[str, PriceNonNull] = {}
  61. for output in recipe['outputs']:
  62. price = prices[output['material_ticker']]
  63. if price.vwap_7d is None or price.average_traded_7d is None:
  64. return # skip recipes with thinly traded outputs
  65. output_prices[output['material_ticker']] = typing.cast(PriceNonNull, price)
  66. # EXTREME DETAIL: Calculate total daily revenue outputs per metric type.
  67. m = get_metrics(output['material_amount'] * runs_per_day, price)
  68. for k in revenue: revenue[k] += m[k]
  69. outputs.append(MatPrice(output['material_ticker'], output['material_amount'], price.vwap_7d, price.bid, price.ask))
  70. input_costs: list[MatPrice] = []
  71. opex = {'vwap': 0.0, 'bid': 0.0, 'ask': 0.0}
  72. for input in recipe['inputs']:
  73. price = prices[input['material_ticker']]
  74. if price.vwap_7d is None:
  75. return # skip recipes with thinly traded inputs
  76. m = get_metrics(input['material_amount'] * runs_per_day, price)
  77. for k in opex: opex[k] += m[k]
  78. input_costs.append(MatPrice(input['material_ticker'], input['material_amount'], price.vwap_7d, price.bid, price.ask))
  79. worker_consumable = building_daily_cost(building, prices)
  80. for k in opex: opex[k] += worker_consumable[k]
  81. capex = building_construction_cost(building, prices)
  82. for worker, hab_cost in hab_capex.items():
  83. workers = building[worker]
  84. if workers > 0:
  85. for k in capex: capex[k] += hab_cost[k] * workers
  86. lowest_liquidity = min(recipe['outputs'],
  87. key=lambda output: output['material_amount'] / output_prices[output['material_ticker']].average_traded_7d)
  88. output_per_day = lowest_liquidity['material_amount'] * runs_per_day
  89. average_traded_7d = output_prices[lowest_liquidity['material_ticker']].average_traded_7d
  90. output_per_area = output_per_day / area
  91. market_capacity_area = average_traded_7d / output_per_area
  92. logistics_per_area = max(
  93. sum(materials[input['material_ticker']]['weight'] * input['material_amount'] for input in recipe['inputs']),
  94. sum(materials[input['material_ticker']]['volume'] * input['material_amount'] for input in recipe['inputs']),
  95. sum(materials[output['material_ticker']]['weight'] * output['material_amount'] for output in recipe['outputs']),
  96. sum(materials[output['material_ticker']]['volume'] * output['material_amount'] for output in recipe['outputs']),
  97. ) * runs_per_day / area
  98. return Profit(outputs, recipe['recipe_name'],
  99. expertise=building['expertise'],
  100. building=building['building_ticker'],
  101. area=area,
  102. capex=capex,
  103. opex=opex,
  104. revenue=revenue,
  105. input_costs=input_costs,
  106. runs_per_day=runs_per_day,
  107. logistics_per_area=logistics_per_area,
  108. output_per_day=output_per_day,
  109. average_traded_7d=average_traded_7d,
  110. market_capacity_area=market_capacity_area)
  111. def building_construction_cost(building: Building, prices: typing.Mapping[str, Price]) -> dict[str, float]:
  112. cost = {'vwap': 0.0, 'bid': 0.0, 'ask': 0.0}
  113. for bc in building['costs']:
  114. m = get_metrics(bc['material_amount'], prices[bc['material_ticker']])
  115. for k in cost: cost[k] += m[k]
  116. # https://handbook.apex.prosperousuniverse.com/wiki/building-costs/#rocky-planets
  117. mcg = get_metrics(building['area_cost'] * 4, prices['MCG'])
  118. for k in cost: cost[k] += mcg[k]
  119. return cost
  120. def building_daily_cost(building: Building, prices: typing.Mapping[str, Price]) -> dict[str, float]:
  121. consumption = {
  122. 'pioneers': [('COF', 0.5), ('DW', 4), ('RAT', 4), ('OVE', 0.5), ('PWO', 0.2)],
  123. 'settlers': [('DW', 5), ('RAT', 6), ('KOM', 1), ('EXO', 0.5), ('REP', 0.2), ('PT', 0.5)],
  124. 'technicians': [('DW', 7.5), ('RAT', 7), ('ALE', 1), ('MED', 0.5), ('SC', 0.1), ('HMS', 0.5), ('SCN', 0.1)],
  125. 'engineers': [('DW', 10), ('MED', 0.5), ('GIN', 1), ('FIM', 7), ('VG', 0.2), ('HSS', 0.2), ('PDA', 0.1)],
  126. 'scientists': [('DW', 10), ('MED', 0.5), ('WIN', 1), ('MEA', 7), ('NST', 0.1), ('LC', 0.2), ('WS', 0.05)],
  127. }
  128. cost = {'vwap': 0.0, 'bid': 0.0, 'ask': 0.0}
  129. for worker, mats in consumption.items():
  130. workers = building[worker]
  131. for mat, per_100 in mats:
  132. m = get_metrics(workers * per_100 / 100, prices[mat])
  133. for k in cost: cost[k] += m[k]
  134. return cost
  135. Worker = typing.Literal['pioneers', 'settlers', 'technicians', 'engineers', 'scientists']
  136. class Recipe(typing.TypedDict):
  137. recipe_name: str
  138. building_ticker: str
  139. inputs: list[RecipeMat]
  140. outputs: list[RecipeMat]
  141. time_ms: int
  142. class RecipeMat(typing.TypedDict):
  143. material_ticker: str
  144. material_amount: int
  145. class Building(typing.TypedDict):
  146. building_ticker: str
  147. expertise: str
  148. area_cost: int
  149. costs: list[BuildingMat]
  150. pioneers: int
  151. settlers: int
  152. technicians: int
  153. engineers: int
  154. scientists: int
  155. class BuildingMat(typing.TypedDict):
  156. material_ticker: str
  157. material_amount: int
  158. class Material(typing.TypedDict):
  159. ticker: str
  160. weight: float
  161. volume: float
  162. class RawPrice(typing.TypedDict):
  163. MaterialTicker: str
  164. ExchangeCode: str
  165. VWAP7D: float | None
  166. AverageTraded7D: float | None
  167. VWAP30D: float | None
  168. Bid: float | None # Added Bid extraction
  169. Ask: float | None # Added Ask extraction
  170. @dataclasses.dataclass(eq=False, frozen=True, slots=True)
  171. class Price:
  172. vwap_7d: float | None
  173. average_traded_7d: float | None
  174. vwap_30d: float | None
  175. bid: float | None
  176. ask: float | None
  177. @dataclasses.dataclass(eq=False, frozen=True, slots=True)
  178. class PriceNonNull:
  179. vwap_7d: float
  180. average_traded_7d: float
  181. @dataclasses.dataclass(eq=False, frozen=True, slots=True)
  182. class Profit:
  183. outputs: typing.Collection[MatPrice]
  184. recipe: str
  185. expertise: str
  186. building: str
  187. area: float
  188. capex: dict[str, float] # Transformed from float to Dict
  189. opex: dict[str, float] # Transformed from float to Dict
  190. revenue: dict[str, float] # Transformed from float to Dict
  191. input_costs: typing.Collection[MatPrice]
  192. runs_per_day: float
  193. logistics_per_area: float
  194. output_per_day: float
  195. average_traded_7d: float
  196. market_capacity_area: float
  197. def __lt__(self, other: Profit) -> bool:
  198. # EXTREME DETAIL: We establish a baseline VWAP sort for the raw JSON payload.
  199. # Even though the frontend now dynamically resorts based on UI dropdown permutations,
  200. # sorting the initial JSON correctly saves the client from experiencing a 'pop-in' rearrangement
  201. # on their very first page load.
  202. p_a = self.revenue['vwap'] - self.opex['vwap']
  203. be_a = (self.capex['vwap'] + 3 * self.opex['vwap']) / p_a if p_a > 0 else 10000 - p_a
  204. p_b = other.revenue['vwap'] - other.opex['vwap']
  205. be_b = (other.capex['vwap'] + 3 * other.opex['vwap']) / p_b if p_b > 0 else 10000 - p_b
  206. return be_a < be_b
  207. @dataclasses.dataclass(eq=False, frozen=True, slots=True)
  208. class MatPrice:
  209. ticker: str
  210. amount: int
  211. vwap_7d: float
  212. bid: float | None
  213. ask: float | None
  214. if __name__ == '__main__':
  215. main()