feat: استيراد كود سيرو إلى تريبز (سيرو @ecfe7568) — بلا تعديل
قرار المالك 2026-07-27: باك إند سيرو PHP هو المعتمد، وتطبيقاته المجرّبة ميدانياً تحل محل إعادة البناء المؤرشفة. سيرو نفسه لم يُمسّ. الخريطة: backend · payment_server · loction_server · ride_server · passenger_server · docker · dashboard · stress_test → الجذر siro_rider → apps/rider siro_driver → apps/driver siro_admin → dashboards/admin siro_service → dashboards/service android_bot → apps/android_bot socialBot → apps/socialBot نُسخ المتعقَّب في git سيرو فقط عبر `git archive` (3,198 ملفاً / ~169 م.ب) لا `cp -r` — فاستُثنيت مخلفات البناء تلقائياً. بلا أي تعديل محتوى عمداً: كل ما يلي يصير فرقاً مقروءاً مقابل المصدر. لم يُستورد وسببه: siromove.com (الموقع التسويقي يبقى marketing/ في تريبز، سيرو فيه 8 ملفات) · docs و planning (تريبز له docs/ الخاص) · deploy.sh (ليس نشراً على سيرفر بل `git add . && git push origin --all` — فخّ في مستودع آخر) · transit_dashboard (بانتظار قرار مصير backend-transit و dashboards/transit-web). ⚠️ لا يبني بعد — ثلاثة نواقص متوقعة ومقصودة: 1. `.env` و `lib/env/env.g.dart` غير متعقَّبين في سيرو (أسرار لكل مستأجر): كل تطبيق فلاتر يحتاج .env خاصاً ثم توليد env.g.dart بـ build_runner. 2. إعدادات Firebase (9 ملفات google-services.json و GoogleService-Info.plist) يستبعدها .gitignore تريبز — ولكل مستأجر مشروع Firebase خاص أصلاً. 3. apps/driver في سيرو يشير إلى `../../Intaleq/packages/get` خارج المستودع → يجب ضمّ الحزم داخله أسوة بـ apps/rider. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9909d9b4c1
commit
4d8414c96b
@@ -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=
|
||||
@@ -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,34 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "siro-pricing-surge",
|
||||
script: "dist/index.js",
|
||||
args: "--mode=surge --hours=3",
|
||||
instances: 1,
|
||||
exec_mode: "fork",
|
||||
cron_restart: "0 * * * *", // كل ساعة على رأس الساعة
|
||||
autorestart: false,
|
||||
watch: false
|
||||
},
|
||||
{
|
||||
name: "siro-pricing-full",
|
||||
script: "dist/index.js",
|
||||
args: "--mode=full --hours=72",
|
||||
instances: 1,
|
||||
exec_mode: "fork",
|
||||
cron_restart: "0 6 * * *", // كل يوم الساعة 6 صباحاً
|
||||
autorestart: false,
|
||||
watch: false
|
||||
},
|
||||
{
|
||||
name: "siro-pricing-report",
|
||||
script: "dist/index.js",
|
||||
args: "--mode=report",
|
||||
instances: 1,
|
||||
exec_mode: "fork",
|
||||
cron_restart: "0 8 * * 0", // كل يوم أحد الساعة 8 صباحاً
|
||||
autorestart: false,
|
||||
watch: false
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Migration 002: Fix surge insights unique key
|
||||
-- Problem: The old unique key included peak_start_hour and peak_end_hour,
|
||||
-- which meant a new record was inserted every time the peak window shifted,
|
||||
-- instead of updating the existing one.
|
||||
-- Fix: The unique key now only covers (competitor_name, country_code),
|
||||
-- so ON DUPLICATE KEY UPDATE always updates the existing row correctly.
|
||||
|
||||
-- Step 1: Drop the old unique key that included peak hours
|
||||
ALTER TABLE `competitor_surge_insights`
|
||||
DROP INDEX `unique_surge`;
|
||||
|
||||
-- Step 2: Add the correct unique key — one row per competitor per country
|
||||
ALTER TABLE `competitor_surge_insights`
|
||||
ADD UNIQUE KEY `unique_surge` (`competitor_name`, `country_code`);
|
||||
|
||||
-- Verify: show the new index
|
||||
-- SHOW INDEX FROM `competitor_surge_insights`;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Migration: Create competitor_surge_zones table for heatmap integration
|
||||
-- Purpose: Bridge the geographic surge anomalies from Node.js pricing engine to PHP heatmap
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `competitor_surge_zones` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`competitor_name` VARCHAR(100) NOT NULL,
|
||||
`country_code` VARCHAR(5) NOT NULL,
|
||||
`zone_key` VARCHAR(50) NOT NULL,
|
||||
`latitude` DECIMAL(10,6) NOT NULL,
|
||||
`longitude` DECIMAL(10,6) NOT NULL,
|
||||
`avg_ppk` DECIMAL(10,3) NOT NULL,
|
||||
`sample_count` INT NOT NULL,
|
||||
`surge_multiplier` DECIMAL(5,3) NOT NULL DEFAULT 1.000,
|
||||
`detected_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `unique_zone` (`competitor_name`, `country_code`, `zone_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
Generated
+877
@@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,112 @@
|
||||
import { RideSample, PricingTier } from './types';
|
||||
import { kMeans } from '../utils/math';
|
||||
|
||||
const TIER_LABELS: Array<'economy' | 'standard' | 'premium'> = [
|
||||
'economy',
|
||||
'standard',
|
||||
'premium',
|
||||
];
|
||||
|
||||
/**
|
||||
* City center coordinates per country code.
|
||||
* Used by classifyZoneType to measure distance from the urban center.
|
||||
* Add more countries here as new competitors are onboarded.
|
||||
*/
|
||||
const CITY_CENTERS: Record<string, { lat: number; lng: number }> = {
|
||||
JO: { lat: 31.95, lng: 35.90 }, // Amman, Jordan
|
||||
SY: { lat: 33.51, lng: 36.29 }, // Damascus, Syria
|
||||
IQ: { lat: 33.34, lng: 44.40 }, // Baghdad, Iraq
|
||||
SA: { lat: 24.69, lng: 46.72 }, // Riyadh, Saudi Arabia
|
||||
AE: { lat: 25.20, lng: 55.27 }, // Dubai, UAE
|
||||
EG: { lat: 30.04, lng: 31.24 }, // Cairo, Egypt
|
||||
LB: { lat: 33.89, lng: 35.50 }, // Beirut, Lebanon
|
||||
KW: { lat: 29.37, lng: 47.98 }, // Kuwait City
|
||||
};
|
||||
|
||||
/** Fallback city center when country code is not mapped yet */
|
||||
const DEFAULT_CITY_CENTER = { lat: 31.95, lng: 35.90 }; // Amman
|
||||
|
||||
/**
|
||||
* Cluster rides into pricing tiers based on price_per_km using K-Means.
|
||||
* Returns sorted tiers (economy < standard < premium).
|
||||
* Uses multi-run K-Means++ for stable, deterministic results.
|
||||
*/
|
||||
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 the city center for a given country.
|
||||
* Falls back to Amman coordinates if countryCode is not in CITY_CENTERS.
|
||||
*
|
||||
* Zone radii (in degrees, ~111km per degree):
|
||||
* centre < 0.025° ≈ 2.8 km
|
||||
* mid < 0.050° ≈ 5.6 km
|
||||
* suburb < 0.100° ≈ 11.1 km
|
||||
* outskirts ≥ 0.100°
|
||||
*/
|
||||
export function classifyZoneType(lat: number, lng: number, countryCode: string = 'JO'): string {
|
||||
const center = CITY_CENTERS[countryCode] ?? DEFAULT_CITY_CENTER;
|
||||
const dlat = lat - center.lat;
|
||||
const dlng = lng - center.lng;
|
||||
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,230 @@
|
||||
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';
|
||||
import { median } from 'simple-statistics';
|
||||
|
||||
export interface EngineOptions {
|
||||
competitorName?: string;
|
||||
countryCode?: string;
|
||||
cleanOutliers?: boolean;
|
||||
/**
|
||||
* Surge threshold as a fraction of the median price (e.g. 0.05 = 5%).
|
||||
* Computed dynamically per dataset so it scales across currencies.
|
||||
*/
|
||||
surgeThresholdFraction?: number;
|
||||
tierCount?: number;
|
||||
/**
|
||||
* Known receipts to validate against — used to sanity-check the formula.
|
||||
* Each entry is a real fare that the engine's formula should be able to predict.
|
||||
*/
|
||||
knownReceipts?: Array<{
|
||||
label: string;
|
||||
distanceKm: number;
|
||||
durationMin: number;
|
||||
actualPrice: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main pricing analysis engine.
|
||||
* Pipeline: fetch → clean → cluster → regress → surge → zone → validate.
|
||||
*/
|
||||
export async function runAnalysis(
|
||||
samples: RideSample[],
|
||||
options: EngineOptions = {}
|
||||
): Promise<AnalysisReport> {
|
||||
const {
|
||||
cleanOutliers = true,
|
||||
surgeThresholdFraction = 0.05,
|
||||
tierCount = 3,
|
||||
knownReceipts = [],
|
||||
} = 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: Compute dynamic surge threshold (5% of median price)
|
||||
const allPrices = cleanSamples.map(s => s.price);
|
||||
const medianPrice = median(allPrices);
|
||||
const surgeThreshold = Math.min(Math.max(medianPrice * surgeThresholdFraction, 0.05), 2.0);
|
||||
|
||||
// Step 3: Group by route and extract base (non-surge) prices
|
||||
const routeGroups = groupByRoute(cleanSamples);
|
||||
const baseSamples = extractBasePrices(routeGroups, surgeThreshold);
|
||||
|
||||
// Step 4: Cluster into pricing tiers by PPK
|
||||
const rawTiers = clusterTiers(cleanSamples, tierCount);
|
||||
|
||||
// Step 5: Run regression on each tier (two-stage + robust-MLR, best wins)
|
||||
const analyzedTiers = analyzeAllTiers(rawTiers);
|
||||
|
||||
// Step 6: Detect surge patterns
|
||||
const surgePatterns = detectSurge(cleanSamples, surgeThreshold);
|
||||
const surgeHours = aggregateSurgeHours(surgePatterns);
|
||||
|
||||
// Step 7: 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 full summary including receipt validation
|
||||
printSummary(report, baseSamples, surgeHours, zoneTypes, surgeThreshold, knownReceipts);
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Summary printing
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
function currencyCode(countryCode: string): string {
|
||||
const map: Record<string, string> = { JO: 'JOD', SY: 'SYP', IQ: 'IQD', SA: 'SAR', AE: 'AED', EG: 'EGP' };
|
||||
return map[countryCode] ?? 'CUR';
|
||||
}
|
||||
|
||||
function printSummary(
|
||||
report: AnalysisReport,
|
||||
baseSamples: RideSample[],
|
||||
surgeHours: ReturnType<typeof aggregateSurgeHours>,
|
||||
zoneTypes: ReturnType<typeof analyzeByZoneType>,
|
||||
surgeThreshold: number,
|
||||
knownReceipts: NonNullable<EngineOptions['knownReceipts']>
|
||||
): void {
|
||||
const sep = '═══════════════════════════════════════════════════════';
|
||||
const cur = currencyCode(report.countryCode);
|
||||
|
||||
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(` Surge threshold: ${surgeThreshold.toFixed(3)} (dynamic, 5% of median)`);
|
||||
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 icon = tier.label === 'economy' ? '💰' : tier.label === 'standard' ? '🚗' : '💎';
|
||||
console.log(` ${icon} ${tier.label.toUpperCase()}: [model: ${reg.modelName}]`);
|
||||
console.log(` Flag Fall: ${reg.baseFare.toFixed(3)} ${cur}`);
|
||||
console.log(` Per KM: ${reg.kmRate.toFixed(3)}`);
|
||||
console.log(` Per Min: ${reg.minRate.toFixed(3)}${reg.minRate < 0.005 ? ' ⚠️ (near-zero — may need more data)' : ''}`);
|
||||
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)}`);
|
||||
|
||||
// Formula preview
|
||||
const formula = buildFormulaString(reg.baseFare, reg.kmRate, reg.minRate, reg.minFare, cur);
|
||||
console.log(` Formula: ${formula}`);
|
||||
} 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)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Top Surge Routes ────────────────────────
|
||||
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)} ${cur} (${sr.maxMultiplier.toFixed(3)}x)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Receipt Validation ──────────────────────
|
||||
if (knownReceipts.length > 0) {
|
||||
console.log(`\n🧾 RECEIPT VALIDATION:`);
|
||||
|
||||
for (const receipt of knownReceipts) {
|
||||
console.log(`\n 📄 ${receipt.label}`);
|
||||
console.log(` Route: ${receipt.distanceKm} km / ${receipt.durationMin.toFixed(2)} min`);
|
||||
console.log(` Actual price: ${receipt.actualPrice.toFixed(3)} ${cur}`);
|
||||
|
||||
for (const tier of report.tiers) {
|
||||
const reg = tier.regression;
|
||||
if (!reg) continue;
|
||||
|
||||
const predicted =
|
||||
reg.baseFare +
|
||||
reg.kmRate * receipt.distanceKm +
|
||||
reg.minRate * receipt.durationMin;
|
||||
|
||||
const effective = reg.hasMinFare && reg.minFare > 0
|
||||
? Math.max(predicted, reg.minFare)
|
||||
: predicted;
|
||||
|
||||
const error = effective - receipt.actualPrice;
|
||||
const errorPct = (error / receipt.actualPrice) * 100;
|
||||
const sign = error >= 0 ? '+' : '';
|
||||
const flag = Math.abs(errorPct) <= 5 ? '✅' : Math.abs(errorPct) <= 15 ? '⚠️' : '❌';
|
||||
|
||||
console.log(` [${tier.label.padEnd(8)}] predicted: ${effective.toFixed(3)} ${cur} error: ${sign}${errorPct.toFixed(1)}% ${flag}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log(sep);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a human-readable formula string for display.
|
||||
* e.g. "price = 0.440 + 0.220 × km + 0.040 × min (min fare: 0.800)"
|
||||
*/
|
||||
function buildFormulaString(
|
||||
baseFare: number,
|
||||
kmRate: number,
|
||||
minRate: number,
|
||||
minFare: number,
|
||||
cur: string
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (baseFare > 0.001) parts.push(`${baseFare.toFixed(3)}`);
|
||||
parts.push(`${kmRate.toFixed(3)} × km`);
|
||||
if (minRate > 0.001) parts.push(`${minRate.toFixed(3)} × min`);
|
||||
|
||||
let formula = `price = ${parts.join(' + ')}`;
|
||||
if (minFare > 0.001) formula += ` (min fare: ${minFare.toFixed(3)} ${cur})`;
|
||||
return formula;
|
||||
}
|
||||
@@ -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,161 @@
|
||||
import { PricingTier, RideSample, RegressionResult } from './types';
|
||||
import {
|
||||
twoStageRegression,
|
||||
robustMultipleLinearRegression,
|
||||
simpleDistanceModel,
|
||||
calcRMSE,
|
||||
calcRSquared,
|
||||
detectMinimumFare,
|
||||
} from '../utils/math';
|
||||
|
||||
type RawModel = { baseFare: number; kmRate: number; minRate: number };
|
||||
|
||||
/** Evaluate RMSE of a model against the actual samples. */
|
||||
function evalRMSE(model: RawModel, samples: RideSample[]): number {
|
||||
const actual = samples.map(s => s.price);
|
||||
const predicted = samples.map(s =>
|
||||
model.baseFare + model.kmRate * s.distance_km + model.minRate * s.duration_min
|
||||
);
|
||||
return calcRMSE(actual, predicted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the best model from two candidates.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Always run both two-stage and robust-MLR.
|
||||
* 2. Primary criterion: lower RMSE wins.
|
||||
* 3. Tie-break: if both models have similar RMSE (within 5%), prefer the one
|
||||
* with a positive minRate — this respects the domain knowledge that time
|
||||
* is always part of the taxi pricing formula.
|
||||
*/
|
||||
function selectBestModel(
|
||||
modelA: RawModel | null, // two-stage
|
||||
modelB: RawModel | null, // robust-MLR
|
||||
samples: RideSample[]
|
||||
): { model: RawModel; name: string } | null {
|
||||
if (!modelA && !modelB) return null;
|
||||
if (!modelA) return { model: modelB!, name: 'robust-MLR' };
|
||||
if (!modelB) return { model: modelA, name: 'two-stage' };
|
||||
|
||||
const rmseA = evalRMSE(modelA, samples);
|
||||
const rmseB = evalRMSE(modelB, samples);
|
||||
|
||||
// If RMSE difference is within 5%, prefer the model with a time component
|
||||
const tolerance = Math.min(rmseA, rmseB) * 0.05;
|
||||
if (Math.abs(rmseA - rmseB) <= tolerance) {
|
||||
const aHasTime = modelA.minRate > 0.005;
|
||||
const bHasTime = modelB.minRate > 0.005;
|
||||
if (aHasTime && !bHasTime) return { model: modelA, name: 'two-stage' };
|
||||
if (bHasTime && !aHasTime) return { model: modelB, name: 'robust-MLR' };
|
||||
}
|
||||
|
||||
return rmseA <= rmseB
|
||||
? { model: modelA, name: 'two-stage' }
|
||||
: { model: modelB, name: 'robust-MLR' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run regression on a single pricing tier.
|
||||
*
|
||||
* Tries two approaches and picks the best:
|
||||
* A) Two-stage regression — estimates flag fall first, then km + min rates
|
||||
* B) Robust MLR — iterative outlier removal on full 3-parameter model
|
||||
*
|
||||
* The winner is chosen by RMSE, with a 5% tie-break that prefers models
|
||||
* with a positive per-minute rate (time is always a component in real meters).
|
||||
*/
|
||||
export function analyzeTier(tier: PricingTier): PricingTier {
|
||||
const samples = tier.samples;
|
||||
if (samples.length < 5) {
|
||||
tier.regression = null;
|
||||
return tier;
|
||||
}
|
||||
|
||||
const input: Array<{ distance_km: number; duration_min: number; price: number }> =
|
||||
samples.map(s => ({
|
||||
distance_km: s.distance_km,
|
||||
duration_min: s.duration_min,
|
||||
price: s.price,
|
||||
}));
|
||||
|
||||
// Run all three models
|
||||
const modelA = twoStageRegression(input); // Stage 1: flag fall | Stage 2: km + min
|
||||
const modelB = robustMultipleLinearRegression(input); // Iterative outlier removal
|
||||
const modelC = simpleDistanceModel(input); // distance-only: price = k × dist
|
||||
|
||||
const best = selectBestModel(modelA, modelB, samples);
|
||||
|
||||
// Distance-only fallback: if it's within 10% of the best model, prefer it
|
||||
let finalModel = best;
|
||||
if (finalModel && modelC) {
|
||||
const rmseBest = evalRMSE(finalModel.model, samples);
|
||||
const rmseDist = evalRMSE(modelC, samples);
|
||||
if (rmseDist <= rmseBest * 1.10) {
|
||||
finalModel = { model: modelC, name: 'distance-only' };
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalModel) {
|
||||
tier.regression = null;
|
||||
return tier;
|
||||
}
|
||||
|
||||
const { model: mlrResult, name: modelName } = finalModel;
|
||||
|
||||
// Compute final metrics using the winning model
|
||||
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 (floor charge for very short trips)
|
||||
const minFare = detectMinimumFare(
|
||||
samples.map(s => s.distance_km),
|
||||
samples.map(s => s.price),
|
||||
mlrResult.kmRate
|
||||
);
|
||||
|
||||
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 (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,
|
||||
modelName, // carry through for display
|
||||
};
|
||||
|
||||
return tier;
|
||||
}
|
||||
|
||||
/** Run regression on all tiers. */
|
||||
export function analyzeAllTiers(tiers: PricingTier[]): PricingTier[] {
|
||||
return tiers.map(tier => analyzeTier(tier));
|
||||
}
|
||||
@@ -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,91 @@
|
||||
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;
|
||||
/** Which regression model was selected: 'two-stage' | 'robust-MLR' */
|
||||
modelName: string;
|
||||
}
|
||||
|
||||
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,82 @@
|
||||
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).
|
||||
* Passes countryCode to classifyZoneType so the correct city center is used.
|
||||
*/
|
||||
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) {
|
||||
// Pass countryCode so we use the correct city center (not always Amman)
|
||||
const zoneType = classifyZoneType(s.startLat, s.startLng, s.countryCode);
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import mysql, { RowDataPacket, ResultSetHeader } from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
// مسارات ثابتة ومحددة بدقة لمنع الوقوع في فخاخ ملفات .env الوهمية
|
||||
const possibleEnvPaths: string[] = [
|
||||
// مسار السيرفر الفعلي حسب الصورة
|
||||
'/home/intaleqapp-jordan-siro/.env',
|
||||
|
||||
// مسارات احتياطية للبيئة المحلية (جهاز الماك الخاص بك)
|
||||
path.resolve(__dirname, '../../../.env'),
|
||||
path.resolve(__dirname, '../../../../.env')
|
||||
];
|
||||
|
||||
let envLoaded = false;
|
||||
for (const envPath of possibleEnvPaths) {
|
||||
if (fs.existsSync(envPath)) {
|
||||
// Basic check to ensure we don't load a dummy Docker env file
|
||||
const content = fs.readFileSync(envPath, 'utf8');
|
||||
if (content.includes('DB_HOST=db')) {
|
||||
console.log(`Skipping trap file: ${envPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
dotenv.config({ path: envPath });
|
||||
console.log(`Loaded environment from: ${envPath}`);
|
||||
envLoaded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!envLoaded) {
|
||||
console.warn('⚠️ No .env file found in the specified exact paths. Falling back to default environment variables.');
|
||||
}
|
||||
|
||||
let mysqlPool: mysql.Pool | null = null;
|
||||
|
||||
export async function getMySQL(): Promise<mysql.Pool> {
|
||||
if (!mysqlPool) {
|
||||
mysqlPool = mysql.createPool({
|
||||
host: process.env.DB_PRIMARY_HOST_V2 || process.env.DB_HOST || '127.0.0.1',
|
||||
port: parseInt(process.env.DB_PORT || '3306'),
|
||||
database: process.env.DB_PRIMARY_NAME_V2 || process.env.DB_NAME || 'siro',
|
||||
user: process.env.DB_PRIMARY_USER_V2 || process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PRIMARY_PASS_V2 || 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* لقطة insert-only من معاملات كل معادلة — لا تُستبدل أبداً، بعكس
|
||||
* competitor_secret_formulas (UPSERT). هاي المصدر اللي يقارن عليه
|
||||
* محرك الثبات (cron_pricing_stability_engine.php) "المعامل اليوم
|
||||
* مقابل المعامل قبل أسبوع" لتمييز التغيير الحقيقي عن البرومو المؤقت.
|
||||
*/
|
||||
export async function saveFormulaHistory(
|
||||
pool: mysql.Pool,
|
||||
formulas: Array<{
|
||||
competitorName: string;
|
||||
countryCode: string;
|
||||
tier: string;
|
||||
baseFare: number;
|
||||
kmRate: number;
|
||||
minRate: number;
|
||||
minFare: number;
|
||||
rSquared: number;
|
||||
sampleCount: number;
|
||||
}>
|
||||
): Promise<void> {
|
||||
if (formulas.length === 0) return;
|
||||
|
||||
const values = formulas.map(() => `(?, ?, ?, ?, ?, ?, ?, ?, ?, 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.rSquared, f.sampleCount
|
||||
);
|
||||
}
|
||||
|
||||
const sql = `INSERT INTO competitor_formula_history
|
||||
(competitor_name, country_code, tier, base_fare, price_per_km, price_per_min, min_fare, r_squared, sample_size, snapshotted_at)
|
||||
VALUES ${values}`;
|
||||
|
||||
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 saveSurgeZones(
|
||||
pool: mysql.Pool,
|
||||
zones: Array<{
|
||||
competitorName: string;
|
||||
countryCode: string;
|
||||
zoneKey: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
avgPpk: number;
|
||||
sampleCount: number;
|
||||
surgeMultiplier: number;
|
||||
}>
|
||||
): Promise<void> {
|
||||
if (zones.length === 0) return;
|
||||
|
||||
const values = zones.map(() => `(?, ?, ?, ?, ?, ?, ?, ?, NOW())`).join(',');
|
||||
const flatParams: (string | number)[] = [];
|
||||
|
||||
for (const z of zones) {
|
||||
flatParams.push(
|
||||
z.competitorName, z.countryCode, z.zoneKey,
|
||||
z.latitude, z.longitude, z.avgPpk, z.sampleCount, z.surgeMultiplier
|
||||
);
|
||||
}
|
||||
|
||||
const sql = `INSERT INTO competitor_surge_zones
|
||||
(competitor_name, country_code, zone_key, latitude, longitude, avg_ppk, sample_count, surge_multiplier, detected_at)
|
||||
VALUES ${values}
|
||||
ON DUPLICATE KEY UPDATE
|
||||
avg_ppk = VALUES(avg_ppk),
|
||||
sample_count = VALUES(sample_count),
|
||||
surge_multiplier = VALUES(surge_multiplier),
|
||||
detected_at = NOW()`;
|
||||
|
||||
await pool.execute(sql, flatParams);
|
||||
}
|
||||
|
||||
export async function closeConnections(): Promise<void> {
|
||||
if (mysqlPool) {
|
||||
await mysqlPool.end();
|
||||
mysqlPool = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* 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, saveFormulaHistory, saveSurgeInsights, saveSurgeZones, 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.1`);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the peak hours array from surge pattern data.
|
||||
* Returns the longest contiguous block of hours where avg multiplier > 1.05.
|
||||
* Used by both formula saving and surge insight saving.
|
||||
*/
|
||||
function computePeakHours(surgePatterns: Array<{ surgePrices: Array<{ time: string; multiplier: number }> }>): {
|
||||
peakHours: number[];
|
||||
peakStart: number;
|
||||
peakEnd: number;
|
||||
} {
|
||||
// Aggregate all route multipliers per hour of day
|
||||
const hourMults = new Map<number, number[]>();
|
||||
for (const sr of surgePatterns) {
|
||||
for (const sp of sr.surgePrices) {
|
||||
const h = parseInt(sp.time.split(':')[0]);
|
||||
if (isNaN(h)) continue;
|
||||
if (!hourMults.has(h)) hourMults.set(h, []);
|
||||
hourMults.get(h)!.push(sp.multiplier);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep only hours where the average multiplier exceeds 1.05
|
||||
const peakHours: number[] = [];
|
||||
for (const [h, mults] of hourMults) {
|
||||
const avg = mults.reduce((a, b) => a + b, 0) / mults.length;
|
||||
if (avg > 1.05) peakHours.push(h);
|
||||
}
|
||||
peakHours.sort((a, b) => a - b);
|
||||
|
||||
// Find the longest contiguous block of peak hours
|
||||
let bestStart = 0, bestEnd = 0, bestLen = 0;
|
||||
let curStart = -1, curEnd = -1;
|
||||
|
||||
for (let i = 0; i < peakHours.length; i++) {
|
||||
if (curStart < 0) {
|
||||
curStart = peakHours[i];
|
||||
curEnd = peakHours[i];
|
||||
} else if (peakHours[i] === curEnd + 1) {
|
||||
curEnd = peakHours[i];
|
||||
} else {
|
||||
if (curEnd - curStart > bestLen) {
|
||||
bestLen = curEnd - curStart;
|
||||
bestStart = curStart;
|
||||
bestEnd = curEnd;
|
||||
}
|
||||
curStart = peakHours[i];
|
||||
curEnd = peakHours[i];
|
||||
}
|
||||
}
|
||||
if (curEnd - curStart > bestLen) {
|
||||
bestLen = curEnd - curStart;
|
||||
bestStart = curStart;
|
||||
bestEnd = curEnd;
|
||||
}
|
||||
|
||||
const peakStart = bestLen > 0 ? bestStart : 0;
|
||||
const peakEnd = bestLen > 0 ? bestEnd : 23;
|
||||
|
||||
return { peakHours, peakStart, peakEnd };
|
||||
}
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
// Known receipts for formula validation.
|
||||
// Add real receipts here as they are collected — the engine will print
|
||||
// predicted vs actual with % error so you can judge formula quality at a glance.
|
||||
const knownReceipts = comp.competitor_name === 'com.taxif.passenger' ? [
|
||||
{
|
||||
label: 'TaxiF receipt 2026-06-11 (Amman)',
|
||||
distanceKm: 2.17,
|
||||
durationMin: 6 + 38 / 60, // 6 min 38 sec
|
||||
actualPrice: 1.15, // 1.18 JOD total − 0.03 BookingFee
|
||||
},
|
||||
] : [];
|
||||
|
||||
const report = await runAnalysis(samples, {
|
||||
competitorName: comp.competitor_name,
|
||||
countryCode: comp.country_code,
|
||||
cleanOutliers: true,
|
||||
// surgeThresholdFraction defaults to 0.05 (5% of median price) — currency-agnostic
|
||||
tierCount: 3,
|
||||
knownReceipts,
|
||||
});
|
||||
|
||||
// --- Compute peak hours once, reuse in both formulas and surge insights ---
|
||||
const { peakHours, peakStart, peakEnd } = report.surgePatterns.length > 0
|
||||
? computePeakHours(report.surgePatterns)
|
||||
: { peakHours: [], peakStart: 0, peakEnd: 23 };
|
||||
|
||||
const peakHoursJson = JSON.stringify(peakHours);
|
||||
|
||||
// --- Save tier formulas (includes actual peak hours) ---
|
||||
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,
|
||||
// Now populated with real peak hours instead of always '[]'
|
||||
peakHours: peakHoursJson,
|
||||
}));
|
||||
|
||||
if (formulas.length > 0) {
|
||||
await saveFormulas(pool, formulas);
|
||||
console.log(` ✅ Saved ${formulas.length} tier formulas`);
|
||||
if (peakHours.length > 0) {
|
||||
console.log(` Peak hours stored: [${peakHours.join(', ')}]`);
|
||||
}
|
||||
|
||||
// لقطة insert-only لمحرك الثبات (drift detection) — لا تُستبدل أبداً
|
||||
await saveFormulaHistory(pool, formulas.map(f => ({
|
||||
competitorName: f.competitorName,
|
||||
countryCode: f.countryCode,
|
||||
tier: f.tier,
|
||||
baseFare: f.baseFare,
|
||||
kmRate: f.kmRate,
|
||||
minRate: f.minRate,
|
||||
minFare: f.minFare,
|
||||
rSquared: f.rSquared,
|
||||
sampleCount: f.sampleCount,
|
||||
})));
|
||||
}
|
||||
|
||||
// --- Save surge insights ---
|
||||
if (opts.mode !== 'report' && report.surgePatterns.length > 0) {
|
||||
const avgMultiplier = report.surgePatterns
|
||||
.reduce((sum, sr) => sum + sr.maxMultiplier, 0) / report.surgePatterns.length;
|
||||
|
||||
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`);
|
||||
}
|
||||
|
||||
// --- Save surge zones ---
|
||||
if (opts.mode !== 'report' && report.zones.length > 0) {
|
||||
// Find the standard tier formula to use as a baseline for calculating surge multipliers
|
||||
const standardTier = formulas.find(f => f.tier === 'standard') || formulas[0];
|
||||
const baselinePpk = standardTier ? standardTier.kmRate : 0.350;
|
||||
|
||||
const surgeZones = report.zones
|
||||
.filter(z => z.avgPpk > baselinePpk * 1.1) // Only keep zones with > 10% surge
|
||||
.map(z => ({
|
||||
competitorName: comp.competitor_name,
|
||||
countryCode: comp.country_code,
|
||||
zoneKey: z.zoneKey,
|
||||
latitude: z.centerLat,
|
||||
longitude: z.centerLng,
|
||||
avgPpk: z.avgPpk,
|
||||
sampleCount: z.samples.length,
|
||||
surgeMultiplier: parseFloat((z.avgPpk / baselinePpk).toFixed(3)),
|
||||
}));
|
||||
|
||||
if (surgeZones.length > 0) {
|
||||
await saveSurgeZones(pool, surgeZones);
|
||||
console.log(` ✅ Saved ${surgeZones.length} surge zones for heatmap`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -0,0 +1,386 @@
|
||||
import { median, mean } from 'simple-statistics';
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Internal helpers
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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]), 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;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Core regression — full 3-parameter model
|
||||
// price = baseFare + kmRate × dist + minRate × dur
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
|
||||
const dists = samples.map(s => s.distance_km);
|
||||
const durs = samples.map(s => s.duration_min);
|
||||
const prices = samples.map(s => s.price);
|
||||
|
||||
const corr = pearsonCorr(dists, durs);
|
||||
// Stronger ridge when predictors are collinear (typical in taxi data)
|
||||
const lambda = Math.abs(corr) > 0.85 ? 1.5 : 0.05;
|
||||
|
||||
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, x2 = s.duration_min, y = s.price;
|
||||
sumX1 += x1; sumX2 += x2; sumY += y;
|
||||
sumX1Sq += x1 * x1; sumX2Sq += x2 * x2; sumX1X2 += x1 * x2;
|
||||
sumX1Y += x1 * y; sumX2Y += x2 * y;
|
||||
}
|
||||
|
||||
const A = [
|
||||
[n, sumX1, sumX2 ],
|
||||
[sumX1, sumX1Sq + lambda, sumX1X2 ],
|
||||
[sumX2, sumX1X2, sumX2Sq + lambda],
|
||||
];
|
||||
const B = [sumY, sumX1Y, sumX2Y];
|
||||
|
||||
try {
|
||||
const beta = gaussianElimination(A, B);
|
||||
return {
|
||||
baseFare: Math.max(0, beta[0]),
|
||||
kmRate: Math.max(0, beta[1]),
|
||||
minRate: Math.max(0, beta[2]),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Two-Stage Regression
|
||||
// Stage 1: estimate flag fall from shortest rides
|
||||
// Stage 2: regress residuals on (dist, dur) with no intercept
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Stage 1 — Estimate the flag fall (فتحة العداد / meter opening charge).
|
||||
*
|
||||
* Takes the shortest 20% of rides by distance (min 5 samples) and fits
|
||||
* a simple linear model: price ~ intercept + slope × dist.
|
||||
* The intercept is the flag fall estimate.
|
||||
*
|
||||
* Clamped to [0, 65% of median price] to avoid unreasonable values.
|
||||
*/
|
||||
export function estimateFlagFall(
|
||||
samples: Array<{ distance_km: number; duration_min: number; price: number }>
|
||||
): number {
|
||||
if (samples.length < 5) return 0;
|
||||
|
||||
const sorted = [...samples].sort((a, b) => a.distance_km - b.distance_km);
|
||||
const shortCount = Math.max(5, Math.floor(sorted.length * 0.20));
|
||||
const shortRides = sorted.slice(0, shortCount);
|
||||
|
||||
// Simple OLS: price ~ a + b × dist on short rides only
|
||||
const n = shortRides.length;
|
||||
const sumX = shortRides.reduce((s, r) => s + r.distance_km, 0);
|
||||
const sumY = shortRides.reduce((s, r) => s + r.price, 0);
|
||||
const sumXX = shortRides.reduce((s, r) => s + r.distance_km ** 2, 0);
|
||||
const sumXY = shortRides.reduce((s, r) => s + r.distance_km * r.price, 0);
|
||||
|
||||
const denom = n * sumXX - sumX * sumX;
|
||||
if (Math.abs(denom) < 1e-10) {
|
||||
// Degenerate case — return a safe lower-bound estimate
|
||||
return Math.min(...shortRides.map(r => r.price)) * 0.4;
|
||||
}
|
||||
|
||||
const slope = (n * sumXY - sumX * sumY) / denom;
|
||||
const intercept = (sumY - slope * sumX) / n;
|
||||
|
||||
const medianPrice = median(samples.map(s => s.price));
|
||||
return Math.max(0, Math.min(intercept, medianPrice * 0.65));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage 2 — Two-variable regression with no intercept.
|
||||
* Fits: (price − fixedBase) ~ kmRate × dist + minRate × dur
|
||||
*
|
||||
* Uses ridge regularization (lambda = 2.0 when dist/dur are collinear)
|
||||
* to distribute the effect between km and min rather than collapsing to one.
|
||||
*/
|
||||
function twoVarNoIntercept(
|
||||
samples: Array<{ distance_km: number; duration_min: number; price: number }>,
|
||||
fixedBase: number
|
||||
): { kmRate: number; minRate: number } | null {
|
||||
const n = samples.length;
|
||||
if (n < 3) return null;
|
||||
|
||||
const dists = samples.map(s => s.distance_km);
|
||||
const durs = samples.map(s => s.duration_min);
|
||||
const corr = pearsonCorr(dists, durs);
|
||||
|
||||
// Higher ridge when predictors are correlated — forces balance between km and min
|
||||
const lambda = Math.abs(corr) > 0.85 ? 2.0 : 0.5;
|
||||
|
||||
let s11 = 0, s22 = 0, s12 = 0, s1y = 0, s2y = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x1 = dists[i], x2 = durs[i];
|
||||
const y = samples[i].price - fixedBase;
|
||||
s11 += x1 * x1; s22 += x2 * x2; s12 += x1 * x2;
|
||||
s1y += x1 * y; s2y += x2 * y;
|
||||
}
|
||||
|
||||
// Solve 2×2 ridge system:
|
||||
// [ s11+λ s12 ] [ kmRate ] [ s1y ]
|
||||
// [ s12 s22+λ ] [ minRate ] = [ s2y ]
|
||||
const a = s11 + lambda, b = s12, d = s22 + lambda;
|
||||
const det = a * d - b * b;
|
||||
if (Math.abs(det) < 1e-12) return null;
|
||||
|
||||
return {
|
||||
kmRate: Math.max(0, (s1y * d - s2y * b) / det),
|
||||
minRate: Math.max(0, (a * s2y - b * s1y) / det),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-Stage Regression (main entry point for the engine).
|
||||
*
|
||||
* Properly decomposes taxi pricing into three components:
|
||||
* price = baseFare (flag fall) + kmRate × dist + minRate × dur
|
||||
*
|
||||
* Stage 1 fixes baseFare from shortest rides.
|
||||
* Stage 2 fits kmRate and minRate on residuals.
|
||||
*
|
||||
* This avoids the distance/duration collinearity problem by removing
|
||||
* the constant component first.
|
||||
*/
|
||||
export function twoStageRegression(
|
||||
samples: Array<{ distance_km: number; duration_min: number; price: number }>
|
||||
): { baseFare: number; kmRate: number; minRate: number } | null {
|
||||
if (samples.length < 5) return null;
|
||||
|
||||
const baseFare = estimateFlagFall(samples);
|
||||
const rates = twoVarNoIntercept(samples, baseFare);
|
||||
if (!rates) return null;
|
||||
|
||||
return { baseFare, kmRate: rates.kmRate, minRate: rates.minRate };
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple distance-only model: price = kmRate × dist
|
||||
* Returns null if data is degenerate.
|
||||
*/
|
||||
export function simpleDistanceModel(
|
||||
samples: Array<{ distance_km: number; duration_min: number; price: number }>
|
||||
): { baseFare: number; kmRate: number; minRate: number } | null {
|
||||
const dists = samples.map(s => s.distance_km);
|
||||
const prices = samples.map(s => s.price);
|
||||
|
||||
// Mean of price/distance ratios, weighted by distance
|
||||
let sumRatio = 0, count = 0;
|
||||
for (let i = 0; i < dists.length; i++) {
|
||||
if (dists[i] > 0 && prices[i] > 0) {
|
||||
sumRatio += prices[i] / dists[i];
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (count < 3) return null;
|
||||
|
||||
const kmRate = Math.round((sumRatio / count) * 1000) / 1000;
|
||||
return { baseFare: 0, kmRate, minRate: 0 };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Robust regression (iterative outlier removal)
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
|
||||
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((_, idx) => actual[idx] - predicted[idx] < 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;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Statistics utilities
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export function calcRMSE(actual: number[], predicted: number[]): number {
|
||||
const n = Math.min(actual.length, predicted.length);
|
||||
if (n === 0) return Infinity;
|
||||
return Math.sqrt(
|
||||
actual.reduce((sum, a, i) => i < predicted.length ? sum + (a - predicted[i]) ** 2 : sum, 0) / n
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
return 1 - actual.reduce((sum, y, i) => i < predicted.length ? sum + (y - predicted[i]) ** 2 : sum, 0) / ssTot;
|
||||
}
|
||||
|
||||
export function findInliersMAD(values: number[], threshold: number = 3.5): number[] {
|
||||
const med = median(values);
|
||||
const mad = median(values.map(v => Math.abs(v - med)));
|
||||
if (mad === 0) return values.map((_, i) => i);
|
||||
return values
|
||||
.map((v, i) => ({ v, i, z: 0.6745 * Math.abs(v - med) / mad }))
|
||||
.filter(x => x.z < threshold)
|
||||
.map(x => x.i);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// K-Means clustering (multi-run for stability)
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
function calcInertia(values: number[], assignments: number[], centroids: number[]): number {
|
||||
return values.reduce((sum, v, i) => sum + (v - centroids[assignments[i]]) ** 2, 0);
|
||||
}
|
||||
|
||||
function kMeansOnce(
|
||||
values: number[],
|
||||
k: number,
|
||||
maxIterations: number
|
||||
): { assignments: number[]; centroids: number[]; inertia: number } {
|
||||
// K-Means++ seeding
|
||||
const 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 => (v - cent) ** 2)));
|
||||
const total = dists.reduce((a, b) => a + b, 0);
|
||||
let r = Math.random() * total;
|
||||
for (let i = 0; i < dists.length; i++) {
|
||||
r -= dists[i];
|
||||
if (r <= 0) { centroids.push(values[i]); break; }
|
||||
}
|
||||
if (centroids.length < c + 1) centroids.push(values[values.length - 1]);
|
||||
}
|
||||
|
||||
const assignments = new Array(values.length).fill(0);
|
||||
for (let iter = 0; iter < maxIterations; iter++) {
|
||||
let changed = false;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
let minDist = Infinity, best = 0;
|
||||
for (let c = 0; c < k; c++) {
|
||||
const dist = Math.abs(values[i] - centroids[c]);
|
||||
if (dist < minDist) { minDist = dist; best = c; }
|
||||
}
|
||||
if (assignments[i] !== best) { assignments[i] = best; changed = true; }
|
||||
}
|
||||
if (!changed) break;
|
||||
for (let c = 0; c < k; c++) {
|
||||
const clusterVals = values.filter((_, i) => assignments[i] === c);
|
||||
if (clusterVals.length > 0) centroids[c] = mean(clusterVals);
|
||||
}
|
||||
}
|
||||
|
||||
return { assignments, centroids, inertia: calcInertia(values, assignments, centroids) };
|
||||
}
|
||||
|
||||
/**
|
||||
* K-Means with multiple restarts — picks the run with lowest inertia
|
||||
* to eliminate randomness instability across executions.
|
||||
*/
|
||||
export function kMeans(
|
||||
values: number[],
|
||||
k: number,
|
||||
maxIterations: number = 100,
|
||||
runs: number = 8
|
||||
): number[] {
|
||||
if (values.length < k) return values.map(() => 0);
|
||||
|
||||
let best: ReturnType<typeof kMeansOnce> | null = null;
|
||||
for (let run = 0; run < runs; run++) {
|
||||
const result = kMeansOnce(values, k, maxIterations);
|
||||
if (!best || result.inertia < best.inertia) best = result;
|
||||
}
|
||||
|
||||
const { assignments, centroids } = best!;
|
||||
const order = centroids.map((c, i) => ({ c, i })).sort((a, b) => a.c - b.c);
|
||||
const map = new Map(order.map((item, idx) => [item.i, idx]));
|
||||
return assignments.map(a => map.get(a)!);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Minimum fare detection
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export function detectMinimumFare(distances: number[], prices: number[], kmRate: number): number | null {
|
||||
if (distances.length < 5) return null;
|
||||
const pairs = distances.map((d, i) => ({ d, p: prices[i] })).sort((a, b) => a.d - b.d);
|
||||
const shortCount = Math.max(5, Math.floor(pairs.length * 0.3));
|
||||
const shortRides = pairs.slice(0, shortCount);
|
||||
const residuals = shortRides.map(({ d, p }) => p - kmRate * d).sort((a, b) => a - b);
|
||||
const trimIdx = Math.max(0, Math.floor(residuals.length * 0.1));
|
||||
const trimmed = residuals.slice(trimIdx, residuals.length - trimIdx);
|
||||
const estimate = trimmed.length > 0 ? Math.max(...trimmed) : 0;
|
||||
return estimate > 0 ? Math.round(estimate * 100) / 100 : null;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user