/** * Siro Pricing Engine CLI * * Usage: * npm run analyze Full analysis all competitors * npm run analyze:taxif TaxiF only * npm run analyze -- --competitor=com.taxif.passenger --country=JO * npm run dev -- --mode=surge Surge-only analysis * * Cron integration: see crontab examples in package.json scripts */ import { getMySQL, fetchSamples, saveFormulas, saveSurgeInsights, closeConnections } from './db/connection'; import { runAnalysis } from './analysis/engine'; import { Pool, RowDataPacket } from 'mysql2/promise'; interface CLIOptions { mode: 'full' | 'report'; competitor?: string; country?: string; hoursBack?: number; } function parseArgs(): CLIOptions { const args = process.argv.slice(2); const opts: CLIOptions = { mode: 'full' }; for (const arg of args) { if (arg.startsWith('--mode=')) { const mode = arg.split('=')[1]; if (mode === 'full' || mode === 'report') { opts.mode = mode; } } else if (arg.startsWith('--competitor=')) { opts.competitor = arg.split('=')[1]; } else if (arg.startsWith('--country=')) { opts.country = arg.split('=')[1]; } else if (arg.startsWith('--hours=')) { opts.hoursBack = parseInt(arg.split('=')[1]); } } return opts; } interface CompetitorEntry { competitor_name: string; country_code: string; } async function main(): Promise { const opts = parseArgs(); const startTime = Date.now(); console.log(`šŸš€ Siro Pricing Engine v1.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(); 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 { 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 { if (opts.competitor) { const countryClause = opts.country ? 'AND country_code = ?' : ''; const params: (string | number)[] = opts.country ? [opts.competitor, opts.country] : [opts.competitor]; const [rows] = await pool.query( `SELECT DISTINCT competitor_name, country_code FROM scraped_competitor_prices WHERE competitor_name = ? AND distance_km > 0 AND duration_min > 0 AND price_amount > 0 ${countryClause} LIMIT 10`, params ); return rows as CompetitorEntry[]; } const [rows] = await pool.query( `SELECT competitor_name, country_code, COUNT(*) as cnt FROM scraped_competitor_prices WHERE distance_km > 0 AND duration_min > 0 AND price_amount > 0 GROUP BY competitor_name, country_code HAVING cnt >= 10 ORDER BY cnt DESC` ); return rows as CompetitorEntry[]; } main();