movers.py 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. from __future__ import annotations
  2. import collections
  3. import dataclasses
  4. import datetime
  5. import typing
  6. import cache
  7. OLD_PRICE_DAYS = 14
  8. def main() -> None:
  9. current_prices: typing.Sequence[RawPrice] = cache.get('https://refined-prun.github.io/refined-prices/all.json')
  10. old_prices = get_old_prices()
  11. movers: dict[str, list[Mover]] = collections.defaultdict(list)
  12. shortages: dict[str, list[Mover]] = collections.defaultdict(list)
  13. traded_map: dict[str, int] = {}
  14. for current_price in current_prices:
  15. if current_price['ExchangeCode'].endswith('2'):
  16. continue
  17. old_price = old_prices[current_price['FullTicker']]
  18. if (mover := analyze_raw_price(current_price, old_price)) is not None:
  19. if mover.score > 10000:
  20. movers[mover.ticker].append(mover)
  21. if mover.days_supply_lost > 7 and mover.days_supply_remaining < 14:
  22. shortages[mover.ticker].append(mover)
  23. assert (traded := current_price['AverageTraded30D']) is not None
  24. traded_map[current_price['FullTicker']] = int(traded)
  25. for ticker_movers in movers.values():
  26. ticker_movers.sort(reverse=True)
  27. for ticker_shortages in shortages.values():
  28. ticker_shortages.sort(reverse=True)
  29. top_movers = sorted(movers.values(), key=lambda m: m[0].score, reverse=True)
  30. top_shortages = sorted(shortages.values(), key=lambda m: m[0].days_supply_remaining, reverse=True)
  31. print('top movers:')
  32. for commodity in top_movers:
  33. print(f'{commodity[0].price_change:5,.2f}', ' '.join(f'{mover.ticker}.{mover.exchange_code}' for mover in commodity))
  34. print('\ntop shortages:')
  35. for commodity in top_shortages:
  36. print(f'{-commodity[0].days_supply_lost:6.1f}d', f'{commodity[0].supply_consumption_rate:7.1f}/d',
  37. ' '.join(f'{mover.ticker}.{mover.exchange_code}' for mover in commodity))
  38. print('\nthin supply:')
  39. for ticker, ticker_shortages in shortages.items():
  40. for commodity in ticker_shortages:
  41. exchange_ticker = f'{commodity.ticker}.{commodity.exchange_code}'
  42. asks: list[Order] = cache.get(f'https://rest.fnar.net/exchange/{exchange_ticker}')['SellingOrders']
  43. if len(asks) == 0:
  44. print(f'{exchange_ticker:3}: no asks')
  45. continue
  46. asks.sort(key=lambda o: o['ItemCost'], reverse=True)
  47. current_ask = asks[-1]['ItemCost']
  48. traded = traded_map[exchange_ticker]
  49. remaining = traded // 2
  50. expected_price = float('-infinity')
  51. while remaining > 0:
  52. if len(asks) == 0:
  53. expected_price = float('infinity')
  54. break
  55. if (asks[-1]['ItemCount'] or float('infinity')) > remaining:
  56. expected_price = asks[-1]['ItemCost']
  57. break
  58. remaining -= asks.pop()['ItemCount']
  59. if (expected_price - current_ask) / current_ask > 0.15:
  60. print(f'{exchange_ticker:>7}: {traded:7,} {current_ask:10,.2f} → {expected_price:10,.2f}')
  61. def get_old_prices() -> typing.Mapping[str, RawPrice]:
  62. week_ago = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=OLD_PRICE_DAYS)
  63. commits = cache.get(f'https://api.github.com/repos/refined-prun/refined-prices/commits?until={week_ago.isoformat()}&per_page=1')
  64. return {p['FullTicker']: p
  65. for p in cache.get(f'https://raw.githubusercontent.com/refined-prun/refined-prices/{commits[0]["sha"]}/all.json')}
  66. def analyze_raw_price(current_price: RawPrice, old_price: RawPrice) -> Mover | None:
  67. if (traded := current_price['AverageTraded30D']) is None or traded < 100:
  68. return
  69. if (current_vwap7d := current_price['VWAP7D']) is None or (old_vwap7d := old_price['VWAP7D']) is None:
  70. return
  71. diff = current_vwap7d - old_vwap7d
  72. supply_delta = (old_price['Supply'] - current_price['Supply'])
  73. days_supply_lost = supply_delta / traded
  74. days_supply_remaining = current_price['Supply'] / traded
  75. if abs(diff) / min(current_vwap7d, old_vwap7d) > 0.15 or (days_supply_lost > 7 and days_supply_remaining < 14):
  76. return Mover(current_price['ExchangeCode'], current_price['MaterialTicker'], abs(diff) * traded,
  77. diff / old_vwap7d, days_supply_lost, days_supply_remaining, supply_delta / OLD_PRICE_DAYS)
  78. class RawPrice(typing.TypedDict):
  79. FullTicker: str
  80. ExchangeCode: str
  81. MaterialTicker: str
  82. VWAP7D: float | None # volume-weighted average price over last 7 days
  83. AverageTraded30D: float | None # averaged daily traded volume over last 30 days
  84. Supply: int
  85. class Order(typing.TypedDict):
  86. ItemCount: int
  87. ItemCost: float
  88. @dataclasses.dataclass(eq=False, frozen=True, slots=True)
  89. class Mover:
  90. exchange_code: str
  91. ticker: str
  92. score: float
  93. price_change: float
  94. days_supply_lost: float
  95. days_supply_remaining: float
  96. supply_consumption_rate: float
  97. def __lt__(self, other: Mover) -> bool:
  98. return self.score < other.score
  99. if __name__ == '__main__':
  100. main()