-
Notifications
You must be signed in to change notification settings - Fork 3
/
Copy pathlcInvestor.py
327 lines (269 loc) · 12.8 KB
/
lcInvestor.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
'''
Created on Nov 21, 2015
@author: Joey Whelan
'''
import ConfigParser
import logging.handlers
import requests
import json
import decimal
import operator
CONFIG_FILENAME = 'lcInvestor.cfg'
LOG_FILENAME = 'lcInvestor.log'
#Global logger, console + rotating file
logger = logging.getLogger('logapp')
logger.setLevel(logging.INFO)
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
fh = logging.handlers.RotatingFileHandler(LOG_FILENAME, maxBytes=1000000, backupCount=2)
fh.setLevel(logging.INFO)
fh.setFormatter(formatter)
ch = logging.StreamHandler()
ch.setLevel(logging.INFO)
ch.setFormatter(formatter)
logger.addHandler(fh)
logger.addHandler(ch)
class ConfigData(object):
"""Class for fetching user-configurable options from a file.
"""
def __init__(self, filename):
"""Fetches user options and sets instance variables.
Args:
self: Instance reference
filename: Name of configuration file
Returns:
None
Raises:
NoSectionError: Raised if section is missing in config file
NoOptionError: Raised if option is missing in config file
"""
logger.debug('Entering ConfigData init(), filename:' + filename)
cfgParser = ConfigParser.ConfigParser()
cfgParser.optionxform = str
cfgParser.read(filename)
self.investorId = self.castNum(cfgParser.get('AccountData', 'investorId'))
self.authKey = cfgParser.get('AccountData', 'authKey')
self.reserveCash = self.castNum(cfgParser.get('AccountData', 'reserveCash'))
self.investAmount = self.castNum(cfgParser.get('AccountData', 'investAmount'))
if self.investAmount < 25 or self.investAmount % 25 != 0: #LC rules dictate that investments must be in multiples of $25
raise RuntimeError('Invalid investment amount specified in configuration file')
self.portfolioName = cfgParser.get('AccountData', 'portfolioName')
criteriaOpts = cfgParser.options('LoanCriteria') #Loan filtering criteria
self.criteria = {}
for opt in criteriaOpts:
self.criteria[opt] = self.castNum(cfgParser.get('LoanCriteria', opt));
logger.debug('ConfigData init(), opt:' + opt + ' val:' + str(self.criteria[opt]))
logger.debug('Exiting ConfigData init()')
def castNum(self, val):
"""Determines types of configuration values and casts them accordingly.
Types are determined via attempts at casting and evaluating any exceptions raised.
Args:
self: Instance reference
val: Value to be tested/casted
Returns:
The value casted to the appropriate type (float, Decimal or string)
Raises:
None
"""
logger.debug('Entering castNum, val:' + str(val))
try:
i = int(val)
logger.debug('Exiting castNum, ' + str(val) + ' was an int')
return i
except ValueError:
try:
d = decimal.Decimal(val)
logger.debug('Exiting castNum, ' + str(val) + ' was a decimal')
return d
except decimal.InvalidOperation:
logger.debug('Exiting castNum, ' + val + ' was a string')
return val
class LendingClub(object):
"""Class for accessing the Lending Club REST API.
Provides simple methods for accessing the LC API. Additionally, provides logic
for filtering loans and submitting orders.
"""
apiVersion = 'v1'
def __init__(self, config):
"""Initializes state variables for class.
Args:
self: Instance reference
config: Instance of ConfigData
Returns:
None
Raises:
None
"""
self.config = config
self.header = {'Authorization' : self.config.authKey, 'Content-Type': 'application/json'}
self.loans = None
self.cash = None
self.portfolioId = None
self.acctSummaryURL = 'https://api.lendingclub.com/api/investor/' + LendingClub.apiVersion + \
'/accounts/' + str(self.config.investorId) + '/summary'
self.loanListURL = 'https://api.lendingclub.com/api/investor/' + LendingClub.apiVersion + \
'/loans/listing'
self.portfoliosURL = 'https://api.lendingclub.com/api/investor/' + LendingClub.apiVersion + \
'/accounts/' + str(self.config.investorId) + '/portfolios'
self.ordersURL = 'https://api.lendingclub.com/api/investor/' + LendingClub.apiVersion + \
'/accounts/' + str(self.config.investorId) + '/orders'
def __getCash(self):
"""Private method for fetching available cash at Lending Club.
Args:
self: Instance reference
Returns:
Cash value fetched from Lending Club in decimal format.
Raises:
HTTPError: Any sort of HTTP 400/500 response returned from Lending Club.
"""
logger.debug('Entering __getCash()')
resp = requests.get(self.acctSummaryURL, headers=self.header)
resp.raise_for_status()
logger.debug('Exiting __getCash()')
return decimal.Decimal(str(resp.json()['availableCash']))
def __getLoans(self):
"""Private method for fetching loans and then filtering them based on user criteria.
Args:
self: Instance reference
Returns:
List of loans that meet the user's criteria. List is composed of tuples: loan ID, percentage of loan funded.
List is sorted in reverse order on funding percentage.
Raises:
HTTPError: Any sort of HTTP 400/500 response returned from Lending Club.
"""
logger.debug('Entering __getLoans()')
payload = {'showAll' : 'true'}
resp = requests.get(self.loanListURL, headers=self.header, params=payload)
resp.raise_for_status()
#Compare each available loan to the user's criteria. Those that match, add the loanID and percentage
#funded to a dictionary object. Finally, return a sorted list (of tuples) based on percentage funded.
loanDict = {}
for loan in resp.json()['loans']:
numChecked = 0
for criterion in self.config.criteria:
if loan[criterion] == self.config.criteria[criterion]:
numChecked += 1
else:
break
if numChecked == len(self.config.criteria):
loanDict[loan['id']] = loan['fundedAmount'] / loan['loanAmount']
logger.info('Loan id:' + str(loan['id']) + \
' was a match, funded percentage = ' + str(loanDict[loan['id']]))
logger.debug('Exiting __getLoans()')
return sorted(loanDict.items(), key=operator.itemgetter(1), reverse=True)
def __getPortfolioId(self):
"""Private method for fetching the portfolio ID for a given portfolio name.
Uses the config data provided by the user in the configuration file
Args:
self: Instance reference
Returns:
ID matching the portfolio name
Raises:
HTTPError: Any sort of HTTP 400/500 response returned from Lending Club.
RuntimeError: If the user-provided portfolio name does not match any fetched from Lending Club
"""
logger.debug('Entering __getPortfolioId()')
resp = requests.get(self.portfoliosURL, headers=self.header)
resp.raise_for_status()
for portfolio in resp.json()['myPortfolios']:
if portfolio['portfolioName'] == self.config.portfolioName:
portfolioId = portfolio['portfolioId']
break
logger.debug('Exiting __getPortfolioId()')
if portfolioId is None:
raise RuntimeError('Invalid Portfolio Name specified in configuration file')
else:
return portfolioId
def __postOrder(self, aid, loanId, requestedAmount, portfolioId):
"""Private method for posting a loan order to Lending Club
Args:
self: Instance reference
aid: Account ID, same as Investor ID provided by user in configuration file
loanId: ID of loan to be invested in
requestedAmount: amount to be invested
portfolioId: Id of portfolio where this loan investment will be placed
Returns:
The final amount invested, per Lending Club's API return value.
Raises:
HTTPError: Any sort of HTTP 400/500 response returned from Lending Club.
"""
logger.debug('Entering __postOrder(), aid:' + str(aid) + ', loanId:' + str(loanId) + \
', requestedAmount:' + str(requestedAmount) + ', portfolioId:' + str(portfolioId))
payload = json.dumps({'aid': aid, \
'orders':[{'loanId' : loanId, \
'requestedAmount' : float(requestedAmount), \
'portfolioId' : portfolioId}]})
resp = requests.post(self.ordersURL, headers=self.header, data=payload)
retVal = resp.json();
#Check for the existence of an 'errors' object in the response.
#If one exists, display the message. The 'errors' object implies a HTTP error code.
#That HTTP error will be raised to an exception with raise_for_status()
if 'errors' in retVal:
for error in retVal['errors']:
logger.error('Order error: ' + error['message'])
resp.raise_for_status()
#Only 1 order is placed per call of this method. Pull the first confirmation and log the amount invested.
confirmation = retVal['orderConfirmations'][0]
logger.info('OrderId:' + str(retVal['orderInstructId']) + ', $' + \
str(confirmation['investedAmount']) + ' was invested in loanId:' + str(confirmation['loanId']))
logger.debug('Exiting __postOrder()')
return decimal.Decimal(str(confirmation['investedAmount']))
def hasCash(self):
"""Method for determining if enough funds exist at Lending Club to place an order
Args:
self: Instance reference
Returns:
Boolean indicating if enough funds exist to execute an order.
Raises:
HTTPError: Any sort of HTTP 400/500 response returned from Lending Club.
"""
logger.debug('Entering hasCash()')
if self.cash is None:
self.cash = self.__getCash()
logger.info('Cash at Lending Club: ' + str(self.cash.quantize(decimal.Decimal('.01'), decimal.ROUND_05UP)))
investMin = self.cash - self.config.reserveCash - self.config.investAmount
logger.debug('Exiting hasCash()')
if (investMin >= 0):
logger.info('Sufficient Cash Available to invest')
return True
else:
logger.info('Insufficient Cash Available to invest')
return False
def hasLoans(self):
"""Method for determining if any loans exist that meet the user's requirements
Args:
self: Instance reference
Returns:
Boolean indicating if matching loans exist
Raises:
HTTPError: Any sort of HTTP 400/500 response returned from Lending Club.
"""
logger.debug('Entering hasLoans()')
if self.loans is None:
self.loans = self.__getLoans()
logger.info('Total number of matching loans available:' + str(len(self.loans)))
logger.debug('Exiting hasLoans()')
return len(self.loans) > 0
def buy(self):
"""Method for submitting a loan order to Lending Club
Method 'pops' a loan from the matching loan list and deducts the investment amount from the available cash
balance
Args:
self: Instance reference
Returns:
None
Raises:
HTTPError: Any sort of HTTP 400/500 response returned from Lending Club.
"""
logger.debug('Entering buy()')
if self.portfolioId is None:
self.portfolioId = self.__getPortfolioId()
self.cash -= self.__postOrder(self.config.investorId, self.loans.pop(0)[0], self.config.investAmount, self.portfolioId)
logger.debug('Exiting buy()')
#Main code block. Instantiates the Lending API access, then loops while cash is available to invest
#and loans meeting the user-defined criteria exist.
try:
lc = LendingClub(ConfigData(CONFIG_FILENAME))
while lc.hasCash() and lc.hasLoans():
lc.buy()
except:
logger.exception('')