| |
| |
| |
| |
| |
| import { ChurnRisk } from './types' |
| import { sendCustomEvent } from './torque-mcp' |
|
|
| export interface ScoringThresholds { |
| inactivityCritical: number |
| inactivityHigh: number |
| inactivityMedium: number |
| volumeDropCritical: number |
| volumeDropHigh: number |
| volumeDropMedium: number |
| preChurnDaysTrader: number |
| preChurnDaysLP: number |
| preChurnDaysStaker: number |
| } |
|
|
| export const DEFAULT_THRESHOLDS: ScoringThresholds = { |
| inactivityCritical: 30, |
| inactivityHigh: 14, |
| inactivityMedium: 7, |
| volumeDropCritical: 80, |
| volumeDropHigh: 50, |
| volumeDropMedium: 25, |
| preChurnDaysTrader: 3, |
| preChurnDaysLP: 5, |
| preChurnDaysStaker: 7, |
| } |
|
|
| export type WalletType = 'trader' | 'lp' | 'staker' |
|
|
| const TRADER_PROTOCOLS = new Set(['Jupiter', 'Raydium', 'Drift', 'Tensor']) |
| const LP_PROTOCOLS = new Set(['Kamino', 'Marginfi', 'Meteora']) |
|
|
| export function classifyWalletType(protocols: string[]): WalletType { |
| const traderCount = protocols.filter(p => TRADER_PROTOCOLS.has(p)).length |
| const lpCount = protocols.filter(p => LP_PROTOCOLS.has(p)).length |
| if (traderCount >= 2) return 'trader' |
| if (lpCount >= 1) return 'lp' |
| return 'staker' |
| } |
|
|
| export function detectPreChurn( |
| daysInactive: number, |
| streak: number, |
| protocols: string[], |
| thresholds: ScoringThresholds = DEFAULT_THRESHOLDS |
| ): { isPreChurn: boolean; walletType: WalletType; threshold: number } { |
| const walletType = classifyWalletType(protocols) |
| const threshold = |
| walletType === 'trader' |
| ? thresholds.preChurnDaysTrader |
| : walletType === 'lp' |
| ? thresholds.preChurnDaysLP |
| : thresholds.preChurnDaysStaker |
| |
| const isPreChurn = |
| daysInactive >= threshold && |
| daysInactive < thresholds.inactivityMedium && |
| streak <= 2 |
| return { isPreChurn, walletType, threshold } |
| } |
|
|
| export function calculateChurnScore( |
| activity: { |
| daysInactive: number; volumeDropPct: number; uniqueProtocols: number; |
| currentStreak: number; hasLiquidation: boolean |
| }, |
| thresholds: ScoringThresholds = DEFAULT_THRESHOLDS |
| ): { score: number; risk: ChurnRisk; signals: string[] } { |
| let score = 0 |
| const signals: string[] = [] |
|
|
| |
| const inactScore = Math.min(activity.daysInactive * 3, 30) |
| score += inactScore |
| if (activity.daysInactive >= thresholds.inactivityMedium) { |
| signals.push('Inactive ' + activity.daysInactive + ' days') |
| } |
|
|
| |
| if (activity.volumeDropPct > 0) { |
| score += Math.min(activity.volumeDropPct / 4, 25) |
| if (activity.volumeDropPct >= thresholds.volumeDropMedium) { |
| signals.push('Volume dropped ' + activity.volumeDropPct.toFixed(0) + '%') |
| } |
| } |
|
|
| |
| if (activity.uniqueProtocols <= 1) { score += 15; signals.push('Single protocol β low engagement') } |
| else if (activity.uniqueProtocols <= 2) { score += 8; signals.push('Limited protocol diversity') } |
|
|
| |
| if (activity.currentStreak === 0) { score += 15; signals.push('Activity streak broken') } |
| else if (activity.currentStreak < 3) score += 5 |
|
|
| |
| if (activity.hasLiquidation) { score += 15; signals.push('Recent liquidation event') } |
|
|
| score = Math.min(Math.max(score, 0), 100) |
|
|
| let risk: ChurnRisk |
| if (score >= 80) risk = 'critical' |
| else if (score >= 60) risk = 'high' |
| else if (score >= 40) risk = 'medium' |
| else if (score >= 20) risk = 'low' |
| else risk = 'safe' |
|
|
| return { score, risk, signals } |
| } |
|
|
| export function selectResponse(risk: ChurnRisk, wallet: string): { action: string; description: string }[] { |
| const responses: { action: string; description: string }[] = [] |
| responses.push({ action: 'detect', description: 'Detected ' + risk + ' churn risk for ' + wallet.slice(0, 8) + '...' }) |
|
|
| switch (risk) { |
| case 'critical': |
| responses.push({ action: 'gift', description: 'Sending 0.5 SOL gift via Anti-Churn campaign' }) |
| responses.push({ action: 'raffle', description: 'Enrolling in Comeback Raffle with 5x multiplier' }) |
| break |
| case 'high': |
| responses.push({ action: 'raffle', description: 'Enrolling in Comeback Raffle with 3x multiplier' }) |
| break |
| case 'medium': |
| responses.push({ action: 'rebate', description: 'Activating 2x rebate boost for 48 hours' }) |
| break |
| } |
| return responses |
| } |
|
|
| export async function executeResponse(wallet: string, action: string) { |
| const eventName = |
| action === 'gift' || action === 'raffle' ? 'churn_risk_high' |
| : action === 'rebate' ? 'churn_risk_medium' |
| : 'inactivity_detected' |
| return sendCustomEvent(wallet, eventName, { triggeredAction: action, detectedBy: 'flowstate-ai-agent', timestamp: new Date().toISOString() }) |
| } |
|
|