Introduction

In the competitive landscape of Fintech, acquiring a user is only half the battle. The real challenge lies in activation: getting a user to not just create a digital wallet, but to fund it and complete their first transaction. Traditional marketing offers static bonuses (e.g., "$10 for signing up"), which are often inefficient—too high for motivated users and too low for hesitant ones.

Enter Reinforcement Learning (RL). By treating wallet onboarding as a sequential decision-making problem, we can dynamically tailor incentives. But to build this, we must first define the core components of the RL loop: State, Action, and Reward.

This article details exactly how we designed these three pillars for a Digital Wallet Onboarding Engine. We will build an end-to-end Proof of Concept (POC) using LangGraph to orchestrate agents that retrieve relevant financial policies via RAG, use an RL Agent to select the best incentive, and manage the user's journey through persistent Memory and State.

Disclaimer: This POC uses simulated transaction history and a simplified Q-Learning algorithm for educational purposes.

The Challenge: Optimizing Wallet Creation & Funding

Financial institutions face a "cold start" problem. We don't know a new user's sensitivity to fees or bonuses until they interact with the system. An RL agent solves this by:

  1. Observing the user's context (State).

  2. Offering a specific incentive (Action).

  3. Learning from whether the user completes the funding step (Reward).

Deconstructing the RL Triad: State, Action, Reward

1. State (StSt)

The state represents the current context of the user and the environment. In our wallet engine, the state is a composite of:

2. Action (AtAt)

The action is the specific incentive or nudge the system presents to the user. Our discrete action space includes:

3. Reward (RtRt)

The reward signal tells the agent how well it performed. We design a composite reward function:

Real-Time Use Case: Intelligent Wallet Onboarding

Scenario: A new user, "Alex," downloads the app. Alex is a student with no prior transaction history in our system. System Behavior:

  1. State Identification: The system identifies Alex as Student + New_User.

  2. RAG Retrieval: The system retrieves current compliance rules: "Students cannot receive >$50 bonus without extra verification."

  3. RL Decision: The RL Agent checks its Q-Table for state Student_New. It selects Action: Small_Cash_Bonus.

  4. Outcome: Alex accepts and funds $50.

  5. Learning: The system records Reward: +10 (Funding) - 5 (Cost) = +5. The Q-Table is updated to favor this action for future students.

Technology Stack Overview

Project Architecture

wallet-rl-onboarding/
├── backend/
│   ├── app/
│   │   ├── main.py
│   │   ├── config.py
│   │   ├── models/
│   │   │   └── state.py
│   │   ├── agents/
│   │   │   ├── policy_retriever.py
│   │   │   ├── rl_agent.py
│   │   │   └── onboarding_manager.py
│   │   ├── graph/
│   │   │   └── workflow.py
│   │   └── rag/
│   │       └── ingest.py
│   └── requirements.txt
├── frontend/
│   ├── src/
│   │   ├── App.jsx
│   │   └── components/
│   │       ├── WalletDashboard.jsx
│   └── package.json
└── README.md

Step-by-Step POC Implementation

1. Backend Setup and Configuration

backend/requirements.txt

fastapi
uvicorn
langgraph
langchain
langchain-community
chromadb
sentence-transformers
numpy
python-dotenv
pydantic

backend/app/config.py

import os
from dotenv import load_dotenv

load_dotenv()

class Settings:
    CHROMA_PATH = "./chroma_db"
    LEARNING_RATE = 0.1
    DISCOUNT_FACTOR = 0.9
    EXPLORATION_RATE = 0.2

settings = Settings()

2. Defining the LangGraph State

backend/app/models/state.py

from typing import TypedDict, List, Optional, Annotated
from langgraph.graph import add_messages

class WalletState(TypedDict):
    messages: Annotated[list, add_messages]
    user_id: str
    user_profile: dict # e.g., {'type': 'student', 'history': 'none'}
    retrieved_policies: List[dict]
    state_key: str
    selected_action: str
    reward: float
    q_table_updated: bool
    final_offer: str

3. The RAG Component: Policy & Incentive Retrieval

backend/app/rag/ingest.py

from langchain_core.documents import Document
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter

def get_policy_docs():
    return [
        Document(
            page_content="Policy: Students are eligible for a max $10 bonus. Requires valid .edu email.",
            metadata={"segment": "Student", "max_bonus": 10}
        ),
        Document(
            page_content="Policy: Professionals can receive up to $50 bonus. Requires proof of income.",
            metadata={"segment": "Professional", "max_bonus": 50}
        ),
        Document(
            page_content="Policy: Fee waivers are available for all users during Q4 holiday season.",
            metadata={"segment": "All", "type": "Fee_Waiver"}
        )
    ]

def ingest_data():
    docs = get_policy_docs()
    splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
    split_docs = splitter.split_documents(docs)
    
    embeddings = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2")
    vector_store = Chroma.from_documents(
        documents=split_docs,
        embedding=embeddings,
        persist_directory="./chroma_db"
    )
    print("Policy knowledge base ingested.")
    return vector_store

4. The RL Agent: Q-Learning for Incentive Optimization

backend/app/agents/rl_agent.py

import numpy as np
import random
from app.config import settings

class QLearningAgent:
    def __init__(self):
        self.q_table = {}
        # Actions: No_Incentive, Small_Bonus, Large_Bonus, Fee_Waiver
        self.actions = ["No_Incentive", "Small_Bonus", "Large_Bonus", "Fee_Waiver"]

    def get_state_key(self, profile):
        return f"{profile.get('type', 'Unknown')}_{profile.get('history', 'None')}"

    def choose_action(self, state_key, allowed_actions):
        if state_key not in self.q_table:
            self.q_table[state_key] = np.zeros(len(self.actions))
        
        # Filter actions based on RAG policies if needed (simplified here)
        if random.uniform(0, 1) < settings.EXPLORATION_RATE:
            return random.choice(self.actions)
        else:
            idx = np.argmax(self.q_table[state_key])
            return self.actions[idx]

    def learn(self, state_key, action, reward):
        if state_key not in self.q_table:
            self.q_table[state_key] = np.zeros(len(self.actions))
        
        idx = self.actions.index(action)
        old_value = self.q_table[state_key][idx]
        next_max = np.max(self.q_table[state_key])
        
        new_value = old_value + settings.LEARNING_RATE * (reward + settings.DISCOUNT_FACTOR * next_max - old_value)
        self.q_table[state_key][idx] = new_value

rl_agent = QLearningAgent()

def rl_node(state):
    state_key = rl_agent.get_state_key(state["user_profile"])
    state["state_key"] = state_key
    
    # Learn from previous turn if reward exists
    if "reward" in state and state["reward"] is not None:
        prev_action = state.get("prev_action")
        if prev_action:
            rl_agent.learn(state_key, prev_action, state["reward"])
            state["q_table_updated"] = True

    # Choose new action
    action = rl_agent.choose_action(state_key, [])
    state["selected_action"] = action
    state["prev_action"] = action
    
    return state

5. The Multi-Agent Workflow

Policy Retriever

backend/app/agents/policy_retriever.py

from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import HuggingFaceEmbeddings

def policy_node(state):
    embeddings = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2")
    db = Chroma(persist_directory="./chroma_db", embedding_function=embeddings)
    
    query = f"policies for {state['user_profile'].get('type', 'user')}"
    docs = db.similarity_search(query, k=2)
    state["retrieved_policies"] = [
        {"content": d.page_content, "metadata": d.metadata} for d in docs
    ]
    return state

Onboarding Manager

backend/app/agents/onboarding_manager.py

def manager_node(state):
    action = state["selected_action"]
    policy = state["retrieved_policies"][0]["content"] if state["retrieved_policies"] else "No specific policy."
    
    offer_map = {
        "Small_Bonus": "$5 Bonus on $50 Deposit",
        "Large_Bonus": "$20 Bonus on $100 Deposit",
        "Fee_Waiver": "0% Fees for 3 Months",
        "No_Incentive": "Standard Account Creation"
    }
    
    state["final_offer"] = f"Offer: {offer_map.get(action, 'Unknown')}. Policy Context: {policy}"
    return state

Orchestrating with LangGraph

backend/app/graph/workflow.py

from langgraph.graph import StateGraph, END
from app.models.state import WalletState
from app.agents.policy_retriever import policy_node
from app.agents.rl_agent import rl_node
from app.agents.onboarding_manager import manager_node
from langgraph.checkpoint.sqlite import SqliteSaver

def create_workflow():
    workflow = StateGraph(WalletState)
    
    workflow.add_node("policy", policy_node)
    workflow.add_node("rl_decision", rl_node)
    workflow.add_node("manager", manager_node)
    
    workflow.set_entry_point("policy")
    workflow.add_edge("policy", "rl_decision")
    workflow.add_edge("rl_decision", "manager")
    workflow.add_edge("manager", END)
    
    memory = SqliteSaver.from_conn_string(":memory:")
    app = workflow.compile(checkpointer=memory)
    return app

wallet_graph = create_workflow()

6. FastAPI Endpoints

backend/app/main.py

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from app.graph.workflow import wallet_graph
from app.rag.ingest import ingest_data
import uuid

app = FastAPI(title="Wallet RL Onboarding")

class OnboardingRequest(BaseModel):
    user_type: str
    transaction_history: str
    conversation_id: str = None
    previous_reward: float = None 

@app.post("/ingest")
def ingest():
    ingest_data()
    return {"status": "Policies Updated"}

@app.post("/offer")
def get_offer(req: OnboardingRequest):
    thread_id = req.conversation_id or str(uuid.uuid4())
    config = {"configurable": {"thread_id": thread_id}}
    
    initial_state = {
        "messages": [{"role": "user", "content": "Start onboarding"}],
        "user_id": "user_123",
        "user_profile": {"type": req.user_type, "history": req.transaction_history},
        "reward": req.previous_reward,
        "q_table_updated": False
    }
    
    try:
        result = wallet_graph.invoke(initial_state, config=config)
        return {
            "conversation_id": thread_id,
            "offer": result["final_offer"],
            "action_taken": result["selected_action"],
            "learned": result["q_table_updated"]
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

Frontend Implementation with React

frontend/src/App.jsx

import React, { useState } from 'react';

function App() {
  const [userType, setUserType] = useState('Student');
  const [result, setResult] = useState(null);
  const [convId, setConvId] = useState(null);
  const [lastReward, setLastReward] = useState(null);

  const handleGetOffer = async () => {
    try {
      const res = await fetch('http://localhost:8000/offer', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ 
          user_type: userType, 
          transaction_history: 'none',
          conversation_id: convId,
          previous_reward: lastReward
        }),
      });
      const data = await res.json();
      setResult(data);
      setConvId(data.conversation_id);
      setLastReward(null);
    } catch (err) {
      console.error(err);
    }
  };

  const simulateOutcome = (reward) => {
    setLastReward(reward);
    alert(`Simulated Outcome: ${reward > 0 ? 'Funded' : 'Churned'}. Click 'Get Offer' again to learn.`);
  };

  return (
    <div className="p-10 max-w-4xl mx-auto">
      <h1 className="text-2xl font-bold mb-4">Wallet Onboarding RL Engine</h1>
      <select value={userType} onChange={(e) => setUserType(e.target.value)} className="p-2 border rounded mr-2">
        <option value="Student">Student</option>
        <option value="Professional">Professional</option>
      </select>
      <button onClick={handleGetOffer} className="bg-blue-600 text-white px-4 py-2 rounded">
        Generate Offer
      </button>

      {result && (
        <div className="mt-6 p-4 border rounded bg-gray-50">
          <h2 className="font-bold">{result.offer}</h2>
          <p className="text-sm text-gray-600">Action: {result.action_taken}</p>
          <p className="text-sm text-green-600">Learned: {result.learned ? 'Yes' : 'No'}</p>
          
          <div className="mt-4 flex gap-2">
            <button onClick={() => simulateOutcome(10)} className="bg-green-500 text-white px-3 py-1 rounded">Simulate Funding</button>
            <button onClick={() => simulateOutcome(-5)} className="bg-red-500 text-white px-3 py-1 rounded">Simulate Churn</button>
          </div>
        </div>
      )}
    </div>
  );
}

export default App;

Running the Application

  1. Backend:

    cd backend
    pip install -r requirements.txt
    python -m uvicorn app.main:app --reload
    
  2. Ingest Data:

    curl -X POST http://localhost:8000/ingest
    
  3. Frontend:

    cd frontend
    npm install
    npm run dev
    

Analyzing the RL Design

Enterprise Production Considerations

Limitations

Conclusion

Designing the State, Action, and Reward is the most critical step in building an RL-powered pricing or onboarding engine. By defining these clearly and wrapping them in a LangGraph multi-agent architecture, we can create systems that are not only intelligent but also compliant and context-aware. This POC demonstrates how to move from static marketing to dynamic, learning-driven user engagement.