File size: 14,049 Bytes
1e11bce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
from __future__ import annotations

import csv
from collections import defaultdict, deque
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path

import pandas as pd
import requests

from app.cache import RedisCache
from app.config import Settings
from app.feature_engineering import KAGGLE_RETAIL_CATEGORICAL_FEATURES, KAGGLE_RETAIL_NUMERIC_FEATURES
from app.feature_engineering import add_derived_features
from app.modeling import load_model_bundle
from app.schemas import (
    KagglePricingRequest,
    KagglePricingResponse,
    OrderEvent,
    PricingRequest,
    PricingResponse,
)


@dataclass
class PricingRecommendation:
    response: PricingResponse
    ml_price: float
    blended_price: float


@dataclass
class KagglePricingRecommendation:
    response: KagglePricingResponse
    ml_price: float


class FlashSaleTracker:
    def __init__(self, threshold: int, lookback_minutes: int):
        self.threshold = threshold
        self.lookback = timedelta(minutes=lookback_minutes)
        self.events: dict[str, deque[datetime]] = defaultdict(deque)

    def register(self, event: OrderEvent) -> bool:
        queue = self.events[event.sku_id]
        queue.append(event.event_time)
        self._trim(event.sku_id, event.event_time)
        return len(queue) >= self.threshold

    def is_flash_sale(self, sku_id: str, now: datetime | None = None) -> bool:
        reference_time = now or datetime.now(UTC)
        self._trim(sku_id, reference_time)
        return len(self.events[sku_id]) >= self.threshold

    def recent_event_count(self) -> int:
        now = datetime.now(UTC)
        total = 0
        for sku_id in list(self.events.keys()):
            self._trim(sku_id, now)
            total += len(self.events[sku_id])
        return total

    def flash_sale_skus(self) -> list[str]:
        now = datetime.now(UTC)
        active = []
        for sku_id in list(self.events.keys()):
            self._trim(sku_id, now)
            if len(self.events[sku_id]) >= self.threshold:
                active.append(sku_id)
        return active

    def _trim(self, sku_id: str, reference_time: datetime) -> None:
        queue = self.events[sku_id]
        while queue and reference_time - queue[0] > self.lookback:
            queue.popleft()


class CompetitorPriceClient:
    def __init__(self, settings: Settings):
        self.settings = settings

    def get_price(self, sku_id: str, fallback_price: float) -> float:
        if not self.settings.competitor_api_url:
            return fallback_price
        try:
            response = requests.get(
                self.settings.competitor_api_url,
                params={"sku_id": sku_id},
                timeout=0.7,
            )
            response.raise_for_status()
            payload = response.json()
            return float(payload.get("competitor_price", fallback_price))
        except Exception:
            return fallback_price


class PricingEngine:
    def __init__(self, settings: Settings):
        if not settings.model_path.exists():
            raise FileNotFoundError(
                f"Model artifact not found at {settings.model_path}. Train the model first."
            )
        self.settings = settings
        self.bundle = load_model_bundle(settings.model_path)
        self.dataset_profile = str(self.bundle.get("dataset_profile", "synthetic"))
        self.pipeline = self.bundle["pipeline"]
        self.flash_sale_tracker = FlashSaleTracker(
            threshold=settings.flash_sale_order_threshold,
            lookback_minutes=settings.flash_sale_lookback_minutes,
        )
        self.competitor_client = CompetitorPriceClient(settings)
        self.cache = RedisCache(settings.redis_url)

    def recommend_price(self, request: PricingRequest) -> PricingRecommendation:
        if self.dataset_profile != "synthetic":
            raise ValueError(
                "The loaded model is not compatible with the synthetic pricing request schema."
            )
        frame = pd.DataFrame([request.model_dump()])
        live_competitor_price = self.competitor_client.get_price(
            request.sku_id, request.competitor_price
        )
        frame["competitor_price"] = live_competitor_price
        enriched = add_derived_features(frame)
        ml_price = float(self.pipeline.predict(enriched)[0])

        blended_price = (
            ml_price * self.settings.model_weight
            + live_competitor_price * self.settings.competitor_weight
        )

        inventory_adjustment = self._inventory_adjustment(
            inventory_level=request.inventory_level,
            inventory_days_cover=request.inventory_days_cover,
        )
        demand_adjustment = self._demand_adjustment(
            units_sold_last_5m=request.units_sold_last_5m,
            units_sold_last_1h=request.units_sold_last_1h,
            conversion_rate=request.conversion_rate,
            is_festival=request.is_festival,
        )
        detected_flash_sale = self.flash_sale_tracker.is_flash_sale(request.sku_id)
        flash_sale_multiplier = 1.12 if detected_flash_sale else 1.0

        candidate_price = blended_price * inventory_adjustment * demand_adjustment
        candidate_price *= flash_sale_multiplier
        guardrailed_price = self._apply_guardrails(
            candidate_price=candidate_price,
            base_cost=request.base_cost,
            current_price=request.current_price,
        )
        confidence = self._confidence_score(request, live_competitor_price)
        reason = self._build_reason(
            inventory_adjustment=inventory_adjustment,
            demand_adjustment=demand_adjustment,
            detected_flash_sale=detected_flash_sale,
        )

        response = PricingResponse(
            sku_id=request.sku_id,
            recommended_price=round(guardrailed_price, 2),
            ml_price=round(ml_price, 2),
            blended_price=round(blended_price, 2),
            inventory_adjustment=round(inventory_adjustment, 3),
            demand_adjustment=round(demand_adjustment, 3),
            flash_sale_multiplier=round(flash_sale_multiplier, 3),
            confidence=round(confidence, 3),
            detected_flash_sale=detected_flash_sale,
            reason=reason,
            generated_at=datetime.now(UTC),
        )
        self._append_price_history(response, request.base_cost)
        return PricingRecommendation(
            response=response,
            ml_price=ml_price,
            blended_price=blended_price,
        )

    def recommend_kaggle_price(
        self, request: KagglePricingRequest
    ) -> KagglePricingRecommendation:
        if self.dataset_profile != "kaggle_retail":
            raise ValueError(
                "The loaded model is not compatible with the Kaggle retail pricing request schema."
            )

        payload = request.model_dump()
        current_price = payload.pop("current_price")
        feature_frame = pd.DataFrame([payload])[
            KAGGLE_RETAIL_NUMERIC_FEATURES + KAGGLE_RETAIL_CATEGORICAL_FEATURES
        ]
        ml_price = float(self.pipeline.predict(feature_frame)[0])

        competitor_values = [
            payload["comp_1"] or 0.0,
            payload["comp_2"] or 0.0,
            payload["comp_3"] or 0.0,
        ]
        non_zero_competitors = [value for value in competitor_values if value > 0]
        competitor_anchor = (
            sum(non_zero_competitors) / len(non_zero_competitors)
            if non_zero_competitors
            else None
        )
        confidence = self._kaggle_confidence_score(request, competitor_anchor)
        reason = self._build_kaggle_reason(current_price, ml_price, competitor_anchor)

        response = KagglePricingResponse(
            product_id=request.product_id,
            product_category_name=request.product_category_name,
            recommended_price=round(ml_price, 2),
            current_price=round(current_price, 2),
            gap_to_current_price=round(ml_price - current_price, 2),
            competitor_anchor_price=(
                round(competitor_anchor, 2) if competitor_anchor is not None else None
            ),
            confidence=round(confidence, 3),
            reason=reason,
            generated_at=datetime.now(UTC),
        )
        return KagglePricingRecommendation(response=response, ml_price=ml_price)

    def register_order_event(self, event: OrderEvent) -> bool:
        if event.event_time.tzinfo is None:
            event = OrderEvent(
                sku_id=event.sku_id,
                quantity=event.quantity,
                event_time=event.event_time.replace(tzinfo=UTC),
            )
        return self.flash_sale_tracker.register(event)

    def get_cached_recommendation(self, sku_id: str) -> dict[str, object] | None:
        return self.cache.get_json(f"price:{sku_id}")

    def _inventory_adjustment(self, inventory_level: int, inventory_days_cover: float) -> float:
        if inventory_level <= 15 or inventory_days_cover < 3:
            return 1.10
        if inventory_level >= 120 or inventory_days_cover > 21:
            return 0.93
        return 1.0

    def _demand_adjustment(
        self,
        units_sold_last_5m: int,
        units_sold_last_1h: int,
        conversion_rate: float,
        is_festival: int,
    ) -> float:
        rapid_demand = units_sold_last_5m >= 8 or units_sold_last_1h >= 40
        strong_conversion = conversion_rate >= 0.045
        if rapid_demand and strong_conversion:
            return 1.08 + (0.03 if is_festival else 0.0)
        if units_sold_last_1h <= 8 and conversion_rate < 0.02:
            return 0.94
        return 1.0

    def _apply_guardrails(self, candidate_price: float, base_cost: float, current_price: float) -> float:
        min_price = base_cost * (1.0 + self.settings.min_margin)
        max_price = current_price * self.settings.max_price_multiplier
        return max(min(candidate_price, max_price), min_price)

    def _confidence_score(self, request: PricingRequest, competitor_price: float) -> float:
        signal_score = min(request.conversion_rate * 12 + request.click_through_rate * 4, 0.45)
        inventory_score = 0.25 if request.inventory_level > 10 else 0.12
        competitor_score = 0.20 if competitor_price > 0 else 0.08
        recency_score = 0.10 if request.units_sold_last_1h > 0 else 0.04
        return min(signal_score + inventory_score + competitor_score + recency_score, 0.98)

    def _build_reason(
        self,
        inventory_adjustment: float,
        demand_adjustment: float,
        detected_flash_sale: bool,
    ) -> str:
        reasons = ["ML baseline with competitor blending"]
        if inventory_adjustment > 1.0:
            reasons.append("low inventory pressure")
        elif inventory_adjustment < 1.0:
            reasons.append("overstock discount")
        if demand_adjustment > 1.0:
            reasons.append("strong short-term demand")
        elif demand_adjustment < 1.0:
            reasons.append("soft demand correction")
        if detected_flash_sale:
            reasons.append("flash sale multiplier active")
        return ", ".join(reasons)

    def _kaggle_confidence_score(
        self,
        request: KagglePricingRequest,
        competitor_anchor: float | None,
    ) -> float:
        demand_score = min((request.qty / 40) + (request.customers / 80), 0.40)
        rating_score = min(request.product_score / 10, 0.20)
        competitor_score = 0.20 if competitor_anchor is not None else 0.08
        recency_score = 0.10 if request.lag_price > 0 else 0.04
        seasonal_score = 0.10 if request.holiday or request.weekend else 0.05
        return min(
            demand_score + rating_score + competitor_score + recency_score + seasonal_score,
            0.97,
        )

    def _build_kaggle_reason(
        self,
        current_price: float,
        ml_price: float,
        competitor_anchor: float | None,
    ) -> str:
        reasons = ["Kaggle retail model baseline"]
        if competitor_anchor is not None:
            if ml_price > competitor_anchor:
                reasons.append("positioned above competitor average")
            elif ml_price < competitor_anchor:
                reasons.append("positioned below competitor average")
            else:
                reasons.append("aligned with competitor average")
        if ml_price > current_price:
            reasons.append("upside versus current price")
        elif ml_price < current_price:
            reasons.append("defensive move versus current price")
        else:
            reasons.append("flat versus current price")
        return ", ".join(reasons)

    def _append_price_history(self, response: PricingResponse, base_cost: float) -> None:
        path = self.settings.price_history_path
        path.parent.mkdir(parents=True, exist_ok=True)
        file_exists = path.exists()
        with path.open("a", newline="", encoding="utf-8") as handle:
            writer = csv.writer(handle)
            if not file_exists:
                writer.writerow(
                    [
                        "generated_at",
                        "sku_id",
                        "recommended_price",
                        "ml_price",
                        "blended_price",
                        "confidence",
                        "detected_flash_sale",
                        "base_cost",
                    ]
                )
            writer.writerow(
                [
                    response.generated_at.isoformat(),
                    response.sku_id,
                    response.recommended_price,
                    response.ml_price,
                    response.blended_price,
                    response.confidence,
                    int(response.detected_flash_sale),
                    round(base_cost, 2),
                ]
            )
        self.cache.set_json(f"price:{response.sku_id}", response.model_dump(mode="json"))