diff --git a/backend/bot/cron_ai_engine.php b/backend/bot/cron_ai_engine.php index 375ceef3..f36847fa 100644 --- a/backend/bot/cron_ai_engine.php +++ b/backend/bot/cron_ai_engine.php @@ -1,13 +1,11 @@ getMessage() . "\n"); } -echo "Starting Siro AI Engine...\n"; - -// نسبة الخصم المستهدفة (6.5% من إجمالي سعر الرحلة) -$targetMargin = 0.065; +echo "Starting Siro AI Engine v2 (Powered by Pricing Engine)...\n"; // ========================================== -// 1. وحدة التسعير الديناميكي بناءً على السعر الإجمالي +// 1. التسعير الديناميكي بناءً على المعادلات الإحصائية // ========================================== -echo "1. Running Smart Pricing Module (Total Price Formula)...\n"; +echo "1. Reading competitor formulas from Pricing Engine...\n"; try { - $sql = "SELECT country_code, - AVG(price_per_km) as avg_price_km, - MIN(price_per_km) as min_price_km - FROM scraped_competitor_prices - WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 HOUR) - AND price_per_km > 0 - GROUP BY country_code"; - + // قراءة آخر المعادلات من التحليل الإحصائي + $sql = "SELECT * FROM competitor_secret_formulas + WHERE last_updated >= DATE_SUB(NOW(), INTERVAL 24 HOUR) + ORDER BY last_updated DESC"; $stmt = $con->query($sql); - $competitorRates = $stmt->fetchAll(PDO::FETCH_ASSOC); + $formulas = $stmt->fetchAll(PDO::FETCH_ASSOC); - foreach ($competitorRates as $rate) { - $country = $rate['country_code']; - $countryNameMap = ['JO' => 'Jordan', 'SY' => 'Syria', 'EG' => 'Egypt', 'IQ' => 'Iraq']; - $countryName = $countryNameMap[$country] ?? null; - - if ($countryName) { - $avgKmPrice = (float)$rate['avg_price_km']; - $minKmPrice = (float)$rate['min_price_km']; - - // 1. حساب السعر الفعّال للكيلومتر بناءً على المتوسط والأرخص - $effectiveCompetitorPrice = round($avgKmPrice * (1 - $targetMargin), 2); - if ($effectiveCompetitorPrice > $minKmPrice) { - $effectiveCompetitorPrice = round(($effectiveCompetitorPrice + $minKmPrice) / 2, 2); + if (empty($formulas)) { + echo " ⚠️ No recent formulas found. Run pricing-engine first.\n"; + } else { + // تجميع المعادلات حسب الدولة + $byCountry = []; + foreach ($formulas as $f) { + $country = $f['country_code']; + if (!isset($byCountry[$country])) $byCountry[$country] = []; + $byCountry[$country][] = $f; + } + + foreach ($byCountry as $country => $countryFormulas) { + $countryNameMap = ['JO' => 'Jordan', 'SY' => 'Syria', 'EG' => 'Egypt', 'IQ' => 'Iraq']; + $countryName = $countryNameMap[$country] ?? $country; + + // اختيار أفضل معادلة للتسعير حسب الأولوية: + // 1. Economy tier (الأكثر تنافسية) + // 2. Standard tier (إذا ما في Economy) + // 3. أي tier ثاني بأعلى R² + $tierPriority = ['economy', 'standard', 'premium']; + $bestFormula = null; + $bestTierIdx = 999; + + foreach ($countryFormulas as $f) { + $tier = $f['tier'] ?? 'standard'; + $tierIdx = array_search($tier, $tierPriority); + if ($tierIdx === false) $tierIdx = 999; + + $currentRSq = (float)($f['r_squared'] ?? 0); + $currentKm = (float)($f['price_per_km'] ?? 0); + + // أفضلية: Tier أعلى أولوية، ثم R² أعلى + if ($currentKm > 0 && ( + $bestFormula === null || + $tierIdx < $bestTierIdx || + ($tierIdx === $bestTierIdx && $currentRSq > (float)($bestFormula['r_squared'] ?? 0)) + )) { + $bestTierIdx = $tierIdx; + $bestFormula = $f; + } } - - // 2. الهندسة العكسية للسعر الإجمالي (Reverse Engineering) - // بما أن سيرو يضيف سعر الدقيقة (والتي تعادل دقيقتين لكل كيلومتر تقريباً)، فإن التكلفة الإضافية للدقائق ترفع السعر الإجمالي بمقدار 1.5x - // لضمان أن يكون السعر النهائي أقل بـ 6%، نقسم الناتج على 1.5 ليمتص تكلفة الدقائق. - $calculatedSpeedPrice = round($effectiveCompetitorPrice / 1.5, 3); - - // 3. تسعير الفئات المتعددة - $newSpeedPrice = $calculatedSpeedPrice; - $newComfortPrice = round($newSpeedPrice * 1.30, 3); - $newLadyPrice = round($newSpeedPrice * 1.10, 3); - $newElectricPrice = round($newSpeedPrice * 1.20, 3); - $newVanPrice = round($newSpeedPrice * 1.50, 3); - $newDeliveryPrice = round($newSpeedPrice * 0.90, 3); - $newMishwarVipPrice = round($newSpeedPrice * 1.40, 3); - $newFixedPrice = $newSpeedPrice; - $newAwfarPrice = round($newSpeedPrice * 0.85, 3); - + + if ($bestFormula) { + $bestKmRate = (float)$bestFormula['price_per_km']; + $bestMinRate = (float)$bestFormula['price_per_min']; + $bestBaseFare = (float)$bestFormula['base_fare']; + $bestMinFare = (float)$bestFormula['min_fare']; + $bestRSq = (float)($bestFormula['r_squared'] ?? 0); + } + + if ($bestKmRate === null) { + echo " ⚠️ No valid formula for $countryName. Skipping.\n"; + continue; + } + + // تسعير Siro: أرخص بنسبة 6.5% من المنافس مع الحفاظ على هيكل التسعير + $discountFactor = 1 - 0.065; + $ourKmRate = round($bestKmRate * $discountFactor, 3); + $ourMinRate = round($bestMinRate * $discountFactor, 3); + $ourBase = round($bestBaseFare * $discountFactor, 3); + + echo " 📊 $countryName: Using formula (R²=$bestRSq)\n"; + echo " Competitor: KM=$bestKmRate, MIN=$bestMinRate, Base=$bestBaseFare, MinFare=$bestMinFare\n"; + echo " Siro (6.5% less): KM=$ourKmRate, MIN=$ourMinRate, Base=$ourBase\n"; + + // تحديث جدول kazan + $speedPrice = $ourKmRate; + $comfortPrice = round($ourKmRate * 1.30, 3); + $ladyPrice = round($ourKmRate * 1.10, 3); + $electricPrice = round($ourKmRate * 1.20, 3); + $vanPrice = round($ourKmRate * 1.50, 3); + $deliveryPrice = round($ourKmRate * 0.90, 3); + $mishwarVipPrice = round($ourKmRate * 1.40, 3); + $fixedPrice = $speedPrice; + $awfarPrice = round($ourKmRate * 0.85, 3); + // أسعار الدقائق if ($country === 'JO') { - $newNormalMin = 0.05; - $newPeakMin = 0.06; - $newLateMin = 0.05; + $normalMin = $ourMinRate > 0 ? $ourMinRate : 0.05; + $peakMin = round($normalMin * 1.15, 3); + $lateMin = $normalMin; } else { - $newNormalMin = round($newSpeedPrice / 4, 3); - $newPeakMin = round($newNormalMin * 1.15, 3); - $newLateMin = round($newNormalMin * 1.25, 3); + $normalMin = $ourMinRate > 0 ? $ourMinRate : round($speedPrice / 4, 3); + $peakMin = round($normalMin * 1.15, 3); + $lateMin = round($normalMin * 1.25, 3); } $updateSql = "UPDATE kazan @@ -97,65 +133,86 @@ try { peakMinPrice = :peakMin, lateMinPrice = :lateMin WHERE country = :countryName"; + $upStmt = $con->prepare($updateSql); $upStmt->execute([ - ':speedPrice' => $newSpeedPrice, - ':comfortPrice' => $newComfortPrice, - ':ladyPrice' => $newLadyPrice, - ':electricPrice' => $newElectricPrice, - ':vanPrice' => $newVanPrice, - ':deliveryPrice' => $newDeliveryPrice, - ':mishwarVipPrice' => $newMishwarVipPrice, - ':fixedPrice' => $newFixedPrice, - ':awfarPrice' => $newAwfarPrice, - ':normalMin' => $newNormalMin, - ':peakMin' => $newPeakMin, - ':lateMin' => $newLateMin, + ':speedPrice' => $speedPrice, + ':comfortPrice' => $comfortPrice, + ':ladyPrice' => $ladyPrice, + ':electricPrice' => $electricPrice, + ':vanPrice' => $vanPrice, + ':deliveryPrice' => $deliveryPrice, + ':mishwarVipPrice' => $mishwarVipPrice, + ':fixedPrice' => $fixedPrice, + ':awfarPrice' => $awfarPrice, + ':normalMin' => $normalMin, + ':peakMin' => $peakMin, + ':lateMin' => $lateMin, ':countryName' => $countryName ]); - - echo " -> Updated $countryName (Total Price Math applied): Speed=$newSpeedPrice JOD/KM, NormalMin=$newNormalMin JOD/MIN\n"; + + echo " ✅ Updated $countryName pricing in kazan table.\n"; } } } catch (Exception $e) { - echo " Error in Pricing Module: " . $e->getMessage() . "\n"; + echo " ❌ Error in Pricing Module: " . $e->getMessage() . "\n"; } // ========================================== -// 2. وحدة توجيه السائقين (Demand Predictor) +// 2. قراءة Surge Insights من الـ Pricing Engine // ========================================== -echo "2. Running Demand Predictor Module...\n"; +echo "2. Reading surge insights from Pricing Engine...\n"; try { - $cacheJson = $redis->get('siro:cache:pricing:grids'); - $hotZones = []; - if ($cacheJson) { - $grids = json_decode($cacheJson, true)['grids'] ?? []; - foreach ($grids as $key => $data) { - if (strpos($key, 'FALLBACK') !== false) continue; - - if ($data['avg_price'] > 0) { - $parts = explode('_', $key); - if (count($parts) == 3) { - $hotZones[] = [ - 'latitude' => (float)$parts[1], - 'longitude' => (float)$parts[2], - 'avg_price' => $data['avg_price'], - 'top_competitor' => $data['top_competitor'], - 'timestamp' => time() - ]; - } + $sql = "SELECT * FROM competitor_surge_insights + WHERE detected_at >= DATE_SUB(NOW(), INTERVAL 12 HOUR) + ORDER BY surge_multiplier DESC + LIMIT 20"; + $stmt = $con->query($sql); + $surgeRecords = $stmt->fetchAll(PDO::FETCH_ASSOC); + + if (!empty($surgeRecords)) { + $surgeByCountry = []; + foreach ($surgeRecords as $sr) { + $country = $sr['country_code']; + if (!isset($surgeByCountry[$country])) { + $surgeByCountry[$country] = [ + 'avg_multiplier' => 0, + 'count' => 0, + 'peak_hours' => [] + ]; } + $surgeByCountry[$country]['avg_multiplier'] += $sr['surge_multiplier']; + $surgeByCountry[$country]['count']++; + $surgeByCountry[$country]['peak_hours'][] = "{$sr['peak_start_hour']}:00-{$sr['peak_end_hour']}:00"; } - - $redis->set('siro:cache:ai:hotzones', json_encode(['status' => 'success', 'data' => $hotZones], JSON_UNESCAPED_UNICODE)); - echo " -> Saved " . count($hotZones) . " Hot Zones to Redis for Driver Map Guidance.\n"; + + foreach ($surgeByCountry as $country => $data) { + $avgMult = round($data['avg_multiplier'] / $data['count'], 3); + $peakHoursStr = implode(', ', array_unique($data['peak_hours'])); + + // تخزين معلومات الـ Surge في Redis — مفتاحين: + // 1. surge:opportunities (متوافق مع get.php الحالي) + // 2. surge:opportunities:{country} (متوافق مع cron_kazan_adjuster.php) + $surgeData = [ + 'avg_competitor_surge' => $avgMult, + 'suggested_multiplier' => round(1.0 + ($avgMult - 1.0) * 0.6, 3), + 'peak_hours' => $data['peak_hours'], + 'updated_at' => date('Y-m-d H:i:s') + ]; + $redis->setex("surge:opportunities", 7200, json_encode($surgeData)); + $redis->setex("surge:opportunities:{$country}", 7200, json_encode($surgeData)); + + echo " ⚡ $country: Avg competitor surge = {$avgMult}x, peak hours: {$peakHoursStr}\n"; + } + } else { + echo " ℹ️ No recent surge insights found.\n"; } } catch (Exception $e) { - echo " Error in Demand Predictor: " . $e->getMessage() . "\n"; + echo " ❌ Error in Surge Module: " . $e->getMessage() . "\n"; } // ========================================== -// 3. وحدة استهداف الركاب الخاملين (Smart Retention) +// 3. وحدة استهداف الركاب الخاملين (Smart Retention) - كما هي // ========================================== echo "3. Running Smart Retention Module...\n"; try { @@ -164,15 +221,13 @@ try { WHERE created_at >= DATE_SUB(NOW(), INTERVAL 3 HOUR) GROUP BY source HAVING opens >= 3"; - + $stmt = $con->query($sql); $idleRiders = $stmt->fetchAll(PDO::FETCH_ASSOC); - $notifiedCount = count($idleRiders); - echo " -> Identified $notifiedCount idle riders requiring push notifications.\n"; + echo " 📱 Identified $notifiedCount idle riders requiring push notifications.\n"; } catch (Exception $e) { - echo " Error in Smart Retention: " . $e->getMessage() . "\n"; + echo " ❌ Error in Smart Retention: " . $e->getMessage() . "\n"; } -echo "AI Engine finished successfully.\n"; -?> +echo "AI Engine v2 finished successfully.\n"; diff --git a/backend/bot/cron_gemini_advisor.php b/backend/bot/cron_gemini_advisor.php index 672ec062..9f7ad92b 100644 --- a/backend/bot/cron_gemini_advisor.php +++ b/backend/bot/cron_gemini_advisor.php @@ -1,9 +1,9 @@ getMessage() . "\n"); } -echo "Starting Gemini Market Advisor Engine...\n"; +echo "Starting Gemini Market Advisor Engine v2...\n"; // 1. إنشاء جدول الإحصائيات الذكية إذا لم يكن موجوداً $sqlInit = " @@ -28,30 +28,37 @@ CREATE TABLE IF NOT EXISTS `gemini_market_insights` ( "; $con->exec($sqlInit); -// 2. سحب آخر المعادلات المكتشفة -$stmt = $con->query("SELECT * FROM competitor_secret_formulas"); +// 2. سحب المعادلات المُطوّرة (مع الفئات والمؤشرات) +$stmt = $con->query(" + SELECT csf.*, + (SELECT surge_multiplier FROM competitor_surge_insights + WHERE competitor_name = csf.competitor_name + AND country_code = csf.country_code + ORDER BY detected_at DESC LIMIT 1) as recent_surge + FROM competitor_secret_formulas csf + WHERE csf.tier IS NOT NULL + ORDER BY csf.country_code, csf.competitor_name, csf.tier +"); $formulas = $stmt->fetchAll(PDO::FETCH_ASSOC); if (empty($formulas)) { - echo "No formulas found to analyze. Run ai_formula_solver.php first.\n"; + echo "No enriched formulas found. Run pricing-engine first.\n"; exit; } -// 3. تمرير البيانات إلى Gemini +// 3. تمرير البيانات المُثراة إلى Gemini $geminiService = new SiroGeminiService(); -echo "Sending data to Gemini AI for strategic analysis...\n"; +echo "Sending enriched data to Gemini AI for strategic analysis...\n"; $result = $geminiService->analyzeCompetitorFormulas($formulas); if ($result && $result['status'] === 'success') { $htmlReport = $result['html_report']; - // 4. حفظ التقرير في قاعدة البيانات $stmtInsert = $con->prepare("INSERT INTO gemini_market_insights (insight_html) VALUES (:html)"); $stmtInsert->execute([':html' => $htmlReport]); - echo "Gemini analysis saved successfully! Admin can now view the strategic report.\n"; + echo "Gemini analysis saved successfully! Admin can view the strategic report.\n"; } else { echo "Failed to get analysis from Gemini. Check API keys and logs.\n"; } -?> diff --git a/backend/bot/generate_price_tasks.php b/backend/bot/generate_price_tasks.php index 1e73d126..8c843af2 100644 --- a/backend/bot/generate_price_tasks.php +++ b/backend/bot/generate_price_tasks.php @@ -50,13 +50,17 @@ CREATE TABLE IF NOT EXISTS `competitor_secret_formulas` ( `id` INT AUTO_INCREMENT PRIMARY KEY, `competitor_name` varchar(100) NOT NULL, `country_code` varchar(10) NOT NULL, + `tier` varchar(20) DEFAULT 'standard', `base_fare` decimal(8,3) NOT NULL, `price_per_km` decimal(8,3) NOT NULL, `price_per_min` decimal(8,3) NOT NULL, - `confidence_score` decimal(5,2) DEFAULT 0, + `min_fare` decimal(8,3) DEFAULT 0, + `rmse` decimal(10,4) DEFAULT 0, + `r_squared` decimal(10,4) DEFAULT 0, + `surge_multiplier` decimal(5,3) DEFAULT 1.0, `sample_size` int DEFAULT 0, `last_updated` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - UNIQUE KEY `idx_comp_country` (`competitor_name`, `country_code`) + UNIQUE KEY `idx_comp_country_tier` (`competitor_name`, `country_code`, `tier`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "; $con->exec($sqlFormula); diff --git a/backend/pricing-engine/.env.example b/backend/pricing-engine/.env.example new file mode 100644 index 00000000..75a5a642 --- /dev/null +++ b/backend/pricing-engine/.env.example @@ -0,0 +1,11 @@ +# MySQL +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_NAME=siro +DB_USER=root +DB_PASS= + +# Redis +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +REDIS_PASS= diff --git a/backend/pricing-engine/README.md b/backend/pricing-engine/README.md new file mode 100644 index 00000000..93259cb7 --- /dev/null +++ b/backend/pricing-engine/README.md @@ -0,0 +1,76 @@ +# Siro Pricing Engine + +Statistical analysis engine for competitor ride-hailing pricing data. + +## Architecture + +``` +MySQL (scraped_competitor_prices) + ↓ +Pricing Engine (Node.js/TypeScript) + ↓ + ├─ Outlier Detection (MAD) + ├─ Tier Clustering (K-Means on PPK) + ├─ Multiple Linear Regression (Gaussian Elimination) + ├─ Minimum Fare Detection + ├─ Surge Pricing Analysis + └─ Zone-Based Analysis + ↓ +MySQL (competitor_secret_formulas + competitor_surge_insights) + ↓ +PHP Backend reads formulas → adjusts Siro pricing +``` + +## Quick Start + +```bash +# Install +cd backend/pricing-engine +npm install + +# Configure +cp .env.example .env +# Edit .env with your MySQL/Redis credentials + +# Run migration once +mysql -u root siro < migrations/001_add_columns.sql + +# Full analysis +npm run analyze + +# TaxiF only +npm run analyze:taxif +``` + +## CLI Commands + +| Command | Description | Schedule | +|---------|-------------|----------| +| `npm run analyze` | Full analysis all competitors | Every 3h | +| `npm run analyze:taxif` | TaxiF deep dive | On-demand | +| `npm run cron:hourly` | Surge + zone quick check (3h window) | Hourly | +| `npm run cron:daily` | Full analysis (72h window) | Daily 6am | +| `npm run cron:weekly` | Full report (7d window) | Weekly Mon 8am | + +## Analysis Pipeline + +1. **Fetch** raw data from `scraped_competitor_prices` +2. **Clean**: MAD-based outlier removal, extract base (non-surge) prices +3. **Cluster**: K-Means on price_per_km → Economy / Standard / Premium tiers +4. **Regress**: Multiple Linear Regression per tier: `price = base + km·dist + min·dur` +5. **Detect Min Fare**: Knee-point detection on short rides +6. **Analyze Surge**: Per-route price variation × time of day +7. **Zone Analysis**: 2.5km grid pricing heatmap +8. **Save** results to `competitor_secret_formulas` and `competitor_surge_insights` + +## Integration with PHP Backend + +The PHP cron jobs (`cron_ai_engine.php`, `cron_kazan_adjuster.php`) read from `competitor_secret_formulas` instead of doing their own simplistic math. The workflow becomes: + +``` +Pricing Engine (Node.js) → writes formulas + surge insights → MySQL + ↓ +PHP (cron_ai_engine.php) → reads formulas, adjusts kazan pricing +PHP (cron_kazan_adjuster) → reads surge insights, adjusts commissions +PHP (cron_gemini_advisor) → sends formulas to Gemini for TEXTUAL strategy only +``` diff --git a/backend/pricing-engine/migrations/001_add_columns.sql b/backend/pricing-engine/migrations/001_add_columns.sql new file mode 100644 index 00000000..19aa7f37 --- /dev/null +++ b/backend/pricing-engine/migrations/001_add_columns.sql @@ -0,0 +1,28 @@ +-- Migration: Add new columns to competitor_secret_formulas for enhanced analysis +-- Run this once to upgrade the table schema + +ALTER TABLE `competitor_secret_formulas` + ADD COLUMN `tier` VARCHAR(20) DEFAULT 'standard' AFTER `country_code`, + ADD COLUMN `min_fare` DECIMAL(8,3) DEFAULT 0 AFTER `price_per_min`, + ADD COLUMN `rmse` DECIMAL(10,4) DEFAULT 0 AFTER `min_fare`, + ADD COLUMN `r_squared` DECIMAL(10,4) DEFAULT 0 AFTER `rmse`, + ADD COLUMN `surge_multiplier` DECIMAL(5,3) DEFAULT 1.0 AFTER `r_squared`, + ADD COLUMN `peak_hours` VARCHAR(255) DEFAULT '[]' AFTER `surge_multiplier`; + +-- Make the unique key include tier for multi-tier support +ALTER TABLE `competitor_secret_formulas` + DROP INDEX `idx_comp_country`, + ADD UNIQUE KEY `idx_comp_country_tier` (`competitor_name`, `country_code`, `tier`); + +-- New table for surge insights +CREATE TABLE IF NOT EXISTS `competitor_surge_insights` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `competitor_name` VARCHAR(100) NOT NULL, + `country_code` VARCHAR(5) NOT NULL, + `surge_multiplier` DECIMAL(5,3) NOT NULL, + `peak_start_hour` INT NOT NULL, + `peak_end_hour` INT NOT NULL, + `sample_count` INT NOT NULL, + `detected_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY `unique_surge` (`competitor_name`, `country_code`, `peak_start_hour`, `peak_end_hour`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/pricing-engine/package-lock.json b/backend/pricing-engine/package-lock.json new file mode 100644 index 00000000..4c3bda51 --- /dev/null +++ b/backend/pricing-engine/package-lock.json @@ -0,0 +1,877 @@ +{ + "name": "siro-pricing-engine", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "siro-pricing-engine", + "version": "1.0.0", + "dependencies": { + "dotenv": "^16.4.5", + "mathjs": "^13.1.0", + "mysql2": "^3.11.0", + "node-cron": "^3.0.3", + "redis": "^4.7.0", + "simple-statistics": "^7.8.5" + }, + "devDependencies": { + "@types/node": "^22.5.0", + "@types/node-cron": "^3.0.11", + "tsx": "^4.19.0", + "typescript": "^5.6.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", + "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "dev": true + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/complex.js": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.4.3.tgz", + "integrity": "sha512-UrQVSUur14tNX6tiP4y8T4w4FeJAX3bi2cIv0pu/DTLFNxoq7z2Yh83Vfzztj6Px3X/lubqQ9IrPp7Bpn6p4MQ==", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==" + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-latex": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz", + "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==" + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "engines": { + "node": ">= 4" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==" + }, + "node_modules/javascript-natural-sort": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", + "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/mathjs": { + "version": "13.2.3", + "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-13.2.3.tgz", + "integrity": "sha512-I67Op0JU7gGykFK64bJexkSAmX498x0oybxfVXn1rroEMZTmfxppORhnk8mEUnPrbTfabDKCqvm18vJKMk2UJQ==", + "dependencies": { + "@babel/runtime": "^7.25.7", + "complex.js": "^2.2.5", + "decimal.js": "^10.4.3", + "escape-latex": "^1.2.0", + "fraction.js": "^4.3.7", + "javascript-natural-sort": "^0.7.1", + "seedrandom": "^3.0.5", + "tiny-emitter": "^2.1.0", + "typed-function": "^4.2.1" + }, + "bin": { + "mathjs": "bin/cli.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mysql2": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.5.tgz", + "integrity": "sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ==", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.3.3" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/redis": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", + "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", + "workspaces": [ + "./packages/*" + ], + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.1", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==" + }, + "node_modules/simple-statistics": { + "version": "7.9.3", + "resolved": "https://registry.npmjs.org/simple-statistics/-/simple-statistics-7.9.3.tgz", + "integrity": "sha512-WXpxUfo7BJCRpyl4besiuMV7wNj9xiPIq7IKmUQO4upIaF8pK2AXwhjttHN5L8KXZrLkGMCHGHq4p+pJXiIahQ==", + "engines": { + "node": "*" + } + }, + "node_modules/sql-escaper": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", + "integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==" + }, + "node_modules/tsx": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", + "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", + "dev": true, + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typed-function": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.2.tgz", + "integrity": "sha512-VwaXim9Gp1bngi/q3do8hgttYn2uC3MoT/gfuMWylnj1IeZBUAyPddHZlo1K05BDoj8DYPpMdiHqH1dDYdJf2A==", + "engines": { + "node": ">= 18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + } + } +} diff --git a/backend/pricing-engine/package.json b/backend/pricing-engine/package.json new file mode 100644 index 00000000..4d3daa69 --- /dev/null +++ b/backend/pricing-engine/package.json @@ -0,0 +1,39 @@ +{ + "name": "siro-pricing-engine", + "version": "1.0.0", + "description": "Statistical pricing analysis engine for Siro - competitor price intelligence", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "analyze": "npm run build && node dist/index.js --mode=full", + "analyze:taxif": "npm run build && node dist/index.js --mode=full --competitor=com.taxif.passenger", + "analyze:careem": "npm run build && node dist/index.js --mode=full --competitor=com.careem.ae", + "analyze:uber": "npm run build && node dist/index.js --mode=full --competitor=com.ubercab", + "analyze:surge": "npm run build && node dist/index.js --mode=surge", + "analyze:report": "npm run build && node dist/index.js --mode=report", + "dev": "tsx src/index.ts", + "cron:hourly": "node dist/index.js --mode=surge --hours=3", + "cron:daily": "node dist/index.js --mode=full --hours=72", + "cron:weekly": "node dist/index.js --mode=report --hours=168" + }, + "cron": { + "hourly": "0 * * * *", + "daily": "0 6 * * *", + "weekly": "0 8 * * 1" + }, + "dependencies": { + "mysql2": "^3.11.0", + "redis": "^4.7.0", + "dotenv": "^16.4.5", + "mathjs": "^13.1.0", + "simple-statistics": "^7.8.5", + "node-cron": "^3.0.3" + }, + "devDependencies": { + "typescript": "^5.6.0", + "@types/node": "^22.5.0", + "@types/node-cron": "^3.0.11", + "tsx": "^4.19.0" + } +} diff --git a/backend/pricing-engine/src/analysis/clustering.ts b/backend/pricing-engine/src/analysis/clustering.ts new file mode 100644 index 00000000..d264b652 --- /dev/null +++ b/backend/pricing-engine/src/analysis/clustering.ts @@ -0,0 +1,84 @@ +import { RideSample, PricingTier } from './types'; +import { kMeans } from '../utils/math'; + +const TIER_LABELS: Array<'economy' | 'standard' | 'premium'> = [ + 'economy', + 'standard', + 'premium', +]; + +/** + * Cluster rides into pricing tiers based on price_per_km using K-Means. + * Returns sorted tiers (economy < standard < premium). + */ +export function clusterTiers( + samples: RideSample[], + k: number = 3 +): PricingTier[] { + if (samples.length < k) { + return [{ + label: 'standard', + samples, + ppkRange: [0, Infinity], + regression: null, + }]; + } + + const ppkValues = samples.map(s => s.ppk); + const assignments = kMeans(ppkValues, k); + + // Calculate centroids for sorting + const centroids = new Array(k).fill(0).map((_, c) => { + const cluster = samples.filter((_, i) => assignments[i] === c); + return cluster.length > 0 + ? cluster.reduce((sum, s) => sum + s.ppk, 0) / cluster.length + : 0; + }); + + // Sort clusters by centroid (ascending) + const sortedClusterIndices = centroids + .map((c, i) => ({ centroid: c, index: i })) + .filter(c => !isNaN(c.centroid) && c.centroid > 0) + .sort((a, b) => a.centroid - b.centroid); + + const tiers: PricingTier[] = sortedClusterIndices.map((cluster, idx) => { + const clusterSamples = samples.filter((_, i) => assignments[i] === cluster.index); + const clusterPPKs = clusterSamples.map(s => s.ppk); + + return { + label: TIER_LABELS[idx] || 'unknown', + samples: clusterSamples, + ppkRange: [ + Math.min(...clusterPPKs), + Math.max(...clusterPPKs), + ], + regression: null, + }; + }); + + return tiers; +} + +/** + * Assign zones to routes based on coordinate grid. + * Grid size ~2.5km (0.025 degrees). + */ +export function assignZone(lat: number, lng: number): string { + const gridLat = Math.round(lat / 0.025) * 0.025; + const gridLng = Math.round(lng / 0.025) * 0.025; + return `${gridLat.toFixed(3)},${gridLng.toFixed(3)}`; +} + +/** + * Classify zone type based on distance from city center (Amman: 31.95, 35.90). + */ +export function classifyZoneType(lat: number, lng: number): string { + const dlat = lat - 31.95; + const dlng = lng - 35.90; + const dist = Math.sqrt(dlat * dlat + dlng * dlng); + + if (dist < 0.025) return 'centre'; + if (dist < 0.050) return 'mid'; + if (dist < 0.100) return 'suburb'; + return 'outskirts'; +} diff --git a/backend/pricing-engine/src/analysis/engine.ts b/backend/pricing-engine/src/analysis/engine.ts new file mode 100644 index 00000000..066d6d96 --- /dev/null +++ b/backend/pricing-engine/src/analysis/engine.ts @@ -0,0 +1,136 @@ +import { RideSample, AnalysisReport } from './types'; +import { removeOutliers, groupByRoute, extractBasePrices } from './outliers'; +import { clusterTiers } from './clustering'; +import { analyzeAllTiers } from './regression'; +import { detectSurge, aggregateSurgeHours } from './surge'; +import { analyzeByZone, analyzeByZoneType } from './zone'; + +export interface EngineOptions { + competitorName?: string; + countryCode?: string; + cleanOutliers?: boolean; + surgeThreshold?: number; + tierCount?: number; +} + +/** + * Main pricing analysis engine. + * Orchestrates the full pipeline: fetch → clean → cluster → regress → surge → zone. + */ +export async function runAnalysis( + samples: RideSample[], + options: EngineOptions = {} +): Promise { + const { + cleanOutliers = true, + surgeThreshold = 0.12, + tierCount = 3, + } = options; + + if (samples.length < 5) { + throw new Error(`Insufficient samples (${samples.length}). Need at least 5.`); + } + + const firstSample = samples[0]; + + // Step 1: Remove statistical outliers (MAD on PPK) + const cleanSamples = cleanOutliers ? removeOutliers(samples) : samples; + + // Step 2: Group by route and extract base (non-surge) prices + const routeGroups = groupByRoute(cleanSamples); + const baseSamples = extractBasePrices(routeGroups, surgeThreshold); + + // Step 3: Cluster into pricing tiers by PPK + const rawTiers = clusterTiers(cleanSamples, tierCount); + + // Step 4: Run regression on each tier + const analyzedTiers = analyzeAllTiers(rawTiers); + + // Step 5: Detect surge patterns + const surgePatterns = detectSurge(cleanSamples, surgeThreshold); + const surgeHours = aggregateSurgeHours(surgePatterns); + + // Step 6: Zone analysis + const zones = analyzeByZone(cleanSamples); + const zoneTypes = analyzeByZoneType(cleanSamples); + + // Build report + const report: AnalysisReport = { + competitorName: firstSample.competitorName, + countryCode: firstSample.countryCode, + tiers: analyzedTiers, + surgePatterns, + zones, + totalSamples: samples.length, + analyzedAt: new Date().toISOString(), + }; + + // Print summary + printSummary(report, baseSamples, surgeHours, zoneTypes); + + return report; +} + +function printSummary( + report: AnalysisReport, + baseSamples: RideSample[], + surgeHours: ReturnType, + zoneTypes: ReturnType +): void { + const sep = '═══════════════════════════════════════════════════════'; + console.log(`\n${sep}`); + console.log(` 📊 Pricing Analysis Report — ${report.competitorName} (${report.countryCode})`); + console.log(` ${report.totalSamples} total samples, ${baseSamples.length} base-price samples`); + console.log(` Analyzed at: ${report.analyzedAt}`); + console.log(sep); + + // Tiers + console.log(`\n📦 PRICING TIERS:`); + for (const tier of report.tiers) { + const reg = tier.regression; + if (reg) { + const tierIcon = tier.label === 'economy' ? '💰' : tier.label === 'standard' ? '🚗' : '💎'; + console.log(` ${tierIcon} ${tier.label.toUpperCase()}:`); + console.log(` Base Fare: ${reg.baseFare.toFixed(3)} ${report.countryCode === 'JO' ? 'JOD' : report.countryCode === 'SY' ? 'SYP' : 'CUR'}`); + console.log(` Per KM: ${reg.kmRate.toFixed(3)}`); + console.log(` Per Min: ${reg.minRate.toFixed(3)}`); + console.log(` Min Fare: ${reg.minFare.toFixed(3)} ${reg.hasMinFare ? '✅ active' : ''}`); + console.log(` RMSE: ${reg.rmse.toFixed(4)}`); + console.log(` R²: ${reg.rSquared.toFixed(4)}`); + console.log(` Samples: ${reg.sampleCount}`); + console.log(` PPK range: ${tier.ppkRange[0].toFixed(3)} – ${tier.ppkRange[1].toFixed(3)}`); + } else { + console.log(` 📄 ${tier.label.toUpperCase()}: ${tier.samples.length} samples (insufficient for regression)`); + } + console.log(''); + } + + // Surge + if (surgeHours.length > 0) { + console.log(`⚡ SURGE PATTERNS (by hour-of-day):`); + for (const sh of surgeHours) { + console.log(` Hour ${sh.hour.toString().padStart(2, '0')}:00 → avg ${sh.avgMultiplier.toFixed(3)}x (${sh.routeCount} routes)`); + } + } else { + console.log(`\nℹ️ No significant surge patterns detected.`); + } + + // Zones + if (zoneTypes.length > 0) { + console.log(`\n📍 ZONE TYPE ANALYSIS:`); + for (const zt of zoneTypes) { + console.log(` ${zt.zoneType.padEnd(12)} → avg ${zt.avgPpk.toFixed(3)}/km (${zt.sampleCount} rides)`); + } + } + + // Surge route details + if (report.surgePatterns.length > 0) { + console.log(`\n🔍 TOP SURGE ROUTES:`); + for (const sr of report.surgePatterns.slice(0, 5)) { + console.log(` ${sr.distanceKm.toFixed(1)}km → base ${sr.basePrice.toFixed(2)}, peak ${(sr.basePrice * sr.maxMultiplier).toFixed(2)} JOD (${sr.maxMultiplier.toFixed(3)}x)`); + } + } + + console.log(sep); + console.log(''); +} diff --git a/backend/pricing-engine/src/analysis/outliers.ts b/backend/pricing-engine/src/analysis/outliers.ts new file mode 100644 index 00000000..91a7c4d0 --- /dev/null +++ b/backend/pricing-engine/src/analysis/outliers.ts @@ -0,0 +1,72 @@ +import { RideSample } from './types'; +import { findInliersMAD } from '../utils/math'; + +/** + * Remove outlier rides using MAD on price_per_km. + * Also removes rides where price is clearly a surge outlier + * by comparing same-route prices. + */ +export function removeOutliers( + samples: RideSample[], + ppkThreshold: number = 3.5 +): RideSample[] { + if (samples.length < 10) return samples; + + const ppkValues = samples.map(s => s.ppk); + const inlierIndices = new Set(findInliersMAD(ppkValues, ppkThreshold)); + + // Also remove rides with price_per_km > 3x the median + const sortedPPK = [...ppkValues].sort((a, b) => a - b); + const medianPPK = sortedPPK[Math.floor(sortedPPK.length / 2)]; + const upperBound = medianPPK * 3; + + return samples.filter((s, i) => + inlierIndices.has(i) && s.ppk <= upperBound && s.ppk > 0 + ); +} + +/** + * Group samples by unique route (start/end coordinates rounded to 4 decimals). + */ +export function groupByRoute(samples: RideSample[]): Map { + const groups = new Map(); + for (const s of samples) { + const key = `${s.startLat.toFixed(4)},${s.startLng.toFixed(4)}->${s.endLat.toFixed(4)},${s.endLng.toFixed(4)}`; + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(s); + } + return groups; +} + +/** + * For each route, keep only the lowest price (non-surge baseline) + * if the price variation exceeds threshold. + */ +export function extractBasePrices( + groups: Map, + surgeThreshold: number = 0.15 +): RideSample[] { + const base: RideSample[] = []; + + for (const [, rides] of groups) { + if (rides.length === 1) { + base.push(rides[0]); + continue; + } + + const prices = rides.map(r => r.price); + const minPrice = Math.min(...prices); + const maxPrice = Math.max(...prices); + + // If variation is small, use all rides + if (maxPrice - minPrice <= surgeThreshold) { + base.push(...rides); + } else { + // Only keep rides within 5% of minimum price + const baseRides = rides.filter(r => r.price <= minPrice * 1.05); + base.push(...baseRides); + } + } + + return base; +} diff --git a/backend/pricing-engine/src/analysis/regression.ts b/backend/pricing-engine/src/analysis/regression.ts new file mode 100644 index 00000000..2980b027 --- /dev/null +++ b/backend/pricing-engine/src/analysis/regression.ts @@ -0,0 +1,120 @@ +import { PricingTier, RegressionResult, RideSample } from './types'; +import { + robustMultipleLinearRegression, + calcRMSE, + calcRSquared, + detectMinimumFare, +} from '../utils/math'; +import { mean } from 'simple-statistics'; + +/** + * Run multiple linear regression on each pricing tier. + * Detects minimum fare and computes RMSE/R². + */ +export function analyzeTier(tier: PricingTier): PricingTier { + const samples = tier.samples; + if (samples.length < 5) { + tier.regression = null; + return tier; + } + + // Primary model: price = baseFare + kmRate * dist + minRate * dur + // We use robust regression to strip out surge outliers and find the floor price + const mlrResult = robustMultipleLinearRegression( + samples.map(s => ({ + distance_km: s.distance_km, + duration_min: s.duration_min, + price: s.price, + })) + ); + + if (!mlrResult) { + tier.regression = null; + return tier; + } + + // Predict and compute RMSE/R² + const actualPrices = samples.map(s => s.price); + const predictedPrices = samples.map(s => + mlrResult.baseFare + + mlrResult.kmRate * s.distance_km + + mlrResult.minRate * s.duration_min + ); + + const rmse = calcRMSE(actualPrices, predictedPrices); + const rSquared = calcRSquared(actualPrices, predictedPrices); + + // Detect minimum fare + const minFare = detectMinimumFare( + samples.map(s => s.distance_km), + samples.map(s => s.price), + mlrResult.kmRate + ); + + // If minFare is detected and the short-ride residuals improve, + // apply minFare-adjusted model + let hasMinFare = false; + let adjustedRMSE = rmse; + let adjustedRSquared = rSquared; + + if (minFare && minFare > 0) { + const adjustedPredicted = samples.map(s => { + const raw = mlrResult.baseFare + mlrResult.kmRate * s.distance_km + mlrResult.minRate * s.duration_min; + return Math.max(raw, minFare); + }); + const adjRmse = calcRMSE(actualPrices, adjustedPredicted); + const adjRsq = calcRSquared(actualPrices, adjustedPredicted); + + // If minimum fare improves the fit, use it + if (adjRmse < rmse) { + hasMinFare = true; + adjustedRMSE = adjRmse; + adjustedRSquared = adjRsq; + } + } + + tier.regression = { + baseFare: mlrResult.baseFare, + kmRate: mlrResult.kmRate, + minRate: mlrResult.minRate, + minFare: minFare || 0, + rmse: adjustedRMSE, + rSquared: adjustedRSquared, + sampleCount: samples.length, + hasMinFare, + }; + + return tier; +} + +/** + * Run regression on all tiers. + */ +export function analyzeAllTiers(tiers: PricingTier[]): PricingTier[] { + return tiers.map(tier => analyzeTier(tier)); +} + +/** + * Simple distance-only regression for comparison. + * price = kmRate * dist + */ +export function distanceOnlyRegression( + samples: RideSample[] +): { kmRate: number; rmse: number } | null { + if (samples.length < 3) return null; + + const distances = samples.map(s => s.distance_km); + const prices = samples.map(s => s.price); + + // Simple average of price/km + const ratios = distances.map((d, i) => d > 0 ? prices[i] / d : 0) + .filter(r => r > 0 && isFinite(r)); + + if (ratios.length < 3) return null; + + const kmRate = mean(ratios); + const predicted = distances.map(d => kmRate * d); + const rmse = calcRMSE(prices, predicted); + + return { kmRate, rmse }; +} diff --git a/backend/pricing-engine/src/analysis/surge.ts b/backend/pricing-engine/src/analysis/surge.ts new file mode 100644 index 00000000..a3099d1e --- /dev/null +++ b/backend/pricing-engine/src/analysis/surge.ts @@ -0,0 +1,96 @@ +import { RideSample, SurgeResult } from './types'; +import { groupByRoute } from './outliers'; + +/** + * Detect surge pricing by analyzing price variation per route across time. + * For routes with multiple samples, identifies base price (minimum) + * and surge multipliers per hour-of-day (aggregated across all days). + */ +export function detectSurge( + samples: RideSample[], + surgeThreshold: number = 0.12 +): SurgeResult[] { + const routes = groupByRoute(samples); + const results: SurgeResult[] = []; + + for (const [routeKey, rides] of routes) { + if (rides.length < 3) continue; + + const prices = rides.map(r => r.price); + const minPrice = Math.min(...prices); + const maxPrice = Math.max(...prices); + + // Only analyze routes with meaningful variation + if (maxPrice - minPrice <= surgeThreshold) continue; + + // Find the time of the base price + const baseRide = rides.find(r => r.price === minPrice); + + // Aggregate surge by hour-of-day across ALL days + const surgeByHour = new Map(); + for (const r of rides) { + const hour = r.scrapedAt.getHours(); + if (!surgeByHour.has(hour)) surgeByHour.set(hour, []); + surgeByHour.get(hour)!.push(r.price); + } + + const surgePrices: SurgeResult['surgePrices'] = []; + let maxMultiplier = 1; + + // Sort hours and compute average multiplier per hour + for (const [hour, hourPrices] of [...surgeByHour.entries()].sort((a, b) => a[0] - b[0])) { + const avgTimePrice = hourPrices.reduce((a, b) => a + b, 0) / hourPrices.length; + const multiplier = minPrice > 0 ? avgTimePrice / minPrice : 1; + if (multiplier > maxMultiplier) maxMultiplier = multiplier; + + surgePrices.push({ + time: `${hour.toString().padStart(2, '0')}:00`, + price: Math.round(avgTimePrice * 100) / 100, + multiplier: Math.round(multiplier * 1000) / 1000, + }); + } + + if (maxMultiplier > 1.05) { + results.push({ + routeKey, + distanceKm: rides[0].distance_km, + basePrice: minPrice, + baseTime: baseRide ? baseRide.scrapedAt.toISOString() : '', + surgePrices, + maxMultiplier: Math.round(maxMultiplier * 1000) / 1000, + }); + } + } + + return results; +} + +/** + * Aggregate surge patterns across all routes to find global peak hours. + * Groups by hour-of-day (0-23) across all detected routes. + */ +export function aggregateSurgeHours( + surgeResults: SurgeResult[] +): Array<{ hour: number; avgMultiplier: number; routeCount: number }> { + const hourlyData = new Map(); + + for (const sr of surgeResults) { + for (const sp of sr.surgePrices) { + const hour = parseInt(sp.time.split(':')[0]); + if (!isNaN(hour)) { + if (!hourlyData.has(hour)) hourlyData.set(hour, []); + hourlyData.get(hour)!.push(sp.multiplier); + } + } + } + + return Array.from(hourlyData.entries()) + .map(([hour, multipliers]) => ({ + hour, + avgMultiplier: Math.round( + (multipliers.reduce((a, b) => a + b, 0) / multipliers.length) * 1000 + ) / 1000, + routeCount: multipliers.length, + })) + .sort((a, b) => a.hour - b.hour); +} diff --git a/backend/pricing-engine/src/analysis/types.ts b/backend/pricing-engine/src/analysis/types.ts new file mode 100644 index 00000000..752d2b4a --- /dev/null +++ b/backend/pricing-engine/src/analysis/types.ts @@ -0,0 +1,89 @@ +export interface ScrapedRide { + id: number; + task_id: string; + app_name: string; + competitor_name: string; + start_lat: number; + start_lng: number; + end_lat: number; + end_lng: number; + price_amount: number; + price_per_km: number; + distance_km: number; + duration_min: number; + currency: string; + country_code: string; + scraped_at: string; + created_at: string; +} + +export interface RideSample { + distance_km: number; + duration_min: number; + price: number; + ppk: number; + startLat: number; + startLng: number; + endLat: number; + endLng: number; + scrapedAt: Date; + competitorName: string; + countryCode: string; +} + +export interface RouteGroup { + key: string; + rides: RideSample[]; + minPrice: number; + maxPrice: number; + avgPrice: number; + distanceKm: number; + durationMin: number; + surgeMultiplier: number | null; +} + +export interface PricingTier { + label: 'economy' | 'standard' | 'premium' | 'unknown'; + samples: RideSample[]; + ppkRange: [number, number]; + regression: RegressionResult | null; +} + +export interface RegressionResult { + baseFare: number; + kmRate: number; + minRate: number; + minFare: number; + rmse: number; + rSquared: number; + sampleCount: number; + hasMinFare: boolean; +} + +export interface SurgeResult { + routeKey: string; + distanceKm: number; + basePrice: number; + baseTime: string; + surgePrices: Array<{ time: string; price: number; multiplier: number }>; + maxMultiplier: number; +} + +export interface ZoneAnalysis { + zoneKey: string; + centerLat: number; + centerLng: number; + samples: RideSample[]; + avgPpk: number; + tierDistribution: Record; +} + +export interface AnalysisReport { + competitorName: string; + countryCode: string; + tiers: PricingTier[]; + surgePatterns: SurgeResult[]; + zones: ZoneAnalysis[]; + totalSamples: number; + analyzedAt: string; +} diff --git a/backend/pricing-engine/src/analysis/zone.ts b/backend/pricing-engine/src/analysis/zone.ts new file mode 100644 index 00000000..92b4808d --- /dev/null +++ b/backend/pricing-engine/src/analysis/zone.ts @@ -0,0 +1,80 @@ +import { RideSample, ZoneAnalysis } from './types'; +import { assignZone, classifyZoneType } from './clustering'; + +/** + * Analyze pricing by geographical zone (2.5km grid). + * Groups samples into zones and computes per-zone statistics. + */ +export function analyzeByZone(samples: RideSample[]): ZoneAnalysis[] { + const zoneMap = new Map(); + + for (const s of samples) { + // Use start location for zone assignment + const zone = assignZone(s.startLat, s.startLng); + if (!zoneMap.has(zone)) zoneMap.set(zone, []); + zoneMap.get(zone)!.push(s); + } + + const results: ZoneAnalysis[] = []; + + for (const [zoneKey, zoneSamples] of zoneMap) { + if (zoneSamples.length < 3) continue; + + const ppkValues = zoneSamples.map(s => s.ppk); + const avgPpk = Math.round( + (ppkValues.reduce((a, b) => a + b, 0) / ppkValues.length) * 1000 + ) / 1000; + + // Count by tier — thresholds depend on currency scale + const tierCounts: Record = {}; + const sample = zoneSamples[0]; + const isHighDenom = sample.countryCode === 'SY' || sample.countryCode === 'IQ'; + const econThreshold = isHighDenom ? 15 : 0.35; + const stdThreshold = isHighDenom ? 40 : 0.55; + + for (const s of zoneSamples) { + const tier = + s.ppk < econThreshold ? 'economy' : + s.ppk < stdThreshold ? 'standard' : 'premium'; + tierCounts[tier] = (tierCounts[tier] || 0) + 1; + } + + const [latStr, lngStr] = zoneKey.split(','); + results.push({ + zoneKey, + centerLat: parseFloat(latStr), + centerLng: parseFloat(lngStr), + samples: zoneSamples, + avgPpk, + tierDistribution: tierCounts, + }); + } + + return results.sort((a, b) => a.avgPpk - b.avgPpk); +} + +/** + * Analyze pricing by zone type (centre, mid, suburb, outskirts). + */ +export function analyzeByZoneType( + samples: RideSample[] +): Array<{ zoneType: string; avgPpk: number; sampleCount: number; avgPrice: number }> { + const typeMap = new Map(); + + for (const s of samples) { + const zoneType = classifyZoneType(s.startLat, s.startLng); + if (!typeMap.has(zoneType)) typeMap.set(zoneType, []); + typeMap.get(zoneType)!.push(s.ppk); + } + + return Array.from(typeMap.entries()) + .map(([zoneType, ppks]) => ({ + zoneType, + avgPpk: Math.round( + (ppks.reduce((a, b) => a + b, 0) / ppks.length) * 1000 + ) / 1000, + sampleCount: ppks.length, + avgPrice: 0, // calculated below if needed + })) + .sort((a, b) => a.avgPpk - b.avgPpk); +} diff --git a/backend/pricing-engine/src/db/connection.ts b/backend/pricing-engine/src/db/connection.ts new file mode 100644 index 00000000..ba039c36 --- /dev/null +++ b/backend/pricing-engine/src/db/connection.ts @@ -0,0 +1,142 @@ +import mysql, { RowDataPacket, ResultSetHeader } from 'mysql2/promise'; +import dotenv from 'dotenv'; +import path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +let mysqlPool: mysql.Pool | null = null; + +export async function getMySQL(): Promise { + if (!mysqlPool) { + mysqlPool = mysql.createPool({ + host: process.env.DB_HOST || '127.0.0.1', + port: parseInt(process.env.DB_PORT || '3306'), + database: process.env.DB_NAME || 'siro', + user: process.env.DB_USER || 'root', + password: process.env.DB_PASS || '', + waitForConnections: true, + connectionLimit: 5, + queueLimit: 0, + }); + } + return mysqlPool; +} + +export async function fetchSamples( + pool: mysql.Pool, + competitorName?: string, + countryCode?: string, + hoursBack?: number +): Promise { + const conditions: string[] = ['distance_km > 0', 'duration_min > 0', 'price_amount > 0']; + const params: (string | number)[] = []; + + if (competitorName) { + conditions.push('competitor_name = ?'); + params.push(competitorName); + } + if (countryCode) { + conditions.push('country_code = ?'); + params.push(countryCode); + } + if (hoursBack) { + conditions.push('scraped_at >= DATE_SUB(NOW(), INTERVAL ? HOUR)'); + params.push(hoursBack); + } + + const sql = `SELECT * FROM scraped_competitor_prices WHERE ${conditions.join(' AND ')} ORDER BY id DESC LIMIT 10000`; + const [rows] = await pool.query(sql, params); + return rows; +} + +export async function saveFormulas( + pool: mysql.Pool, + formulas: Array<{ + competitorName: string; + countryCode: string; + tier: string; + baseFare: number; + kmRate: number; + minRate: number; + minFare: number; + rmse: number; + rSquared: number; + sampleCount: number; + surgeMultiplier: number; + peakHours: string; + }> +): Promise { + if (formulas.length === 0) return; + + // Batch INSERT with ON DUPLICATE KEY UPDATE + const values = formulas.map(f => `(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())`).join(','); + const flatParams: (string | number)[] = []; + + for (const f of formulas) { + flatParams.push( + f.competitorName, f.countryCode, f.tier, + f.baseFare, f.kmRate, f.minRate, f.minFare, + f.rmse, f.rSquared, f.surgeMultiplier, + f.sampleCount, f.peakHours + ); + } + + const sql = `INSERT INTO competitor_secret_formulas + (competitor_name, country_code, tier, base_fare, price_per_km, price_per_min, min_fare, rmse, r_squared, surge_multiplier, sample_size, peak_hours, last_updated) + VALUES ${values} + ON DUPLICATE KEY UPDATE + base_fare = VALUES(base_fare), + price_per_km = VALUES(price_per_km), + price_per_min = VALUES(price_per_min), + min_fare = VALUES(min_fare), + rmse = VALUES(rmse), + r_squared = VALUES(r_squared), + surge_multiplier = VALUES(surge_multiplier), + sample_size = VALUES(sample_size), + peak_hours = VALUES(peak_hours), + last_updated = NOW()`; + + await pool.execute(sql, flatParams); +} + +export async function saveSurgeInsights( + pool: mysql.Pool, + insights: Array<{ + competitorName: string; + countryCode: string; + surgeMultiplier: number; + peakStartHour: number; + peakEndHour: number; + sampleCount: number; + }> +): Promise { + if (insights.length === 0) return; + + const values = insights.map(() => `(?, ?, ?, ?, ?, ?, NOW())`).join(','); + const flatParams: (string | number)[] = []; + + for (const ins of insights) { + flatParams.push( + ins.competitorName, ins.countryCode, + ins.surgeMultiplier, ins.peakStartHour, + ins.peakEndHour, ins.sampleCount + ); + } + + const sql = `INSERT INTO competitor_surge_insights + (competitor_name, country_code, surge_multiplier, peak_start_hour, peak_end_hour, sample_count, detected_at) + VALUES ${values} + ON DUPLICATE KEY UPDATE + surge_multiplier = VALUES(surge_multiplier), + sample_count = VALUES(sample_count), + detected_at = NOW()`; + + await pool.execute(sql, flatParams); +} + +export async function closeConnections(): Promise { + if (mysqlPool) { + await mysqlPool.end(); + mysqlPool = null; + } +} diff --git a/backend/pricing-engine/src/index.ts b/backend/pricing-engine/src/index.ts new file mode 100644 index 00000000..ba587d0c --- /dev/null +++ b/backend/pricing-engine/src/index.ts @@ -0,0 +1,218 @@ +/** + * Siro Pricing Engine CLI + * + * Usage: + * npm run analyze Full analysis all competitors + * npm run analyze:taxif TaxiF only + * npm run analyze -- --competitor=com.taxif.passenger --country=JO + * npm run dev -- --mode=surge Surge-only analysis + * + * Cron integration: see crontab examples in package.json scripts + */ + +import { getMySQL, fetchSamples, saveFormulas, saveSurgeInsights, closeConnections } from './db/connection'; +import { runAnalysis } from './analysis/engine'; +import { Pool, RowDataPacket } from 'mysql2/promise'; + +interface CLIOptions { + mode: 'full' | 'report'; + competitor?: string; + country?: string; + hoursBack?: number; +} + +function parseArgs(): CLIOptions { + const args = process.argv.slice(2); + const opts: CLIOptions = { mode: 'full' }; + + for (const arg of args) { + if (arg.startsWith('--mode=')) { + const mode = arg.split('=')[1]; + if (mode === 'full' || mode === 'report') { + opts.mode = mode; + } + } else if (arg.startsWith('--competitor=')) { + opts.competitor = arg.split('=')[1]; + } else if (arg.startsWith('--country=')) { + opts.country = arg.split('=')[1]; + } else if (arg.startsWith('--hours=')) { + opts.hoursBack = parseInt(arg.split('=')[1]); + } + } + + return opts; +} + +interface CompetitorEntry { + competitor_name: string; + country_code: string; +} + +async function main(): Promise { + const opts = parseArgs(); + const startTime = Date.now(); + + console.log(`🚀 Siro Pricing Engine v1.0`); + console.log(` Mode: ${opts.mode}`); + if (opts.competitor) console.log(` Competitor: ${opts.competitor}`); + if (opts.country) console.log(` Country: ${opts.country}`); + console.log(''); + + try { + const pool = await getMySQL(); + + const competitors = await fetchCompetitors(pool, opts); + + if (competitors.length === 0) { + console.log('❌ No competitors found with sufficient data.'); + return; + } + + // Process competitors in parallel for speed + const results = await Promise.allSettled( + competitors.map(comp => processCompetitor(pool, comp, opts)) + ); + + const succeeded = results.filter(r => r.status === 'fulfilled').length; + const failed = results.filter(r => r.status === 'rejected').length; + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.log(`\n✨ Analysis complete in ${elapsed}s (${succeeded} succeeded, ${failed} failed)`); + + if (failed > 0) { + console.log('\n❌ Failures:'); + results.forEach((r, i) => { + if (r.status === 'rejected') { + console.log(` ${competitors[i].competitor_name} (${competitors[i].country_code}): ${r.reason}`); + } + }); + } + } catch (err) { + console.error('❌ Fatal error:', err); + process.exit(1); + } finally { + await closeConnections(); + } +} + +async function processCompetitor( + pool: Pool, + comp: CompetitorEntry, + opts: CLIOptions +): Promise { + console.log(`\n📥 Fetching data for ${comp.competitor_name} (${comp.country_code})...`); + const rows = await fetchSamples(pool, comp.competitor_name, comp.country_code, opts.hoursBack); + + if (rows.length < 10) { + console.log(` ⏩ Only ${rows.length} samples — skipping (need 10+)`); + return; + } + + const samples = rows.map((row: RowDataPacket) => ({ + distance_km: parseFloat(row.distance_km), + duration_min: parseFloat(row.duration_min), + price: parseFloat(row.price_amount), + ppk: parseFloat(row.price_per_km), + startLat: parseFloat(row.start_lat), + startLng: parseFloat(row.start_lng), + endLat: parseFloat(row.end_lat), + endLng: parseFloat(row.end_lng), + scrapedAt: new Date(row.scraped_at), + competitorName: row.competitor_name, + countryCode: row.country_code, + })); + + const report = await runAnalysis(samples, { + competitorName: comp.competitor_name, + countryCode: comp.country_code, + cleanOutliers: true, + surgeThreshold: 0.12, + tierCount: 3, + }); + + // Save tier formulas + const formulas = report.tiers + .filter(t => t.regression !== null && t.regression!.sampleCount >= 5) + .map(tier => ({ + competitorName: comp.competitor_name, + countryCode: comp.country_code, + tier: tier.label, + baseFare: tier.regression!.baseFare, + kmRate: tier.regression!.kmRate, + minRate: tier.regression!.minRate, + minFare: tier.regression!.minFare, + rmse: tier.regression!.rmse, + rSquared: tier.regression!.rSquared, + sampleCount: tier.regression!.sampleCount, + surgeMultiplier: 1.0, + peakHours: '[]', + })); + + if (formulas.length > 0) { + await saveFormulas(pool, formulas); + console.log(` ✅ Saved ${formulas.length} tier formulas`); + } + + // Save surge insights — use the average multiplier across all detected routes + if (opts.mode !== 'report' && report.surgePatterns.length > 0) { + const avgMultiplier = report.surgePatterns + .reduce((sum, sr) => sum + sr.maxMultiplier, 0) / report.surgePatterns.length; + + // Find peak hour range from aggregate pattern + const allHours = report.surgePatterns.flatMap(sr => + sr.surgePrices + .filter(sp => sp.multiplier > 1.05) + .map(sp => parseInt(sp.time.split(':')[0])) + ); + + const peakStart = allHours.length > 0 ? Math.min(...allHours) : 0; + const peakEnd = allHours.length > 0 ? Math.max(...allHours) : 23; + + const surgeInsights = [{ + competitorName: comp.competitor_name, + countryCode: comp.country_code, + surgeMultiplier: Math.round(avgMultiplier * 1000) / 1000, + peakStartHour: peakStart, + peakEndHour: peakEnd, + sampleCount: report.surgePatterns.length, + }]; + + await saveSurgeInsights(pool, surgeInsights); + console.log(` ✅ Saved surge insight: avg ${(avgMultiplier).toFixed(3)}x, hours ${peakStart}:00-${peakEnd}:00`); + } +} + +async function fetchCompetitors( + pool: Pool, + opts: CLIOptions +): Promise { + if (opts.competitor) { + const countryClause = opts.country ? 'AND country_code = ?' : ''; + const params: (string | number)[] = opts.country + ? [opts.competitor, opts.country] + : [opts.competitor]; + + const [rows] = await pool.query( + `SELECT DISTINCT competitor_name, country_code + FROM scraped_competitor_prices + WHERE competitor_name = ? + AND distance_km > 0 AND duration_min > 0 AND price_amount > 0 + ${countryClause} + LIMIT 10`, + params + ); + return rows as CompetitorEntry[]; + } + + const [rows] = await pool.query( + `SELECT competitor_name, country_code, COUNT(*) as cnt + FROM scraped_competitor_prices + WHERE distance_km > 0 AND duration_min > 0 AND price_amount > 0 + GROUP BY competitor_name, country_code + HAVING cnt >= 10 + ORDER BY cnt DESC` + ); + return rows as CompetitorEntry[]; +} + +main(); diff --git a/backend/pricing-engine/src/utils/math.ts b/backend/pricing-engine/src/utils/math.ts new file mode 100644 index 00000000..cca531fb --- /dev/null +++ b/backend/pricing-engine/src/utils/math.ts @@ -0,0 +1,326 @@ +/** + * Matrix and statistical utilities for pricing analysis. + * Pure math — no external dependencies except simple-statistics. + */ + +import { median, mean, standardDeviation } from 'simple-statistics'; + +/** + * Compute Pearson correlation between two arrays. + */ +function pearsonCorr(x: number[], y: number[]): number { + const n = Math.min(x.length, y.length); + if (n < 3) return 0; + const mx = x.reduce((a, b) => a + b, 0) / n; + const my = y.reduce((a, b) => a + b, 0) / n; + let num = 0, dx2 = 0, dy2 = 0; + for (let i = 0; i < n; i++) { + const dx = x[i] - mx; + const dy = y[i] - my; + num += dx * dy; + dx2 += dx * dx; + dy2 += dy * dy; + } + const denom = Math.sqrt(dx2 * dy2); + return denom === 0 ? 0 : num / denom; +} + +/** + * Multiple linear regression via Gaussian elimination with ridge regularization. + * Solves: price = baseFare + kmRate*distance + minRate*duration + * + * Uses L2 ridge (lambda=0.1) when distance≈duration are collinear. + * Falls back to distance-only model if necessary. + */ +export function multipleLinearRegression( + samples: Array<{ distance_km: number; duration_min: number; price: number }> +): { baseFare: number; kmRate: number; minRate: number } | null { + const n = samples.length; + if (n < 3) return null; + + // Check collinearity: if distance and duration are highly correlated + const dists = samples.map(s => s.distance_km); + const durs = samples.map(s => s.duration_min); + const corr = pearsonCorr(dists, durs); + + const lambda = Math.abs(corr) > 0.85 ? 0.5 : 0.01; // ridge penalty + + let sumX1 = 0, sumX2 = 0, sumY = 0; + let sumX1Sq = 0, sumX2Sq = 0, sumX1X2 = 0; + let sumX1Y = 0, sumX2Y = 0; + + for (const s of samples) { + const x1 = s.distance_km; + const x2 = s.duration_min; + const y = s.price; + + sumX1 += x1; sumX2 += x2; sumY += y; + sumX1Sq += x1 * x1; sumX2Sq += x2 * x2; sumX1X2 += x1 * x2; + sumX1Y += x1 * y; sumX2Y += x2 * y; + } + + // Ridge: add lambda to diagonal of X^T X (except intercept) + const A = [ + [n, sumX1, sumX2], + [sumX1, sumX1Sq + lambda, sumX1X2], + [sumX2, sumX1X2, sumX2Sq + lambda], + ]; + + const B = [sumY, sumX1Y, sumX2Y]; + + try { + const beta = gaussianElimination(A, B); + const baseFare = Math.max(0, beta[0]); + let kmRate = Math.max(0, beta[1]); + let minRate = Math.max(0, beta[2]); + + // If minRate is essentially zero after ridge, keep it minimal + if (minRate < 0.001) minRate = 0; + + // If both non-intercept terms are zero, try distance-only model + if (kmRate === 0 && minRate === 0) { + const k = sumX1Y / (sumX1Sq + lambda); + if (k > 0) { + kmRate = k; + } + } + + return { baseFare, kmRate, minRate }; + } catch { + return null; + } +} + +/** + * Robust iterative regression to find floor pricing (exclude surge outliers). + * Uses a fixed JOD/SYP threshold per iteration instead of tightening RMSE. + */ +export function robustMultipleLinearRegression( + samples: Array<{ distance_km: number; duration_min: number; price: number }>, + maxIterations: number = 4 +): { baseFare: number; kmRate: number; minRate: number } | null { + let currentSamples = [...samples]; + let bestModel = multipleLinearRegression(currentSamples); + if (!bestModel) return null; + + // Determine threshold from data scale (median price × 0.3) + const prices = samples.map(s => s.price).sort((a, b) => a - b); + const medianPrice = prices[Math.floor(prices.length / 2)]; + const fixedThreshold = Math.max(medianPrice * 0.3, 0.1); + + for (let i = 0; i < maxIterations; i++) { + const predicted = currentSamples.map( + s => bestModel!.baseFare + bestModel!.kmRate * s.distance_km + bestModel!.minRate * s.duration_min + ); + const actual = currentSamples.map(s => s.price); + + const inliers = currentSamples.filter((s, idx) => { + const residual = actual[idx] - predicted[idx]; + return residual < fixedThreshold; + }); + + if (inliers.length < Math.max(5, samples.length * 0.3)) break; + if (inliers.length === currentSamples.length) break; + + currentSamples = inliers; + const newModel = multipleLinearRegression(currentSamples); + if (!newModel) break; + bestModel = newModel; + } + + return bestModel; +} + +/** + * Gaussian elimination for solving Ax = B (3x3 system). + */ +function gaussianElimination(A: number[][], B: number[]): number[] { + const n = A.length; + const a = A.map(row => [...row]); + const b = [...B]; + + for (let i = 0; i < n; i++) { + let maxEl = Math.abs(a[i][i]); + let maxRow = i; + for (let k = i + 1; k < n; k++) { + if (Math.abs(a[k][i]) > maxEl) { + maxEl = Math.abs(a[k][i]); + maxRow = k; + } + } + + [a[maxRow], a[i]] = [a[i], a[maxRow]]; + [b[maxRow], b[i]] = [b[i], b[maxRow]]; + + if (Math.abs(a[i][i]) < 1e-12) continue; + + for (let k = i + 1; k < n; k++) { + const c = -a[k][i] / a[i][i]; + for (let j = i; j < n; j++) { + if (i === j) a[k][j] = 0; + else a[k][j] += c * a[i][j]; + } + b[k] += c * b[i]; + } + } + + const x = new Array(n).fill(0); + for (let i = n - 1; i >= 0; i--) { + if (Math.abs(a[i][i]) < 1e-12) continue; + x[i] = b[i] / a[i][i]; + for (let k = i - 1; k >= 0; k--) { + b[k] -= a[k][i] * x[i]; + } + } + return x; +} + +/** + * Calculate RMSE between predicted and actual values. + */ +export function calcRMSE(actual: number[], predicted: number[]): number { + const n = Math.min(actual.length, predicted.length); + if (n === 0) return Infinity; + const sumSq = actual.reduce((sum, a, i) => { + if (i >= predicted.length) return sum; + return sum + (a - predicted[i]) ** 2; + }, 0); + return Math.sqrt(sumSq / n); +} + +/** + * Calculate R² coefficient of determination. + */ +export function calcRSquared(actual: number[], predicted: number[]): number { + const n = Math.min(actual.length, predicted.length); + if (n < 2) return 0; + const meanActual = mean(actual); + const ssTot = actual.reduce((sum, y) => sum + (y - meanActual) ** 2, 0); + if (ssTot === 0) return 1; + const ssRes = actual.reduce((sum, y, i) => { + if (i >= predicted.length) return sum; + return sum + (y - predicted[i]) ** 2; + }, 0); + return 1 - ssRes / ssTot; +} + +/** + * Median Absolute Deviation outlier detection. + * Returns indices of inlier samples. + */ +export function findInliersMAD( + values: number[], + threshold: number = 3.5 +): number[] { + const med = median(values); + const absDevs = values.map(v => Math.abs(v - med)); + const mad = median(absDevs); + if (mad === 0) return values.map((_, i) => i); + + return values + .map((v, i) => ({ v, i, modifiedZ: 0.6745 * Math.abs(v - med) / mad })) + .filter(x => x.modifiedZ < threshold) + .map(x => x.i); +} + +/** + * K-Means clustering (for PPK-based tier detection). + * Returns cluster assignments (0..k-1) for each sample. + */ +export function kMeans( + values: number[], + k: number, + maxIterations: number = 100 +): number[] { + if (values.length < k) return values.map(() => 0); + + // Initialize centroids using k-means++ + let centroids: number[] = []; + centroids.push(values[Math.floor(Math.random() * values.length)]); + for (let c = 1; c < k; c++) { + const dists = values.map(v => Math.min( + ...centroids.map(cent => Math.abs(v - cent)) + )); + const totalDist = dists.reduce((a, b) => a + b, 0); + let r = Math.random() * totalDist; + for (let i = 0; i < dists.length; i++) { + r -= dists[i]; + if (r <= 0) { + centroids.push(values[i]); + break; + } + } + } + + const assignments = new Array(values.length).fill(0); + + for (let iter = 0; iter < maxIterations; iter++) { + // Assign + let changed = false; + for (let i = 0; i < values.length; i++) { + let minDist = Infinity; + let bestCluster = 0; + for (let c = 0; c < k; c++) { + const dist = Math.abs(values[i] - centroids[c]); + if (dist < minDist) { + minDist = dist; + bestCluster = c; + } + } + if (assignments[i] !== bestCluster) { + assignments[i] = bestCluster; + changed = true; + } + } + + if (!changed) break; + + // Update centroids + for (let c = 0; c < k; c++) { + const clusterVals = values.filter((_, i) => assignments[i] === c); + if (clusterVals.length > 0) { + centroids[c] = mean(clusterVals); + } + } + } + + // Sort clusters by centroid value (ascending: economy < standard < premium) + const centroidOrder = centroids + .map((c, i) => ({ centroid: c, index: i })) + .sort((a, b) => a.centroid - b.centroid); + + const labelMap = new Map(); + centroidOrder.forEach((item, newIdx) => labelMap.set(item.index, newIdx)); + + return assignments.map(a => labelMap.get(a)!); +} + +/** + * Find "knee point" in price-vs-distance curve for minimum fare detection. + * Uses simple piecewise linear fit. + */ +export function detectMinimumFare( + distances: number[], + prices: number[], + kmRate: number +): number | null { + if (distances.length < 5) return null; + + // Sort by distance + const pairs = distances.map((d, i) => ({ d, p: prices[i] })) + .sort((a, b) => a.d - b.d); + + // Compute expected price without min fare + const residuals = pairs.map(({ d, p }) => p - kmRate * d); + + // Find where actual price consistently exceeds predicted + // The minimum fare is the max of (price - kmRate*dist) for short rides + const shortRides = pairs.filter(({ d }) => d < 10); + if (shortRides.length < 3) return null; + + const minFareEstimate = Math.max( + ...shortRides.map(({ d, p }) => p - kmRate * d) + ); + + return minFareEstimate > 0 ? Math.round(minFareEstimate * 100) / 100 : null; +} diff --git a/backend/pricing-engine/tsconfig.json b/backend/pricing-engine/tsconfig.json new file mode 100644 index 00000000..19517784 --- /dev/null +++ b/backend/pricing-engine/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/docs/PRICING_ENGINE_ARCHITECTURE.md b/docs/PRICING_ENGINE_ARCHITECTURE.md new file mode 100644 index 00000000..9e790ffd --- /dev/null +++ b/docs/PRICING_ENGINE_ARCHITECTURE.md @@ -0,0 +1,434 @@ +# Siro Pricing Engine — Architecture & Deployment Guide + +
+ +## 1. نظرة عامة على المنظومة + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Siro PRICING ECOSYSTEM │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────┐ ┌──────────────────────────────┐ │ +│ │ Android Bot Scraper │ │ Node.js Pricing Engine │ │ +│ │ (Java/Kotlin) │─────▶│ (TypeScript) │ │ +│ │ يرسخن أسعار │ │ تحليل إحصائي متقدم │ │ +│ │ TaxiF, Careem, Uber │ │ ┌────────────────────────┐ │ │ +│ │ Jeeny... │ │ │ MAD Outlier Detection │ │ │ +│ └──────────┬───────────┘ │ │ K-Means Tier Clustering│ │ │ +│ │ │ │ Ridge Regression (MLR) │ │ │ +│ ▼ │ │ Min Fare Detection │ │ │ +│ ┌──────────────────────┐ │ │ Surge Analysis │ │ │ +│ │ MySQL: │ │ │ Zone Pricing │ │ │ +│ │ scraped_competitor_ │◀─────│ └────────────────────────┘ │ │ +│ │ prices │ └──────────────┬───────────────┘ │ +│ └──────────────────────┘ │ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌──────────────────────┐ ┌──────────────────────────────┐ │ +│ │ MySQL: │ │ MySQL: │ │ +│ │ competitor_secret_ │ │ competitor_surge_insights │ │ +│ │ formulas │ │ (ساعات الذروة + المضاعف) │ │ +│ │ (معادلات المنافسين) │ └──────────────┬───────────────┘ │ +│ └──────────┬───────────┘ │ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ PHP Cron Jobs (Backend) │ │ +│ │ │ │ +│ │ cron_ai_engine.php: يقرأ المعادلات ويحدث kazan │ │ +│ │ cron_kazan_adjuster: يقرأ surge ويضبط العمولة │ │ +│ │ cron_gemini_advisor: يرسل المعادلات لـ Gemini لتقارير │ │ +│ └────────────────────────┬───────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ Redis Cache Layer │ │ +│ │ │ │ +│ │ surge:opportunities → مضاعف Surge المقترح │ │ +│ │ surge:opportunities:{JO} → لكل دولة │ │ +│ │ siro:cache:pricing:grids → أسعار حسب Grid 2.5km │ │ +│ └────────────────────────┬───────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ Real-time APIs (Rider & Driver Apps) │ │ +│ │ │ │ +│ │ ride/pricing/get.php: حساب السعر الفوري للراكب │ │ +│ │ ride/heatmap/: خريطة حرارية للسائق │ │ +│ │ api/ride/competitor: مقارنة أسعار المنافسين │ │ +│ └────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. Deployment: Node.js إلى جانب PHP + +### المشكلة + +النظام الحالي PHP على Apache/Nginx. نحتاج Node.js للتشغيل جنباً إلى جنب. + +### الحل: PM2 Process Manager + +```bash +# 1. تثبيت Node.js على السيرفر (مرة واحدة) +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - +sudo apt-get install -y nodejs + +# 2. رفع مجلد pricing-engine إلى السيرفر +# (scp أو git pull) + +# 3. تثبيت PM2 (مدير عمليات Node.js) +npm install -g pm2 + +# 4. تثبيت dependencies +cd /var/www/siro/backend/pricing-engine +npm install +cp .env.example .env +# عدّل .env ببيانات MySQL + Redis + +# 5. تشغيل الخدمات مع PM2 +pm2 start ecosystem.config.js +pm2 save +pm2 startup # عشان يشتغل تلقائياً بعد reboot +``` + +### ملف PM2 Ecosystem + +```javascript +// backend/pricing-engine/ecosystem.config.js +module.exports = { + apps: [ + { + name: 'siro-pricing-hourly', + script: 'dist/index.js', + args: '--mode=surge --hours=3', + cron_restart: '0 * * * *', // كل ساعة + autorestart: false, + time: true, + }, + { + name: 'siro-pricing-daily', + script: 'dist/index.js', + args: '--mode=full --hours=72', + cron_restart: '0 6 * * *', // كل يوم 6 صباحاً + autorestart: false, + time: true, + }, + { + name: 'siro-pricing-weekly', + script: 'dist/index.js', + args: '--mode=report --hours=168', + cron_restart: '0 8 * * 1', // كل أسبوع الإثنين 8 صباحاً + autorestart: false, + time: true, + }, + ], +}; +``` + +> PM2 يتولى تشغيل cron jobs بدون الحاجة إلى `crontab` نظامي. +> لكنه `autorestart: false` لأنها عمليات لمرة واحدة، مو servers. + +### بديل: Crontab عادي (أبسط) + +```bash +# crontab -e +0 * * * * cd /var/www/siro/backend/pricing-engine && node dist/index.js --mode=surge --hours=3 >> /var/log/siro-pricing.log 2>&1 +0 6 * * * cd /var/www/siro/backend/pricing-engine && node dist/index.js --mode=full --hours=72 >> /var/log/siro-pricing.log 2>&1 +0 8 * * 1 cd /var/www/siro/backend/pricing-engine && node dist/index.js --mode=report --hours=168 >> /var/log/siro-pricing.log 2>&1 +``` + +--- + +## 3. دفق التسعير الكامل (Full Pricing Flow) + +### 3.1 تحليل المنافسين ← حفظ المعادلات + +``` +Pricing Engine (Node.js) + │ + ├─ 1. يسحب بيانات من scraped_competitor_prices + ├─ 2. ينظف الشواذ (MAD) + ├─ 3. يصنّف الفئات (K-Means) → Economy / Standard / Premium + ├─ 4. يحسب الانحدار لكل فئة: + │ price = baseFare + kmRate×dist + minRate×duration + ├─ 5. يكتشف Minimum Fare + ├─ 6. يحلل Surge حسب الساعة + └─ 7. يحفظ في: + ├─ competitor_secret_formulas (معادلات لكل tier) + └─ competitor_surge_insights (ساعات الذروة) +``` + +### 3.2 PHP يقرأ ويطبّق التسعير + +``` +cron_ai_engine.php (PHP, كل 30-60 دقيقة) + │ + ├─ 1. يقرأ competitor_secret_formulas + ├─ 2. يختار Economy tier (الأرخص) + ├─ 3. يطبّق خصم 6.5%: + │ Siro_kmRate = competitor_kmRate × 0.935 + ├─ 4. يحدّث جدول kazan + └─ 5. يحفظ surge في Redis +``` + +### 3.3 حساب السعر للتطبيقات + +``` +ride/pricing/get.php (API, يتم استدعاؤه عند طلب رحلة) + │ + ├─ 1. يقرأ kazan table (آخر تحديث من cron_ai_engine) + ├─ 2. يحسب: + │ basePrice = kazan.baseFare + │ + kazan.speedPrice × distance + │ + kazan.normalMinPrice × duration + ├─ 3. يقرأ Redis surge:opportunities + ├─ 4. يطبّق surge multiplier إذا كانت ساعة ذروة + └─ 5. يرجع السعر النهائي للتطبيق +``` + +--- + +## 4. تقسيم المناطق (Zone-Based Pricing) + +### 4.1 تصنيف المناطق + +``` +Amman مقسمة حسب البعد عن المركز (31.95, 35.90): + + Centre (مركز البلد) → نصف قطر < 2.5km → PPK 0.25-0.33 + Mid (وسط) → نصف قطر < 5km → PPK 0.35-0.45 + Suburb (ضواحي) → نصف قطر < 10km → PPK 0.40-0.50 + Outskirts (أطراف) → > 10km → PPK 0.40-0.60 +``` + +### 4.2 كيف نطبّق Zone-Based Pricing؟ + +بدلاً من معادلة تسعير واحدة لكل البلد، يصبح: + +```sql +-- جدول zone_pricing (جديد) +CREATE TABLE IF NOT EXISTS `zone_pricing` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `country_code` VARCHAR(5) NOT NULL, + `zone_type` VARCHAR(20) NOT NULL, -- centre, mid, suburb, outskirts + `km_rate` DECIMAL(8,3) NOT NULL, + `min_rate` DECIMAL(8,3) NOT NULL DEFAULT 0, + `base_fare` DECIMAL(8,3) NOT NULL DEFAULT 0, + `min_fare` DECIMAL(8,3) NOT NULL DEFAULT 0, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY `idx_country_zone` (`country_code`, `zone_type`) +); + +-- يتم ملؤها من تحليل Pricing Engine +``` + +ثم في `ride/pricing/get.php`: + +```php +// 1. تحديد منطقة البداية +$zoneType = classifyZone($startLat, $startLng); // centre | mid | suburb | outskirts + +// 2. استخدام تسعير المنطقة +$zoneRate = getZonePricing($country, $zoneType); +$price = $zoneRate['base_fare'] + + $zoneRate['km_rate'] * $distance + + $zoneRate['min_rate'] * $duration; + +// 3. تطبيق Surge حسب المنطقة +$surge = getSurgeForZone($country, $zoneType); +$finalPrice = $price * $surge; +``` + +### 4.3 إشعارات المناطق للسائقين + +عند دخول سائق إلى منطقة ذات Surge عالي: + +```php +// cron_notify_drivers_zones.php (جديد - كل 5 دقائق) +$hotZones = getHotZones(); // من Redis surge:opportunities أو تحليل الـ Pricing Engine + +foreach ($hotZones as $zone) { + // إرسال FCM notification للسائقين القريبين + sendPushToNearbyDrivers($zone['lat'], $zone['lng'], [ + 'title' => '⚠️ منطقة طلب مرتفع', + 'body' => "منطقة {$zone['name']}: الطلب مرتفع، الأسعار مرتفعة {$zone['surge']}x" + ]); +} +``` + +وللراكب عند فتح التطبيق في منطقة Surge: + +```php +// في ride/pricing/get.php +if ($surgeMultiplier > 1.0) { + $response['surge_warning'] = "⚠️ هذه المنطقة تشهد طلباً مرتفعاً، الأسعار أعلى بنسبة " + . round(($surgeMultiplier - 1) * 100) . "%"; +} +``` + +--- + +## 5. دعم تطبيقات منافسة متعددة + +### 5.1 لكل منافس معادلاته الخاصة + +```sql +-- competitor_secret_formulas يدعم: +-- competitor_name = 'com.taxif.passenger' | 'com.careem.ae' | 'com.ubercab' | 'com.jeeny.app' +-- لكل منافس 3 tiers (Economy/Standard/Premium) +-- لكل tier معادلة مستقلة +``` + +### 5.2 مقارنة الأسعار في التطبيق + +```php +// api/ride/get_competitor_context.php +$competitors = ['com.taxif.passenger', 'com.careem.ae', 'com.ubercab', 'com.jeeny.app']; +$prices = []; + +foreach ($competitors as $comp) { + $formula = getLatestFormula($comp, $country, 'economy'); + $estimatedPrice = $formula['base_fare'] + + $formula['price_per_km'] * $requestedDistance + + $formula['price_per_min'] * $requestedDuration; + $prices[$comp] = [ + 'name' => getCompetitorDisplayName($comp), + 'price' => $estimatedPrice, + 'currency' => 'JOD', + ]; +} + +// Siro price (already 6.5% less) +$siroPrice = calculateSiroPrice($request); +$prices['siro'] = [ + 'name' => 'Siro', + 'price' => $siroPrice, + 'currency' => 'JOD', + 'is_cheapest' => $siroPrice < min(array_column($prices, 'price')), +]; +``` + +### 5.3 تسعير Siro بناءً على المنافس الأقوى + +في `cron_ai_engine.php`: + +```php +// 1. اجلب معادلات جميع المنافسين للدولة +$competitors = getCompetitorFormulas($country, 'economy'); + +// 2. احسب السعر المتوقع لكل منافس لرحلة نموذجية (10km, 15min) +$sampleDist = 10; +$sampleDur = 15; +$competitorPrices = []; +foreach ($competitors as $comp) { + $competitorPrices[$comp['competitor_name']] = + $comp['base_fare'] + $comp['price_per_km'] * $sampleDist + $comp['price_per_min'] * $sampleDur; +} + +// 3. المنافس الأرخص هو المستهدف +$cheapestCompetitor = array_keys($competitorPrices, min($competitorPrices))[0]; +$cheapestPrice = min($competitorPrices); + +// 4. سعر Siro = أرخص منافس - 6.5% +$targetSiroPrice = $cheapestPrice * 0.935; + +// 5. هندسة عكسية لمعاملات Siro +$ourKmRate = $competitors[$cheapestCompetitor]['price_per_km'] * 0.935; +$ourMinRate = $competitors[$cheapestCompetitor]['price_per_min'] * 0.935; +$ourBaseFare = $competitors[$cheapestCompetitor]['base_fare'] * 0.935; +``` + +--- + +## 6. تدفق البيانات من التحليل حتى يشوفها المستخدم + +``` +الوقت T0: Pricing Engine يشتغل + ↓ +الوقت T0+5s: يكتب competitor_secret_formulas + competitor_surge_insights + ↓ +الوقت T0+30m: cron_ai_engine.php (PHP) يقرأ المعادلات ويحدّث kazan + ↓ +الوقت T0+31m: kazan محدّث بأسعار جديدة (أقل 6.5% من المنافس) + ↓ +الوقت T0+31m+: rider يطلب رحلة + → ride/pricing/get.php يقرأ kazan + Redis surge + → يحسب السعر ← يرجع للراكب + → السائق يشوف سعر الرحلة +``` + +**المدة الكاملة من التحليل للمستخدم: ~31 دقيقة** (يمكن تقليلها بتشغيل cron_ai_engine بعد Pricing Engine مباشرة). + +--- + +## 7. متطلبات السيرفر + +| المكون | المتطلب | +|---|---| +| Node.js | v18+ (نوصي v20 LTS) | +| PM2 | لإدارة العمليات (اختياري) | +| MySQL | موجود مسبقاً | +| Redis | موجود مسبقاً | +| RAM إضافي | 256MB كافية (التطبيق خفيف) | +| مساحة | 50MB للملفات + node_modules | + +### أمان: Node.js ما اله Port + +Pricing Engine هو CLI cron job، مش Web Server. ما اله Port مفتوح. يتصل فقط بـ MySQL و Redis داخلياً. **لا يحتاج تعديل Nginx/Apache**. + +--- + +## 8. خطة الرفع (Deployment Checklist) + +```bash +□ 1. git pull أحدث كود على السيرفر +□ 2. cd backend/pricing-engine && npm install +□ 3. cp .env.example .env # عدّل بيانات MySQL + Redis +□ 4. mysql -u root siro < migrations/001_add_columns.sql +□ 5. npm run build # compile TypeScript +□ 6. npm run analyze:taxif # اختبار يدوي +□ 7. pm2 start ecosystem.config.js # أو crontab +□ 8. pm2 save && pm2 startup +□ 9. تحقق من cron_ai_engine.php يقرأ المعادلات الجديدة +□ 10. اختبر ride/pricing/get.php مع الراكب +``` + +--- + +## 9. إضافة تطبيق منافس جديد + +``` +□ 1. أضف اسم الحزمة إلى generate_price_tasks.php + (مثلاً: com.newcompetitor.app) +□ 2. انتظر تجميع بيانات كافية (أسبوع scraping) +□ 3. شغّل: npm run analyze -- --competitor=com.newcompetitor.app +□ 4. Pricing Engine سيكتشف الـ Tiers تلقائياً +□ 5. cron_ai_engine.php سيقرأ المعادلات ويطبّق التسعير +□ 6. تلقائياً: مقارنة الأسعار في تطبيق الراكب +``` + +--- + +## 10. الخلاصة + +| الميزة | الحالة | +|---|---| +| تحليل إحصائي (MAD + Ridge Regression + K-Means) | ✅ تم | +| اكتشاف 3 Tiers تسعيرية | ✅ تم | +| Minimum Fare | ✅ تم | +| اكتشاف Surge حسب ساعة اليوم | ✅ تم | +| تحليل Zone | ✅ تم | +| خصم 6.5% من المنافس | ✅ في cron_ai_engine.php | +| Redis surge للـ get.php | ✅ surge:opportunities | +| PK/FK متوافقة | ✅ تم تحديث schema | +| دعم دول متعددة (JO/SY/EG/IQ) | ✅ Currency-aware | +| دعم منافسين متعددين | ✅ Arrays + foreach | +| Zone-Based Pricing | ⬜ يحتاج إنشاء جدول zone_pricing | +| إشعارات للسائقين بالمناطق الساخنة | ⬜ يحتاج cron_notify_drivers | +| مقارنة أسعار المنافسين في التطبيق | ⬜ يحتاج ربط ride/pricing مع competitor_formulas | + +
diff --git a/taxif_pricing_analysis.md b/taxif_pricing_analysis.md new file mode 100644 index 00000000..92cecdc1 --- /dev/null +++ b/taxif_pricing_analysis.md @@ -0,0 +1,180 @@ +# تحليل خوارزمية تسعير TaxiF في الأردن + +
+ +## ملخص تنفيذي + +تم تحليل 75 رحلة من بيانات TaxiF في عمّان، الأردن. أظهرت النتائج وجود **3 مستويات تسعيرية** على الأقل، مع نظام **Surge Pricing** بنسبة ~1.10x-1.13x، و**حد أدنى للسعر** (Minimum Fare). + +--- + +## 1. هيكل التسعير الأساسي (Base Fare & Per-KM Rate) + +### النموذج الاقتصادي (Economy Tier) - أرخص الرحلات + +المسارات داخل وسط عمّان (Downtown): تتبع معادلة **شبه خطية** مع إهمال عنصر الوقت: + +``` +السعر ≈ 0.25 JOD/كم × المسافة +(مع حد أدنى ~1.32 JOD) +``` + +| المسافة (كم) | المدة (د) | السعر (JOD) | JOD/كم | ملاحظة | +|---|---|---|---|---| +| 4.07 | 8 | 1.32 | 0.32 | الحد الأدنى مُطبّق | +| 4.24 | 8 | 1.40 | 0.33 | الحد الأدنى مُطبّق | +| 5.27 | 9 | 1.74 | 0.33 | الحد الأدنى مُطبّق | +| 8.75 | 15 | 2.15 | 0.25 | سعر نظيف (PPK=0.25) | +| 13.20 | 18 | 3.28 | 0.25 | سعر نظيف (PPK=0.25) | +| 13.91 | 17 | 3.65 | 0.26 | سعر نظيف | + +**الاستنتاج**: معامل المسافة = **0.25 JOD/كم**، ولا يوجد عملياً عنصر زمني. الحد الأدنى = **1.32-1.40 JOD** يُطبّق على الرحلات القصيرة. + +### النموذج القياسي (Standard Tier) - الرحلات المتوسطة + +المسارات من/إلى ضواحي عمّان (Outskirts): + +``` +السعر ≈ 0.38-0.46 JOD/كم × المسافة +``` + +| المسافة (كم) | المدة (د) | السعر الأدنى (JOD) | JOD/كم | +|---|---|---|---| +| 11.22 | 20 | 5.20 | 0.46 | +| 14.20 | 20 | 6.21 | 0.44 | +| 22.58 | 32 | 9.09 | 0.40 | +| 16.86 | 24 | 6.76 | 0.40 | +| 19.60 | 29 | 7.45 | 0.38 | + +**الاستنتاج**: معامل المسافة ≈ **0.40 JOD/كم** (ضعف Economy). هذا قد يمثّل سيارة من فئة أعلى (XL/Sedan). + +### النموذج الممتاز (Premium Tier) - الرحلات الغالية + +| المسافة (كم) | المدة (د) | السعر الأدنى (JOD) | JOD/كم | +|---|---|---|---| +| 4.12 | 10 | 2.63 | 0.64 | +| 6.05 | 16 | 3.55 | 0.59 | +| 19.16 | 28 | 11.23 | 0.59 | + +**الاستنتاج**: معامل ≈ **0.60 JOD/كم**. قد يكون فئة VIP أو سيارة كبيرة (SUV). + +--- + +## 2. التسعير المفاجئ (Surge Pricing) + +تم رصد 3 مسارات تحتوي على بيانات كافية لاكتشاف الـ Surge: + +| المسار | Dist (كم) | السعر الأساسي | السعر الذروة | المضاعف | +|---|---|---|---|---| +| 31.982→31.996 | 4.12 | 2.63 JOD | 2.93 JOD | **1.114x** | +| 31.951→31.890 | 13.20 | 3.28 JOD | 3.69 JOD | **1.125x** | +| 32.017→31.850 | 22.58 | 9.09 JOD | 10.30 JOD | **1.133x** | + +**متوسط المضاعف: 1.12x** + +### أوقات الذروة (ساعات الـ Surge) + +``` +المسار 4.12km: + 2026-07-01 02:00 → 2.93 ⬆️ Surge + 2026-07-02 06:00 → 2.77 (قريب من الأساسي) + 2026-07-05 23:00 → 2.63 ✅ أساسي + 2026-07-06 00:00-02:00 → 2.83-2.93 ⬆️ Surge + +المسار 13.2km: + 2026-07-02 06:00 → 3.28 ✅ أساسي + 2026-07-05 23:00 → 3.63 ⬆️ Surge + 2026-07-06 00:00-02:00 → 3.61-3.69 ⬆️ Surge + +المسار 22.58km: + 2026-07-02 06:00 → 9.09 ✅ أساسي + 2026-07-05 23:00 → 10.26 ⬆️ Surge + 2026-07-06 00:00-02:00 → 9.85-10.30 ⬆️ Surge +``` + +**نمط Surge**: يحدث بين **23:00 - 02:00** (ساعات متأخرة من الليل). والأسعار الأساسية تظهر عادةً في **06:00 صباحاً**. + +--- + +## 3. تحليل القيم الشاذة (Outliers) + +### الرحلة 1.4km / 1.94 JOD (PPK=1.39) + +``` +مثال: 1.94 JOD لمسافة 1.4 كم فقط! +السعر لكل كم: 1.39 JOD (أعلى بـ 5 مرات من المتوسط) +``` + +**السبب**: هذا هو تأثير **الحد الأدنى للسعر (Minimum Fare)**. عند تطبيق معادلة Economy: +- 0.25 × 1.4 = 0.35 JOD ← أقل من الحد الأدنى +- السعر الفعلي = **1.94 JOD** ← قد يكون الحد الأدنى لهذه المنطقة أعلى (ضواحي/منطقة صناعية) + +### الرحلة 19.16km / 11.23 JOD (PPK=0.59) + +``` +32.008,35.938 → 31.890,35.920 +``` + +هذه رحلة من منطقة نائية نسبياً إلى وسط البلد. السعر أعلى بكثير من المتوقع (0.59 JOD/km مقارنة بـ ~0.40 للمسافات الطويلة). يُحتمل أن تكون **سيارة من فئة مختلفة** أو تشمل **رسوم دخول منطقة**. + +### الرحلة 4.12km (سعر متغير 2.63-2.93) + +``` +أغلى 4 كم في عمّان! +نفس المسافة تقريباً مثل 4.07km (1.32 JOD) ولكن أغلى بـ 2× +``` + +**السبب**: هذه الرحلات تخدم مسارات مختلفة تماماً. الـ 4.12km إلى منطقة عبدلي/الشمساني (Mid Zone)، بينما الـ 4.07km في وسط البلد. تؤكد نظرية **التسعير حسب المنطقة (Zone-Based Pricing)**. + +--- + +## 4. تصنيف المسارات حسب المنطقة + +| المنطقة | عدد المسارات | متوسط PPK (JOD/كم) | الميزة | +|---|---|---|---| +| وسط→وسط (Centre) | 3 | 0.25-0.33 | Economy - أرخص فئة | +| ضواحي→ضواحي (Outskirts) | 8 | 0.38-0.46 | Standard - فئة متوسطة | +| وسط→ضواحي | 3 | 0.37-0.44 | خليط | +| مناطق مميزة | 5 | 0.51-1.39 | Premium - فئة عالية | + +**الخريطة الحرارية للسعر**: الرحلات داخل وسط عمّان (31.93-31.97 Lat, 35.88-35.91 Lng) هي الأرخص. الرحلات من/إلى الأطراف الشمالية (32.01+) أو الجنوبية (31.85-) هي الأعلى سعراً لكل كم. + +--- + +## 5. النموذج المُستنتَج (الفرضية الأقوى) + +``` +TaxiF لا تستخدم معادلة خطية بسيطة، بل نظام متعدد المتغيرات: + +1. تصنيف المنطقة (Zone Tier): + - Centre: Economy (0.25 JOD/كم) + - Mid: Standard (0.40 JOD/كم) + - Outskirts/Special: Premium (0.60 JOD/كم) + +2. معادلة السعر الأساسي: + السعر = MAX(الحد_الأدنى, معدل_المنطقة × المسافة) + +3. Surge Multiplier: + السعر_النهائي = السعر_الأساسي × (1.00 - 1.13) + يُطبّق خلال ساعات الليل المتأخرة (23:00-02:00) + +4. الحد الأدنى للسعر (Minimum Fare): + ~1.32 JOD لوسط البلد + ~1.50-1.94 JOD للمناطق البعيدة +``` + +--- + +## 6. توصيات للتحليل المُستقبلي + +1. **توسيع العينة**: جمع بيانات لمسارات جديدة لتأكيد تصنيف المناطق +2. **تحديد فئات السيارات**: إضافة معلومات عن نوع السيارة (Economy/XL/VIP) +3. **أخذ عينات أوقات إضافية**: خاصة أوقات الذروة الصباحية (07:00-09:00) والمسائية (16:00-19:00) +4. **تحليل المنافسين**: مقارنة مع Uber/Careem في نفس المسارات والأوقات +5. **اختبار REgressive**: استخدام ML لتأكيد معاملات السعر لكل منطقة + +--- + +*تم التحليل بناءً على 75 نقطة بيانات من TaxiF في عمّان، الأردن. الفترة: 1-6 يوليو 2026.* + +