diff --git a/simplecoin/models.py b/simplecoin/models.py index e96ef2e7..48bb72e9 100755 --- a/simplecoin/models.py +++ b/simplecoin/models.py @@ -1,4 +1,5 @@ import calendar +import decimal from decimal import Decimal import logging @@ -41,6 +42,23 @@ def make_upper_lower(trim=None, span=None, offset=None, clip=None, fmt="dt"): return lower, upper +class TradeResult(base): + """ Represents the results of a single trade update from our trading + backend. When an update to a TradeRequest gets posted a new trade result + will get created to record associated trade information. The results of the + trade are distributed among it's credits. Creation is handled by + TradeRequest.update """ + id = db.Column(db.Integer, primary_key=True) + # Quantity of currency to be traded + quantity = db.Column(db.Numeric, nullable=False) + exchanged_quantity = db.Column(db.Numeric, default=None) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + req_id = db.Column(db.Integer, db.ForeignKey('trade_request.id')) + req = db.relationship('TradeRequest', foreign_keys=[req_id], + backref='results') + + class TradeRequest(base): """ Used to provide info necessary to external applications for trading currencies @@ -48,57 +66,145 @@ class TradeRequest(base): Created rows will be checked + updated externally """ id = db.Column(db.Integer, primary_key=True) - # 3-8 letter code for the currency to be traded + # 3-8 letter code for the currency to be traded. This is the currency to + # buy for a buy, and the currency we're selling for a sale currency = db.Column(db.String, nullable=False) # Quantity of currency to be traded quantity = db.Column(db.Numeric, nullable=False) + quantity_traded = db.Column(db.Numeric, nullable=False, default=0) created_at = db.Column(db.DateTime, default=datetime.utcnow) type = db.Column(db.Enum("sell", "buy", name="req_type"), nullable=False) - - # These values should only be updated by sctrader - exchanged_quantity = db.Column(db.Numeric, default=None) - # Fees from fulfilling this tr - fees = db.Column(db.Numeric, default=None) _status = db.Column(db.SmallInteger, default=0) - def distribute(self): - assert self.type in ["buy", "sell"], "Invalid type!" - assert self.exchanged_quantity > 0 - - # Check config to see if we're charging exchange fees or not - payable_amount = self.exchanged_quantity - if current_app.config.get('charge_autoex_fees', False): - payable_amount -= self.fees + @property + def avg_price(self): + if self.type == "buy": + return self.quantity / (self.exchanged_quantity + self.fees) + elif self.type == "sell": + return (self.exchanged_quantity + self.fees) / self.quantity + def update(self, quantity, source_quantity, fees): credits = self.credits # Do caching here, avoid multiple lookups if not credits: current_app.logger.warn("Trade request #{} has no attached credits" .format(self.id)) + return + + # Get the amount of source currency that hasn't been distributed to + # credits + total_unpaid = 0 + unpaid_credits = [] + for credit in credits: + # We need to skip credits that are already attached to a result + if credit.trade_result: + continue + + unpaid_credits.append(credit) + + if self.type == "sell": + total_unpaid += credit.amount + else: + total_unpaid += credit.sell_amount + + source_total = 0 + destination_total = 0 + fee_total = 0 + for result in self.trade_results: + source_total += result.quantity + destination_total += result.exchanged_quantity + fee_total += result.fees + + if quantity <= destination_total or source_quantity <= source_total: + current_app.logger.warn( + "Nothing to update, quantity and source_quantity have not changed") + return + + new_result = TradeResult(quantity=source_quantity - source_total, + exchanged_quantity=quantity - destination_total, + fees=fees - fee_total) + db.session.add(new_result) + + distribute_amount = new_result.exchanged_quantity + # If we're not covering exchange fees, remove them from the amount + # we distribute + if not current_app.config.get('cover_autoex_fees', False): + distribute_amount -= new_result.fees + + # If the upaid credits sum up to more than the amount of the + # TradeResult then we're going to have to split the credit objects so + # we can perform a partial credit + if total_unpaid > distribute_amount: + ratio = {'traded': total_unpaid - distribute_amount, 'remaining': distribute_amount} + + payable_credits = [] + for credit in unpaid_credits: + # Copy the original credit's metadata + split_credit = CreditExchange( + user=credit.user, + sharechain_id=credit.sharechain_id, + sell_req=credit.sell_req, + buy_req=credit.sell_req, + currency=credit.currency, + address=credit.address, + fee_perc=credit.fee_perc, + pd_perc=credit.pd_perc, + block=credit.block) + + # Calculate the new CreditExchange's amount, and sell amount if + # this is a buy trade request + new_amounts = distributor(credit.amount, ratio) + if self.type == "buy": + new_sell_amounts = distributor(credit.sell_amount, ratio) + split_credit.amount = new_amounts['traded'] + split_credit.sell_amount = new_sell_amounts['traded'] + credit.amount = new_amounts['remaining'] + credit.sell_amount = new_sell_amounts['remaining'] + + db.session.add(split_credit) + payable_credits.append(split_credit) + + # Populate the id values of the new credits + db.session.flush() + current_app.logger.info("Successfully split {:,} credits." + .format(len(unpaid_credits))) + self._status = 5 + elif total_unpaid == distribute_amount: + self._status = 6 + payable_credits = unpaid_credits else: - # calculate user payouts based on percentage of the total - # exchanged value + raise ValueError("We have been told that more currency was " + "traded then what we requested!") + + # Set the trade result object + for credit in payable_credits: + if self.type == "buy": + credit.buy_result = new_result + credit.payable = True + else: + credit.sell_result = new_result + + # calculate user payouts based on percentage of the total + # exchanged value + if self.type == "sell": + portions = {c.id: c.amount for c in payable_credits} + elif self.type == "buy": + portions = {c.id: c.sell_amount for c in payable_credits} + amounts = distributor(new_result.exchanged_quantity, portions) + + for credit in credits: if self.type == "sell": - portions = {c.id: c.amount for c in credits} + assert credit.sell_amount is None + credit.sell_amount = amounts[credit.id] elif self.type == "buy": - portions = {c.id: c.sell_amount for c in credits} - amounts = distributor(payable_amount, portions) - - for credit in credits: - if self.type == "sell": - assert credit.sell_amount is None - credit.sell_amount = amounts[credit.id] - elif self.type == "buy": - assert credit.buy_amount is None + if credit.payable is False: credit.buy_amount = amounts[credit.id] # Mark the credit ready for payout to users credit.payable = True - current_app.logger.info( - "Successfully pushed trade result for request id {:,} and " - "amount {:,} to {:,} credits.". - format(self.id, self.exchanged_quantity, len(credits))) - - self._status = 6 + current_app.logger.info( + "Successfully pushed trade result for request id {:,} and " + "amount {:,} to {:,} credits.". + format(self.id, self.exchanged_quantity, len(credits))) @property def credits(self): @@ -437,10 +543,16 @@ class CreditExchange(Credit): sell_req_id = db.Column(db.Integer, db.ForeignKey('trade_request.id')) sell_req = db.relationship('TradeRequest', foreign_keys=[sell_req_id], backref='sell_credits') + #sell_res_id = db.Column(db.Integer, db.ForeignKey('trade_result.id')) + #sell_res = db.relationship('TradeResult', foreign_keys=[buy_req_id], + # backref='sell_credits') sell_amount = db.Column(db.Numeric) buy_req_id = db.Column(db.Integer, db.ForeignKey('trade_request.id')) buy_req = db.relationship('TradeRequest', foreign_keys=[buy_req_id], + #buy_res_id = db.Column(db.Integer, db.ForeignKey('trade_result.id')) backref='buy_credits') + #buy_res = db.relationship('TradeResult', foreign_keys=[buy_req_id], + # backref='buy_credits') buy_amount = db.Column(db.Numeric) @property diff --git a/simplecoin/rpc_views.py b/simplecoin/rpc_views.py index 910cf074..fa2aaa8f 100644 --- a/simplecoin/rpc_views.py +++ b/simplecoin/rpc_views.py @@ -65,10 +65,11 @@ def update_trade_requests(): assert isinstance(tr, dict) assert 'status' in tr g.signed['trs'][tr_id]['status'] = int(tr['status']) - if tr['status'] == 6: - assert 'quantity' in tr - assert 'fees' in tr - except (AssertionError, TypeError): + if tr['status'] == 5 or tr['status'] == 6: + tr['source_quantity'] = Decimal(tr['source_quantity']) + tr['quantity'] = Decimal(tr['quantity']) + tr['fees'] = Decimal(tr['fees']) + except (AssertionError, TypeError, KeyError): current_app.logger.warn("Invalid data passed to update_sell_requests", exc_info=True) abort(400) @@ -76,14 +77,19 @@ def update_trade_requests(): updated = [] for tr_id, tr_dict in g.signed['trs'].iteritems(): try: + status = tr_dict['status'] tr = (TradeRequest.query.filter_by(id=int(tr_id)). with_lockmode('update').one()) - tr._status = tr_dict['status'] + tr._status = status + + if status == 5 or status == 6: + tr.exchanged_quantity = tr_dict['quantity'] + tr_dict['stuck_quantity'] + tr.fees = tr_dict['fees'] + # Get the amount of fees that we incurred during this trade + # update + applied_fees = tr_dict['fees'] - (tr.fees or 0) + tr.distribute(tr_dict['stuck_quantity'], applied_fees) - if tr_dict['status'] == 6: - tr.exchanged_quantity = Decimal(tr_dict['quantity']) - tr.fees = Decimal(tr_dict['fees']) - tr.distribute() except Exception: db.session.rollback() current_app.logger.error("Unable to update trade request {}" diff --git a/simplecoin/scheduler.py b/simplecoin/scheduler.py index 9760e23a..6a438bc8 100644 --- a/simplecoin/scheduler.py +++ b/simplecoin/scheduler.py @@ -1,7 +1,7 @@ import logging import itertools import datetime -from pprint import pprint +from pprint import pformat import time import simplejson as json import urllib3 @@ -169,7 +169,7 @@ def create_payouts(): payout_summary[currency] += payout.amount current_app.logger.info("############### SUMMARY OF PAYOUTS GENERATED #####################") - current_app.logger.info(pprint(payout_summary)) + current_app.logger.info(pformat(payout_summary)) db.session.commit() @@ -441,6 +441,7 @@ def _distributor(amount, splits, scale=None, addtl_prec=0): fashion. `addtl_prec` allows you to specify additional precision for computing share remainders, allowing a higher likelyhood of fair distribution of amount remainders among keys. Usually not needed. """ + scale = int(scale or 28) * -1 amount = Decimal(amount) diff --git a/simplecoin/tests/test_payout.py b/simplecoin/tests/test_payout.py index 028cce37..40814517 100644 --- a/simplecoin/tests/test_payout.py +++ b/simplecoin/tests/test_payout.py @@ -2,6 +2,7 @@ import flask import unittest import random +import decimal from simplecoin import db, currencies from simplecoin.scheduler import _distributor @@ -156,7 +157,7 @@ def test_payout_generation(self): class TestTradeRequest(UnitTest): - def test_push_tr_buy(self): + def test_complete_tr_buy(self): credits = [] tr = m.TradeRequest( quantity=sum(xrange(1, 20)), @@ -176,7 +177,8 @@ def test_push_tr_buy(self): db.session.add(c) db.session.commit() - push_data = {'trs': {tr.id: {"status": 6, "quantity": "1000", "fees": "1"}}} + push_data = {'trs': {tr.id: {"status": 6, "quantity": "1000", + "fees": "1", "stuck_quantity": "0"}}} db.session.expunge_all() with self.app.test_request_context('/?name=Peter'): @@ -195,7 +197,7 @@ def test_push_tr_buy(self): assert m.TradeRequest.query.first()._status == 6 - def test_push_tr(self): + def test_complete_tr_sell(self): credits = [] tr = m.TradeRequest( quantity=sum(xrange(1, 20)), @@ -213,7 +215,8 @@ def test_push_tr(self): db.session.add(c) db.session.commit() - push_data = {'trs': {tr.id: {"status": 6, "quantity": "1000", "fees": "1"}}} + push_data = {'trs': {tr.id: {"status": 6, "quantity": "1000", + "fees": "1", "stuck_quantity": "0"}}} db.session.expunge_all() with self.app.test_request_context('/?name=Peter'): @@ -232,6 +235,150 @@ def test_push_tr(self): assert m.TradeRequest.query.first()._status == 6 + def test_update_tr_buy(self): + blk1 = self.make_block(mature=True) + + credits = [] + tr = m.TradeRequest( + quantity=sum(xrange(1, 20)) / 2, + type="buy", + currency="TEST" + ) + db.session.add(tr) + for i in xrange(1, 20): + amount = float(i) / 2 + c = m.CreditExchange( + amount=amount, + sell_amount=i, + sell_req=None, + buy_req=tr, + currency="TEST", + address="test{}".format(i), + block=blk1) + credits.append(c) + db.session.add(c) + + db.session.commit() + + posted_payable_amt = 149 + posted_stuck_amt = 40 + posted_fees = 1 + push_data = {'trs': {tr.id: {"status": 5, + "quantity": str(posted_payable_amt), + "fees": str(posted_fees), + "stuck_quantity": str(posted_stuck_amt)}}} + db.session.expunge_all() + + with self.app.test_request_context(): + flask.g.signer = TimedSerializer(self.app.config['rpc_signature']) + flask.g.signed = push_data + update_trade_requests() + + db.session.rollback() + db.session.expunge_all() + + tr2 = m.TradeRequest.query.first() + credits2 = m.CreditExchange.query.all() + + with decimal.localcontext(decimal.BasicContext) as ctx: + ctx.traps[decimal.Inexact] = True + ctx.prec = 100 + + # Check config to see if we're charging exchange fees or not + if self.app.config.get('cover_autoex_fees', False): + posted_payable_amt += posted_fees + + # Assert that the the new payable amount is 150 + payable_amt = sum([credit.buy_amount for credit in credits2 if credit.payable is True]) + assert payable_amt == posted_payable_amt + + # Assert that the stuck amount is 40 + stuck_amt = sum([credit.buy_amount for credit in credits2 if credit.payable is False]) + assert stuck_amt == posted_stuck_amt + + # Check that the total BTC quantity represented hasn't changed + btc_quant = sum([credit.amount for credit in credits2]) + assert btc_quant == tr2.quantity + + # Check that the old credits BTC amounts look sane + old_btc_quant = sum([credit.amount for credit in credits2 if credit.payable is True]) + assert (old_btc_quant / tr2.avg_price) - posted_fees == posted_payable_amt + + # Check that the new credits BTC amounts look sane + new_btc_quant = sum([credit.amount for credit in credits2 if credit.payable is False]) + assert new_btc_quant / tr2.avg_price == posted_stuck_amt + + # Check that new credit attrs are the same as old (fees, etc) + for credit in credits2: + if credit.payable is False: + c2 = credits2[credit.id - 20] + assert c2.fee_perc == credit.fee_perc + assert c2.pd_perc == credit.pd_perc + assert c2.block == credit.block + assert c2.user == credit.user + assert c2.sharechain_id == credit.sharechain_id + assert c2.address == credit.address + assert c2.currency == credit.currency + assert c2.type == credit.type + assert c2.payout == credit.payout + + # Assert that the status is 5, partially completed + assert tr2._status == 5 + + def test_close_after_update_tr_buy(self): + + self.test_update_tr_buy() + + tr = m.TradeRequest.query.first() + + # Check that another update can be pushed. + posted_payable_amt = 188.5 + posted_stuck_amt = 0 + posted_fees = 1.5 + push_data = {'trs': {tr.id: {"status": 6, + "quantity": str(posted_payable_amt), + "fees": str(posted_fees), + "stuck_quantity": str(posted_stuck_amt)}}} + db.session.expunge_all() + + with self.app.test_request_context('/?name=Peter'): + flask.g.signer = TimedSerializer(self.app.config['rpc_signature']) + flask.g.signed = push_data + update_trade_requests() + + db.session.rollback() + db.session.expunge_all() + + tr2 = m.TradeRequest.query.first() + credits2 = m.CreditExchange.query.all() + + # Assert that the payable amount is 150 + with decimal.localcontext(decimal.BasicContext) as ctx: + # ctx.traps[decimal.Inexact] = True + ctx.prec = 100 + + # Check config to see if we're charging exchange fees or not + if self.app.config.get('cover_autoex_fees', False): + posted_payable_amt += posted_fees + + # Assert that the the total payable amount is what we posted + payable_amt = sum([credit.buy_amount for credit in credits2 if credit.payable is True]) + assert payable_amt == posted_payable_amt + total_curr_quant = sum([credit.buy_amount for credit in credits2]) + assert total_curr_quant + Decimal(posted_fees) == 190 + + # Check that the total BTC quantity equals out + btc_quant = sum([credit.amount for credit in credits2]) + assert btc_quant == tr2.quantity + + # Check that the BTC amounts look sane + btc_quant = sum([credit.amount for credit in credits2 if credit.payable is True]) + assert (btc_quant / tr2.avg_price) - Decimal(posted_fees) == posted_payable_amt + + # Check that there is nothing marked unpayable + not_payable = sum([credit.amount for credit in credits2 if credit.payable is False]) + assert not not_payable + class TestPayouts(RedisUnitTest): test_block_data = { diff --git a/templates/user_stats.html b/templates/user_stats.html index 56c2aea5..6da03abc 100755 --- a/templates/user_stats.html +++ b/templates/user_stats.html @@ -58,11 +58,11 @@

Worker Hashrates
  • Day
  • Month
  • -
    - +
    + +
    - {% if workers %}