fixed price drops/moved logging outside of init_monitor

This commit is contained in:
TPV committed 2026-07-13 18:57:23 -07:00
1 parent 7d070dec40
commit 00d3c12ee0
3 files changed
+98 -27

No files matched your search

+34 -4
View File
@@ -94,10 +94,12 @@ async def add_products_to_database(
): ):
"""Upsert products, tracking deduplicated price history and emitting alerts. """Upsert products, tracking deduplicated price history and emitting alerts.
Rewrites current fields on each upsert. Prices are recorded in a Rewrites current fields on each upsert. priceBeforeDrop is derived here,
pastPrices array of price/date entries; a price already present in the not by monitors: it is set to the previous stored currentPrice when the new
array is never recorded again, so each distinct price keeps only its price is lower (a drop) and cleared otherwise, so any price_before_drop on
first-seen timestamp. New products and never-before-recorded prices 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 upsert an alert referencing the product; a price returning to a value
already in pastPrices emits nothing, so per-source price oscillation already in pastPrices emits nothing, so per-source price oscillation
(member vs non-member quotes for the same SKU) alerts once per distinct (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 document["_id"] = doc_id
existing_doc = existing.get(doc_id) existing_doc = existing.get(doc_id)
new_price = document.get("currentPrice") 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) recorded_prices = extract_recorded_prices(existing_doc)
never_seen_price = new_price is not None and new_price not in recorded_prices never_seen_price = new_price is not None and new_price not in recorded_prices
set_on_insert = {"discoveredDate": now} set_on_insert = {"discoveredDate": now}
@@ -193,6 +200,29 @@ async def write_alert_ops(
raise 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]: def extract_recorded_prices(existing_doc: dict | None) -> set[float]:
"""Return the set of prices already recorded in a document's pastPrices. """Return the set of prices already recorded in a document's pastPrices.
+34 -23
View File
@@ -28,34 +28,19 @@ from monitor_utils.sessions import get_base_session, get_tls_session
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
async def init_monitor( def setup_monitor_logging(monitor_name: str, logging_level=LOGGING_LEVEL):
monitor_name: str, """Configure JSON logging for a monitor and quiet noisy libraries.
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.
Configures JSON logging under the monitor's name, applies logger levels, Call once at module import, at global scope, before creating module loggers,
loads the monitor config and Mongo collection, and loads proxies from the so logging is configured synchronously at startup rather than inside the
production or testing path based on development_mode. Async because the async init_monitor. Logs go to logs/latest.log with rotated history keyed by
config now lives in Mongo; call it from inside the monitor's event loop monitor_name.
(in main), not at module import.
Args: Args:
monitor_name: Monitor identifier used for logging and config lookup. monitor_name: Monitor name used as the rotated-history base.
development_mode: Selects production or testing config, collection, logging_level: Root logging level.
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.
""" """
use_production = is_production(development_mode)
setup_logging( setup_logging(
name="logs/latest.log", name="logs/latest.log",
history_name=monitor_name, history_name=monitor_name,
@@ -63,6 +48,32 @@ async def init_monitor(
output="json", output="json",
) )
set_logger_levels() 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( category_names, _, collection = await setup_config(
monitor_name=monitor_name, development_mode=development_mode monitor_name=monitor_name, development_mode=development_mode
) )
+30
View File
@@ -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")