294 lines
9.3 KiB
TypeScript
294 lines
9.3 KiB
TypeScript
/**
|
||
* 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();
|