Credora data model
All data is synthetic, created by the mock data generator (mocks/generator). Sapphire Bank's own data lives in three RDS database servers (Oracle, MySQL, PostgreSQL). External data lives behind three mock APIs, each with its own database. Exasol reads the bank databases through virtual schemas and calls the mock APIs from UDFs.
Identifiers are stable strings so they can be joined across systems:
| ID | Format | Example |
|---|---|---|
| Company CIF | C + 6 digits |
C000042 |
| Partner ID | P + 7 digits |
P0000123 |
| RM ID | RM + 3 digits |
RM007 |
| Application ID | A + 7 digits |
A0000042 |
| Sapphire account number | 5011 + 10 digits |
50110000004213 |
| PAN | 5 letters, 4 digits, 1 letter; 4th letter C for company, F for firm, P for person |
AAACS1234K |
| GSTIN | 2-digit state code + PAN + 1Z + check char |
27AAACS1234K1Z5 |
1. Oracle: core banking (CBS schema)#
Sapphire Bank accounts and transactions.
CBS.ACCOUNT (
ACCOUNT_NO VARCHAR2(14) PRIMARY KEY,
OWNER_TYPE VARCHAR2(8) NOT NULL, -- 'COMPANY' | 'PARTNER'
OWNER_ID VARCHAR2(12) NOT NULL, -- CIF id (Cxxxxxx) or partner id (Pxxxxxxx)
ACCT_TYPE VARCHAR2(4) NOT NULL, -- 'CA' current, 'SA' savings, 'OD' overdraft
BRANCH VARCHAR2(40),
OPENED_ON DATE NOT NULL,
STATUS VARCHAR2(8) NOT NULL, -- 'ACTIVE' | 'DORMANT' | 'CLOSED'
OD_LIMIT NUMBER(18,2) DEFAULT 0
)
CBS.TXN (
TXN_ID NUMBER(12) PRIMARY KEY,
ACCOUNT_NO VARCHAR2(14) NOT NULL,
TXN_DATE DATE NOT NULL,
DR_CR CHAR(1) NOT NULL, -- 'D' | 'C'
AMOUNT NUMBER(18,2) NOT NULL,
CHANNEL VARCHAR2(6) NOT NULL, -- NEFT RTGS IMPS UPI CHQ CASH
COUNTERPARTY VARCHAR2(80),
NARRATION VARCHAR2(120),
BALANCE_AFTER NUMBER(18,2) NOT NULL,
BOUNCED CHAR(1) DEFAULT 'N' NOT NULL -- 'Y' for a returned cheque / failed debit
)
Every company has one CA account with 12–24 months of transactions. About 30% of partners hold an SA account at Sapphire Bank.
2. MySQL: customer master (cif database)#
cif.rm (
rm_id VARCHAR(6) PRIMARY KEY,
name VARCHAR(60) NOT NULL,
branch VARCHAR(40) NOT NULL
)
cif.company (
cif_id VARCHAR(8) PRIMARY KEY,
legal_name VARCHAR(120) NOT NULL,
constitution VARCHAR(30) NOT NULL, -- 'Private Limited' | 'LLP' | 'Partnership' | 'Public Limited'
pan CHAR(10) NOT NULL,
gstin CHAR(15) NOT NULL,
incorporated_on DATE NOT NULL,
industry VARCHAR(40) NOT NULL,
city VARCHAR(40) NOT NULL,
state VARCHAR(30) NOT NULL,
rm_id VARCHAR(6) NOT NULL,
kyc_status VARCHAR(10) NOT NULL, -- 'VERIFIED' | 'EXPIRED' | 'PENDING'
kyc_expiry DATE NOT NULL,
customer_since DATE NOT NULL
)
cif.partner (
partner_id VARCHAR(8) PRIMARY KEY,
cif_id VARCHAR(8) NOT NULL, -- the company
name VARCHAR(80) NOT NULL,
pan CHAR(10) NOT NULL,
dob DATE NOT NULL,
gender CHAR(1) NOT NULL,
role VARCHAR(20) NOT NULL, -- 'Director' | 'Partner' | 'Promoter'
share_pct DECIMAL(5,2) NOT NULL,
city VARCHAR(40) NOT NULL,
sapphire_customer TINYINT(1) NOT NULL -- 1 if the partner banks with Sapphire
)
Each company has 2–5 partners; their share_pct adds up to 100.
3. PostgreSQL: loan origination (los database) and CRM (crm database)#
Two databases on the bank PostgreSQL server, tables in the public schema of each (the Exasol PostgreSQL virtual schema adapter exposes one schema of one database). The master user owns both and the backend writes crm with it; role credora_ro has SELECT on all four tables. DDL: db/postgres_los.sql, db/postgres_crm.sql.
-- database los
application (
app_id VARCHAR(8) PRIMARY KEY,
cif_id VARCHAR(8) NOT NULL,
applied_on DATE NOT NULL,
product VARCHAR(4) NOT NULL, -- 'WC' working capital | 'TL' term loan
amount_requested NUMERIC(18,2) NOT NULL, -- rupees
tenure_requested INTEGER NOT NULL, -- months
purpose VARCHAR(80) NOT NULL,
collateral_type VARCHAR(30) NOT NULL, -- 'Property' | 'Plant & machinery' | 'Receivables' | 'None'
collateral_value NUMERIC(18,2) NOT NULL,
status VARCHAR(12) NOT NULL -- 'NEW' | 'ANALYSED'
)
-- Past loans with outcomes: training data for the risk model.
-- Features are the snapshot the bank stored when the loan was sanctioned.
past_loan (
loan_id VARCHAR(10) PRIMARY KEY,
sanctioned_on DATE NOT NULL,
product VARCHAR(4) NOT NULL,
amount NUMERIC(18,2) NOT NULL,
gst_years NUMERIC(4,1) NOT NULL,
gst_turnover_12m NUMERIC(18,2) NOT NULL,
gst_late_filings_12m INTEGER NOT NULL,
gst_bank_gap_pct NUMERIC(7,2) NOT NULL, -- |GST turnover - bank credits| / GST turnover * 100
avg_balance_12m NUMERIC(18,2) NOT NULL,
inflow_volatility NUMERIC(7,4) NOT NULL, -- stddev / mean of monthly credits
bounce_ratio NUMERIC(7,4) NOT NULL, -- bounced / debit count
cmr_rank INTEGER NOT NULL, -- 1 best .. 10 worst
partner_min_score INTEGER NOT NULL,
partner_avg_score NUMERIC(6,1) NOT NULL,
partner_overdue_amt NUMERIC(18,2) NOT NULL,
partner_other_bank_bal NUMERIC(18,2) NOT NULL,
dscr NUMERIC(7,3) NOT NULL,
defaulted BOOLEAN NOT NULL
)
-- database crm
offer (
offer_id SERIAL PRIMARY KEY,
run_id VARCHAR(20) NOT NULL,
app_id VARCHAR(8) NOT NULL,
cif_id VARCHAR(8) NOT NULL,
decision VARCHAR(8) NOT NULL, -- 'APPROVE' | 'REFER' | 'DECLINE'
grade VARCHAR(3),
pd NUMERIC(7,5),
rank_no INTEGER,
offer_amount NUMERIC(18,2),
offer_roi NUMERIC(6,3),
offer_tenure INTEGER,
floor_roi NUMERIC(6,3),
max_amount NUMERIC(18,2),
max_tenure INTEGER,
stage VARCHAR(12) NOT NULL, -- OFFERED NEGOTIATING ESCALATED VERIFIED SANCTIONED DECLINED
agreed_amount NUMERIC(18,2),
agreed_roi NUMERIC(6,3),
agreed_tenure INTEGER,
updated_at TIMESTAMP NOT NULL DEFAULT now()
)
offer_event (
event_id SERIAL PRIMARY KEY,
offer_id INTEGER NOT NULL,
actor VARCHAR(20) NOT NULL, -- app user name
action VARCHAR(20) NOT NULL, -- PROPOSE SUBMIT ESCALATE VERIFY SANCTION REJECT NOTE
amount NUMERIC(18,2),
roi NUMERIC(6,3),
tenure INTEGER,
note VARCHAR(400),
at TIMESTAMP NOT NULL DEFAULT now()
)
4. Mock CIBIL (cibil database, served by mocks/cibil)#
consumer_report (pan CHAR(10) PRIMARY KEY, name VARCHAR(80), dob DATE, score INT, -- 300..900, or -1 = no history
report_date DATE, enquiries_6m INT, enquiries_12m INT)
consumer_account (id SERIAL PRIMARY KEY, pan CHAR(10), lender VARCHAR(40), acct_type VARCHAR(30),
-- 'Home Loan' 'Personal Loan' 'Credit Card' 'Auto Loan' 'Business Loan' 'Gold Loan'
opened_on DATE, sanctioned NUMERIC(18,2), balance NUMERIC(18,2), overdue NUMERIC(18,2),
status VARCHAR(12), -- 'ACTIVE' 'CLOSED' 'WRITTEN_OFF' 'SETTLED'
dpd_36m VARCHAR(144)) -- 36 comma-separated monthly DPD values, newest first ('0,0,30,...')
commercial_report (pan CHAR(10) PRIMARY KEY, name VARCHAR(120), cmr_rank INT, -- 1..10
report_date DATE, total_exposure NUMERIC(18,2), overdue NUMERIC(18,2),
suit_filed BOOLEAN, wilful_default BOOLEAN, facilities INT)
5. Mock GSTN (gstn database, served by mocks/gstn)#
registration (gstin CHAR(15) PRIMARY KEY, legal_name VARCHAR(120), pan CHAR(10), state VARCHAR(30),
registered_on DATE, status VARCHAR(10)) -- 'ACTIVE' | 'SUSPENDED'
filing (gstin CHAR(15), period CHAR(7), -- 'YYYY-MM'
gstr1_turnover NUMERIC(18,2), gstr3b_tax_paid NUMERIC(18,2),
due_date DATE, filed_on DATE, -- NULL = not filed
PRIMARY KEY (gstin, period))
A return that was not filed has filed_on, gstr1_turnover and gstr3b_tax_paid all NULL. Only periods whose due date (the 20th of the next month) has passed are generated.
Each company has 2–10 years of monthly filings, chosen at random. 2–4 years is a New company, 5–7 Mid-level, 8–10 Stable.
6. Mock Account Aggregator (aa database, served by mocks/aa)#
Accounts that partners (and some companies) hold at other banks.
fi_account (aa_account_id VARCHAR(16) PRIMARY KEY, owner_pan CHAR(10), owner_name VARCHAR(120),
fip VARCHAR(40), -- 'State Bank of India' 'HDFC Bank' 'ICICI Bank' 'Axis Bank' 'Kotak Mahindra Bank'
-- 'Punjab National Bank' 'Bank of Baroda' 'Canara Bank' 'Union Bank of India' 'IndusInd Bank'
acct_type VARCHAR(10), -- 'SAVINGS' | 'CURRENT'
masked_no VARCHAR(20), opened_on DATE, current_balance NUMERIC(18,2))
fi_txn (id BIGSERIAL PRIMARY KEY, aa_account_id VARCHAR(16), txn_date DATE, dr_cr CHAR(1), amount NUMERIC(18,2),
narration VARCHAR(120), balance_after NUMERIC(18,2))
consent (consent_id VARCHAR(36) PRIMARY KEY, pan CHAR(10), purpose VARCHAR(60), status VARCHAR(10),
created_at TIMESTAMP, expires_at TIMESTAMP)
Each partner has 1–3 accounts at other banks, with 12 months of transactions.
7. Hidden truth (generator output, database truth on mockdb)#
truth (cif_id VARCHAR(8) PRIMARY KEY, true_pd NUMERIC(7,5), would_default BOOLEAN)
generation_job (job_id, seed, status, started_at, finished_at, seconds, counts JSONB, error) -- backs GET /last
Every mockdb database (cibil, gstn, aa, truth) also has a one-row meta (generated_at, seed, job_id) table written at the end of each generation; the mocks' /status reads it. DDL for all of mockdb: db/mockdb.sql.
The generator gives every company a hidden risk level that drives all its data consistently. Risky companies file GST late, bounce more, show a bigger gap between GST turnover and bank credits, and have partners with lower scores. It saves the truth per company (cif_id, true_pd, would_default) to S3 and to the mock database truth table, so the admin scorecard can measure precision and recall. Exasol never reads it during a decision.
8. Feature definitions#
los.past_loan stores these features as they stood at sanction. For a current application Exasol computes the same features from the raw data. The generator draws past-loan features from the same latent-risk process (and the same simulators) it uses for current companies, so a model trained on past_loan applies to the current applications.
Definitions, for one application:
as_of=los.application.applied_on.- 12m window = the 12 complete calendar months before the month of
as_of:TXN_DATE >= ADD_MONTHS(TRUNC(as_of,'MM'), -12) AND TXN_DATE < TRUNC(as_of,'MM'). Calendar months are used because a partial month at either end would distort the monthly-credit volatility, and because GST periods are calendar months. - Last 12 periods = the 12 most recent GST periods with
period < TO_CHAR(as_of,'YYYY-MM'). - Company CA = the company's
CBS.ACCOUNTrow withOWNER_TYPE='COMPANY'andACCT_TYPE='CA'. Every current company has at least 13 months of CA transactions, so the 12m window is always full. - Partners = the company's
cif.partnerrows. Partner score =consumer_report.score, with-1(no history) counted as 650.
| Feature | Formula |
|---|---|
gst_years |
(months from registration.registered_on to as_of) / 12, where months = (year·12 + month of as_of) − (year·12 + month of registered_on); 1 decimal |
gst_turnover_12m |
SUM(gstr1_turnover) over the last 12 periods (unfiled periods are NULL and add nothing) |
gst_late_filings_12m |
count of the last 12 periods with filed_on IS NULL OR filed_on > due_date |
gst_bank_gap_pct |
ABS(gst_turnover_12m − SUM(AMOUNT) of CA rows with DR_CR='C' in the 12m window) / gst_turnover_12m * 100 |
avg_balance_12m |
AVG(BALANCE_AFTER) over all CA rows in the 12m window (per transaction, not per day) |
inflow_volatility |
STDDEV_POP(m) / AVG(m) where m = the 12 monthly sums of CA credits (DR_CR='C') in the 12m window; a month with no credits counts as 0 |
bounce_ratio |
COUNT(BOUNCED='Y') / COUNT(DR_CR='D') over CA rows in the 12m window (bounced rows are debits and count in the denominator) |
cmr_rank |
commercial_report.cmr_rank for the company PAN |
partner_min_score |
MIN(partner score) |
partner_avg_score |
AVG(partner score), 1 decimal |
partner_overdue_amt |
SUM(consumer_account.overdue) over all accounts of all partners (any status) |
partner_other_bank_bal |
SUM(fi_account.current_balance) over AA accounts whose owner_pan is a partner PAN (company-owned AA accounts are excluded) |
dscr |
gst_turnover_12m * 0.12 / debt_service. TL: debt_service = 12 × EMI of amount_requested at 11% p.a. over tenure_requested months, EMI = P·r / (1 − (1+r)^−n), r = 0.11/12. WC: debt_service = amount_requested * 0.11 (interest only) |
For past loans product and amount are stored; the tenure behind their dscr is not. The default rate in past_loan is about 8%.