Update: 2026-07-06 17:00:43

This commit is contained in:
Hamza-Ayed
2026-07-06 17:00:43 +03:00
parent 61cb615ae7
commit e42d700245
21 changed files with 3212 additions and 119 deletions
+159 -104
View File
@@ -1,13 +1,11 @@
<?php
/**
* cron_ai_engine.php
* المحرك الرئيسي للذكاء الاصطناعي (AI Engine)
* يتم تشغيله كـ Cron Job كل 30 دقيقة أو ساعة لتقليل الضغط على السيرفر.
* cron_ai_engine.php - AI Pricing Engine v2
*
* يدمج 3 وحدات (Modules) ذكية:
* 1. AI Pricing (Total Price Math): تعديل جدول kazan ليكون السعر الإجمالي أرخص بـ 6.5% من المنافس الأقوى بدقة.
* 2. AI Dispatch: تحديد مناطق الذروة وتوجيه السائقين إليها.
* 3. AI Retention: اصطياد الركاب الخاملين.
* يقرأ معادلات المنافسين من Node.js Pricing Engine (competitor_secret_formulas)
* ويطبّق تسعيراً ذكياً بناءً على النتائج الإحصائية بدلاً من الحسابات البدائية.
*
* لم يعُد هذا الملف يحسب بنفسه - بل يعتمد على التحليل الإحصائي من pricing-engine.
*/
require_once __DIR__ . '/../core/bootstrap.php';
@@ -20,67 +18,105 @@ try {
die("Connection failed: " . $e->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";
+20 -13
View File
@@ -1,9 +1,9 @@
<?php
/**
* cron_gemini_advisor.php
* يعمل هذا الملف كـ Cron Job (يفضل أسبوعياً أو كل 3 أيام)
* وظيفته: أخذ معادلات المنافسين المكتشفة، وإرسالها إلى جيميناي لاستخراج تقرير تسويقي متقدم
* ثم حفظ التقرير في قاعدة البيانات ليراه مدير النظام في لوحة الإدارة.
* cron_gemini_advisor.php - Gemini Market Advisor
*
* يأخذ المعادلات المُكتشَفة من Pricing Engine (مع الفئات والمؤشرات الإحصائية)
* ويُرسلها إلى Gemini لتحليل استراتيجي - لم يعُد التحليل الإحصائي من مسؤوليته.
*/
require_once __DIR__ . '/../core/bootstrap.php';
@@ -16,7 +16,7 @@ try {
die("Database connection failed: " . $e->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";
}
?>
+6 -2
View File
@@ -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);
+11
View File
@@ -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=
+76
View File
@@ -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
```
@@ -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;
+877
View File
@@ -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=="
}
}
}
+39
View File
@@ -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"
}
}
@@ -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';
}
@@ -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<AnalysisReport> {
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<typeof aggregateSurgeHours>,
zoneTypes: ReturnType<typeof analyzeByZoneType>
): 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('');
}
@@ -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<string, RideSample[]> {
const groups = new Map<string, RideSample[]>();
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<string, RideSample[]>,
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;
}
@@ -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 };
}
@@ -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<number, number[]>();
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<number, number[]>();
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);
}
@@ -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<string, number>;
}
export interface AnalysisReport {
competitorName: string;
countryCode: string;
tiers: PricingTier[];
surgePatterns: SurgeResult[];
zones: ZoneAnalysis[];
totalSamples: number;
analyzedAt: string;
}
@@ -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<string, RideSample[]>();
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<string, number> = {};
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<string, number[]>();
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);
}
+142
View File
@@ -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<mysql.Pool> {
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<RowDataPacket[]> {
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<RowDataPacket[]>(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<void> {
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<void> {
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<void> {
if (mysqlPool) {
await mysqlPool.end();
mysqlPool = null;
}
}
+218
View File
@@ -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<void> {
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<void> {
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<CompetitorEntry[]> {
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<RowDataPacket[]>(
`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<RowDataPacket[]>(
`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();
+326
View File
@@ -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<number, number>();
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;
}
+19
View File
@@ -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"]
}
+434
View File
@@ -0,0 +1,434 @@
# Siro Pricing Engine — Architecture & Deployment Guide
<div dir="rtl">
## 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 |
</div>
+180
View File
@@ -0,0 +1,180 @@
# تحليل خوارزمية تسعير TaxiF في الأردن
<div dir="rtl">
## ملخص تنفيذي
تم تحليل 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.*
</div>