roi.py 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  1. from __future__ import annotations
  2. import dataclasses
  3. import typing
  4. import cache
  5. def main() -> None:
  6. recipes: list[Recipe] = cache.get('https://api.prunplanner.org/data/recipes')
  7. buildings: dict[str, Building] = {m['Ticker']: m for m in cache.get('https://api.prunplanner.org/data/buildings')}
  8. materials: dict[str, Material] = {m['Ticker']: m for m in cache.get('https://api.prunplanner.org/data/materials')}
  9. raw_prices: list[RawPrice] = cache.get('https://refined-prun.github.io/refined-prices/all.json')
  10. prices: dict[str, Price] = {
  11. p['MaterialTicker']: Price(p['VWAP7D'], p['AverageTraded7D']) for p in raw_prices # pyright: ignore[reportArgumentType]
  12. if p['ExchangeCode'] == 'IC1' and p['VWAP7D'] is not None
  13. }
  14. profits = []
  15. for recipe in recipes:
  16. if profit := calc_profit(recipe, buildings, materials, prices):
  17. profits.append(profit)
  18. profits.sort(reverse=True)
  19. for p in profits:
  20. print(f'{p.output:5} \033[32m{p.profit: 10,.0f}\033[31m', end='')
  21. if p.low_volume:
  22. print(' low volume', end='')
  23. if p.heavy_logistics:
  24. print(' heavy logistics', end='')
  25. if p.high_opex:
  26. print(' high opex', end='')
  27. print(f'\n\033[30m{p.recipe:30} \033[33m{p.capex: 6,.0f} \033[35m{p.cost_per_day: 6,.0f}\033[0m')
  28. def calc_profit(recipe: Recipe, buildings: typing.Mapping[str, Building], materials: typing.Mapping[str, Material],
  29. prices: typing.Mapping[str, Price]) -> Profit | None:
  30. try:
  31. (output,) = recipe['Outputs']
  32. except ValueError: # skip recipes that don't have exactly 1 output
  33. return
  34. try:
  35. output_price = prices[output['Ticker']]
  36. cost = sum(prices[input['Ticker']].vwap * input['Amount'] for input in recipe['Inputs'])
  37. except KeyError: # skip recipes with thinly traded materials
  38. return
  39. revenue = output_price.vwap * output['Amount']
  40. capex = sum(bm['Amount'] * prices[bm['CommodityTicker']].vwap
  41. for bm in buildings[recipe['BuildingTicker']]['BuildingCosts'])
  42. profit_per_run = revenue - cost
  43. runs_per_day = 24 * 60 * 60 * 1000 / recipe['TimeMs']
  44. cost_per_day = cost * runs_per_day
  45. output_per_day = output['Amount'] * runs_per_day
  46. logistics_per_day = max(
  47. sum(materials[input['Ticker']]['Weight'] * input['Amount'] for input in recipe['Inputs']),
  48. sum(materials[input['Ticker']]['Volume'] * input['Amount'] for input in recipe['Inputs']),
  49. materials[output['Ticker']]['Weight'] * output['Amount'],
  50. materials[output['Ticker']]['Volume'] * output['Amount'],
  51. ) * runs_per_day
  52. return Profit(output['Ticker'], recipe['RecipeName'], profit=profit_per_run * runs_per_day,
  53. capex=capex,
  54. cost_per_day=cost_per_day,
  55. low_volume=output_price.average_traded < output_per_day * 20,
  56. heavy_logistics=logistics_per_day > 100)
  57. class Recipe(typing.TypedDict):
  58. RecipeName: str
  59. BuildingTicker: str
  60. Inputs: list[RecipeMat]
  61. Outputs: list[RecipeMat]
  62. TimeMs: int
  63. class RecipeMat(typing.TypedDict):
  64. Ticker: str
  65. Amount: int
  66. class Building(typing.TypedDict):
  67. Ticker: str
  68. AreaCost: int
  69. BuildingCosts: list[BuildingMat]
  70. class BuildingMat(typing.TypedDict):
  71. CommodityTicker: str
  72. Amount: int
  73. class Material(typing.TypedDict):
  74. Ticker: str
  75. Weight: float
  76. Volume: float
  77. class RawPrice(typing.TypedDict):
  78. MaterialTicker: str
  79. ExchangeCode: str
  80. PriceAverage: int
  81. VWAP7D: float | None # volume-weighted average price over last 7 days
  82. AverageTraded7D: float | None # averaged daily traded volume over last 7 days
  83. @dataclasses.dataclass(eq=False, frozen=True, slots=True)
  84. class Price:
  85. vwap: float
  86. average_traded: float
  87. @dataclasses.dataclass(eq=False, slots=True)
  88. class Profit:
  89. output: str
  90. recipe: str
  91. profit: float
  92. capex: float
  93. cost_per_day: float
  94. low_volume: bool
  95. heavy_logistics: bool
  96. high_opex: bool = dataclasses.field(init=False)
  97. score: float = dataclasses.field(init=False)
  98. def __post_init__(self) -> None:
  99. self.high_opex = self.cost_per_day > 50_000
  100. self.score = self.profit
  101. if self.low_volume:
  102. self.score *= 0.2
  103. if self.heavy_logistics:
  104. self.score *= 0.5
  105. if self.high_opex:
  106. self.score *= 0.8
  107. def __lt__(self, other: Profit) -> bool:
  108. return self.score < other.score
  109. if __name__ == '__main__':
  110. main()