import json import logging from dataclasses import dataclass, asdict from datetime import datetime from pathlib import Path from typing import List, Dict, Any import aiosqlite import asyncio from loader import load_items from esi.api import get_region_orders logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) ORDERS_CACHE = Path(__file__).resolve().parent.parent / "cache" / "orders_snapshot_orders.json" REGION_IDS = [ 10000002, # The Forge 10000016, # Lonetrek 10000030, # Heimatar 10000033, # The Citadel 10000032, # Sinq Laison 10000042, # Metropolis 10000064, # Essence 10000037, # Everyshore 10000043, # Domain 10000036, # Devoid 10000052, # Kador 10000067, # Genesis 10000068, # Verge Vendor 10000001, # Derelik 10000020, # Tash-Murkon ] @dataclass class Order: order_id: int region_id: int location_id: int type_id: int is_buy_order: bool price: float issued: datetime volume_remain: int item_name: str = '' item_cargo_volume: float = 1.0 class Market: """ 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() async def get_orders(self) -> List[Order]: """Retrieves the current list of market 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 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. """ 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 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, ) 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. """ current_time = datetime.utcnow() expiry_time = self.expires_times.get(region_id) return expiry_time is None or current_time >= expiry_time async def download_orders(self, region_id: int) -> List[Order]: """ Downloads orders for a given region and updates the 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 list(orders) 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. """ 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'], location_id=raw_order['location_id'], type_id=raw_order['type_id'], is_buy_order=raw_order['is_buy_order'], price=raw_order['price'], 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), ) orders.append(order) return orders 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: 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}") async def load_orders(self) -> List[Order]: """ Loads orders from the cache file. """ try: with ORDERS_CACHE.open("r", encoding="utf-8") as f: orders_data = json.load(f) self.orders = [self._deserialize_order(data) for data in orders_data] logger.info("Orders loaded from cache successfully.") except (IOError, json.JSONDecodeError) as e: logger.error(f"Error loading orders from cache: {e}") self.orders = [] return self.orders def _serialize_order(self, order: Order) -> Dict[str, Any]: """ Serializes an Order instance to a dictionary suitable for JSON serialization. """ order_dict = asdict(order) order_dict['issued'] = order.issued.isoformat() return order_dict def _deserialize_order(self, data: Dict[str, Any]) -> Order: """ Deserializes a dictionary into an Order instance. """ 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'] )