Files
Siro/backend/pricing-engine/src/index.ts
T

294 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Siro Pricing Engine CLI
*
* Usage:
* npm run analyze Full analysis all competitors
* npm run analyze:taxif TaxiF only
* npm run analyze -- --competitor=com.taxif.passenger --country=JO
* npm run dev -- --mode=surge Surge-only analysis
*
* Cron integration: see crontab examples in package.json scripts
*/
import { getMySQL, fetchSamples, saveFormulas, saveSurgeInsights, closeConnections } from './db/connection';
import { runAnalysis } from './analysis/engine';
import { Pool, RowDataPacket } from 'mysql2/promise';
interface CLIOptions {
mode: 'full' | 'report';
competitor?: string;
country?: string;
hoursBack?: number;
}
function parseArgs(): CLIOptions {
const args = process.argv.slice(2);
const opts: CLIOptions = { mode: 'full' };
for (const arg of args) {
if (arg.startsWith('--mode=')) {
const mode = arg.split('=')[1];
if (mode === 'full' || mode === 'report') {
opts.mode = mode;
}
} else if (arg.startsWith('--competitor=')) {
opts.competitor = arg.split('=')[1];
} else if (arg.startsWith('--country=')) {
opts.country = arg.split('=')[1];
} else if (arg.startsWith('--hours=')) {
opts.hoursBack = parseInt(arg.split('=')[1]);
}
}
return opts;
}
interface CompetitorEntry {
competitor_name: string;
country_code: string;
}
async function main(): Promise<void> {
const opts = parseArgs();
const startTime = Date.now();
console.log(`🚀 Siro Pricing Engine v1.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(', ')}]`);
}
}
// --- 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`);
}
}
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();