import pandas as pd
import numpy as np
import uuid
import random
from datetime import datetime
from faker import Faker
from sklearn.ensemble import IsolationForest
from dash import Dash, html, dcc, dash_table, Input, Output, State, callback_context
import plotly.express as px
import plotly.graph_objects as go
# Initialize Faker
fake = Faker()
# ==========================
# RTR CONFIGURATION
# ==========================
# Canadian bank locations (only Canadian coordinates)
BANK_LOCATIONS = {
"RTR Exchange": {"city": "Ottawa", "lat": 45.4215, "lon": -75.6972, "type": "exchange"},
"RBC": {"city": "Toronto", "lat": 43.6532, "lon": -79.3832, "type": "bank"},
"TD": {"city": "Toronto", "lat": 43.6532, "lon": -79.3832, "type": "bank"},
"Scotiabank": {"city": "Halifax", "lat": 44.6488, "lon": -63.5752, "type": "bank"},
"BMO": {"city": "Montreal", "lat": 45.5017, "lon": -73.5673, "type": "bank"},
"CIBC": {"city": "Toronto", "lat": 43.6532, "lon": -79.3832, "type": "bank"},
"Desjardins": {"city": "Levis", "lat": 46.8033, "lon": -71.1779, "type": "bank"},
"National Bank": {"city": "Montreal", "lat": 45.5017, "lon": -73.5673, "type": "bank"},
"HSBC": {"city": "Vancouver", "lat": 49.2827, "lon": -123.1207, "type": "bank"},
"NeoBank": {"city": "Toronto", "lat": 43.6532, "lon": -79.3832, "type": "bank"} # hypothetical
}
BANKS = list(BANK_LOCATIONS.keys())
BANK_IDS = {bank: f"{i:03d}" for i, bank in enumerate(BANKS) if bank != "RTR Exchange"}
# Status codes
STATUS_CODES = {
"ACSC": "Settled",
"ACSP": "Finality",
"ACWP": "Accepted",
"RJCT": "Rejected"
}
# Rejection reasons
REJECT_REASONS = {
"AM04": "Insufficient funds",
"AC01": "Incorrect account number",
"DUPL": "Duplicate payment detected",
"FF07": "Invalid amount format",
"MD07": "Customer account deceased",
"AB04": "Settlement process aborted",
"SL01": "Daily limit exceeded",
"SL02": "Monthly threshold reached",
"RF01": "Risk scoring triggered",
"ML01": "ML anomaly detected",
"CB01": "Cross-border restriction",
"NB01": "NeoBank risk policy"
}
# ==========================
# DATA STORAGE
# ==========================
# Store transactions
transactions_df = pd.DataFrame(columns=[
"timestamp", "uetr", "debtor", "creditor", "amount",
"status", "reason_code", "reason_description",
"risk_score", "risk_label", "risk_factors"
])
# Bank liquidity (in dollars) - RTR Exchange has no liquidity
liquidity = {bank: random.randint(200000, 500000) for bank in BANKS if bank != "RTR Exchange"}
# ML model
model = IsolationForest(contamination=0.02, random_state=42)
# ==========================
# PAYMENT GENERATOR WITH ML ENHANCED REASONS
# ==========================
def calculate_risk_factors(amount, debtor, creditor, is_cross_border=False):
"""Calculate risk factors and return detailed reasons"""
risk_factors = []
risk_score = 0
# Amount-based risk factors
if amount > 10000:
risk_factors.append({
"factor": "EXTREME_HIGH_AMOUNT",
"description": f"Extremely high amount: ${amount:,.2f} (10x normal)",
"score": 70,
"ml_relevant": True
})
elif amount > 5000:
risk_factors.append({
"factor": "VERY_HIGH_AMOUNT",
"description": f"Very high amount: ${amount:,.2f} (5x normal)",
"score": 50,
"ml_relevant": True
})
elif amount > 1000:
risk_factors.append({
"factor": "HIGH_AMOUNT",
"description": f"High amount: ${amount:,.2f} (above normal range)",
"score": 20,
"ml_relevant": True
})
# Bank-based risk factors
if debtor == "NeoBank":
risk_factors.append({
"factor": "NEOBANK_RISK",
"description": "NeoBank - higher risk profile (newer institution)",
"score": 10,
"ml_relevant": False
})
if creditor == "NeoBank":
risk_factors.append({
"factor": "NEOBANK_RECIPIENT",
"description": "Payment to NeoBank - monitor for unusual patterns",
"score": 5,
"ml_relevant": False
})
# Cross-border risk
if is_cross_border:
risk_factors.append({
"factor": "CROSS_BORDER",
"description": "Cross-border payment - additional compliance required",
"score": 15,
"ml_relevant": True
})
# Round amount risk (potential structuring)
if amount % 1000 == 0 and amount > 5000:
risk_factors.append({
"factor": "ROUND_AMOUNT",
"description": f"Suspicious round amount: ${amount:,.2f} (possible structuring)",
"score": 25,
"ml_relevant": True
})
# Small amount just below threshold (smurfing)
if 9500 < amount < 10000:
risk_factors.append({
"factor": "JUST_BELOW_THRESHOLD",
"description": f"Amount just below reporting threshold: ${amount:,.2f}",
"score": 30,
"ml_relevant": True
})
# Calculate total score
risk_score = sum(f["score"] for f in risk_factors)
# Determine risk label
if risk_score > 70:
risk_label = "CRITICAL RISK"
elif risk_score > 50:
risk_label = "HIGH RISK"
elif risk_score > 20:
risk_label = "MEDIUM RISK"
else:
risk_label = "LOW RISK"
return risk_factors, risk_score, risk_label
def generate_payment():
"""Generate a single payment transaction with detailed reasons"""
# Select banks
available_banks = [b for b in BANKS if b != "RTR Exchange"]
debtor = random.choice(available_banks)
creditor = random.choice([b for b in available_banks if b != debtor])
# Generate amount with various patterns to trigger different risk factors
amount_pattern = random.random()
if amount_pattern < 0.6: # 60% normal amounts
amount = round(abs(np.random.normal(100, 50)), 2)
elif amount_pattern < 0.75: # 15% high amounts
amount = round(random.uniform(2000, 8000), 2)
elif amount_pattern < 0.85: # 10% very high amounts
amount = round(random.uniform(8000, 15000), 2)
elif amount_pattern < 0.92: # 7% round amounts (structuring)
amount = random.choice([5000, 7500, 9000, 9500, 9900])
else: # 8% just below threshold
amount = random.choice([9800, 9850, 9900, 9950, 9990])
# Cross-border flag (10% chance)
is_cross_border = random.random() < 0.1
# Calculate risk factors
risk_factors, risk_score, risk_label = calculate_risk_factors(amount, debtor, creditor, is_cross_border)
# Check liquidity for rejection
reason_code = None
reason_description = None
status = None
if liquidity[debtor] < amount:
status = "RJCT"
reason_code = "AM04"
reason_description = REJECT_REASONS["AM04"]
# No liquidity change
else:
# Check if risk-based rejection (5% chance for high risk)
if risk_label in ["CRITICAL RISK", "HIGH RISK"] and random.random() < 0.3:
status = "RJCT"
reason_code = "RF01"
reason_description = f"Risk policy triggered: {risk_label} - {risk_factors[0]['description'] if risk_factors else 'Unknown risk'}"
# No money transfer - refund liquidity
else:
# 90% success rate for approved transactions
if random.random() < 0.9:
status = random.choice(["ACSC", "ACSP", "ACWP"])
reason_code = None
reason_description = "Transaction completed successfully"
# Transfer money
liquidity[debtor] -= amount
liquidity[creditor] += amount
else:
status = "RJCT"
reason_code = random.choice(["AC01", "DUPL", "FF07", "MD07", "AB04"])
reason_description = REJECT_REASONS[reason_code]
# No money transfer
# Format risk factors for display
risk_factors_display = "; ".join([f"{f['factor']}: {f['description']}" for f in risk_factors])
return {
"timestamp": datetime.now(),
"uetr": str(uuid.uuid4()),
"debtor": debtor,
"creditor": creditor,
"amount": amount,
"status": status,
"reason_code": reason_code,
"reason_description": reason_description,
"risk_score": risk_score,
"risk_label": risk_label,
"risk_factors": risk_factors_display
}
def process_payment_cycle():
"""Generate multiple payments and apply ML"""
global transactions_df, model
new_transactions = []
for _ in range(random.randint(3, 7)):
new_transactions.append(generate_payment())
if new_transactions:
new_df = pd.DataFrame(new_transactions)
global transactions_df
transactions_df = pd.concat([transactions_df, new_df], ignore_index=True)
# Apply ML anomaly detection if enough data
if len(transactions_df) > 20:
try:
# Train on amount and risk score
features = transactions_df[["amount", "risk_score"]].fillna(0)
model.fit(features)
# Get predictions
transactions_df["ml_anomaly"] = model.predict(features)
# Update risk labels for ML anomalies
anomaly_mask = transactions_df["ml_anomaly"] == -1
# For ML anomalies, enhance the reason
for idx in transactions_df[anomaly_mask].index:
current_factors = transactions_df.at[idx, "risk_factors"]
if pd.isna(current_factors) or current_factors == "":
transactions_df.at[idx, "risk_factors"] = "ML_ANOMALY: Transaction pattern deviates from historical norms"
else:
transactions_df.at[idx, "risk_factors"] += "; ML_ANOMALY: Unusual pattern detected by Isolation Forest"
# Update risk label
transactions_df.at[idx, "risk_label"] = "ML HIGH RISK"
# If transaction was accepted but ML flagged it, add warning to reason
if transactions_df.at[idx, "status"] != "RJCT":
transactions_df.at[idx, "reason_description"] = "ACCEPTED WITH ML WARNING: Transaction approved but flagged for review"
transactions_df.at[idx, "reason_code"] = "ML01"
except Exception as e:
print(f"ML training error: {e}")
# ==========================
# DASH APP
# ==========================
app = Dash(__name__)
app.layout = html.Div([
html.H1("π¨π¦ RTR Real-Time Payment Rail Monitoring Dashboard",
style={"textAlign": "center", "color": "#2c3e50", "marginBottom": 20}),
html.P(
"This dashboard simulates the Real-Time Rail (RTR) payment flows with AI/ML risk detection. "
"Watch for ML anomalies and rule-based risk factors in the Reasons column.",
style={"textAlign": "center", "margin": "10px 20px"}
),
# Control buttons
html.Div([
html.Button("βΆοΈ Start", id="start-btn", n_clicks=0,
style={"fontSize": 16, "padding": "10px 20px", "margin": "5px",
"backgroundColor": "#FF8C00", "color": "white", "border": "none",
"borderRadius": "5px", "cursor": "pointer"}),
html.Button("βΈοΈ Stop", id="stop-btn", n_clicks=0,
style={"fontSize": 16, "padding": "10px 20px", "margin": "5px",
"backgroundColor": "#e67e22", "color": "white", "border": "none",
"borderRadius": "5px", "cursor": "pointer"}),
html.Button("π Reset", id="reset-btn", n_clicks=0,
style={"fontSize": 16, "padding": "10px 20px", "margin": "5px",
"backgroundColor": "#95a5a6", "color": "white", "border": "none",
"borderRadius": "5px", "cursor": "pointer"}),
html.Button("π₯ Download CSV", id="download-btn", n_clicks=0,
style={"fontSize": 16, "padding": "10px 20px", "margin": "5px",
"backgroundColor": "#3498db", "color": "white", "border": "none",
"borderRadius": "5px", "cursor": "pointer"}),
], style={"textAlign": "center", "margin": "20px"}),
# ML Status indicator
html.Div(id="ml-status", style={"textAlign": "center", "fontSize": 14, "margin": 5, "color": "#7f8c8d"}),
# Download component
dcc.Download(id="download-dataframe-csv"),
# Status indicator
html.Div(id="stream-status", style={"textAlign": "center", "fontSize": 18, "margin": 10}),
# Interval component
dcc.Interval(id="stream-interval", interval=2000, disabled=True),
# Metrics row
html.Div(id="metrics", style={
"display": "flex", "justifyContent": "space-around",
"margin": "20px", "padding": "15px",
"backgroundColor": "#f8f9fa", "borderRadius": "5px"
}),
# Charts row
html.Div([
html.Div([
dcc.Graph(id="amount-chart")
], style={"width": "50%", "display": "inline-block"}),
html.Div([
dcc.Graph(id="liquidity-chart")
], style={"width": "50%", "display": "inline-block"}),
]),
# Map
html.Div([
html.H3("π¦ RTR Network - Geographic Map of Canada", style={"margin": "10px"}),
dcc.Graph(id="network-map")
]),
# Timeline
html.Div([
html.H3("π Payment Status Timeline", style={"margin": "10px"}),
dcc.Graph(id="status-timeline")
]),
# Filters
html.Div([
html.H3("π Filter Transactions", style={"margin": "10px"}),
html.Div([
html.Div([
html.Label("Bank (Debtor/Creditor):"),
dcc.Dropdown(
id="bank-filter",
options=[{"label": "All Banks", "value": "all"}] +
[{"label": bank, "value": bank} for bank in BANKS if bank != "RTR Exchange"],
value="all",
multi=False,
style={"width": "200px"}
)
], style={"display": "inline-block", "margin": "10px"}),
html.Div([
html.Label("Status:"),
dcc.Dropdown(
id="status-filter",
options=[{"label": "All Status", "value": "all"}] +
[{"label": v, "value": k} for k, v in STATUS_CODES.items()],
value="all",
multi=False,
style={"width": "200px"}
)
], style={"display": "inline-block", "margin": "10px"}),
html.Div([
html.Label("Risk Level:"),
dcc.Dropdown(
id="risk-filter",
options=[
{"label": "All Risks", "value": "all"},
{"label": "ML HIGH RISK", "value": "ML HIGH RISK"},
{"label": "CRITICAL RISK", "value": "CRITICAL RISK"},
{"label": "HIGH RISK", "value": "HIGH RISK"},
{"label": "MEDIUM RISK", "value": "MEDIUM RISK"},
{"label": "LOW RISK", "value": "LOW RISK"}
],
value="all",
multi=False,
style={"width": "200px"}
)
], style={"display": "inline-block", "margin": "10px"}),
html.Div([
html.Label("Min Amount ($):"),
dcc.Input(id="min-amount", type="number", value=0, style={"width": "150px"})
], style={"display": "inline-block", "margin": "10px"}),
html.Div([
html.Label("Max Amount ($):"),
dcc.Input(id="max-amount", type="number", value=100000, style={"width": "150px"})
], style={"display": "inline-block", "margin": "10px"}),
html.Div([
html.Button("Apply Filters", id="apply-filters", n_clicks=0,
style={"backgroundColor": "#2c3e50", "color": "white",
"border": "none", "padding": "10px 20px",
"borderRadius": "5px", "cursor": "pointer"})
], style={"display": "inline-block", "margin": "10px"}),
]),
], style={"backgroundColor": "#f8f9fa", "padding": "10px", "margin": "10px", "borderRadius": "5px"}),
# Table
html.Div([
html.H3("π Filtered Transactions with AI/ML Risk Assessment", style={"margin": "10px"}),
dash_table.DataTable(
id="message-table",
page_size=10,
style_table={"overflowX": "auto"},
style_cell={"textAlign": "left", "padding": "8px", "whiteSpace": "normal", "height": "auto"},
style_header={"backgroundColor": "#2c3e50", "color": "white", "fontWeight": "bold"},
style_data_conditional=[
{"if": {"filter_query": "{risk_label} = 'ML HIGH RISK'"},
"backgroundColor": "#ff6b6b", "color": "white"},
{"if": {"filter_query": "{risk_label} = 'CRITICAL RISK'"},
"backgroundColor": "#dc3545", "color": "white"},
{"if": {"filter_query": "{risk_label} = 'HIGH RISK'"},
"backgroundColor": "#ffcccc"},
{"if": {"filter_query": "{risk_label} = 'MEDIUM RISK'"},
"backgroundColor": "#fff3cd"},
],
filter_action="native",
sort_action="native",
export_format="csv",
export_headers="display",
tooltip_data=[],
tooltip_duration=None
)
]),
])
# ==========================
# CALLBACKS
# ==========================
@app.callback(
[Output("stream-interval", "disabled"),
Output("stream-status", "children")],
[Input("start-btn", "n_clicks"),
Input("stop-btn", "n_clicks"),
Input("reset-btn", "n_clicks")]
)
def control_stream(start_clicks, stop_clicks, reset_clicks):
"""Control the payment stream"""
ctx = callback_context
if not ctx.triggered:
return True, "βΈοΈ Stream Stopped"
button_id = ctx.triggered[0]["prop_id"].split(".")[0]
if button_id == "start-btn":
return False, "βΆοΈ Stream Running"
elif button_id == "stop-btn":
return True, "βΈοΈ Stream Stopped"
elif button_id == "reset-btn":
global transactions_df, liquidity
transactions_df = pd.DataFrame(columns=transactions_df.columns)
liquidity = {bank: random.randint(200000, 500000) for bank in BANKS if bank != "RTR Exchange"}
return True, "βΈοΈ Stream Stopped - Data Reset"
return True, "βΈοΈ Stream Stopped"
@app.callback(
Output("download-dataframe-csv", "data"),
Input("download-btn", "n_clicks"),
prevent_initial_call=True
)
def download_csv(n_clicks):
"""Download transactions as CSV"""
if not transactions_df.empty:
return dcc.send_data_frame(transactions_df.to_csv, "rtr_transactions.csv")
return None
@app.callback(
Output("ml-status", "children"),
Input("stream-interval", "n_intervals")
)
def update_ml_status(n):
"""Show ML model status"""
if len(transactions_df) > 20:
ml_count = len(transactions_df[transactions_df["ml_anomaly"] == -1]) if "ml_anomaly" in transactions_df.columns else 0
return f"π€ ML Model Active: {ml_count} anomalies detected | Training size: {len(transactions_df)} transactions"
else:
return f"π€ ML Model Training: Need {20 - len(transactions_df)} more transactions to start"
@app.callback(
[Output("metrics", "children"),
Output("amount-chart", "figure"),
Output("liquidity-chart", "figure"),
Output("network-map", "figure"),
Output("status-timeline", "figure"),
Output("message-table", "data"),
Output("message-table", "columns"),
Output("message-table", "tooltip_data")],
[Input("stream-interval", "n_intervals"),
Input("reset-btn", "n_clicks"),
Input("apply-filters", "n_clicks")],
[State("stream-interval", "disabled"),
State("bank-filter", "value"),
State("status-filter", "value"),
State("risk-filter", "value"),
State("min-amount", "value"),
State("max-amount", "value")]
)
def update_dashboard(n_intervals, reset_clicks, filter_clicks,
interval_disabled, bank_filter, status_filter,
risk_filter, min_amount, max_amount):
"""Update all dashboard components"""
global transactions_df
# Generate new transactions only if stream is running
if not interval_disabled:
process_payment_cycle()
# Apply filters
filtered_df = transactions_df.copy() if not transactions_df.empty else pd.DataFrame()
if not filtered_df.empty:
# Bank filter
if bank_filter and bank_filter != "all":
filtered_df = filtered_df[(filtered_df["debtor"] == bank_filter) |
(filtered_df["creditor"] == bank_filter)]
# Status filter
if status_filter and status_filter != "all":
filtered_df = filtered_df[filtered_df["status"] == status_filter]
# Risk filter
if risk_filter and risk_filter != "all":
filtered_df = filtered_df[filtered_df["risk_label"] == risk_filter]
# Amount range
if min_amount is not None:
filtered_df = filtered_df[filtered_df["amount"] >= min_amount]
if max_amount is not None:
filtered_df = filtered_df[filtered_df["amount"] <= max_amount]
# Prepare display data (last 50 of filtered)
display_df = filtered_df.tail(50).copy() if not filtered_df.empty else pd.DataFrame()
# Calculate metrics
total_payments = len(filtered_df)
if total_payments > 0:
success_rate = (filtered_df["status"] != "RJCT").mean() * 100
high_risk = len(filtered_df[filtered_df["risk_label"].str.contains("HIGH|CRITICAL|ML", na=False)])
ml_anomalies = len(filtered_df[filtered_df["risk_label"] == "ML HIGH RISK"]) if "risk_label" in filtered_df.columns else 0
total_value = filtered_df["amount"].sum()
else:
success_rate = 0
high_risk = 0
ml_anomalies = 0
total_value = 0
# Metrics display with ML stats
metrics = html.Div([
html.Div([html.H4("Filtered Payments"), html.H3(f"{total_payments:,}")],
style={"textAlign": "center"}),
html.Div([html.H4("Success Rate"), html.H3(f"{success_rate:.1f}%")],
style={"textAlign": "center"}),
html.Div([html.H4("High Risk"), html.H3(f"{high_risk}")],
style={"textAlign": "center"}),
html.Div([html.H4("ML Anomalies"), html.H3(f"{ml_anomalies}")],
style={"textAlign": "center"}),
html.Div([html.H4("Total Value"), html.H3(f"${total_value:,.2f}")],
style={"textAlign": "center"}),
], style={"display": "flex", "justifyContent": "space-around", "width": "100%"})
# Amount chart with ML anomalies highlighted
if not filtered_df.empty:
# Create scatter plot to show ML anomalies
fig_amount = px.scatter(
filtered_df.tail(500),
x=filtered_df.tail(500).index,
y="amount",
color="risk_label",
title=f"Transaction Amounts with Risk Levels",
labels={"amount": "Amount (CAD)", "index": "Transaction Number"},
color_discrete_map={
"ML HIGH RISK": "#ff6b6b",
"CRITICAL RISK": "#dc3545",
"HIGH RISK": "#ffcccc",
"MEDIUM RISK": "#fff3cd",
"LOW RISK": "#d4edda"
}
)
else:
fig_amount = px.scatter(title="No transactions match filters")
# Liquidity chart
liquidity_display = pd.DataFrame([
{"bank": bank, "balance": bal}
for bank, bal in liquidity.items()
])
fig_liq = px.bar(
liquidity_display,
x="bank",
y="balance",
title="Bank Liquidity (CAD)",
labels={"balance": "Balance", "bank": ""},
color="balance",
color_continuous_scale=["#e74c3c", "#f1c40f", "#2ecc71"]
)
# Network map
map_fig = go.Figure()
# Get active banks from filtered transactions
active_banks = set()
if not filtered_df.empty:
active_banks.update(filtered_df.tail(50)["debtor"].values)
active_banks.update(filtered_df.tail(50)["creditor"].values)
# Add connection lines colored by risk
if not filtered_df.empty:
for _, row in filtered_df.tail(30).iterrows():
debtor, creditor, amount, risk_label = row["debtor"], row["creditor"], row["amount"], row["risk_label"]
if debtor in BANK_LOCATIONS and creditor in BANK_LOCATIONS:
# Color lines based on risk
if "ML" in risk_label or "CRITICAL" in risk_label:
line_color = "rgba(220, 53, 69, 0.6)" # Red for high risk
elif "HIGH" in risk_label:
line_color = "rgba(255, 193, 7, 0.5)" # Yellow for high risk
else:
line_color = "rgba(52, 152, 219, 0.3)" # Blue for normal
map_fig.add_trace(go.Scattergeo(
lon=[BANK_LOCATIONS[debtor]["lon"], BANK_LOCATIONS[creditor]["lon"]],
lat=[BANK_LOCATIONS[debtor]["lat"], BANK_LOCATIONS[creditor]["lat"]],
mode="lines",
line=dict(width=max(1, amount/1000), color=line_color),
hoverinfo="none",
showlegend=False
))
# Add markers
for bank, loc in BANK_LOCATIONS.items():
if bank == "RTR Exchange":
color = "#FF8C00"
size = 35
symbol = "star"
hover_text = (f"<b>{bank}</b><br>"
f"ποΈ Payments Canada HQ<br>"
f"π {loc['city']}, ON<br>"
f"β‘ Central Clearing Hub<br>"
f"β
ALWAYS ACTIVE")
else:
# Check if bank has any high risk transactions
bank_high_risk = False
if not filtered_df.empty:
bank_transactions = filtered_df[(filtered_df["debtor"] == bank) | (filtered_df["creditor"] == bank)]
if not bank_transactions.empty:
bank_high_risk = bank_transactions["risk_label"].str.contains("ML|CRITICAL|HIGH", na=False).any()
if bank_high_risk:
color = "#dc3545" # Red for high risk banks
elif bank in active_banks:
color = "#2ecc71" # Green for active
else:
color = "#3498db" # Blue for inactive
size = 15 + (liquidity.get(bank, 200000) / 50000)
symbol = "circle"
hover_text = (f"<b>{bank}</b><br>"
f"π° Liquidity: ${liquidity.get(bank, 0):,.2f}<br>"
f"π {loc['city']}<br>"
f"{'β οΈ HIGH RISK ACTIVITY' if bank_high_risk else ('β ACTIVE' if bank in active_banks else 'β Inactive')}")
map_fig.add_trace(go.Scattergeo(
lon=[loc["lon"]],
lat=[loc["lat"]],
mode="markers+text",
marker=dict(size=size, color=color, symbol=symbol, line=dict(width=2, color="white")),
text=[bank],
textposition="top center",
hovertext=[hover_text],
hoverinfo="text",
showlegend=False
))
map_fig.update_layout(
title="RTR Network - Risk Heat Map (π΄ High Risk Banks | π’ Active | π΅ Inactive)",
geo=dict(
scope="north america",
showland=True,
landcolor="rgb(243, 243, 243)",
projection_type="mercator",
lonaxis=dict(range=[-140, -50]),
lataxis=dict(range=[40, 70]),
center=dict(lon=-95, lat=55),
),
height=500
)
# Status timeline
if not filtered_df.empty:
timeline_data = filtered_df.tail(50).copy()
fig_timeline = px.scatter(
timeline_data,
x="timestamp",
y="amount",
color="risk_label",
title=f"Recent Transactions with Risk Levels",
labels={"amount": "Amount (CAD)", "timestamp": "Time"},
color_discrete_map={
"ML HIGH RISK": "#ff6b6b",
"CRITICAL RISK": "#dc3545",
"HIGH RISK": "#ffcccc",
"MEDIUM RISK": "#fff3cd",
"LOW RISK": "#d4edda"
},
hover_data=["debtor", "creditor", "reason_description", "risk_factors"]
)
fig_timeline.update_traces(marker=dict(size=12))
else:
fig_timeline = px.scatter(title="No transactions match filters")
# Table columns with descriptive headers
columns = [
{"name": "Time", "id": "timestamp"},
{"name": "UETR", "id": "uetr"},
{"name": "Debtor", "id": "debtor"},
{"name": "Creditor", "id": "creditor"},
{"name": "Amount", "id": "amount", "type": "numeric", "format": {"specifier": "$,.2f"}},
{"name": "Status", "id": "status"},
{"name": "Reason", "id": "reason_description"},
{"name": "Risk Level", "id": "risk_label"},
{"name": "Risk Factors", "id": "risk_factors"}
]
# Prepare table data with tooltips
table_data = []
tooltip_data = []
if not display_df.empty:
for _, row in display_df.sort_values("timestamp", ascending=False).head(50).iterrows():
# Format the row data
row_data = {
"timestamp": row["timestamp"].strftime("%H:%M:%S") if isinstance(row["timestamp"], datetime) else "",
"uetr": row["uetr"][:8] + "..." if pd.notna(row["uetr"]) else "",
"debtor": row["debtor"],
"creditor": row["creditor"],
"amount": row["amount"],
"status": row["status"],
"reason_description": row["reason_description"] if pd.notna(row["reason_description"]) else "No reason provided",
"risk_label": row["risk_label"],
"risk_factors": row["risk_factors"][:100] + "..." if pd.notna(row["risk_factors"]) and len(str(row["risk_factors"])) > 100 else str(row["risk_factors"]) if pd.notna(row["risk_factors"]) else ""
}
table_data.append(row_data)
# Create tooltip with full details
tooltip_row = {}
for col in columns:
col_id = col["id"]
if col_id == "risk_factors" and pd.notna(row["risk_factors"]):
tooltip_row[col_id] = {"value": str(row["risk_factors"]), "type": "markdown"}
elif col_id == "reason_description" and pd.notna(row["reason_description"]):
full_reason = f"**Reason Code:** {row['reason_code'] if pd.notna(row['reason_code']) else 'N/A'}\n\n**Description:** {row['reason_description']}"
tooltip_row[col_id] = {"value": full_reason, "type": "markdown"}
else:
tooltip_row[col_id] = {"value": str(row[col_id]) if pd.notna(row[col_id]) else "", "type": "markdown"}
tooltip_data.append(tooltip_row)
return metrics, fig_amount, fig_liq, map_fig, fig_timeline, table_data, columns, tooltip_data
# ==========================
# PyCafe compatible server
# ==========================
server = app.server
if __name__ == "__main__":
app.run_server(debug=False, use_reloader=False) # use_reloader=False for PyCafe