summaryrefslogtreecommitdiff
path: root/backend/src/market.py
diff options
context:
space:
mode:
Diffstat (limited to 'backend/src/market.py')
-rw-r--r--backend/src/market.py251
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']
+ )