Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement buying and selling rates #60

Merged
merged 1 commit into from
Sep 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bin/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
name: M.H.M.U.
tick: 30
restock: 3600
use_buying_rates: false
use_selling_rates: false

# sql
hostname: 127.0.0.1
Expand Down
32 changes: 18 additions & 14 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ options are passed as command line arguments.
name: M.H.M.U. # Name that appears on AH when buying and selling
tick: 30 # seconds between buying
restock: 3600 # seconds between selling
use_buying_rates: false # Only buy items a fraction of the time?
use_selling_rates: false # Only sell items a fraction of the time?

# sql
hostname: 127.0.0.1 # SQL parameter
Expand Down Expand Up @@ -138,17 +140,19 @@ This command will remove all items for sale from the AH.

This may result in exploits on your server if you are not careful.

| column | description | value |
| ------------ | ------------------------------ | ----------------- |
| itemid | unique item id | integer >=0 |
| name | item name | string |
| sell_single | sell single? | 0=false 1=true |
| buy_single | buy single? | 0=false 1=true |
| price_single | price for single | integer >=1 |
| stock_single | restock count (single) | integer >=0 |
| rate_single | buy rate (single) **not used** | float 0 <= x <= 1 |
| sell_stacks | sell stack? | 0=false 1=true |
| buy_stacks | buy stack? | 0=false 1=true |
| price_stacks | price for stack | integer >=1 |
| stock_stacks | restock count (stack) | integer >=0 |
| rate_stacks | buy rate (stack) **not used** | float 0 <= x <= 1 |
| column | description | value | note |
|------------------|------------------------|-------------------|----------------------------------------|
| itemid | unique item id | integer >=0 | |
| name | item name | string | |
| sell_single | sell single? | 0=false 1=true | |
| buy_single | buy single? | 0=false 1=true | |
| price_single | price for single | integer >=1 | |
| stock_single | restock count (single) | integer >=0 | |
| sell_rate_single | sell rate (single) | float 0 <= x <= 1 | disabled unless use_selling_rates=true |
| buy_rate_single | buy rate (single) | float 0 <= x <= 1 | disabled unless use_buying_rates=true |
| sell_stacks | sell stack? | 0=false 1=true | |
| buy_stacks | buy stack? | 0=false 1=true | |
| price_stacks | price for stack | integer >=1 | |
| stock_stacks | restock count (stack) | integer >=0 | |
| sell_rate_stacks | sell rate (stack) | float 0 <= x <= 1 | disabled unless use_selling_rates=true |
| buy_rate_stacks | buy rate (stack) | float 0 <= x <= 1 | disabled unless use_buying_rates=true |
4 changes: 2 additions & 2 deletions ffxiahbot/apps/broker.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def main(
if buy_items:
buy_kwargs: dict[str, Any] = {} if not buy_immediately else {"next_run_time": datetime.now().astimezone()}
scheduler.add_job(
lambda: manager.buy_items(item_list=item_list),
lambda: manager.buy_items(item_list=item_list, use_buying_rates=config.use_buying_rates),
trigger="interval",
id="buy_items",
seconds=config.tick,
Expand All @@ -90,7 +90,7 @@ def main(
if sell_items:
sell_kwargs: dict[str, Any] = {} if not restock_immediately else {"next_run_time": datetime.now().astimezone()}
scheduler.add_job(
lambda: manager.restock_items(item_list=item_list),
lambda: manager.restock_items(item_list=item_list, use_selling_rates=config.use_selling_rates),
trigger="interval",
id="restock_items",
seconds=config.restock,
Expand Down
2 changes: 1 addition & 1 deletion ffxiahbot/apps/refill.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,5 @@ def main(

if no_prompt or confirm("Restock all items?", abort=True, show_default=True):
logger.info("restocking...")
manager.restock_items(item_list=item_list)
manager.restock_items(item_list=item_list, use_selling_rates=config.use_selling_rates)
logger.info("exit after restock")
46 changes: 34 additions & 12 deletions ffxiahbot/auction/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import contextlib
import datetime
import random
from collections import Counter
from collections.abc import Generator
from dataclasses import dataclass
Expand Down Expand Up @@ -113,12 +114,13 @@ def add_to_blacklist(self, rowid: int) -> None:
logger.info("blacklisting: row=%d", rowid)
self.blacklist.add(rowid)

def buy_items(self, item_list: ItemList) -> None:
def buy_items(self, item_list: ItemList, use_buying_rates: bool = False) -> None: # noqa: C901
"""
The main buy item loop.

Args:
item_list: Item data.
use_buying_rates: If True, only buy items at their buying rate.
"""
with self.scoped_session(fail=self.fail) as session:
# find rows that are still up for sale
Expand Down Expand Up @@ -150,20 +152,26 @@ def buy_items(self, item_list: ItemList) -> None:
self.add_to_blacklist(row.id)
counts["forbidden item"] += 1
else:
if self._buy_row(row, item.price_stacks):
counts["stack purchased"] += 1
if not use_buying_rates or random.random() <= item.buy_rate_stacks:
if self._buy_row(row, item.price_stacks):
counts["stack purchased"] += 1
else:
counts["price too high"] += 1
else:
counts["price too high"] += 1
counts["buy rate too low"] += 1
else:
if not item.buy_single:
logger.debug("not allowed to buy item! itemid=%d", row.itemid)
self.add_to_blacklist(row.id)
counts["forbidden item"] += 1
else:
if self._buy_row(row, item.price_single):
counts["single purchased"] += 1
if not use_buying_rates or random.random() <= item.buy_rate_single:
if self._buy_row(row, item.price_single):
counts["single purchased"] += 1
else:
counts["price too high"] += 1
else:
counts["price too high"] += 1
counts["buy rate too low"] += 1

counts_frame = pd.DataFrame.from_dict(counts, orient="index").rename(columns={0: "count"})
if not counts_frame.empty:
Expand Down Expand Up @@ -194,24 +202,37 @@ def _buy_row(self, row: AuctionHouse, max_price: int) -> bool:
self.add_to_blacklist(row.id)
return False

def restock_items(self, item_list: ItemList) -> None:
def restock_items(self, item_list: ItemList, use_selling_rates: bool = False) -> None:
"""
The main restock loop.

Args:
item_list: Item data.
use_selling_rates: If True, only restock items at their selling rate.
"""
# loop over items
with progress_bar("[red]Restocking Items...", total=len(item_list)) as (progress, task):
for item in item_list.items.values():
# singles
if item.sell_single:
self._sell_item(item.itemid, stack=False, price=item.price_single, stock=item.stock_single)
self._sell_item(
item.itemid,
stack=False,
price=item.price_single,
stock=item.stock_single,
rate=None if not use_selling_rates else item.sell_rate_single,
)
progress.update(task, advance=0.5)

# stacks
if item.sell_stacks:
self._sell_item(item.itemid, stack=True, price=item.price_stacks, stock=item.stock_stacks)
self._sell_item(
item.itemid,
stack=True,
price=item.price_stacks,
stock=item.stock_stacks,
rate=None if not use_selling_rates else item.sell_rate_stacks,
)
progress.update(task, advance=0.5)

@property
Expand All @@ -221,7 +242,7 @@ def _sell_time(self) -> int:
"""
return timeutils.timestamp(datetime.datetime(2099, 1, 1))

def _sell_item(self, itemid: int, stack: bool, price: int, stock: int) -> None:
def _sell_item(self, itemid: int, stack: bool, price: int, stock: int, rate: float | None) -> None:
"""
Sell an item.

Expand All @@ -244,7 +265,8 @@ def _sell_item(self, itemid: int, stack: bool, price: int, stock: int) -> None:
# restock
if current_stock < stock:
for _ in range(stock - current_stock):
self.seller.sell_item(itemid=itemid, stack=stack, date=self._sell_time, price=price, count=1)
if rate is None or random.random() <= rate:
self.seller.sell_item(itemid=itemid, stack=stack, date=self._sell_time, price=price, count=1)


@contextlib.contextmanager
Expand Down
2 changes: 2 additions & 0 deletions ffxiahbot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ class Config(BaseModel):
name: str = Field(default="M.H.M.U.") # Bot name
tick: int = Field(default=30) # Tick interval (seconds)
restock: int = Field(default=3600) # Restock interval (seconds)
use_buying_rates: bool = Field(default=False) # Only buy items a fraction of the time?
use_selling_rates: bool = Field(default=False) # Only sell items a fraction of the time?

# Database
hostname: str = Field(default="127.0.0.1") # SQL address
Expand Down
79 changes: 58 additions & 21 deletions ffxiahbot/item.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from __future__ import annotations

import collections
import functools
from collections.abc import Generator

from pydantic import BaseModel, ConfigDict, Field
from pydantic import AliasChoices, BaseModel, ConfigDict, Field

fmt = collections.OrderedDict()
fmt["itemid"] = "{:>8}"
Expand All @@ -11,12 +13,14 @@
fmt["buy_single"] = "{:>11}"
fmt["price_single"] = "{:>16}"
fmt["stock_single"] = "{:>12}"
fmt["rate_single"] = "{:>12}"
fmt["buy_rate_single"] = "{:>16}"
fmt["sell_rate_single"] = "{:>16}"
fmt["sell_stacks"] = "{:>11}"
fmt["buy_stacks"] = "{:>11}"
fmt["price_stacks"] = "{:>16}"
fmt["stock_stacks"] = "{:>12}"
fmt["rate_stacks"] = "{:>12}"
fmt["buy_rate_stacks"] = "{:>16}"
fmt["sell_rate_stacks"] = "{:>16}"


def item_csv_title_str() -> str:
Expand All @@ -38,19 +42,25 @@ def item_csv_value_str(item: Item) -> str:

_template = """
[Item]
addr = {addr}
itemid = {self.itemid}
name = {self.name}
sell_single = {self.sell_single}
buy_single = {self.buy_single}
price_single = {self.price_single}
stock_single = {self.stock_single}
rate_single = {self.rate_single}
sell_stacks = {self.sell_stacks}
buy_stacks = {self.buy_stacks}
price_stacks = {self.price_stacks}
stock_stacks = {self.stock_stacks}
rate_stacks = {self.rate_stacks}
id = {self.itemid}
addr = {addr}
name = {self.name}

[Item.Single]
buy = {self.buy_single}
sell = {self.sell_single}
price = {self.price_single}
stock = {self.stock_single}
buy_rate = {self.buy_rate_single}
sell_rate = {self.sell_rate_single}

[Item.Stacks]
buy = {self.buy_stacks}
sell = {self.sell_stacks}
price = {self.price_stacks}
stock = {self.stock_stacks}
buy_rate = {self.buy_rate_stacks}
sell_rate = {self.sell_rate_stacks}
"""[:-1]


Expand All @@ -69,14 +79,16 @@ class Item(BaseModel):
buy_stacks: buy stacks?
price_stacks: price (>= 1) for stacks.
stock_stacks: restock count (>= 0) for stacks.
rate_single: sell rate (0.0 <= rate <= 1.0) for singles.
rate_stacks: sell rate (0.0 <= rate <= 1.0) for stacks.
buy_rate_single: buy rate (0.0 <= rate <= 1.0) for singles.
sell_rate_single: sell rate (0.0 <= rate <= 1.0) for singles.
sell_rate_stacks: sell rate (0.0 <= rate <= 1.0) for stacks.
buy_rate_stacks: buy rate (0.0 <= rate <= 1.0) for stacks.
"""

model_config = ConfigDict(extra="forbid")

#: A unique item id.
itemid: int = Field(ge=0)
itemid: int = Field(ge=0, validation_alias=AliasChoices("id", "itemid"))
#: The item's name.
name: str = "?"
#: Sell singles?
Expand All @@ -95,10 +107,35 @@ class Item(BaseModel):
stock_single: int = Field(default=0, ge=0)
#: Restock count (>= 0) for stacks.
stock_stacks: int = Field(default=0, ge=0)
#: Buy rate (0.0 <= rate <= 1.0) for singles.
buy_rate_single: float = Field(default=1.0, ge=0.0, le=1.0, validation_alias=AliasChoices("buy_rate_single"))
#: Sell rate (0.0 <= rate <= 1.0) for singles.
rate_single: float = Field(default=1.0, ge=0.0, le=1.0)
sell_rate_single: float = Field(
default=1.0, ge=0.0, le=1.0, validation_alias=AliasChoices("sell_rate_single", "rate_single")
)
#: Buy rate (0.0 <= rate <= 1.0) for stacks.
buy_rate_stacks: float = Field(default=1.0, ge=0.0, le=1.0, validation_alias=AliasChoices("buy_rate_stacks"))
#: Sell rate (0.0 <= rate <= 1.0) for stacks.
rate_stacks: float = Field(default=1.0, ge=0.0, le=1.0)
sell_rate_stacks: float = Field(
default=1.0, ge=0.0, le=1.0, validation_alias=AliasChoices("sell_rate_stacks", "rate_stacks")
)

@classmethod
def aliases(cls) -> Generator[str]:
yield from cls.model_fields.keys()

def __str__(self) -> str:
return _template.format(self=self, addr=hex(id(self)))


@functools.lru_cache(maxsize=1)
def allowed_item_keys() -> set[str]:
def _() -> Generator[str]:
for key, field_info in Item.model_fields.items():
yield key
if hasattr(field_info, "validation_alias") and isinstance(field_info.validation_alias, AliasChoices):
for alias in field_info.validation_alias.choices:
if isinstance(alias, str):
yield alias

return set(_())
6 changes: 3 additions & 3 deletions ffxiahbot/itemlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from pathlib import Path
from typing import Any, cast

from ffxiahbot.item import Item, item_csv_title_str, item_csv_value_str
from ffxiahbot.item import Item, allowed_item_keys, item_csv_title_str, item_csv_value_str
from ffxiahbot.logutils import logger


Expand Down Expand Up @@ -181,15 +181,15 @@ def load_csv(self, csv_path: Path | str) -> None: # noqa: C901
tokens: list[str | int | None] = [x.strip() for x in line.split(",")]

# check for new title line
if set(tokens).issubset(Item.model_fields.keys()):
if set(tokens).issubset(allowed_item_keys()):
keys = cast(list[str], tokens)

# check for primary key
if "itemid" not in keys:
raise RuntimeError(f"missing itemid column:\n\t{keys}")

# validate line
elif set(tokens).intersection(Item.model_fields.keys()):
elif set(tokens).intersection(allowed_item_keys()):
raise RuntimeError("something wrong with line %d" % line_number)

# process normal line
Expand Down
4 changes: 2 additions & 2 deletions tests/auction/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ def test_buy_items(

# perform buy loop multiple times
for _ in range(3):
manager.buy_items(item_list)
manager.buy_items(item_list, use_buying_rates=False)
assert manager.blacklist == expected_blacklist

# validate database after buy loop
Expand Down Expand Up @@ -134,7 +134,7 @@ def test_restock_items(

# perform restock loop multiple times
for _ in range(3):
manager.restock_items(item_list)
manager.restock_items(item_list, use_selling_rates=False)

# validate the historical prices after restock loop
for itemid, (singles_price, stacks_price) in expected_history.items():
Expand Down
Loading