root/trac/hacks/marketplugin/0.9/tracmarket/ledger.py

Revision 116 (checked in by stevegt, 6 years ago)

add traders.py

Line 
1 # Ledger plugin
2
3 import time
4
5 from trac.core import *
6 from trac.db import Table, Column, Index
7 from trac.env import IEnvironmentSetupParticipant
8 from trac.web.chrome import INavigationContributor, ITemplateProvider, add_stylesheet
9 from trac.web.main import IRequestHandler
10 from trac.util import escape, Markup
11
12 from tracmarket.api import *
13 from tracmarket.db import *
14 from tracmarket.util import *
15
16 class Ledger(Component):
17     implements(ILedger)
18
19     # ILedger methods
20
21     def post(self, db, author, legs, memo=''):
22         """Record a transaction; 'legs' is a list of transaction legs
23         that have been created using mkleg().  Memo is a string.  Legs
24         can be mixed-currency.  Ensures debits and credits are
25         balanced within each currency.  Returns integer transaction
26         id."""
27         env = self.env
28         x = Transaction(env, db, author=author, legs=legs, memo=memo)
29         x.insert()
30         return x.id
31
32     def mkleg(self, db,
33             entity, account, symbol, debit=None, credit=None, serial=None):
34         """Convenience method to help create a proper transaction leg.
35         Does no storage.  Returns an object suitable for sending to
36         post().  Entity is an entity string.  Account is an account name
37         string.  Symbol is a string, and can be any symbol previously
38         defined by mksymbol().  Serial is an optional string, and is
39         used to designate a unique instance of symbol to be transferred,
40         e.g. equipment, stock certificate, or banknote serial number."""
41         env = self.env
42         debit = Price(debit)
43         credit = Price(credit)
44         acct = Account.selectone(env, db, entity=entity, account=account)
45         if not acct:
46             raise MissingAccount, dict(entity=entity, account=account)
47         if (debit and credit) \
48                 or not (debit or credit) \
49                 or (debit and debit < 0) \
50                 or (credit and credit < 0):
51             raise ValueError("%s %s %s debit=%s, credit=%s" %
52                     (entity, account, symbol, debit, credit))
53         balance = acct.balance(symbol)
54         # print "mkleg balance", entity, account, symbol, debit, credit, balance
55         if credit and acct.normal == 'debit' and balance < credit:
56             raise InsufficientFunds, dict(entity=entity, account=account,
57                     symbol=symbol, balance=balance, debit=debit,
58                     credit=credit, amount=credit)
59         if debit and acct.normal == 'credit' and balance < debit:
60             raise InsufficientFunds, dict(entity=entity, account=account,
61                     symbol=symbol, balance=balance, debit=debit,
62                     credit=credit, amount=debit)
63         leg = Leg(env, db,
64                  entity=entity,
65                  account=account,
66                  symbol=symbol,
67                  debit=debit,
68                  credit=credit,
69                  serial=serial,
70                  )
71         return leg
72
73     def mkaccount(self, db, entity, account, type, description=None):
74         """Create a currency or inventory account for entity.  Account
75         is an account name string.  Description is a string.  Returns
76         silently if account already exists and is same type."""
77         acct = Account.selectone(self.env, db, entity=entity, account=account)
78         if acct and acct.type == type:
79             return
80         acct = Account(self.env, db,
81             entity=entity, account=account, description=description, type=type)
82         acct.insert()
83
84     def getacct(self, db, entity, account):
85         return Account.selectone(self.env, db, entity=entity, account=account)
86
87     def history(self, db, entity=None, account=None, symbol=None,
88             start_time=None, end_time=None, limit=None, desc=False):
89         """Return a generator of transaction objects, sorted by
90         transaction id."""
91         kwargs = dict(
92                     entity=entity,
93                     account=account,
94                     symbol=symbol,
95                     limit=limit,
96                     desc=desc,
97                 )
98         args = []
99         if start_time:
100             args.append(dict(time=('>', start_time)))
101         if end_time:
102             args.append(dict(time=('>', end_time)))
103         gen = Transaction.select(self.env, db, *args, **kwargs)
104         return gen
105
106     def last(self, db, symbol, denomination, moving_average=None):
107         """Return last trade price and time for symbol, denominated in
108         'denomination' units.  If specified, moving_average is a
109         floating point number of days to use in calculating a
110         volume-weighted moving average price."""
111         assert not moving_average
112         h = self.history(db, symbol=symbol, desc=True)
113         for x in h:
114             legs = list(x.legs)
115             if len(legs) != 4:
116                 continue
117             qty = None
118             price = None
119             for l in legs:
120                 if l.debit and l.symbol == denomination:
121                     price = l.debit
122                 if l.debit and l.symbol == symbol:
123                     qty = l.debit
124             if qty and price:
125                 return price/qty
126         return None
127
128     def balance(self, db, entity, account, symbol, serial=None):
129         """Return quantity of symbol in account."""
130         env = self.env
131         return AccountBalance.balance(env, db, entity=entity,
132                 account=account, symbol=symbol, serial=serial)
133
134     def balances(self, db, entity=None, account=None, symbol=None,
135             serial=None):
136         """Return quantity of symbol in all accounts which match kwargs."""
137         assert not serial
138         env = self.env
139         s = Select(env, db, AccountBalance,
140             columns='entity,account,symbol,debit,credit,normal',
141             entity=entity, account=account, symbol=symbol)
142         for row in s.rows:
143             if row.normal == 'debit':
144                 balance = row.debit - row.credit
145             else:
146                 balance = row.credit - row.debit
147             yield row.entity,row.account,row.symbol,Price(balance)
148
149     def normal(self, db, entity, account):
150         """Return normal balance of account as 'debit' or 'credit' string."""
151         env = self.env
152         acct = Account.selectone(env, db, entity=entity, account=account)
153         if not acct:
154             raise MissingAccount, dict(entity=entity, account=account)
155         return acct.normal
156        
157     def accounts(self, db, entity=None):
158         '''List accounts.  Returns a generator of account objects.'''
159         env = self.env
160         accts = Account.select(env, db, entity=entity, order='entity,account')
161         return accts
162
163     def symbols(self, db, entity=None, account=None):
164         '''List symbols from ledger.  Returns a generator of sorted, unique
165         symbol names.'''
166         names = []
167         s = Select(self.env, db, Leg, columns='distinct symbol',
168                 entity=entity, account=account, order='symbol')
169         for row in s.rows:
170             yield row.symbol
171
172 class Account(DbObject):
173
174     VERSION = 1
175     TABLE = Table('account', key=('entity', 'account'))[
176                 Column('id', type='integer', auto_increment=True),
177                 Column('entity'),
178                 Column('account'),
179                 Column('description'),
180                 Column('type')
181             ]
182     NAME = TABLE.name
183     COLUMNS = [ c.name for c in TABLE.columns ]
184
185     def _ck_values(self):
186         self._ignore('id')
187         # check account type
188         self.normal
189
190     def normal(self):
191         # Account Type    Debit(+)    Credit(-)
192         # ASSETS          Increases   Decreases
193         # LIABILITIES     Decreases   Increases
194         # EQUITY          Decreases   Increases
195         # INCOME          Decreases   Increases
196         # EXPENSES        Increases   Decreases
197         normal = {
198             'asset': 'debit',
199             'liability': 'credit',
200             'equity': 'credit',
201             'income': 'credit',
202             'expense': 'debit',
203         }
204         normal = normal.get(self['type'], None)
205         if normal is None:
206             raise ValueError("invalid account type: %s" % self['type'])
207         return normal
208     normal = property(normal)
209
210     def balance(self, symbol):
211         # bal = AccountBalance
212         return AccountBalance.balance(self._env, self._db,
213             entity=self.entity, account=self.account, symbol=symbol)
214        
215 class AccountBalance(DbObject):
216
217     VERSION = 1
218     TABLE = Table('account_balance', key=('entity', 'account', 'symbol'))[
219                 Column('id', type='integer', auto_increment=True),
220                 Column('entity'),
221                 Column('account'),
222                 Column('symbol'),
223                 Column('serial'),
224                 Column('debit', type='numeric'),
225                 Column('credit', type='numeric'),
226                 Column('normal'),
227             ]
228     NAME = TABLE.name
229     COLUMNS = [ c.name for c in TABLE.columns ]
230
231     def _set_defaults(self):
232         self.chattr(debit=0, credit=0)
233
234     def _ck_values(self):
235         self._ignore('id','serial')
236
237     def balance(cls, env, db,
238             entity=None, account=None, symbol=None, serial=None):
239         assert not serial
240         bal = cls.selectone(env, db,
241                 entity=entity, account=account, symbol=symbol)
242         if not bal:
243             return 0.0
244         if bal.normal == 'debit':
245             balance = bal.debit - bal.credit
246         else:
247             balance = bal.credit - bal.debit
248         return Price(balance)
249     balance = classmethod(balance)
250
251 class Leg(DbObject):
252
253     VERSION = 1
254     TABLE = Table('leg', key=('id'))[
255                 Column('id', type='integer', auto_increment=True),
256                 Column('xid', type='integer'),
257                 Column('entity'),
258                 Column('account'),
259                 Column('debit', type='numeric'),
260                 Column('credit', type='numeric'),
261                 Column('symbol'),
262                 Column('serial'),
263             ]
264     NAME = TABLE.name
265     COLUMNS = [ c.name for c in TABLE.columns ]
266
267     def __str__(self):
268         if self.debit:
269             s = "%4s %15s %15s %6.2f %8s %s\n" % (
270                     (self.id, self.entity, self.account, self.debit,
271                         '', self.symbol))
272         else:
273             s = "%4s %15s %15s %8s %6.2f %s\n" % (
274                     (self.id, self.entity, self.account, '',
275                         self.credit, self.symbol))
276         return s
277
278     def _set_defaults(self):
279         self.chattr(debit=0, credit=0)
280
281     def _ck_values(self):
282         self._ignore('id', 'serial')
283
284 class Transaction(DbObject):
285
286     VERSION = 1
287     TABLE = Table('ledger', key=('id'))[
288                 Column('id', type='integer', auto_increment=True),
289                 Column('memo'),
290                 Column('author'),
291                 Column('ref_site'),
292                 Column('ref_type'),
293                 Column('ref_id'),
294                 Column('time', type='numeric')
295             ]
296     NAME = TABLE.name
297     COLUMNS = [ c.name for c in TABLE.columns ]
298
299     def __str__(self):
300         s = "%4s %s\n\t%s" % (self.id, self.memo,
301                 '\t'.join([ str(leg) for leg in self._legs ]))
302         return s
303
304     def _set_defaults(self):
305         self.chattr(memo='', time=time.time())
306         self._legs = []
307         self.readonly = False
308
309     def _ck_values(self):
310         self._ignore('id', 'ref_site', 'ref_type', 'ref_id')
311         legs = self.pop('legs', None)
312         if legs:
313             assert not self._legs
314             self._legs = legs
315         # if not self._legs:
316         #     raise ValueError("missing legs: \n %s" % str(self))
317         if not self.balanced():
318             raise ValueError("unbalanced transaction: \n %s" % str(self))
319
320     def leg(self, n):
321         return self._legs[n]
322
323     def legs(self):
324         for leg in self._legs:
325             yield leg
326     legs = property(legs)
327
328     def select(cls, env, db, append=None, parms=None, desc=False, **kwargs):
329         xtable = cls.NAME
330         ltable = Leg.NAME
331         if desc:
332             order = "%s.id DESC, %s.id" % (xtable, ltable)
333         else:
334             order = "%s.id, %s.id" % (xtable, ltable)
335         xcolumns = cls.COLUMNS
336         lcolumns = Leg.COLUMNS
337         xkwargs = {}
338         lkwargs = {}
339         if kwargs:
340             for var, val in kwargs.items():
341                 if var in xcolumns:
342                     xkwargs[var] = val
343                 if var in lcolumns:
344                     lkwargs[var] = val
345             lkwargs.pop('id', None)
346         if not append:
347             append = ''
348         append += "%s.id = %s.xid" % (xtable, ltable)
349         if lkwargs:
350             lselect = Select(env, db, Leg, columns='xid', **lkwargs)
351             xselect = Select(env, db, (Transaction, Leg),
352                     id=('IN', lselect),
353                     append=append, parms=parms, order=order, **xkwargs)
354         else:
355             xselect = Select(env, db, (Transaction, Leg),
356                     append=append, parms=parms, order=order, **xkwargs)
357         cursor = xselect.cursor()
358         transaction = None
359         state = 'build'
360         while True:
361             row = cursor.fetchone()
362             if not row:
363                 state = 'end'
364             elif transaction and transaction.id != row.get("%s.xid" % ltable):
365                 state = 'send'
366             if state in ('send', 'end'):
367                 if transaction and transaction.balanced():
368                     transaction.readonly = True
369                     yield transaction
370                 transaction = None
371             if state == 'end':
372                 raise StopIteration
373             if not transaction:
374                 kw = {}
375                 for var in xcolumns:
376                     kw[var] = row.get('%s.%s' % (xtable, var))
377                 transaction = cls(env, db, **kw)
378             kw = {}
379             for var in lcolumns:
380                 kw[var] = row.get('%s.%s' % (ltable, var))
381             leg = Leg(env, db, **kw)
382             transaction._legs.append(leg)
383             state = 'build'
384     select = classmethod(select)
385
386     def insert(self, db=None, cursor=None):
387         assert not self.readonly
388         env = self._env
389         if not db:
390             db = self._db
391         if not cursor:
392             cursor = db.cursor()
393         self.pop('id', None)
394         # import pdb; pdb.set_trace()
395         super(Transaction, self).insert(
396                 db=self._db, cursor=cursor)
397         self['id'] = self._db.get_last_id(cursor, self.NAME)
398         for leg in self._legs:
399             leg.chattr(xid=self.id)
400             leg.insert(db=self._db)
401             bal = AccountBalance.selectone(env, db,
402                 entity=leg.entity, account=leg.account, symbol=leg.symbol)
403             if not bal:
404                 # create balance record
405                 columns = 'sum(debit) as d, sum(credit) as c'
406                 s = Select(env, db, Leg, columns=columns,
407                     entity=leg.entity, account=leg.account,
408                     symbol=leg.symbol)
409                 debit = 0
410                 credit = 0
411                 rows = list(s.rows)
412                 assert len(rows) == 1
413                 for row in rows:
414                     debit = row.d
415                     credit = row.c
416                 acct = Account.selectone(env, db, entity=leg.entity,
417                         account=leg.account)
418                 if not acct:
419                     raise MissingAccount, dict(entity=leg.entity,
420                             account=leg.account)
421                 normal = acct.normal
422                 bal = AccountBalance(env, db,
423                     entity=leg.entity, account=leg.account, symbol=leg.symbol,
424                     debit=debit, credit=credit, normal=normal)
425                 bal.insert(db=db)
426             else:
427                 # update balance record
428                 set = ["debit=debit+%s", "credit=credit+%s"]
429                 parms = [leg.debit, leg.credit]
430                 count = bal.update(set=set, parms=parms)
431                 assert count == 1
432         self.readonly = True
433
434     def balanced(self):
435         debits = {}
436         credits = {}
437         symbols = {}
438         for leg in self._legs:
439             symbol = leg.symbol
440             symbols[symbol] = 1
441             debits.setdefault(symbol, 0)
442             credits.setdefault(symbol, 0)
443             debits[symbol] += leg.debit
444             credits[symbol] += leg.credit
445         for s in symbols.keys():
446             if debits[s] != credits[s]:
447                 return False
448         return True
449
450
451     def addleg(self, leg=None, **kwargs):
452         assert not self.readonly
453         if leg is None:
454             leg = Leg(self._env, self._db, **kwargs)
455         self._legs.append(leg)
456
457
458 class InitLedger(InitDB):
459     implements(IEnvironmentSetupParticipant)
460     dbobjects = (Account, AccountBalance, Leg, Transaction)
461
Note: See TracBrowser for help on using the browser.