├── license.txt ├── README.md ├── test_pycama.py └── PyCaMa.py /license.txt: -------------------------------------------------------------------------------- 1 | PyCaMa License Notice 2 | ---------------------------------------------------------- 3 | 4 | Copyright (c) 2017, Francisco Salas-Molina 5 | 6 | All rights reserved. 7 | 8 | Redistribution and use in source and binary forms, with or without 9 | modification, are permitted provided that the following conditions are 10 | met: 11 | 12 | * Redistributions of source code must retain the above copyright 13 | notice, this list of conditions and the following disclaimer. 14 | * Redistributions in binary form must reproduce the above copyright 15 | notice, this list of conditions and the following disclaimer in 16 | the documentation and/or other materials provided with the distribution 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 21 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 22 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 23 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 24 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 25 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 26 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 27 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 28 | POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PyCaMa 2 | Python for multiobjective cash management 3 | 4 | Despite the recent advances in cash management, there is a lack of supporting software to aid the transition from theory to practice. In order to fill this gap, we provide a cash management module in Python for practitioners interested in either building decision support systems for cash management or performing their own experiments. 5 | 6 | Cash managers usually deal with multiple banks to receive payments from customers and to send payments to suppliers. Operating such a cash management system implies a number of transactions between accounts, what is called a policy, to maintain the system in a state of equilibrium, meaning that there exists enough cash balance to face payments and avoid an overdraft. In addition, optimal policies allow to keep the sum of both transaction and holding costs at a minimum. However, cash managers may be interested not only in cost but also the risk of policies. Hence, risk analysis can also be incorporated as an additional goal to be minimized in cash management. As a result, deriving optimal policies in terms of both cost and risk within systems with multiple bank accounts is not an easy task. PyCaMa is able to provide such optimal policies. 7 | 8 | PyCaMa is a Python-Gurobi tool aimed to automate multiobjective decision-making in cash management. PyCaMa contributes to support scientific discovery in cash management by: (i) empowering cash managers to perform experiments, e.g., on the utility of forecasts; (ii) eliciting the best precautionary minimum balances; (iii) allowing an easy extension to a multiobjective approach by considering additional objectives such as the risk of the policies. In addition, PyCaMa support daily decision-making in cash management by providing a tool to derive optimal policies within a real-world context where cash management systems with multiple bank accounts are the rule rather than the exception. 9 | 10 | All source codes are published under the 3-Clause BSD License (see license.txt) with respect to the Python code exclusively. 11 | -------------------------------------------------------------------------------- /test_pycama.py: -------------------------------------------------------------------------------- 1 | # Tests for PyCaMa. Python for cash management 2 | 3 | from PyCaMa import * 4 | import numpy as np 5 | import unittest 6 | 7 | # Input data for testing 8 | # Planning horizon 9 | h = 5 10 | 11 | # Transactions 12 | trans = [1,2,3,4,5,6] 13 | 14 | # Bank accounts 15 | banks = [1,2,3] 16 | 17 | # Trans fixed costs 18 | g0 = {1:50, 2:50, 3:100, 4:50, 5:100, 6:50} 19 | 20 | # Trans variable costs 21 | g1 = {1:0.0, 2:0.0, 3:0.0001, 4:0.00001, 5:0.0001, 6:0.00001} 22 | 23 | # Initial balance 24 | b0 = [0, 0, 10000000] 25 | 26 | # Minimum balances 27 | bmin = [0, 0, 0] 28 | 29 | # Holding costs per bank account 30 | v = {1:0.0001, 2:0.0001, 3:0} 31 | 32 | # Allowed transactions between accounts 33 | A = np.array([[1, -1, 0, 0, 1, -1],[-1, 1, 1, -1, 0, 0],[ 0, 0, -1, 1, -1, 1]]).T 34 | 35 | # Creates an instance of the problem 36 | 37 | test_problem = multibank(banks, trans, A, g0, g1, v, bmin) 38 | 39 | # Random forecast 40 | size = h*len(banks) 41 | fcast = np.random.randint(low=-1000,high=1000,size=size).reshape((h,len(banks))) 42 | 43 | test_problem.h = fcast.shape[0] 44 | 45 | 46 | class TestPyCaMa(unittest.TestCase): 47 | 48 | def test_int(self): 49 | self.assertEqual(type(1), type(test_problem.h)) 50 | 51 | def test_dict(self): 52 | self.assertEqual(type({1:2}), type(test_problem.gzero)) 53 | self.assertEqual(type({1:2}), type(test_problem.gone)) 54 | self.assertEqual(type({1:2}), type(test_problem.v)) 55 | 56 | def test_list(self): 57 | self.assertEqual(type([1]), type(test_problem.trans)) 58 | self.assertEqual(type([1]), type(test_problem.banks)) 59 | self.assertEqual(type([1]), type(test_problem.bmin)) 60 | 61 | def test_matrix(self): 62 | self.assertEqual(type(np.array([1])), type(fcast)) 63 | self.assertEqual(type(np.array([1])), type(A)) 64 | 65 | def test_dimensions(self): 66 | self.assertEqual(A.shape, (len(test_problem.trans),len(test_problem.banks))) 67 | self.assertEqual(fcast.shape, (test_problem.h,len(test_problem.banks))) 68 | self.assertEqual(len(b0), len(test_problem.banks)) 69 | self.assertTrue(len(test_problem.banks) > 1) -------------------------------------------------------------------------------- /PyCaMa.py: -------------------------------------------------------------------------------- 1 | # PyCaMa: Python for multiobjective cash management with multiple bank accounts 2 | 3 | 4 | from gurobipy import * 5 | import numpy as np 6 | 7 | class multibank(object): 8 | 9 | def __init__(self, banks, trans, A, gzero, gone, v, bmin): 10 | """Defines the cash management system""" 11 | 12 | self.banks = list(banks) # List of banks 13 | self.trans = list(trans) # List of transactions 14 | self.A = np.array(A, dtype= int) # Incidence matrix 15 | self.gzero = gzero # Dict of fixed transaction cost 16 | self.gone = gone # Dict of variable transaction costs 17 | self.v = v # Dict of holding costs 18 | self.bmin = list(bmin) # List of minimum balances 19 | self.h = 1 # Planning horizon (default to 1) 20 | self.resx = [] # Optimal policy 21 | self.resb = [] # Optimal balance 22 | self.objval = 0 # Objective value 23 | self.costmax = 1 # Maximum cost for multiobjective optimization 24 | self.riskmax = 1 # Maximum cost for multiobjective optimization 25 | self.costref = 0 # Cost reference for multiobjective optimization 26 | self.costweight = 1 # Maximum cost for multiobjective optimization 27 | self.riskweight = 0 # Cost reference for multiobjective optimization 28 | 29 | 30 | # Checks types of input data 31 | if type(self.gzero) != dict: 32 | self.gzero = dict([(i,0) for i in self.trans]) 33 | self.A = np.zeros((len(self.banks), len(self.trans))) 34 | print("Fixed costs must be a dictionary") 35 | if type(self.gone) != dict: 36 | self.gone = dict([(i,0) for i in self.trans]) 37 | self.A = np.zeros((len(self.banks), len(self.trans))) 38 | print("Variable costs must be a dictionary") 39 | if type(self.v) != dict: 40 | self.v = dict([(i,0) for i in self.banks]) 41 | self.A = np.zeros((len(self.banks), len(self.trans))) 42 | print("Holding costs must be a dictionary") 43 | 44 | # Checks dimension agreement 45 | if self.A.shape != (len(self.banks), len(self.trans)): 46 | self.A = np.zeros((len(self.banks), len(self.trans))) 47 | print("Incidence matrix dimensions do not agree with banks or transactions") 48 | if len(self.bmin) != len(self.banks): 49 | self.banks = [] 50 | print("Minimum balances must agree with banks") 51 | if len(self.gzero) != len(self.gone): 52 | self.gzero = [] 53 | self.gone = [] 54 | print("Fixed and variable transaction costs must agree") 55 | if len(self.gzero) != len(self.trans): 56 | self.trans = [] 57 | print("Transaction costs must agree with transactions") 58 | if len(self.v) != len(self.banks): 59 | self.banks = [] 60 | print("Holding costs must agree with banks") 61 | if len(self.banks) <= 1: 62 | self.banks = [] 63 | print("A system must have at least two banks") 64 | 65 | def describe(self): 66 | """Describe the main characteristics of the system""" 67 | 68 | print('Banks =', self.banks) 69 | print('Trans =', self.trans) 70 | print('Fixed costs =', self.gzero) 71 | print('Variable costs =', self.gone) 72 | print('Holding costs =', self.v) 73 | print('Minimum balances =', self.bmin) 74 | print('A =', self.A) 75 | 76 | def solvecost(self, b0, fcast): 77 | """Solve mba problem from initial balance b0 and forecast fcast 78 | fcast: an h x m matrix with h forecasts for m accounts 79 | b0: list with initial balances for each account""" 80 | 81 | # Reset solution values 82 | self.resx = [] # Optimal policy 83 | self.resb = [] # Optimal cash balance 84 | self.objval = 0 # Objective value 85 | 86 | # Checks dimensions 87 | fcast = np.array(fcast) 88 | if len(fcast) <= len(self.banks): 89 | fcast = fcast.reshape((1,len(self.banks))) # For one-step horizons 90 | self.h = fcast.shape[0] 91 | 92 | if len(b0) != len(self.banks): 93 | return (print("Dimension for minimum balances must agree with banks")) 94 | 95 | if fcast.shape != (self.h, len(self.banks)): 96 | return (print("Dimensions for forecasts must agree with horizon and banks")) 97 | 98 | # Init model 99 | m = Model("example") 100 | 101 | #Ranges 102 | tr_range = range(len(self.trans)) 103 | bk_range = range(len(self.banks)) 104 | time_range = range(self.h) 105 | 106 | # Fixed costs: z = 1 if trans x occurs at time tau 107 | fixed = [] 108 | for tau in time_range: 109 | fixed.append([]) 110 | for t in self.trans: 111 | fixed[tau].append(m.addVar(obj = self.gzero[t], vtype = GRB.BINARY, name="z%d,%d" %(tau,t))) 112 | m.update() 113 | 114 | # Variable costs are proportional to transaction decision variables 115 | var = [] 116 | for tau in time_range: 117 | var.append([]) 118 | for t in self.trans: 119 | var[tau].append(m.addVar(obj = self.gone[t], vtype = GRB.CONTINUOUS, name="x%d,%d" %(tau,t))) 120 | m.update() 121 | 122 | # Holding costs are proportional to balance auxiliary decision variables 123 | bal = [] 124 | for tau in time_range: 125 | bal.append([]) 126 | for j in self.banks: 127 | bal[tau].append(m.addVar(obj = self.v[j], vtype = GRB.CONTINUOUS, name="b%d,%d" %(tau, j))) 128 | m.update() 129 | 130 | # Initial transition constraints and minimum balance constraints 131 | for j in bk_range: 132 | m.addConstr(b0[j] + fcast[0][j] + LinExpr(self.A[j], var[0][:]) == bal[0][j], 'IniBal%d'% j) 133 | m.addConstr(bal[0][j] >= self.bmin[j], 'Bmin%s'%j) 134 | m.update() 135 | 136 | # Rest of transition constraints 137 | for tau in range(1, self.h): 138 | for j in bk_range: 139 | m.addConstr(bal[tau-1][j] + fcast[tau][j] + LinExpr(self.A[j],var[tau][:]) == bal[tau][j], 'Bal%d,%d'%(tau, j)) 140 | m.addConstr(bal[tau][j] >= self.bmin[j], 'Bmin%d%d'%(tau,j)) 141 | m.update() 142 | 143 | # Bounds and binary variables constraints 144 | K = 9999 145 | k = 0.0001 146 | for tau in time_range: 147 | for i in tr_range: 148 | m.addConstr(var[tau][i] <= K * fixed[tau][i], name="c1%d%d" %(tau, i)) # K is a very large number 149 | m.addConstr(var[tau][i] >= k * fixed[tau][i], name="c2%d%d" %(tau, i)) # k is a very small number 150 | m.update() 151 | 152 | # Optimization 153 | m.setParam('OutputFlag', 0) 154 | m.modelSense = GRB.MINIMIZE 155 | m.optimize() 156 | 157 | # Checks if model is optimal and present results 158 | if m.status == 2: 159 | self.objval = m.ObjVal 160 | for dv in m.getVars(): 161 | if 'x' in dv.varName: 162 | self.resx.append([dv.varName, int(dv.x)]) 163 | if 'b' in dv.varName: 164 | self.resb.append([dv.varName, int(dv.x)]) 165 | return(self.resx) 166 | else: 167 | return(print("I was unable to find a solution")) 168 | 169 | 170 | def solverisk(self, b0, fcast, c0, Cmax, Rmax, w1, w2): 171 | """Solve mba problem from initial balance b0 and forecast fcast 172 | fcast: an h x m matrix with h forecasts for m accounts 173 | b0: list with initial balances for each account 174 | """ 175 | 176 | # Reset solution values 177 | self.resx = [] # Optimal policy 178 | self.resb = [] # Optimal cash balance 179 | self.objval = 0 # Objective value 180 | self.costref = c0 # Stores cost reference 181 | self.costmax = Cmax # Stores maximum cost 182 | self.riskmax = Rmax # Stores maximum risk 183 | self.costweight = w1 # Weight for cost 184 | self.riskweight = w2 # Weight for risk 185 | 186 | 187 | # Checks dimensions 188 | fcast = np.array(fcast) 189 | if len(fcast) <= len(self.banks): 190 | fcast = fcast.reshape((1,len(self.banks))) # For one-step horizons 191 | self.h = fcast.shape[0] 192 | 193 | if len(b0) != len(self.banks): 194 | return (print("Dimension for minimum balances must agree with banks")) 195 | 196 | if fcast.shape != (self.h, len(self.banks)): 197 | return (print("Dimensions for forecasts must agree with horizon and banks")) 198 | 199 | # Init model 200 | m = Model("example") 201 | 202 | #Ranges 203 | tr_range = range(len(self.trans)) 204 | bk_range = range(len(self.banks)) 205 | time_range = range(self.h) 206 | 207 | # Fixed costs: z = 1 if trans x occurs at time tau 208 | fixed = [] 209 | for tau in time_range: 210 | fixed.append([]) 211 | for t in self.trans: 212 | fixed[tau].append(m.addVar(obj = self.gzero[t], vtype = GRB.BINARY, name = "z%d,%d" %(tau, t))) 213 | m.update() 214 | 215 | # Variable costs are proportional to transaction decision variables 216 | var = [] 217 | for tau in time_range: 218 | var.append([]) 219 | for t in self.trans: 220 | var[tau].append(m.addVar(obj = self.gone[t], vtype = GRB.CONTINUOUS, name = "x%d,%d" %(tau, t))) 221 | m.update() 222 | 223 | # Holding costs are proportional to balance auxiliary decision variables 224 | bal = [] 225 | for tau in time_range: 226 | bal.append([]) 227 | for j in self.banks: 228 | bal[tau].append(m.addVar(obj = self.v[j], vtype = GRB.CONTINUOUS, name="b%d,%d" %(tau, j))) 229 | m.update() 230 | 231 | # Deviational variables above a given cost 232 | devpos = [] 233 | for tau in time_range: 234 | devpos.append(m.addVar(vtype=GRB.CONTINUOUS, name = "devpos%d" %tau)) 235 | m.update() 236 | 237 | # Deviation constraints 238 | tc = [] 239 | hc = [] 240 | for tau in time_range: 241 | tc.append(sum([self.gzero[t]*fixed[tau][t-1] + self.gone[t]*var[tau][t-1] for t in self.trans])) 242 | hc.append(sum([self.v[j]*bal[tau][j-1] for j in self.banks])) 243 | m.addConstr(tc[tau] + hc[tau] - devpos[tau] <= c0, 'DevCon%s' %tau) 244 | 245 | # Intitial transition constraints and minimum balance constraints 246 | for j in bk_range: 247 | m.addConstr(b0[j] + fcast[0][j] + LinExpr(self.A[j], var[0][:]) == bal[0][j], 'IniBal%d'% j) 248 | m.addConstr(bal[0][j] >= self.bmin[j], 'Bmin%s'%j) 249 | m.update() 250 | 251 | # Rest of transition constraints 252 | for tau in range(1, self.h): 253 | for j in bk_range: 254 | m.addConstr(bal[tau-1][j] + fcast[tau][j] + LinExpr(self.A[j],var[tau][:]) == bal[tau][j], 'Bal%d,%d'%(tau, j)) 255 | m.addConstr(bal[tau][j] >= self.bmin[j], 'Bmin%d%d'%(tau,j)) 256 | m.update() 257 | 258 | # Bounds and binary variables constraints 259 | K = 9999 260 | k = 0.0001 261 | for tau in time_range: 262 | for i in tr_range: 263 | m.addConstr(var[tau][i] <= K * fixed[tau][i], name = "c%d%d" %(tau, i)) # K is a very large number 264 | m.addConstr(var[tau][i] >= k * fixed[tau][i], name="c2%d%d" %(tau, i)) # k is a very small number 265 | m.update() 266 | 267 | # Setting the objectives 268 | transcost = sum([self.gzero[t] * fixed[tau][t-1] + self.gone[t] * var[tau][t-1] for tau in time_range for t in self.trans]) 269 | holdcost = sum([self.v[j] * bal[tau][j-1] for tau in time_range for j in self.banks]) 270 | devcost = sum([devpos[tau] for tau in time_range]) 271 | m.setObjective((w1 / Cmax) * (transcost + holdcost)+(w2 / Rmax) * devcost, GRB.MINIMIZE) 272 | m.update() 273 | 274 | # Budget constraints 275 | m.addConstr(transcost + holdcost <= Cmax, name = "CostBudget") 276 | m.addConstr(devcost <= Rmax, name = "RiskBudget") 277 | m.update() 278 | 279 | # Optimization 280 | m.setParam('OutputFlag', 0) 281 | m.optimize() 282 | 283 | # Checks if model is optimal and present results 284 | if m.status == 2: 285 | self.objval = m.ObjVal 286 | for dv in m.getVars(): 287 | if 'x' in dv.varName: 288 | self.resx.append([dv.varName, int(dv.x)]) 289 | if 'b' in dv.varName: 290 | self.resb.append([dv.varName, int(dv.x)]) 291 | return(self.resx) 292 | else: 293 | return(print("I was unable to find a solution")) 294 | 295 | 296 | def policy(self): 297 | """Returns a matrix with policy for each transaction""" 298 | if len(self.resx) > 0: 299 | plan = np.array(self.resx) 300 | planmat = np.array([int(i) for i in plan[:,1]]).reshape((self.h,len(self.trans))) 301 | return(planmat) 302 | else: 303 | return(print("Nothing to show")) 304 | 305 | def balance(self): 306 | """Returns a matrix with balances for each bank account""" 307 | if len(self.resb) > 0: 308 | bals = np.array(self.resb) 309 | balsmat = np.array([int(i) for i in bals[:,1]]).reshape((self.h,len(self.banks))) 310 | return(balsmat) 311 | else: 312 | return(print("Nothing to show")) --------------------------------------------------------------------------------