Introduce V2 of current and historical functions
This commit is contained in:
0
wow_token/__init__.py
Normal file
0
wow_token/__init__.py
Normal file
0
wow_token/db/__init__.py
Normal file
0
wow_token/db/__init__.py
Normal file
28
wow_token/db/cache.py
Normal file
28
wow_token/db/cache.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import datetime
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
from wow_token.db.cached_range import CachedRange
|
||||
from wow_token.db.trinity import Trinity
|
||||
|
||||
|
||||
class Cache:
|
||||
_cache : Dict[str, List[Tuple[datetime.datetime, int]]]
|
||||
_db : 'Compacted'
|
||||
|
||||
def __init__(self, compacted_db: 'Compacted'):
|
||||
self._db = compacted_db
|
||||
self._cache = {}
|
||||
|
||||
|
||||
def get_month(self, trinity: Trinity) -> List[Tuple[datetime.datetime, int]]:
|
||||
current_time = datetime.datetime.now(datetime.UTC)
|
||||
if isinstance(trinity.range, CachedRange):
|
||||
raise NotImplementedError
|
||||
|
||||
current_month = trinity.range.month == current_time.month and trinity.range.year == current_time.year
|
||||
|
||||
if not current_month and str(trinity) in self._cache:
|
||||
return self._cache[str(trinity)]
|
||||
|
||||
self._cache[str(trinity)] = self._db.ddb_get_data(trinity)
|
||||
return self._cache[str(trinity)]
|
||||
14
wow_token/db/cached_range.py
Normal file
14
wow_token/db/cached_range.py
Normal file
@@ -0,0 +1,14 @@
|
||||
class CachedRange:
|
||||
_PRECOMPUTE_RANGES = ['30d', '90d', '6m', '1y', '2y', 'all']
|
||||
# I despise magic strings but this is about as good as I can get without enum support
|
||||
def __init__(self, _range: str):
|
||||
if _range not in CachedRange._PRECOMPUTE_RANGES:
|
||||
raise ValueError(f'Invalid range: {_range}')
|
||||
self._range = _range
|
||||
|
||||
@property
|
||||
def range(self):
|
||||
return self._range
|
||||
|
||||
def __str__(self):
|
||||
return self._range
|
||||
84
wow_token/db/compacted.py
Normal file
84
wow_token/db/compacted.py
Normal file
@@ -0,0 +1,84 @@
|
||||
import datetime
|
||||
import os
|
||||
from typing import List, Dict, Tuple, Union, Type
|
||||
|
||||
import boto3
|
||||
from boto3.dynamodb.conditions import Key
|
||||
|
||||
from wow_token.db.trinity import Trinity
|
||||
from wow_token.db.year_month import YearMonth
|
||||
from wow_token.db.cache import Cache
|
||||
from wow_token.region import Region
|
||||
|
||||
# TODO: Reduce Compacted Table Sprawl
|
||||
|
||||
REGION_MAP = {
|
||||
'us-west-1': 'us-west-1',
|
||||
'us-west-2': 'us-west-2',
|
||||
'us-east-1': 'us-east-1',
|
||||
'us-east-2': 'us-east-2',
|
||||
'ap-south-1': 'eu-north-1',
|
||||
'ap-northeast-3': 'ap-northeast-1',
|
||||
'ap-northeast-2': 'ap-northeast-1',
|
||||
'ap-southeast-1': 'ap-southeast-1',
|
||||
'ap-southeast-2': 'ap-southeast-2',
|
||||
'ap-northeast-1': 'ap-northeast-1',
|
||||
'ca-central-1': 'us-east-1',
|
||||
'eu-central-1': 'eu-north-1',
|
||||
'eu-west-1': 'eu-west-1',
|
||||
'eu-west-2': 'eu-west-1',
|
||||
'eu-west-3': 'eu-west-3',
|
||||
'eu-north-1': 'eu-north-1',
|
||||
'sa-east-1': 'sa-east-1',
|
||||
'eu-south-1': 'eu-north-1'
|
||||
}
|
||||
|
||||
|
||||
def _region_selector():
|
||||
if os.environ['AWS_REGION'] in REGION_MAP:
|
||||
local_region = REGION_MAP[os.environ['AWS_REGION']]
|
||||
else:
|
||||
local_region = 'eu-central-1'
|
||||
return local_region
|
||||
|
||||
|
||||
def _data_as_str(data: List[Tuple[datetime.datetime, int]]) -> List[Tuple[str, int]]:
|
||||
data_as_str = []
|
||||
for timestamp, price in data:
|
||||
data_as_str.append((timestamp.isoformat(), price))
|
||||
return data_as_str
|
||||
|
||||
|
||||
class Compacted:
|
||||
_cache : Cache
|
||||
def __init__(self):
|
||||
self._ddb = boto3.resource('dynamodb', region_name=_region_selector())
|
||||
self._table = self._ddb.Table('wow-token-compacted')
|
||||
self._cache = Cache(self)
|
||||
|
||||
def ddb_get_data(self, trinity: Trinity, _type: Union[Type[str], Type[datetime.datetime]] = datetime.datetime) -> Union[List[Tuple[datetime.datetime, int]], List[Tuple[str, int]]]:
|
||||
data = []
|
||||
response = self._table.query(
|
||||
KeyConditionExpression=Key('region-flavor-timestamp').eq(str(trinity))
|
||||
)
|
||||
if response['Items']:
|
||||
for timestamp, price in response['Items'][0]['data'].items():
|
||||
date_time = datetime.datetime.fromtimestamp(int(timestamp), datetime.UTC)
|
||||
if _type == str:
|
||||
date_time = date_time.isoformat()
|
||||
data.append((
|
||||
date_time,
|
||||
int(price)
|
||||
))
|
||||
return sorted(data, key=lambda x: x[0])
|
||||
|
||||
def get_month(self, trinity: Trinity, _type: Union[Type[str], Type[datetime.datetime]] = datetime.datetime) -> Union[List[Tuple[datetime.datetime, int]], List[Tuple[str, int]]]:
|
||||
if _type == str:
|
||||
return _data_as_str(self._cache.get_month(trinity))
|
||||
return self._cache.get_month(trinity)
|
||||
|
||||
def get_precomputed_range(self, trinity: Trinity, _type: Union[Type[str], Type[datetime.datetime]] = datetime.datetime) -> Union[List[Tuple[datetime.datetime, int]], List[Tuple[str, int]]]:
|
||||
if isinstance(trinity.range, YearMonth):
|
||||
return self.get_month(trinity, _type=_type)
|
||||
else:
|
||||
return self.ddb_get_data(trinity, _type=_type)
|
||||
64
wow_token/db/current.py
Normal file
64
wow_token/db/current.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import datetime
|
||||
import os
|
||||
from typing import List, Dict, Tuple
|
||||
|
||||
import boto3
|
||||
from boto3.dynamodb.conditions import Key
|
||||
|
||||
from wow_token.flavor import Flavor
|
||||
from wow_token.region import Region
|
||||
|
||||
REGION_MAP = {
|
||||
'us-west-1': 'us-west-1',
|
||||
'us-west-2': 'us-west-2',
|
||||
'us-east-1': 'us-east-1',
|
||||
'us-east-2': 'us-east-2',
|
||||
'ap-south-1': 'eu-north-1',
|
||||
'ap-northeast-3': 'ap-northeast-1',
|
||||
'ap-northeast-2': 'ap-northeast-1',
|
||||
'ap-southeast-1': 'ap-southeast-1',
|
||||
'ap-southeast-2': 'ap-southeast-2',
|
||||
'ap-northeast-1': 'ap-northeast-1',
|
||||
'ca-central-1': 'us-east-1',
|
||||
'eu-central-1': 'eu-north-1',
|
||||
'eu-west-1': 'eu-west-1',
|
||||
'eu-west-2': 'eu-west-1',
|
||||
'eu-west-3': 'eu-west-3',
|
||||
'eu-north-1': 'eu-north-1',
|
||||
'sa-east-1': 'sa-east-1',
|
||||
'eu-south-1': 'eu-north-1'
|
||||
}
|
||||
|
||||
|
||||
def _region_selector():
|
||||
if os.environ['AWS_REGION'] in REGION_MAP:
|
||||
local_region = REGION_MAP[os.environ['AWS_REGION']]
|
||||
else:
|
||||
local_region = 'eu-central-1'
|
||||
return local_region
|
||||
|
||||
|
||||
class Current:
|
||||
def __init__(self):
|
||||
self._ddb = boto3.resource('dynamodb', region_name=_region_selector())
|
||||
self._tables = {
|
||||
Flavor.RETAIL: self._ddb.Table('wow-token-price'),
|
||||
Flavor.CLASSIC: self._ddb.Table('wow-token-classic-price'),
|
||||
}
|
||||
|
||||
def _ddb_get_current_all(self, flavor: Flavor) -> Dict[Region, Tuple[datetime.datetime, int]]:
|
||||
response = self._tables[flavor].scan()
|
||||
data = {}
|
||||
for item in response['Items']:
|
||||
region = Region(item['region'])
|
||||
data[region] = (
|
||||
datetime.datetime.fromtimestamp(int(item['current_time']), datetime.UTC),
|
||||
int(int(item['price']) / 10_000) # the raw copper value is what is stored in DynamoDB
|
||||
)
|
||||
return data
|
||||
|
||||
def get_current_all(self, flavor: Flavor) -> Dict[Region, Tuple[str, int]]:
|
||||
data = {}
|
||||
for region, (timestamp, _) in self._ddb_get_current_all(flavor).items():
|
||||
data[region] = (timestamp.isoformat(), _)
|
||||
return data
|
||||
67
wow_token/db/recent.py
Normal file
67
wow_token/db/recent.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import datetime
|
||||
import os
|
||||
from typing import List, Dict, Tuple
|
||||
|
||||
import boto3
|
||||
from boto3.dynamodb.conditions import Key
|
||||
|
||||
from wow_token.flavor import Flavor
|
||||
from wow_token.region import Region
|
||||
|
||||
REGION_MAP = {
|
||||
'us-west-1': 'us-west-1',
|
||||
'us-west-2': 'us-west-2',
|
||||
'us-east-1': 'us-east-1',
|
||||
'us-east-2': 'us-east-2',
|
||||
'ap-south-1': 'eu-north-1',
|
||||
'ap-northeast-3': 'ap-northeast-1',
|
||||
'ap-northeast-2': 'ap-northeast-1',
|
||||
'ap-southeast-1': 'ap-southeast-1',
|
||||
'ap-southeast-2': 'ap-southeast-2',
|
||||
'ap-northeast-1': 'ap-northeast-1',
|
||||
'ca-central-1': 'us-east-1',
|
||||
'eu-central-1': 'eu-north-1',
|
||||
'eu-west-1': 'eu-west-1',
|
||||
'eu-west-2': 'eu-west-1',
|
||||
'eu-west-3': 'eu-west-3',
|
||||
'eu-north-1': 'eu-north-1',
|
||||
'sa-east-1': 'sa-east-1',
|
||||
'eu-south-1': 'eu-north-1'
|
||||
}
|
||||
|
||||
|
||||
def _region_selector():
|
||||
if os.environ['AWS_REGION'] in REGION_MAP:
|
||||
local_region = REGION_MAP[os.environ['AWS_REGION']]
|
||||
else:
|
||||
local_region = 'eu-central-1'
|
||||
return local_region
|
||||
|
||||
|
||||
class Recent:
|
||||
def __init__(self):
|
||||
self._ddb = boto3.resource('dynamodb', region_name=_region_selector())
|
||||
self._tables = {
|
||||
Flavor.RETAIL: self._ddb.Table('wow-token-price-recent'),
|
||||
Flavor.CLASSIC: self._ddb.Table('wow-token-classic-price-recent'),
|
||||
}
|
||||
|
||||
def get_after_unix_timestamp(self, flavor: Flavor, region: Region, timestamp: int) -> List[Tuple[str, int]]:
|
||||
response = self._tables[flavor].query(
|
||||
KeyConditionExpression=(
|
||||
Key('region').eq(region.value) &
|
||||
Key('timestamp').gte(timestamp)
|
||||
)
|
||||
)
|
||||
data = []
|
||||
last_price = 0
|
||||
for item in response['Items']:
|
||||
price = int(int(item['price']) / 10_000) # the raw copper value is what is stored in DynamoDB
|
||||
if last_price != price:
|
||||
item_time = datetime.datetime.fromtimestamp(int(item['timestamp']), datetime.UTC).isoformat()
|
||||
data.append((
|
||||
item_time,
|
||||
price
|
||||
))
|
||||
last_price = price
|
||||
return data
|
||||
26
wow_token/db/trinity.py
Normal file
26
wow_token/db/trinity.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from wow_token.db.cached_range import CachedRange
|
||||
from wow_token.db.year_month import YearMonth
|
||||
from wow_token.flavor import Flavor
|
||||
from wow_token.region import Region
|
||||
|
||||
|
||||
class Trinity:
|
||||
def __init__(self, _region: Region, _flavor: Flavor, _range: CachedRange | YearMonth):
|
||||
self._region = _region
|
||||
self._flavor = _flavor
|
||||
self._range = _range
|
||||
|
||||
@property
|
||||
def region(self) -> Region:
|
||||
return self._region
|
||||
|
||||
@property
|
||||
def flavor(self) -> Flavor:
|
||||
return self._flavor
|
||||
|
||||
@property
|
||||
def range(self) -> CachedRange | YearMonth:
|
||||
return self._range
|
||||
|
||||
def __str__(self):
|
||||
return f"{self._region.value}-{self._flavor.value}-{self._range}"
|
||||
23
wow_token/db/year_month.py
Normal file
23
wow_token/db/year_month.py
Normal file
@@ -0,0 +1,23 @@
|
||||
class YearMonth:
|
||||
# I really don't like how this class is named and used but
|
||||
# past me is my own worst enemy and used it to make sorting on Dynamo easier
|
||||
VALID_YEARS = [2020, 2021, 2022, 2023, 2024, 2025, 2026, 2027, 2028, 2029, 2030]
|
||||
VALID_MONTHS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
|
||||
def __init__(self, year: int, month: int):
|
||||
if year not in YearMonth.VALID_YEARS:
|
||||
raise ValueError(f'Invalid year: {year}')
|
||||
if month not in YearMonth.VALID_MONTHS:
|
||||
raise ValueError(f'Invalid month: {month}')
|
||||
self._year = year
|
||||
self._month = month
|
||||
|
||||
@property
|
||||
def month(self) -> int:
|
||||
return self._month
|
||||
|
||||
@property
|
||||
def year(self) -> int:
|
||||
return self._year
|
||||
|
||||
def __str__(self):
|
||||
return f'{self._year}-{self._month}'
|
||||
6
wow_token/flavor.py
Normal file
6
wow_token/flavor.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Flavor(str, Enum):
|
||||
RETAIL = 'retail'
|
||||
CLASSIC = 'classic'
|
||||
0
wow_token/path_handler/__init__.py
Normal file
0
wow_token/path_handler/__init__.py
Normal file
50
wow_token/path_handler/math_path_handler.py
Normal file
50
wow_token/path_handler/math_path_handler.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import datetime
|
||||
from typing import List, Tuple
|
||||
|
||||
from wow_token.db.cached_range import CachedRange
|
||||
from wow_token.db.compacted import Compacted
|
||||
from wow_token.db.recent import Recent
|
||||
from wow_token.db.trinity import Trinity
|
||||
from wow_token.flavor import Flavor
|
||||
from wow_token.path_handler.relative_error import InvalidRelativePathError
|
||||
from wow_token.path_handler.relative_path_handler import RelativePathHandler
|
||||
from wow_token.region import Region
|
||||
|
||||
|
||||
class MathPathHandler:
|
||||
_cdb : Compacted
|
||||
_rdb : Recent
|
||||
def __init__(self, cdb: Compacted, rdb: Recent):
|
||||
self._cdb = cdb
|
||||
self._rdb = rdb
|
||||
|
||||
def path_handler(self, uri: str) -> List[Tuple[str, int]]:
|
||||
# This URI takes the form of /v2/math/{math_function}/{flavor}/{region}/{range}
|
||||
split_uri = uri.split('/')
|
||||
math_function = split_uri[-4]
|
||||
data = RelativePathHandler(self._cdb, self._rdb).path_handler(uri)
|
||||
|
||||
match math_function:
|
||||
case 'avg':
|
||||
return self._avg(data)
|
||||
case _:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def _avg(self, data: List[Tuple[str, int]]) -> List[Tuple[str, int]]:
|
||||
avg_buckets = []
|
||||
bucket_timestamp = None
|
||||
bucket_price = 0
|
||||
bucket_count = 0
|
||||
for timestamp, price in data:
|
||||
if bucket_timestamp is None:
|
||||
bucket_timestamp = datetime.datetime.fromisoformat(timestamp)
|
||||
elif bucket_timestamp.date() != datetime.datetime.fromisoformat(timestamp).date():
|
||||
bucket_head = datetime.datetime(year=bucket_timestamp.year, month=bucket_timestamp.month, day=bucket_timestamp.day)
|
||||
avg_buckets.append((bucket_head.isoformat(), int(bucket_price/bucket_count)))
|
||||
bucket_price = 0
|
||||
bucket_count = 0
|
||||
bucket_timestamp = datetime.datetime.fromisoformat(timestamp)
|
||||
bucket_price += price
|
||||
bucket_count += 1
|
||||
return avg_buckets
|
||||
2
wow_token/path_handler/relative_error.py
Normal file
2
wow_token/path_handler/relative_error.py
Normal file
@@ -0,0 +1,2 @@
|
||||
class InvalidRelativePathError(Exception):
|
||||
pass
|
||||
98
wow_token/path_handler/relative_path_handler.py
Normal file
98
wow_token/path_handler/relative_path_handler.py
Normal file
@@ -0,0 +1,98 @@
|
||||
import datetime
|
||||
from typing import List, Tuple
|
||||
|
||||
from wow_token.db.cached_range import CachedRange
|
||||
from wow_token.db.compacted import Compacted
|
||||
from wow_token.db.recent import Recent
|
||||
from wow_token.db.trinity import Trinity
|
||||
from wow_token.flavor import Flavor
|
||||
from wow_token.path_handler.relative_error import InvalidRelativePathError
|
||||
from wow_token.region import Region
|
||||
|
||||
|
||||
class RelativePathHandler:
|
||||
_cdb : Compacted
|
||||
_rdb : Recent
|
||||
def __init__(self, cdb: Compacted, rdb: Recent):
|
||||
self._cdb = cdb
|
||||
self._rdb = rdb
|
||||
|
||||
|
||||
def get_by_timedelta(self, flavor: Flavor, region: Region, timedelta: datetime.timedelta) -> List[Tuple[str, int]]:
|
||||
current_time = datetime.datetime.now(datetime.UTC)
|
||||
start_time = current_time - timedelta
|
||||
|
||||
if timedelta.days < 61:
|
||||
return self._rdb.get_after_unix_timestamp(flavor, region, int(start_time.timestamp()))
|
||||
elif timedelta.days <= 90:
|
||||
trinity = Trinity(region, flavor, CachedRange('90d'))
|
||||
elif timedelta.days <= 183:
|
||||
trinity = Trinity(region, flavor, CachedRange('6m'))
|
||||
elif timedelta.days <= 365:
|
||||
trinity = Trinity(region, flavor, CachedRange('1y'))
|
||||
elif timedelta.days <= 730:
|
||||
trinity = Trinity(region, flavor, CachedRange('2y'))
|
||||
else:
|
||||
trinity = Trinity(region, flavor, CachedRange('all'))
|
||||
|
||||
# If the data is exactly the size of the precomputed structure, go ahead and return it directly
|
||||
if timedelta.days == 90 or timedelta.days == 182 or timedelta.days == 365 or timedelta.days == 730:
|
||||
return self._cdb.get_precomputed_range(trinity, str)
|
||||
|
||||
final_data = []
|
||||
data = self._cdb.get_precomputed_range(trinity)
|
||||
for timestamp, price in data:
|
||||
if timestamp >= start_time:
|
||||
final_data.append((timestamp.isoformat(), price))
|
||||
return final_data
|
||||
|
||||
|
||||
def relative_time_handler(self, flavor: Flavor, region: Region, relative_range: str) -> List[Tuple[str, int]]:
|
||||
if relative_range == '30d':
|
||||
relative_range = '744h'
|
||||
|
||||
relative_unit = relative_range[-1]
|
||||
|
||||
match relative_unit:
|
||||
case 'h':
|
||||
hours = int(relative_range[:-1])
|
||||
if hours > 1488:
|
||||
raise InvalidRelativePathError
|
||||
start_time = datetime.datetime.now(datetime.UTC) - datetime.timedelta(hours=hours)
|
||||
return self._rdb.get_after_unix_timestamp(flavor, region, int(start_time.timestamp()))
|
||||
case 'd':
|
||||
days = int(relative_range[:-1])
|
||||
if days > 730:
|
||||
raise InvalidRelativePathError
|
||||
delta = datetime.timedelta(days=days)
|
||||
return self.get_by_timedelta(flavor, region, delta)
|
||||
case 'm':
|
||||
months = int(relative_range[:-1])
|
||||
if months > 48:
|
||||
raise InvalidRelativePathError
|
||||
delta = datetime.timedelta(days=int(30.437*months))
|
||||
return self.get_by_timedelta(flavor, region, delta)
|
||||
case 'y':
|
||||
years = int(relative_range[:-1])
|
||||
if years > 10:
|
||||
raise InvalidRelativePathError
|
||||
delta = datetime.timedelta(days=int(365.25*years))
|
||||
return self.get_by_timedelta(flavor, region, delta)
|
||||
case _:
|
||||
if relative_range == 'all':
|
||||
return self._cdb.get_precomputed_range(Trinity(region, flavor, CachedRange('all')), str)
|
||||
raise InvalidRelativePathError
|
||||
|
||||
|
||||
|
||||
def path_handler(self, uri) -> List[Tuple[str, int]]:
|
||||
# This URI takes the form of /v2/relative/{flavor}/{region}/{range}
|
||||
split_uri = uri.split('/')
|
||||
flavor = Flavor(split_uri[-3])
|
||||
region = Region(split_uri[-2])
|
||||
_range = split_uri[-1]
|
||||
if split_uri[-1].endswith('.json'):
|
||||
_range = split_uri[-1][:-5]
|
||||
|
||||
return self.relative_time_handler(flavor, region, _range)
|
||||
|
||||
7
wow_token/region.py
Normal file
7
wow_token/region.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from enum import Enum
|
||||
|
||||
class Region(str, Enum):
|
||||
US = 'us'
|
||||
EU = 'eu'
|
||||
KR = 'kr'
|
||||
TW = 'tw'
|
||||
Reference in New Issue
Block a user