diff --git a/src/monitor_utils/database.py b/src/monitor_utils/database.py index 5224fa3..4f8ca5f 100644 --- a/src/monitor_utils/database.py +++ b/src/monitor_utils/database.py @@ -94,10 +94,12 @@ async def add_products_to_database( ): """Upsert products, tracking deduplicated price history and emitting alerts. - Rewrites current fields on each upsert. Prices are recorded in a - pastPrices array of price/date entries; a price already present in the - array is never recorded again, so each distinct price keeps only its - first-seen timestamp. New products and never-before-recorded prices + Rewrites current fields on each upsert. priceBeforeDrop is derived here, + not by monitors: it is set to the previous stored currentPrice when the new + price is lower (a drop) and cleared otherwise, so any price_before_drop on + the incoming product is ignored. Prices are recorded in a pastPrices array + of price/date entries; a price already present in the array is never + recorded again, so each distinct price keeps only its first-seen timestamp. New products and never-before-recorded prices upsert an alert referencing the product; a price returning to a value already in pastPrices emits nothing, so per-source price oscillation (member vs non-member quotes for the same SKU) alerts once per distinct @@ -133,6 +135,11 @@ async def add_products_to_database( document["_id"] = doc_id existing_doc = existing.get(doc_id) new_price = document.get("currentPrice") + previous_price = existing_doc.get("currentPrice") if existing_doc else None + document["priceBeforeDrop"] = resolve_price_before_drop( + previous_price, new_price + ) + recorded_prices = extract_recorded_prices(existing_doc) never_seen_price = new_price is not None and new_price not in recorded_prices set_on_insert = {"discoveredDate": now} @@ -193,6 +200,29 @@ async def write_alert_ops( raise +def resolve_price_before_drop( + previous_price: float | None, new_price: float | None +) -> float | None: + """Return the pre-drop price when new_price is a drop from previous_price. + + Owns price-drop detection so monitors do not: a drop is a new price strictly + below the previously stored one, and its priceBeforeDrop is that prior price. + Any other case (no prior price, unchanged, or a rise) is not a drop. + + Args: + previous_price: The product's last stored currentPrice, or None when new. + new_price: The incoming currentPrice. + + Returns: + previous_price on a drop, otherwise None. + """ + + if previous_price is not None and new_price is not None and new_price < previous_price: + return previous_price + + return None + + def extract_recorded_prices(existing_doc: dict | None) -> set[float]: """Return the set of prices already recorded in a document's pastPrices. diff --git a/src/monitor_utils/runner.py b/src/monitor_utils/runner.py index e9b3d5a..d28412b 100644 --- a/src/monitor_utils/runner.py +++ b/src/monitor_utils/runner.py @@ -28,34 +28,19 @@ from monitor_utils.sessions import get_base_session, get_tls_session LOGGER = logging.getLogger(__name__) -async def init_monitor( - monitor_name: str, - development_mode: DevelopmentMode, - production_proxy_path: str = "proxies.txt", - testing_proxy_path: str = "configs/proxies.txt", - logging_level=LOGGING_LEVEL, -) -> tuple[list[str] | dict[str, str], AsyncIOMotorCollection, AioProxies]: - """Run shared monitor startup and return categories, collection, and proxies. +def setup_monitor_logging(monitor_name: str, logging_level=LOGGING_LEVEL): + """Configure JSON logging for a monitor and quiet noisy libraries. - Configures JSON logging under the monitor's name, applies logger levels, - loads the monitor config and Mongo collection, and loads proxies from the - production or testing path based on development_mode. Async because the - config now lives in Mongo; call it from inside the monitor's event loop - (in main), not at module import. + Call once at module import, at global scope, before creating module loggers, + so logging is configured synchronously at startup rather than inside the + async init_monitor. Logs go to logs/latest.log with rotated history keyed by + monitor_name. Args: - monitor_name: Monitor identifier used for logging and config lookup. - development_mode: Selects production or testing config, collection, - and proxy path. - production_proxy_path: Proxy file path used in production mode. - testing_proxy_path: Proxy file path used in testing mode. - logging_level: Level passed to setup_logging. - - Returns: - Tuple of category names, Mongo collection, and loaded proxies. + monitor_name: Monitor name used as the rotated-history base. + logging_level: Root logging level. """ - use_production = is_production(development_mode) setup_logging( name="logs/latest.log", history_name=monitor_name, @@ -63,6 +48,32 @@ async def init_monitor( output="json", ) set_logger_levels() + + +async def init_monitor( + monitor_name: str, + development_mode: DevelopmentMode, + production_proxy_path: str = "proxies.txt", + testing_proxy_path: str = "configs/proxies.txt", +) -> tuple[list[str] | dict[str, str], AsyncIOMotorCollection, AioProxies]: + """Load a monitor's config, collection, and proxies for the mode. + + Async because config now lives in Mongo; call it from inside the monitor's + event loop (in main), not at module import. Logging is configured separately + by setup_monitor_logging at module scope and is not touched here. + + Args: + monitor_name: Monitor identifier used for config and collection lookup. + development_mode: Selects production or testing config, collection, + and proxy path. + production_proxy_path: Proxy file path used in production mode. + testing_proxy_path: Proxy file path used in testing mode. + + Returns: + Tuple of category names, Mongo collection, and loaded proxies. + """ + + use_production = is_production(development_mode) category_names, _, collection = await setup_config( monitor_name=monitor_name, development_mode=development_mode ) diff --git a/tests/test_price_before_drop.py b/tests/test_price_before_drop.py new file mode 100644 index 0000000..58af89d --- /dev/null +++ b/tests/test_price_before_drop.py @@ -0,0 +1,30 @@ +"""unit test: resolve_price_before_drop detects a drop from the prior stored price + +run from the repo root: + uv run python tests/test_price_before_drop.py + +pure and offline — no Mongo, no product construction; just the drop rule +add_products_to_database relies on +""" + +from monitor_utils.database import resolve_price_before_drop + + +def test_drop_returns_previous_price(): + """a strictly lower new price is a drop; the prior price is returned""" + + assert resolve_price_before_drop(200.0, 150.0) == 200.0 + + +def test_non_drops_return_none(): + """no prior price, an unchanged price, or a rise are not drops""" + + assert resolve_price_before_drop(None, 150.0) is None # new product + assert resolve_price_before_drop(150.0, 150.0) is None # unchanged + assert resolve_price_before_drop(150.0, 200.0) is None # price rose + assert resolve_price_before_drop(200.0, None) is None # missing new price + + +test_drop_returns_previous_price() +test_non_drops_return_none() +print("price before drop test passed")