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:
Observing the user's context (State).
Offering a specific incentive (Action).
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:
User Demographics: (e.g., Student, Professional) - Retrieved via RAG from profile data.
Transaction History Pattern: (e.g., High-frequency small transactions vs. Low-frequency large transfers).
Current Funnel Stage: (e.g., App Downloaded, KYC Pending, Wallet Created, Unfunded).
External Context: (e.g., Holiday Season, Competitor Promotion).
2. Action (AtAt)
The action is the specific incentive or nudge the system presents to the user. Our discrete action space includes:
No Incentive: Standard onboarding flow.
Small Cash Bonus: "$5 upon first $50 deposit."
Large Cash Bonus: "$20 upon first $100 deposit."
Fee Waiver: "Zero fees for first 3 months."
Gamified Nudge: "Complete profile to unlock badge."
3. Reward (RtRt)
The reward signal tells the agent how well it performed. We design a composite reward function:
+10: User funds wallet within 24 hours.
+5: User completes KYC verification.
-1: User drops off at the funding screen.
-5: User creates wallet but never funds it (Churn).
-0.1: Cost of the incentive provided (to prevent overspending).
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:
State Identification: The system identifies Alex as
Student+New_User.RAG Retrieval: The system retrieves current compliance rules: "Students cannot receive >$50 bonus without extra verification."
RL Decision: The RL Agent checks its Q-Table for state
Student_New. It selects Action:Small_Cash_Bonus.Outcome: Alex accepts and funds $50.
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
Backend: Python 3.12, FastAPI, Uvicorn
Orchestration: LangGraph
RAG: LangChain, ChromaDB, HuggingFace Embeddings
RL: Custom Q-Learning Implementation (NumPy)
Memory: SQLite via LangGraph Checkpointer
Frontend: React, Vite, Tailwind CSS
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: str3. 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_store4. 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 state5. 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
Backend:
cd backend pip install -r requirements.txt python -m uvicorn app.main:app --reloadIngest Data:
curl -X POST http://localhost:8000/ingestFrontend:
cd frontend npm install npm run dev
Analyzing the RL Design
State: By combining
user_typeandtransaction_history, we create a granular view of the user.Action: The discrete actions allow the business to control costs while testing different levers.
Reward: The composite reward ensures the agent optimizes for profitable activation, not just any activation.
Enterprise Production Considerations
Safety Guards: Implement hard limits on bonus amounts to prevent financial loss due to RL exploration.
Batch Learning: In production, update the Q-Table in batches overnight rather than in real-time to ensure stability.
A/B Testing: Run the RL agent alongside a control group to measure lift.
Limitations
Simplified state space for demonstration.
Simulated user behavior does not reflect real-world complexity.
Financial regulations may restrict certain dynamic incentives.
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.

Join the conversation! Your thoughts help the community grow.