How cardwise computes things
1. Exact simulation (engine.simulate)
Transactions of one card are replayed in date order. For each one:
- If its category is excluded: base earn is 0, no rule applies (the fuel surcharge waiver still can).
- Base earn =
earn_units(amount, base_rate)with spend rounding and point rounding. - For each matching rule, bonus = rule earn - base earn. The rule's cap bucket (
rule id, period bucket of the date) has some allowance left; the usable bonus ismin(bonus, allowance). The rule with the largest usable bonus wins and consumes allowance. What does not fit is reported as "cap loss". - Fuel surcharge (if in range) is added, capped per period bucket in rupees.
- Eligible spend feeds each milestone's
(milestone, period bucket)counter. The first transaction that takes a counter to its target carries the reward. - Value in rupees =
(base + bonus + milestone points) * point_value + surcharge + milestone cash.
Worked example (Dining Delight: base 2 pts/Rs 100, dining 10 pts/Rs 100, cap 1,000 bonus points per cycle, 1 point = Rs 0.25). A Rs 6,000 dinner earns 120 base points and 600 at the rule rate, so its bonus is 480. In one cycle the first dinner earns 480 bonus points and the second 480 (960 used). The third finds only 40 left: it earns 40 and reports 440 points (Rs 110) of cap loss.
Cycle bucket: with statement_day: 15, 15 Jan is in the cycle ending 15 Jan and 16 Jan in the cycle ending 15 Feb.
Annual fee: the fee (with GST) is waived when spend not in fee_waiver.exclude reaches the threshold within the calendar year.
2. Optimal assignment (optimizer)
Question: given every transaction of the year, which card should each have been put on to maximise total value, when caps, milestones and fee waivers couple the transactions?
Greedy per-transaction choice fails because caps are shared. Example: card A pays 10% on dining up to Rs 100 bonus per month, card B pays 5% flat. Three Rs 1,000 dinners: independent best-card-per-transaction puts all three on A (cap exhausted after one); the optimum is one on A and two on B.
It is solved as a mixed-integer linear program with SciPy's HiGHS:
- Options. For each transaction
tand cardcthere is one binary variable per choice: the base rate, or one of the matching rules. Exactly one option over all cards is chosen per transaction. A choice contributes its base value immediately. - Caps. For each
(card, rule, period bucket)with a cap there is a continuous variabley <= capandy <= sum(bonus_value * option)over the transactions that could use the rule.yis the capped bonus actually earned. The same construction models the fuel surcharge cap. An uncapped bonus is added straight into the objective. - Milestones. For each
(card, milestone, period bucket)a binarymwithtarget * m <= sum(amount * option)over eligible transactions on that card in the bucket.mearns the reward value. - Fee waivers (
--net-of-fees, always on forfee-check). A binarywper card withwaiver_spend * w <= sum(amount * option)over counted spend.wearns the fee amount, i.e. avoids it. - Objective. Maximise total value. A tiny bonus (Rs 0.001) for keeping a transaction on its actual card breaks ties towards fewer moves.
After solving, the assignment is replayed through the exact simulator, and that number is what is reported. Moves that gain nothing (many equal-value solutions exist, e.g. rent that no card rewards) are reverted greedily, and if the simulated optimum were ever below what you actually earned, your actual assignment is kept. The model lets a transaction use any matching rule while the simulator chooses the best one available in date order, so the two agree except in rare cases of several overlapping capped rules.
HiGHS runs with a relative gap of 1e-4 (about Rs 3 on Rs 30,000), which turns a 25 second proof of optimality into under a second on a year of data. Tests compare the result against brute force over all assignments on small random instances with caps, milestones, rounding and fees.
Assumption: any card can be used at any merchant.
3. Policy derivation (policy)
A per-transaction plan is not actionable. The policy converts the optimal assignment into rules of the form "use A for the first Rs X per month in this category, then B":
- For each category and card, take the monthly spend the optimum put there.
- Drop cards with under 5% of the category's spend.
- Order cards by effective rate in that category (milestone rewards excluded, because they are not earned per rupee).
- A card's monthly limit is the median of its non-zero months, rounded to Rs 100. The last card takes the overflow.
The policy is then replayed on the real transactions month by month, and the earned value is printed next to your actual and the theoretical optimum, so you can see how much of the gain a simple rule retains. Milestone-driven moves are not captured as limits, so the policy usually lands slightly below the optimum.
Example from the sample data: online shopping is "cashback-plus for the first Rs 23,300/month, then travel-elite". The 5% rule's Rs 1,000 cycle cap is used up at about Rs 25,000 of spend. Past that, cashback-plus would still pay its 1% base, but the optimum sends the overflow to travel-elite because that spend is what lets travel-elite reach its quarterly and annual milestones.
4. Fee worth-it (fee-check)
For each card c:
net benefit = rewards on c in the optimum
- fee payable on c (0 if the optimum reaches the waiver threshold)
- foregone elsewhere
+ extras (welcome benefit with --first-year, lounge value with --lounge-visits)
foregone = optimum(without c) - (optimum(with c) - net of c)
Optima are net of fees on all cards, so the fee-waiver effect is part of every number. KEEP if net benefit is at least 25% of the fee, MARGINAL if positive but below, DROP if negative, FREE for no-fee cards that do no harm.
5. Milestone pacing (policy)
For each unfinished milestone in the period containing --as-of:
- remaining = target - eligible spend so far; projected = the last 90 days of eligible spend on that card scaled to the days left.
- gap = remaining - projected. If it is zero, "on track".
- The last 90 days of eligible spend on your other cards, scaled the same way, is the pool that could be shifted. Each transaction's cost of shifting is its actual earn minus what it would earn on this card (ignoring caps). The cheapest transactions are taken until the gap is covered; the sum is the foregone reward.
- Verdict: if the gap exceeds the pool it needs extra spending, which costs more than the reward; if the reward exceeds the foregone amount, it is worth shifting existing spend; otherwise not.
6. Prospective cards (simulate)
A spend profile (monthly rupees per category) is expanded into 12 months of synthetic transactions (count from avg_txn, default Rs 2,000). Standalone value is the optimum with only that card. With --alongside, marginal benefit is the optimum with the held cards plus the candidate, minus the optimum with the held cards alone, both net of fees.