diff options
Diffstat (limited to 'backend/src/market.py')
| -rw-r--r-- | backend/src/market.py | 251 |
1 files changed, 184 insertions, 67 deletions
diff --git a/backend/src/market.py b/backend/src/market.py index 311b181..045b07e 100644 --- a/backend/src/market.py +++ b/backend/src/market.py @@ -3,7 +3,9 @@ import logging from dataclasses import dataclass, asdict from datetime import datetime from pathlib import Path -from typing import List, Dict +from typing import List, Dict, Any +import aiosqlite +import asyncio from loader import load_items from esi.api import get_region_orders @@ -46,89 +48,211 @@ class Order: class Market: """ - Manages market orders, including fetching, updating, and caching. + Manages market orders, including fetching, updating, and caching via in-memory SQLite for high performance. """ def __init__(self): self.orders: List[Order] = [] self.expires_times: Dict[int, datetime] = {} + self.db_conn: aiosqlite.Connection | None = None + self._lock = asyncio.Lock() + + async def init_db(self): + """Initializes the in-memory SQLite database.""" + if not self.db_conn: + self.db_conn = await aiosqlite.connect(':memory:') + await self.db_conn.execute(''' + CREATE TABLE IF NOT EXISTS orders ( + order_id INTEGER PRIMARY KEY, + region_id INTEGER, + location_id INTEGER, + type_id INTEGER, + is_buy_order BOOLEAN, + price REAL, + issued TEXT, + volume_remain INTEGER, + item_name TEXT, + item_cargo_volume REAL + ) + ''') + await self.db_conn.execute('CREATE INDEX IF NOT EXISTS idx_arbitrage ON orders(type_id, is_buy_order, region_id)') + await self.db_conn.commit() - def get_orders(self) -> List[Order]: + async def get_orders(self) -> List[Order]: """Retrieves the current list of market orders.""" - self.update_orders() + await self.update_orders() return self.orders + + async def get_all_orders_from_db(self) -> List[Order]: + """Retrieves all orders from the database as Order objects (mostly for fallback/testing).""" + if not self.db_conn: + return [] + + async with self.db_conn.execute('SELECT * FROM orders') as cursor: + rows = await cursor.fetchall() + orders = [] + for r in rows: + orders.append(Order( + order_id=r[0], + region_id=r[1], + location_id=r[2], + type_id=r[3], + is_buy_order=bool(r[4]), + price=r[5], + issued=datetime.fromisoformat(r[6]), + volume_remain=r[7], + item_name=r[8], + item_cargo_volume=r[9] + )) + return orders - def update_orders(self) -> List[int]: + async def update_orders(self) -> List[int]: """ Updates market orders by fetching new data for expired regions. Returns: A list of region IDs that were updated. """ - updated_orders: List[Order] = [] - regions_updated: List[int] = [] + if not self.db_conn: + await self.init_db() + + async with self._lock: + regions_updated: List[int] = [] + + for region_id in REGION_IDS: + if self.check_expired(region_id): + logger.info(f"Downloading orders for region {region_id}") + orders = await self.download_orders(region_id) + self.orders = [o for o in self.orders if o.region_id != region_id] + self.orders.extend(orders) + + # Update SQLite DB + await self.db_conn.execute('DELETE FROM orders WHERE region_id = ?', (region_id,)) + + # Bulk insert + insert_data = [ + (o.order_id, o.region_id, o.location_id, o.type_id, o.is_buy_order, + o.price, o.issued.isoformat(), o.volume_remain, o.item_name, o.item_cargo_volume) + for o in orders + ] + await self.db_conn.executemany(''' + INSERT OR REPLACE INTO orders ( + order_id, region_id, location_id, type_id, is_buy_order, + price, issued, volume_remain, item_name, item_cargo_volume + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', insert_data) + await self.db_conn.commit() + regions_updated.append(region_id) + + return regions_updated + + async def apply_order_volume_adjustments(self, order_adjustments: Dict[int, int]) -> Dict[str, int]: + if not order_adjustments: + return { + 'affected_orders': 0, + 'updated_orders': 0, + 'removed_orders': 0, + 'ignored_orders': 0, + 'consumed_units': 0, + } + + if not self.db_conn: + await self.init_db() + + async with self._lock: + order_index = {order.order_id: order for order in self.orders} + update_rows: list[tuple[int, int]] = [] + delete_rows: list[tuple[int]] = [] + updated_orders = 0 + removed_orders = 0 + ignored_orders = 0 + consumed_units = 0 + + for order_id, requested_quantity in order_adjustments.items(): + if requested_quantity <= 0: + continue + + order = order_index.get(order_id) + if order is None: + ignored_orders += 1 + continue + + applied_quantity = min(order.volume_remain, requested_quantity) + if applied_quantity <= 0: + ignored_orders += 1 + continue - for region_id in REGION_IDS: - if self.check_expired(region_id): - logger.info(f"Downloading orders for region {region_id}") - orders = self.download_orders(region_id) - updated_orders.extend(orders) - regions_updated.append(region_id) - else: - # Include existing orders for this region - updated_orders.extend( - [order for order in self.orders if order.region_id == region_id] + order.volume_remain -= applied_quantity + consumed_units += applied_quantity + + if order.volume_remain <= 0: + delete_rows.append((order_id,)) + removed_orders += 1 + else: + update_rows.append((order.volume_remain, order_id)) + updated_orders += 1 + + if update_rows: + await self.db_conn.executemany( + 'UPDATE orders SET volume_remain = ? WHERE order_id = ?', + update_rows, + ) + + if delete_rows: + await self.db_conn.executemany( + 'DELETE FROM orders WHERE order_id = ?', + delete_rows, ) - self.orders = updated_orders - return regions_updated + if update_rows or delete_rows: + await self.db_conn.commit() + + self.orders = [order for order in self.orders if order.volume_remain > 0] + + return { + 'affected_orders': updated_orders + removed_orders, + 'updated_orders': updated_orders, + 'removed_orders': removed_orders, + 'ignored_orders': ignored_orders, + 'consumed_units': consumed_units, + } def check_expired(self, region_id: int) -> bool: """ Checks if the cached orders for a region have expired. - - Args: - region_id: The ID of the region to check. - - Returns: - True if the orders have expired or are not cached, False otherwise. """ current_time = datetime.utcnow() expiry_time = self.expires_times.get(region_id) return expiry_time is None or current_time >= expiry_time - def download_orders(self, region_id: int) -> List[Order]: + async def download_orders(self, region_id: int) -> List[Order]: """ Downloads orders for a given region and updates the expiry time. - - Args: - region_id: The ID of the region to download orders for. - - Returns: - A list of Order instances for the region. """ - raw_orders, expiry = get_region_orders(region_id) - expiry_time = datetime.strptime(expiry, "%a, %d %b %Y %H:%M:%S %Z") - self.expires_times[region_id] = expiry_time + raw_orders, expiry = await get_region_orders(region_id) + if expiry: + expiry_time = datetime.strptime(expiry, "%a, %d %b %Y %H:%M:%S %Z") + self.expires_times[region_id] = expiry_time + else: + self.expires_times[region_id] = datetime.utcnow() # Fallback orders = self.add_item_info_to_orders(raw_orders) - return orders + return list(orders) - def add_item_info_to_orders(self, raw_orders: List[Dict]) -> List[Order]: + def add_item_info_to_orders(self, raw_orders: List[Dict[str, Any]]) -> List[Order]: """ Enhances raw order data with item information and converts them into Order instances. - - Args: - raw_orders: A list of raw order dictionaries from the API. - - Returns: - A list of Order instances with additional item information. """ items = load_items() orders: List[Order] = [] for raw_order in raw_orders: item_info = items.get(raw_order['type_id'], {}) + # Handle different datetime formats from ESI if missing timezone + issued_str = raw_order['issued'] + if issued_str.endswith('Z'): + issued_str = issued_str[:-1] + '+00:00' + order = Order( order_id=raw_order['order_id'], region_id=raw_order['region_id'], @@ -136,7 +260,7 @@ class Market: type_id=raw_order['type_id'], is_buy_order=raw_order['is_buy_order'], price=raw_order['price'], - issued=datetime.strptime(raw_order['issued'], "%Y-%m-%dT%H:%M:%S%z"), + issued=datetime.fromisoformat(issued_str), volume_remain=raw_order['volume_remain'], item_name=item_info.get('type_name', f"Not found: {raw_order['type_id']}"), item_cargo_volume=item_info.get('volume', 1.0), @@ -145,23 +269,19 @@ class Market: return orders - def cache_orders(self) -> None: + async def cache_orders(self) -> None: """Caches the current list of orders to a JSON file.""" try: with ORDERS_CACHE.open("w", encoding="utf-8") as f: - # Serialize orders to dictionaries and handle datetime serialization orders_data = [self._serialize_order(order) for order in self.orders] json.dump(orders_data, f, ensure_ascii=False, indent=4) logger.info("Orders cached successfully.") except IOError as e: logger.error(f"Error caching orders: {e}") - def load_orders(self) -> List[Order]: + async def load_orders(self) -> List[Order]: """ Loads orders from the cache file. - - Returns: - A list of Order instances loaded from the cache. """ try: with ORDERS_CACHE.open("r", encoding="utf-8") as f: @@ -174,30 +294,27 @@ class Market: return self.orders - def _serialize_order(self, order: Order) -> Dict: + def _serialize_order(self, order: Order) -> Dict[str, Any]: """ Serializes an Order instance to a dictionary suitable for JSON serialization. - - Args: - order: The Order instance to serialize. - - Returns: - A dictionary representation of the Order. """ order_dict = asdict(order) - # Convert datetime objects to ISO format strings order_dict['issued'] = order.issued.isoformat() return order_dict - def _deserialize_order(self, data: Dict) -> Order: + def _deserialize_order(self, data: Dict[str, Any]) -> Order: """ Deserializes a dictionary into an Order instance. - - Args: - data: The dictionary representation of an Order. - - Returns: - An Order instance. """ - data['issued'] = datetime.fromisoformat(data['issued']) - return Order(**data) + return Order( + order_id=data['order_id'], + region_id=data['region_id'], + location_id=data['location_id'], + type_id=data['type_id'], + is_buy_order=data['is_buy_order'], + price=data['price'], + issued=datetime.fromisoformat(data['issued']), + volume_remain=data['volume_remain'], + item_name=data['item_name'], + item_cargo_volume=data['item_cargo_volume'] + ) |
