Churn Prediction Models for Fintech
A technical guide to building churn prediction models in TypeScript for fintech CRM platforms, covering feature engineering, model training, scoring pipelines, and operationalizing predictions.
Churn is the silent revenue killer in fintech. Unlike a product outage, which triggers immediate alarms, customer churn happens gradually — a missed login, a declining transaction frequency, a support ticket left unresolved. By the time a customer formally closes their account, the real churn happened weeks or months earlier. The purpose of a churn prediction model is to detect that invisible inflection point and create an opportunity for intervention before the customer is gone.
At Klivvr, CVM Nova's churn prediction system processes millions of customer signals daily and assigns a churn probability to every active customer. This article covers the end-to-end implementation: feature engineering, model architecture, scoring pipelines, and the operational patterns that make predictions actionable.
Defining Churn in Fintech
Before building a model, you must define what "churn" means for your product. In contractual businesses (subscriptions), churn is unambiguous: the customer cancelled. In non-contractual fintech products — banking apps, payment platforms, investment tools — churn is fuzzy. A customer who has not logged in for 60 days might be churned, or they might be on vacation.
CVM Nova uses a multi-signal churn definition that combines activity indicators rather than relying on a single threshold.
interface ChurnDefinition {
lookbackDays: number;
signals: ChurnSignal[];
minimumSignalsRequired: number;
}
interface ChurnSignal {
name: string;
condition: (customer: CustomerActivity) => boolean;
weight: number;
}
interface CustomerActivity {
customerId: string;
lastLoginDate: Date | null;
lastTransactionDate: Date | null;
transactionCount30d: number;
transactionCount90d: number;
supportTicketsOpen: number;
balanceChange30d: number;
featureUsageCount30d: number;
}
const fintechChurnDefinition: ChurnDefinition = {
lookbackDays: 90,
signals: [
{
name: "no-login-30d",
condition: (c) =>
!c.lastLoginDate ||
Date.now() - c.lastLoginDate.getTime() > 30 * 24 * 60 * 60 * 1000,
weight: 0.3,
},
{
name: "no-transactions-60d",
condition: (c) =>
!c.lastTransactionDate ||
Date.now() - c.lastTransactionDate.getTime() > 60 * 24 * 60 * 60 * 1000,
weight: 0.3,
},
{
name: "declining-transaction-frequency",
condition: (c) =>
c.transactionCount30d < c.transactionCount90d / 3 * 0.5,
weight: 0.2,
},
{
name: "balance-withdrawn",
condition: (c) => c.balanceChange30d < -0.5 * Math.abs(c.balanceChange30d + 1),
weight: 0.2,
},
],
minimumSignalsRequired: 2,
};
function isChurned(
customer: CustomerActivity,
definition: ChurnDefinition
): { churned: boolean; score: number; triggeredSignals: string[] } {
const triggered: string[] = [];
let totalWeight = 0;
for (const signal of definition.signals) {
if (signal.condition(customer)) {
triggered.push(signal.name);
totalWeight += signal.weight;
}
}
return {
churned: triggered.length >= definition.minimumSignalsRequired,
score: totalWeight,
triggeredSignals: triggered,
};
}This definition labels historical customers as "churned" or "active," producing the training labels for the predictive model. The multi-signal approach is more robust than a single threshold because it accounts for the different ways customers disengage — some stop transacting but keep logging in, others maintain a balance but stop all engagement.
Feature Engineering
Feature engineering is where domain knowledge meets data science. The features you build determine the model's ceiling — no algorithm can compensate for missing or irrelevant features.
interface ChurnFeatureVector {
customerId: string;
// Recency features
daysSinceLastLogin: number;
daysSinceLastTransaction: number;
daysSinceLastSupportContact: number;
// Frequency features
loginCount7d: number;
loginCount30d: number;
loginCount90d: number;
transactionCount7d: number;
transactionCount30d: number;
transactionCount90d: number;
// Monetary features
totalBalance: number;
balanceChange7d: number;
balanceChange30d: number;
averageTransactionAmount30d: number;
// Trend features
loginTrend: number; // ratio of 30d to 90d activity
transactionTrend: number; // ratio of 30d to 90d activity
// Engagement features
distinctFeaturesUsed30d: number;
pushNotificationOptIn: boolean;
emailOpenRate30d: number;
// Tenure features
accountAgeDays: number;
// Support features
openTicketCount: number;
avgTicketResolutionHours: number;
negSentimentTicketCount90d: number;
}
function computeChurnFeatures(
activity: CustomerActivity,
detailed: DetailedCustomerData
): ChurnFeatureVector {
const now = Date.now();
const daysSince = (date: Date | null): number => {
if (!date) return 9999;
return Math.floor((now - date.getTime()) / (1000 * 60 * 60 * 24));
};
const trend = (recent: number, historical: number): number => {
if (historical === 0) return recent > 0 ? 2.0 : 0;
return recent / (historical / 3); // Normalize 90d to 30d equivalent
};
return {
customerId: activity.customerId,
daysSinceLastLogin: daysSince(activity.lastLoginDate),
daysSinceLastTransaction: daysSince(activity.lastTransactionDate),
daysSinceLastSupportContact: daysSince(detailed.lastSupportContactDate),
loginCount7d: detailed.loginCount7d,
loginCount30d: detailed.loginCount30d,
loginCount90d: detailed.loginCount90d,
transactionCount7d: detailed.transactionCount7d,
transactionCount30d: activity.transactionCount30d,
transactionCount90d: activity.transactionCount90d,
totalBalance: detailed.totalBalance,
balanceChange7d: detailed.balanceChange7d,
balanceChange30d: activity.balanceChange30d,
averageTransactionAmount30d: detailed.averageTransactionAmount30d,
loginTrend: trend(detailed.loginCount30d, detailed.loginCount90d),
transactionTrend: trend(activity.transactionCount30d, activity.transactionCount90d),
distinctFeaturesUsed30d: activity.featureUsageCount30d,
pushNotificationOptIn: detailed.pushNotificationOptIn,
emailOpenRate30d: detailed.emailOpenRate30d,
accountAgeDays: daysSince(detailed.accountCreatedDate),
openTicketCount: activity.supportTicketsOpen,
avgTicketResolutionHours: detailed.avgTicketResolutionHours,
negSentimentTicketCount90d: detailed.negSentimentTicketCount90d,
};
}
interface DetailedCustomerData {
loginCount7d: number;
loginCount30d: number;
loginCount90d: number;
transactionCount7d: number;
lastSupportContactDate: Date | null;
totalBalance: number;
balanceChange7d: number;
averageTransactionAmount30d: number;
pushNotificationOptIn: boolean;
emailOpenRate30d: number;
accountCreatedDate: Date;
avgTicketResolutionHours: number;
negSentimentTicketCount90d: number;
}The trend features are particularly important. A customer who made 10 transactions in the last 90 days but only 1 in the last 30 days has a declining trend that is a stronger churn signal than the raw counts alone. Similarly, balance changes over time reveal customer intent — a steady drawdown of funds often precedes formal account closure.
Model Training Pipeline
CVM Nova uses a gradient-boosted decision tree model for churn prediction. The model is trained in a batch pipeline that runs weekly on the latest labeled data.
interface TrainingDataPoint {
features: number[];
label: 0 | 1; // 0 = active, 1 = churned
customerId: string;
observationDate: Date;
}
interface ModelMetrics {
accuracy: number;
precision: number;
recall: number;
f1Score: number;
aucRoc: number;
confusionMatrix: {
truePositives: number;
trueNegatives: number;
falsePositives: number;
falseNegatives: number;
};
}
class ChurnModelTrainer {
async prepareTrainingData(
asOfDate: Date,
churnWindowDays: number
): Promise<TrainingDataPoint[]> {
// Look at customer state as of asOfDate
// Label based on whether they churned in the following churnWindowDays
const customers = await this.getActiveCustomersAsOf(asOfDate);
const trainingData: TrainingDataPoint[] = [];
for (const customer of customers) {
const features = await this.computeFeaturesAsOf(customer.id, asOfDate);
const futureDate = new Date(
asOfDate.getTime() + churnWindowDays * 24 * 60 * 60 * 1000
);
const didChurn = await this.customerChurnedBetween(
customer.id,
asOfDate,
futureDate
);
trainingData.push({
features: this.vectorize(features),
label: didChurn ? 1 : 0,
customerId: customer.id,
observationDate: asOfDate,
});
}
return trainingData;
}
evaluateModel(
predictions: Array<{ predicted: number; actual: 0 | 1 }>
): ModelMetrics {
let tp = 0, tn = 0, fp = 0, fn = 0;
for (const { predicted, actual } of predictions) {
const predictedLabel = predicted >= 0.5 ? 1 : 0;
if (predictedLabel === 1 && actual === 1) tp++;
else if (predictedLabel === 0 && actual === 0) tn++;
else if (predictedLabel === 1 && actual === 0) fp++;
else fn++;
}
const precision = tp / (tp + fp) || 0;
const recall = tp / (tp + fn) || 0;
return {
accuracy: (tp + tn) / (tp + tn + fp + fn),
precision,
recall,
f1Score: 2 * (precision * recall) / (precision + recall) || 0,
aucRoc: this.computeAucRoc(predictions),
confusionMatrix: {
truePositives: tp,
trueNegatives: tn,
falsePositives: fp,
falseNegatives: fn,
},
};
}
private vectorize(features: ChurnFeatureVector): number[] {
return [
features.daysSinceLastLogin,
features.daysSinceLastTransaction,
features.loginCount7d,
features.loginCount30d,
features.transactionCount7d,
features.transactionCount30d,
features.totalBalance,
features.balanceChange30d,
features.loginTrend,
features.transactionTrend,
features.distinctFeaturesUsed30d,
features.pushNotificationOptIn ? 1 : 0,
features.emailOpenRate30d,
features.accountAgeDays,
features.openTicketCount,
];
}
private computeAucRoc(
predictions: Array<{ predicted: number; actual: 0 | 1 }>
): number {
// Simplified AUC-ROC computation using the trapezoidal rule
const sorted = [...predictions].sort((a, b) => b.predicted - a.predicted);
let tpRate = 0, fpRate = 0;
let prevTpRate = 0, prevFpRate = 0;
let auc = 0;
const totalPositives = sorted.filter((p) => p.actual === 1).length;
const totalNegatives = sorted.length - totalPositives;
for (const { actual } of sorted) {
if (actual === 1) {
tpRate += 1 / totalPositives;
} else {
fpRate += 1 / totalNegatives;
auc += (tpRate + prevTpRate) * (fpRate - prevFpRate) / 2;
}
prevTpRate = tpRate;
prevFpRate = fpRate;
}
return auc;
}
private async getActiveCustomersAsOf(date: Date): Promise<Array<{ id: string }>> {
return [];
}
private async computeFeaturesAsOf(
customerId: string,
date: Date
): Promise<ChurnFeatureVector> {
return {} as ChurnFeatureVector;
}
private async customerChurnedBetween(
customerId: string,
start: Date,
end: Date
): Promise<boolean> {
return false;
}
}Training data preparation uses a "point-in-time" approach: features are computed as of a specific observation date, and the label is determined by what happened in the following 30 days. This prevents data leakage — the most common and most damaging mistake in churn modeling. If you use features that include information from the churn window, the model will appear to perform brilliantly in testing but fail completely in production because it was effectively peeking at the answer.
Operationalizing Predictions
A churn score sitting in a database is useless. Operationalizing means connecting the model's output to systems that take action.
interface ChurnPrediction {
customerId: string;
churnProbability: number;
riskTier: "low" | "medium" | "high" | "critical";
topRiskFactors: RiskFactor[];
predictedAt: Date;
modelVersion: string;
}
interface RiskFactor {
feature: string;
contribution: number;
description: string;
}
function classifyRiskTier(probability: number): ChurnPrediction["riskTier"] {
if (probability >= 0.8) return "critical";
if (probability >= 0.5) return "high";
if (probability >= 0.3) return "medium";
return "low";
}
class ChurnActionRouter {
async routePrediction(prediction: ChurnPrediction): Promise<void> {
switch (prediction.riskTier) {
case "critical":
await this.triggerPersonalOutreach(prediction);
await this.notifyRelationshipManager(prediction);
break;
case "high":
await this.enrollInRetentionCampaign(prediction);
await this.triggerInAppIntervention(prediction);
break;
case "medium":
await this.enrollInEngagementCampaign(prediction);
break;
case "low":
// No action — monitor passively
break;
}
}
private async triggerPersonalOutreach(prediction: ChurnPrediction): Promise<void> {
// Creates a task for the customer's relationship manager
}
private async notifyRelationshipManager(prediction: ChurnPrediction): Promise<void> {
// Sends a notification with the customer's risk factors
}
private async enrollInRetentionCampaign(prediction: ChurnPrediction): Promise<void> {
// Adds the customer to the appropriate retention campaign
}
private async triggerInAppIntervention(prediction: ChurnPrediction): Promise<void> {
// Sends a personalized in-app message or offer
}
private async enrollInEngagementCampaign(prediction: ChurnPrediction): Promise<void> {
// Adds the customer to a gentle re-engagement campaign
}
}The action router maps risk tiers to intervention strategies. Critical-risk customers get personal outreach from their relationship manager. High-risk customers receive targeted retention campaigns and in-app interventions. Medium-risk customers get lighter engagement nudges. Low-risk customers are left alone — over-communicating with satisfied customers is a churn risk in itself.
The topRiskFactors field is crucial for relationship managers. Knowing that a customer has a 75% churn probability is actionable. Knowing that their churn risk is driven by a 60% decline in transaction frequency and an unresolved support ticket from two weeks ago is transformative — it tells the manager exactly what to address in the outreach conversation.
Conclusion
Churn prediction in fintech is a system, not a model. The model is important, but it is one component in a pipeline that spans data collection, feature engineering, training, scoring, and action routing. Each component has its own failure modes and optimization opportunities.
The most impactful advice we can offer is to start with the action layer, not the model. Define what interventions you will take for high-risk customers before you build the model that identifies them. If the answer is "nothing" — because you do not have the support capacity, the campaign infrastructure, or the organizational will to act on predictions — then the model is a waste of engineering effort. Build the action capability first, then feed it with increasingly sophisticated predictions.
Related Articles
Real-Time Customer Profiles with Event Streaming
A technical guide to building real-time customer profile systems using event streaming in TypeScript, covering event-driven architecture, stream processing, profile materialization, and consistency guarantees.
Customer Engagement Metrics That Matter
A practical guide to defining, measuring, and acting on customer engagement metrics in CRM platforms, with a focus on metrics that drive retention and revenue in fintech.
Data-Driven CRM: Strategy and Implementation
A strategic guide to building and operating a data-driven CRM practice, covering organizational alignment, data governance, analytics maturity models, and practical implementation roadmaps.