roi.py 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  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']) for p in raw_prices # pyright: ignore[reportArgumentType]
  13. if p['ExchangeCode'] == 'IC1' and p['VWAP7D'] is not None
  14. }
  15. profits: list[Profit] = []
  16. for recipe in recipes:
  17. if profit := calc_profit(recipe, buildings, materials, prices):
  18. profits.append(profit)
  19. profits.sort()
  20. with open('www/roi.json', 'w') as f:
  21. json.dump([dataclasses.asdict(p) for p in profits], f, indent='\t')
  22. def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], materials: typing.Mapping[str, Material],
  23. prices: typing.Mapping[str, Price]) -> Profit | None:
  24. try:
  25. (output,) = recipe['Outputs']
  26. except ValueError: # skip recipes that don't have exactly 1 output
  27. return
  28. try:
  29. output_price = prices[output['Ticker']]
  30. cost = sum(prices[input['Ticker']].vwap * input['Amount'] for input in recipe['Inputs'])
  31. except KeyError: # skip recipes with thinly traded materials
  32. return
  33. revenue = output_price.vwap * output['Amount']
  34. building = buildings[recipe['BuildingTicker']]
  35. capex = sum(bm['Amount'] * prices[bm['CommodityTicker']].vwap
  36. for bm in building['BuildingCosts'])
  37. profit_per_run = revenue - cost
  38. runs_per_day = 24 * 60 * 60 * 1000 / recipe['TimeMs']
  39. worker_consumable_daily_cost = building_daily_cost(building, prices)
  40. cost_per_day = cost * runs_per_day + worker_consumable_daily_cost
  41. output_per_day = output['Amount'] * runs_per_day
  42. logistics_per_area = max(
  43. sum(materials[input['Ticker']]['Weight'] * input['Amount'] for input in recipe['Inputs']),
  44. sum(materials[input['Ticker']]['Volume'] * input['Amount'] for input in recipe['Inputs']),
  45. materials[output['Ticker']]['Weight'] * output['Amount'],
  46. materials[output['Ticker']]['Volume'] * output['Amount'],
  47. ) * runs_per_day / building['AreaCost']
  48. return Profit(output['Ticker'], recipe['RecipeName'],
  49. expertise=building['Expertise'].replace('_', ' ').lower(),
  50. profit_per_day=(profit_per_run * runs_per_day - worker_consumable_daily_cost),
  51. area=building['AreaCost'],
  52. capex=capex,
  53. cost_per_day=cost_per_day,
  54. logistics_per_area=logistics_per_area,
  55. output_per_day=output_per_day,
  56. average_traded_7d=output_price.average_traded_7d)
  57. def building_daily_cost(building: Building, prices: typing.Mapping[str, Price]) -> float:
  58. consumption = {
  59. 'Pioneers': [('COF', 0.5), ('DW', 4), ('RAT', 4), ('OVE', 0.5), ('PWO', 0.2)],
  60. 'Settlers': [('DW', 5), ('RAT', 6), ('KOM', 1), ('EXO', 0.5), ('REP', 0.2), ('PT', 0.5)],
  61. 'Technicians': [('DW', 7.5), ('RAT', 7), ('ALE', 1), ('MED', 0.5), ('SC', 0.1), ('HMS', 0.5), ('SCN', 0.1)],
  62. 'Engineers': [('DW', 10), ('MED', 0.5), ('GIN', 1), ('FIM', 7), ('VG', 0.2), ('HSS', 0.2), ('PDA', 0.1)],
  63. 'Scientists': [('DW', 10), ('MED', 0.5), ('WIN', 1), ('MEA', 7), ('NST', 0.1), ('LC', 0.2), ('WS', 0.1)],
  64. }
  65. cost = 0
  66. for worker, mats in consumption.items():
  67. workers = building[worker]
  68. for mat, per_100 in mats:
  69. cost += prices[mat].vwap * workers * per_100 / 100
  70. return cost
  71. class Recipe(typing.TypedDict):
  72. RecipeName: str
  73. BuildingTicker: str
  74. Inputs: list[RecipeMat]
  75. Outputs: list[RecipeMat]
  76. TimeMs: int
  77. class RecipeMat(typing.TypedDict):
  78. Ticker: str
  79. Amount: int
  80. class Building(typing.TypedDict):
  81. Ticker: str
  82. Expertise: str
  83. AreaCost: int
  84. BuildingCosts: list[BuildingMat]
  85. Pioneers: int
  86. Settlers: int
  87. Technicians: int
  88. Engineers: int
  89. Scientists: int
  90. class BuildingMat(typing.TypedDict):
  91. CommodityTicker: str
  92. Amount: int
  93. class Material(typing.TypedDict):
  94. Ticker: str
  95. Weight: float
  96. Volume: float
  97. class RawPrice(typing.TypedDict):
  98. MaterialTicker: str
  99. ExchangeCode: str
  100. PriceAverage: int
  101. VWAP7D: float | None # volume-weighted average price over last 7 days
  102. AverageTraded7D: float | None # averaged daily traded volume over last 7 days
  103. @dataclasses.dataclass(eq=False, frozen=True, slots=True)
  104. class Price:
  105. vwap: float
  106. average_traded_7d: float
  107. @dataclasses.dataclass(eq=False, frozen=True, slots=True)
  108. class Profit:
  109. output: str
  110. recipe: str
  111. expertise: str
  112. profit_per_day: float
  113. area: float
  114. capex: float
  115. cost_per_day: float
  116. logistics_per_area: float
  117. output_per_day: float
  118. average_traded_7d: float
  119. def __lt__(self, other: Profit) -> bool:
  120. if (break_even := self.capex / self.profit_per_day) < 0:
  121. break_even = 10000 - self.profit_per_day
  122. if (other_break_even := other.capex / other.profit_per_day) < 0:
  123. other_break_even = 10000 - other.profit_per_day
  124. return break_even < other_break_even
  125. if __name__ == '__main__':
  126. main()