roi.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  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['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. prices: dict[str, Price] = {
  12. p['MaterialTicker']: Price(p['VWAP7D'], p['AverageTraded7D'], p['VWAP30D']) for p in raw_prices # pyright: ignore[reportArgumentType]
  13. if p['ExchangeCode'] == 'IC1'
  14. }
  15. habitation: typing.Mapping[Worker, str] = {
  16. 'Pioneers': 'HB1',
  17. 'Settlers': 'HB2',
  18. 'Technicians': 'HB3',
  19. 'Engineers': 'HB4',
  20. 'Scientists': 'HB5',
  21. }
  22. hab_area_cost: dict[Worker, float] = {}
  23. hab_capex: dict[Worker, float] = {}
  24. for worker, hab in habitation.items():
  25. hab_area_cost[worker] = buildings[hab]['AreaCost'] / 100
  26. hab_capex[worker] = building_construction_cost(buildings[hab], prices) / 100
  27. profits: list[Profit] = []
  28. for recipe in recipes:
  29. if profit := calc_profit(recipe, buildings, hab_area_cost, hab_capex, materials, prices):
  30. profits.append(profit)
  31. profits.sort()
  32. with open('www/roi.json', 'w') as f:
  33. json.dump([dataclasses.asdict(p) for p in profits], f, indent='\t')
  34. def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], hab_area_cost: typing.Mapping[Worker, float],
  35. hab_capex: typing.Mapping[Worker, float], materials: typing.Mapping[str, Material],
  36. prices: typing.Mapping[str, Price]) -> Profit | None:
  37. if len(recipe['Outputs']) == 0:
  38. return
  39. outputs: list[MatPrice] = []
  40. revenue = 0
  41. output_prices: dict[str, PriceNonNull] = {}
  42. for output in recipe['Outputs']:
  43. price = prices[output['Ticker']]
  44. if price.vwap_7d is None or price.average_traded_7d is None:
  45. return # skip recipes with thinly traded outputs
  46. output_prices[output['Ticker']] = typing.cast(PriceNonNull, price)
  47. outputs.append(MatPrice(output['Ticker'], output['Amount'], price.vwap_7d))
  48. revenue += price.vwap_7d * output['Amount']
  49. input_costs: list[MatPrice] = []
  50. cost = 0
  51. for input in recipe['Inputs']:
  52. if (input_cost := prices[input['Ticker']].vwap_7d) is None:
  53. return # skip recipes with thinly traded inputs
  54. input_costs.append(MatPrice(input['Ticker'], input['Amount'], input_cost))
  55. cost += input_cost * input['Amount']
  56. profit_per_run = revenue - cost
  57. building = buildings[recipe['BuildingTicker']]
  58. area = building['AreaCost'] + sum(hab_area_cost[worker] * building[worker] for worker in hab_area_cost)
  59. capex = building_construction_cost(building, prices) + \
  60. sum(hab_capex[worker] * building[worker] for worker in hab_capex)
  61. runs_per_day = 24 * 60 * 60 * 1000 / recipe['TimeMs']
  62. if building['Ticker'] in ('FRM', 'ORC'):
  63. runs_per_day *= 1.1212 # promitor's fertility
  64. worker_consumable_daily_cost = building_daily_cost(building, prices)
  65. cost_per_day = cost * runs_per_day + worker_consumable_daily_cost
  66. lowest_liquidity = min(recipe['Outputs'],
  67. key=lambda output: output['Amount'] / output_prices[output['Ticker']].average_traded_7d)
  68. output_per_day = lowest_liquidity['Amount'] * runs_per_day
  69. logistics_per_area = max(
  70. sum(materials[input['Ticker']]['Weight'] * input['Amount'] for input in recipe['Inputs']),
  71. sum(materials[input['Ticker']]['Volume'] * input['Amount'] for input in recipe['Inputs']),
  72. sum(materials[output['Ticker']]['Weight'] * output['Amount'] for output in recipe['Outputs']),
  73. sum(materials[output['Ticker']]['Volume'] * output['Amount'] for output in recipe['Outputs']),
  74. ) * runs_per_day / area
  75. return Profit(outputs, recipe['RecipeName'],
  76. expertise=building['Expertise'],
  77. profit_per_day=(profit_per_run * runs_per_day - worker_consumable_daily_cost),
  78. area=area,
  79. capex=capex,
  80. cost_per_day=cost_per_day,
  81. input_costs=input_costs,
  82. worker_consumable_cost_per_day=worker_consumable_daily_cost,
  83. runs_per_day=runs_per_day,
  84. logistics_per_area=logistics_per_area,
  85. output_per_day=output_per_day,
  86. average_traded_7d=output_prices[lowest_liquidity['Ticker']].average_traded_7d)
  87. def building_construction_cost(building: Building, prices: typing.Mapping[str, Price]) -> float:
  88. return sum(bc['Amount'] * prices[bc['CommodityTicker']].vwap_7d for bc in building['BuildingCosts']) # pyright: ignore[reportOperatorIssue]
  89. def building_daily_cost(building: Building, prices: typing.Mapping[str, Price]) -> float:
  90. consumption = {
  91. 'Pioneers': [('COF', 0.5), ('DW', 4), ('RAT', 4), ('OVE', 0.5), ('PWO', 0.2)],
  92. 'Settlers': [('DW', 5), ('RAT', 6), ('KOM', 1), ('EXO', 0.5), ('REP', 0.2), ('PT', 0.5)],
  93. 'Technicians': [('DW', 7.5), ('RAT', 7), ('ALE', 1), ('MED', 0.5), ('SC', 0.1), ('HMS', 0.5), ('SCN', 0.1)],
  94. 'Engineers': [('DW', 10), ('MED', 0.5), ('GIN', 1), ('FIM', 7), ('VG', 0.2), ('HSS', 0.2), ('PDA', 0.1)],
  95. 'Scientists': [('DW', 10), ('MED', 0.5), ('WIN', 1), ('MEA', 7), ('NST', 0.1), ('LC', 0.2), ('WS', 0.1)],
  96. }
  97. cost = 0
  98. for worker, mats in consumption.items():
  99. workers = building[worker]
  100. for mat, per_100 in mats:
  101. mat_price = prices[mat]
  102. cost += (mat_price.vwap_7d or mat_price.vwap_30d) * workers * per_100 / 100
  103. return cost
  104. Worker = typing.Literal['Pioneers', 'Settlers', 'Technicians', 'Engineers', 'Scientists']
  105. class Recipe(typing.TypedDict):
  106. RecipeName: str
  107. BuildingTicker: str
  108. Inputs: list[RecipeMat]
  109. Outputs: list[RecipeMat]
  110. TimeMs: int
  111. class RecipeMat(typing.TypedDict):
  112. Ticker: str
  113. Amount: int
  114. class Building(typing.TypedDict):
  115. Ticker: str
  116. Expertise: str
  117. AreaCost: int
  118. BuildingCosts: list[BuildingMat]
  119. Pioneers: int
  120. Settlers: int
  121. Technicians: int
  122. Engineers: int
  123. Scientists: int
  124. class BuildingMat(typing.TypedDict):
  125. CommodityTicker: str
  126. Amount: int
  127. class Material(typing.TypedDict):
  128. Ticker: str
  129. Weight: float
  130. Volume: float
  131. class RawPrice(typing.TypedDict):
  132. MaterialTicker: str
  133. ExchangeCode: str
  134. VWAP7D: float | None # volume-weighted average price over last 7 days
  135. AverageTraded7D: float | None # averaged daily traded volume over last 7 days
  136. VWAP30D: float | None
  137. @dataclasses.dataclass(eq=False, frozen=True, slots=True)
  138. class Price:
  139. vwap_7d: float | None
  140. average_traded_7d: float | None
  141. vwap_30d: float | None
  142. @dataclasses.dataclass(eq=False, frozen=True, slots=True)
  143. class PriceNonNull:
  144. vwap_7d: float
  145. average_traded_7d: float
  146. @dataclasses.dataclass(eq=False, frozen=True, slots=True)
  147. class Profit:
  148. outputs: typing.Collection[MatPrice]
  149. recipe: str
  150. expertise: str
  151. profit_per_day: float
  152. area: float
  153. capex: float
  154. cost_per_day: float
  155. input_costs: typing.Collection[MatPrice]
  156. worker_consumable_cost_per_day: float
  157. runs_per_day: float
  158. logistics_per_area: float
  159. output_per_day: float
  160. average_traded_7d: float
  161. def __lt__(self, other: Profit) -> bool:
  162. if (break_even := self.capex / self.profit_per_day) < 0:
  163. break_even = 10000 - self.profit_per_day
  164. if (other_break_even := other.capex / other.profit_per_day) < 0:
  165. other_break_even = 10000 - other.profit_per_day
  166. return break_even < other_break_even
  167. @dataclasses.dataclass(eq=False, frozen=True, slots=True)
  168. class MatPrice:
  169. ticker: str
  170. amount: int
  171. vwap_7d: float
  172. if __name__ == '__main__':
  173. main()