movers.py 3.3 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
  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. for current_price in current_prices:
  14. if current_price['ExchangeCode'].endswith('2'):
  15. continue
  16. old_price = old_prices[current_price['FullTicker']]
  17. if (mover := analyze_raw_price(current_price, old_price)) is not None:
  18. if mover.score > 10000:
  19. movers[mover.ticker].append(mover)
  20. if mover.days_supply_lost > 7 and mover.days_supply_remaining < 14:
  21. shortages[mover.ticker].append(mover)
  22. for ticker_movers in movers.values():
  23. ticker_movers.sort(reverse=True)
  24. for ticker_shortages in shortages.values():
  25. ticker_shortages.sort(reverse=True)
  26. top_movers = sorted(movers.values(), key=lambda m: m[0].score, reverse=True)
  27. top_shortages = sorted(shortages.values(), key=lambda m: m[0].days_supply_remaining, reverse=True)
  28. print('top movers:')
  29. for commodity in top_movers:
  30. print(f'{commodity[0].price_change:5,.2f}', ' '.join(f'{mover.ticker}.{mover.exchange_code}' for mover in commodity))
  31. print('\ntop shortages:')
  32. for commodity in top_shortages:
  33. print(f'{-commodity[0].days_supply_lost:6.1f}d', f'{commodity[0].supply_consumption_rate:7.1f}/d',
  34. ' '.join(f'{mover.ticker}.{mover.exchange_code}' for mover in commodity))
  35. def get_old_prices() -> typing.Mapping[str, RawPrice]:
  36. week_ago = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=OLD_PRICE_DAYS)
  37. commits = cache.get(f'https://api.github.com/repos/refined-prun/refined-prices/commits?until={week_ago.isoformat()}&per_page=1')
  38. return {p['FullTicker']: p
  39. for p in cache.get(f'https://raw.githubusercontent.com/refined-prun/refined-prices/{commits[0]["sha"]}/all.json')}
  40. def analyze_raw_price(current_price: RawPrice, old_price: RawPrice) -> Mover | None:
  41. if (traded := current_price['AverageTraded30D']) is None or traded < 100:
  42. return
  43. if (current_vwap7d := current_price['VWAP7D']) is None or (old_vwap7d := old_price['VWAP7D']) is None:
  44. return
  45. diff = current_vwap7d - old_vwap7d
  46. supply_delta = (old_price['Supply'] - current_price['Supply'])
  47. days_supply_lost = supply_delta / traded
  48. days_supply_remaining = current_price['Supply'] / traded
  49. if abs(diff) / min(current_vwap7d, old_vwap7d) > 0.15 or (days_supply_lost > 7 and days_supply_remaining < 14):
  50. return Mover(current_price['ExchangeCode'], current_price['MaterialTicker'], abs(diff) * traded,
  51. diff / old_vwap7d, days_supply_lost, days_supply_remaining, supply_delta / OLD_PRICE_DAYS)
  52. class RawPrice(typing.TypedDict):
  53. FullTicker: str
  54. ExchangeCode: str
  55. MaterialTicker: str
  56. VWAP7D: float | None # volume-weighted average price over last 7 days
  57. AverageTraded30D: float | None # averaged daily traded volume over last 30 days
  58. Supply: int
  59. @dataclasses.dataclass(eq=False, frozen=True, slots=True)
  60. class Mover:
  61. exchange_code: str
  62. ticker: str
  63. score: float
  64. price_change: float
  65. days_supply_lost: float
  66. days_supply_remaining: float
  67. supply_consumption_rate: float
  68. def __lt__(self, other: Mover) -> bool:
  69. return self.score < other.score
  70. if __name__ == '__main__':
  71. main()