roi.py 3.3 KB

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