← 返回 ramp 的题目列表Detect Recurring Transactions
类型:qbank
Given a list of transactions (date, merchant, amount, currency), identify the recurring ones and print them as e.g. "Netflix, $30 per month". The hard part is separating genuine subscriptions from frequent one-off noise and supporting multiple cadences.
Requirements
Given a list of transactions, each with transaction_date, merchant, amount, and currency, find the recurring transactions and print the merchant and amount, e.g. Netflix, $30 per month.
The spec is intentionally vague — clarifying it is part of the round:
Distinguish true recurring charges from a high volume of small non-recurring transactions (e.g. transit fares) that share a merchant or amount.
Support multiple cadences simultaneously — daily, weekly, monthly, etc. — and report which cadence each recurring series follows. Separating these reliably is the core challenge.
The problem is canonically staged in two parts — build the monthly-only detector first, then generalize to multiple cadences:
# Part 1 — monthly only.
def detect_monthly_recurring(transactions: list[dict]) -> list[str]: ...
# Each transaction is {"date": "YYYY-MM-DD", "merchant": str, "amount": int|float, "currency": str}.
# Emit one string per detected monthly series, e.g. "Netflix, $30 per month".
# Part 2 — infer cadence per series.
def detect_recurring_transactions(transactions: list[dict]) -> list[str]: ...
# Same input; emit "<merchant>, <amount> per day|week|month" with the inferred cadence.
Notes
The interviewer gives little guidance and expects you to drive the clarification: what tolerance on amount counts as "the same" charge, what date spacing tolerance defines a cadence, and how many occurrences are needed to call something recurring.
A reasonable approach is to group by (merchant, approximate amount), sort each group's dates, and inspect the gaps between consecutive charges to infer a cadence within a tolerance window. State your thresholds out loud.
Building a basic single-cadence version first and then generalizing is the expected progression; the round is more about modeling an ambiguous problem than algorithmic depth.
Recurrence rules to state before coding
A defensible deterministic definition of "recurring" — announce these thresholds out loud rather than assuming them:
Grouping key: same normalized merchant (lowercased, whitespace-collapsed), exact amount, and currency. Keep a separate display form of the merchant for the output string.
Minimum observations: at least 3 charges in a group before it can qualify.
Ignore refunds / non-positive amounts: skip any transaction with amount <= 0 (refunds and negative postings) before grouping.
Cadence from consecutive gaps (sort the group's dates first, dedupe, then check every consecutive gap):
Daily — all consecutive gaps are exactly 1 day.
Weekly — all consecutive gaps are exactly 7 days.
Monthly — every consecutive pair is one calendar month apart (month_delta == 1) with a day-of-month tolerance, day_tolerance = 3 (i.e. abs(a.day - b.day) <= 3). This tolerates the 30/31-day and month-boundary drift that a raw 28–31 day-gap check would misclassify.
Output string format: exactly "<merchant>, <amount> per <cadence>", e.g. Netflix, $30 per month. Amount is rendered $<n> for USD (<CURRENCY> <n> otherwise), integer-formatted unless the value is a non-integer float (then two decimals). Return the list sorted.
Cadence-detection helper
The key modeling move for Part 2 is a single cadence classifier over the sorted dates — this keeps the grouping loop identical to Part 1 and isolates the recurrence logic:
def matching_cadence(dates: list[date]) -> str | None: ...
# dates: sorted, deduped charge dates for one (merchant, amount, currency) group.
# Returns "day" / "week" / "month" if all consecutive gaps match one cadence, else None.
# Requires len(dates) >= 3.
# "day": all consecutive gaps == 1 day
# "week": all consecutive gaps == 7 days
# "month": every consecutive pair is is_calendar_interval(a, b, months=1, day_tolerance=3)
The common mistake is to flag any repeated (merchant, amount) pair without validating timing — that incorrectly promotes frequent small purchases (bus fares) to subscriptions. Grouping alone is never sufficient; the spacing check is what separates a subscription from noisy repeated purchases.
Examples
Monthly Netflix charges on roughly the 5th of each month qualify; three same-price bus fares clustered in early January do not, because their gaps are not monthly:
transactions = [
{"date": "2025-01-05", "merchant": "Netflix", "amount": 30, "currency": "USD"},
{"date": "2025-02-05", "merchant": "Netflix", "amount": 30, "currency": "USD"},
{"date": "2025-03-06", "merchant": "Netflix", "amount": 30, "currency": "USD"},
{"date": "2025-01-02", "merchant": "Bus", "amount": 3, "currency": "USD"},
{"date": "2025-01-03", "merchant": "Bus", "amount": 3, "currency": "USD"},
{"date": "2025-01-06", "merchant": "Bus", "amount": 3, "currency": "USD"},
]
# detect_monthly_recurring(transactions) -> ["Netflix, $30 per month"]
Preparation
Implement a grouped-by-merchant cadence detector that tolerates small amount and date jitter, and decide up front how many occurrences justify "recurring."
Practice articulating the trade-offs (amount tolerance, gap tolerance, minimum occurrences) before coding, since the interviewer will not volunteer them.
Rehearse the two-part progression: a monthly-only detect_monthly_recurring first, then refactor its group loop to call a matching_cadence helper so the Part 2 extension (detect_recurring_transactions) is a small, clean delta rather than a rewrite.