Skip to content

Instantly share code, notes, and snippets.

@brotsky
Created April 22, 2020 02:57
Show Gist options
  • Save brotsky/bcad15117b1ebeb3169a76e4f25c6b66 to your computer and use it in GitHub Desktop.
Save brotsky/bcad15117b1ebeb3169a76e4f25c6b66 to your computer and use it in GitHub Desktop.
Calculon
import os
import json
import base64
from decimal import *
from flask import Flask, jsonify, request
from datetime import date, datetime, timedelta
from dateutil import parser
import collections
app = Flask(__name__)
headers = {
'Access-Control-Allow-Origin': '*'
}
# Global Config
# TODO: move global constants to firebase once it is live
SHARING_MULTIPLIER = Decimal(0.01) # 0.01 per shar-199
CASH_NEED_MULTIPLIER = Decimal(0.001) # 0.001 per Brandon & Steve discussion 20200412 🦆
SHARING_MINIMUM_AMOUNT = 20
SHARING_PERIOD_LENGTH = 28
# Fees are a post-mvp feature as of 20200412
# USER_FEES = {'Account Fee': 100, 'Other User Fee': 20}
def cache_sharing_amount(user_id, sharing_amount):
"""
# TODO: define cache location
"""
return False
def round_sharing_amount(sharing_amount):
"""
We use Bankers' Rounding or the "Round Half to Even" Rounding Strategy which is the rounding rule defined in https://en.wikipedia.org/wiki/IEEE_754#Rounding_rules
The first google result on this topic is helpful: https://realpython.com/python-rounding/
"The “rounding half to even strategy” is the ... default rounding rule in the IEEE-754 standard."
also: "The default precision of the decimal module is twenty-eight digits..."
"""
# .quantize() uses decimal.ROUND_HALF_EVEN rounding strategy
return Decimal(sharing_amount).quantize(Decimal("1.00"))
def did_user_register_recently(registration_date):
"""
# TODO:
# - This is disabled until we can discuss where this belongs architecturally
# - This is part of a larger discussion around "Sharing Filters"
"""
today = date.today()
days_since_registration = today - registration_date
return days_since_registration
def get_next_positive_account(positive_accounts, current_positive_user_id):
"""
# grab next user id with a positive potential
"""
print('positive_accounts: ', positive_accounts)
for user_id in sorted(positive_accounts, key=positive_accounts.get, reverse=True):
print('positive_accounts[user_id]: ', positive_accounts[user_id])
if positive_accounts[user_id] > 0:
return user_id
return False
def get_next_negative_account(negative_accounts, current_negative_user_id):
"""
# grab next user id with a negative need
"""
print('negative_accounts: ', negative_accounts)
last_user_id = 0
for user_id in sorted(negative_accounts, key=negative_accounts.get, reverse=True):
print('negative_accounts[user_id]: ', negative_accounts[user_id])
if abs(negative_accounts[user_id]) > 0:
return user_id
return False
def user_recently_registered(users, user_id):
"""
# TODO:
# - This is disabled until we can discuss where this belongs architecturally
# - This is part of a larger discussion around "Sharing Filters"
"""
half_sharing_period = SHARING_PERIOD_LENGTH / 2
fourteen_days_ago = datetime.today() - timedelta(days=half_sharing_period)
for user in users:
if user['userId'] == user_id:
return parser.parse(user['registrationDate'])
time_since_registration = datetime.now() - registration_date
if time_since_registration > fourteen_days_ago:
return False
return True
def does_sharing_zero(transfers):
"""
# Make sure the transfers balance to 0
"""
total_transfer_amount = 0
for user_id, transfer_amount in transfers:
total_transfer_amount += Decimal(transfer_amount)
if total_transfer_amount != 0:
print('Unbalanced Distribution: Positive and negative transfer amounts must balance to 0. Transfer Balance: ', total_transfer_amount)
return False
return True
def is_transfer_above_minimum_threshold(positive_transfer_amount, negative_fill_need):
# sharing filter 1 - make sure all transfers are larger than the minimum sharing amount
# - When a user has less than the minimum allowed for a transfer they will owe to the community.
if positive_transfer_amount < SHARING_MINIMUM_AMOUNT and negative_fill_need < SHARING_MINIMUM_AMOUNT:
print('++cache++ transfer below SHARING_MINIMUM_AMOUNT: ',
positive_transfer_amount)
# TODO: cache this
# - where?
# - in the same place as global config (firebase)
return False
return True
# fees are a post-mvp feature as of 20200412
# as of 20200421 there is still some question about this
# DJ_POWER_POINT #39: https://docs.google.com/presentation/d/15avMjQcwcBAqN1H_u-q6zwxBaAjvF9Vpcn3K_w1ZJQ4/edit#slide=id.g7e5529e358_5_10
# - F = AUM fee (0.25% - may vary by user if tiered and/or waived for small accounts)
# - if this is the True Spec then fees are defined here. 0.25% bb, you can calculate that!!!
# def get_total_fee(fees):
# total_fee = 0
# for fee_type, fee in fees.items():
# total_fee += fee
# return total_fee
def get_remaining_positive_transfer_amount(positive_accounts, positive_user_id):
print('positive_accounts: ', positive_accounts)
print('positive_user_id: ', positive_user_id)
return positive_accounts[positive_user_id]
def get_remaining_negative_need(negative_accounts, negative_user_id):
return negative_accounts[negative_user_id]
@app.route('/')
def nope():
return '✋'
@app.route('/determineSharingAmountAverage', methods=['POST'])
def determine_sharing_amount_average():
""" Get the average annual sharing amount for all accounts
1. get total value of all accounts (total in Dwolla + total in brokerage accounts including stock value and cash)
2. get average of all accounts
3. return 1% of the average
`TOTAL_ACCOUNT_VALUE / NUMBER_OF_ACCOUNTS * 0.01` per shar-199
This gives the yearly average. If we did monthly, we would divide by 12.
"""
total_account_value = request.get_json()['totalAccountValue']
number_of_accounts = request.get_json()['numberOfAccounts']
try:
total_account_value = Decimal(total_account_value)
number_of_accounts = Decimal(number_of_accounts)
except Exception as e:
print(e)
sharing_amount_average = (total_account_value /
number_of_accounts) * SHARING_MULTIPLIER
sharing_amount_average = round_sharing_amount(sharing_amount_average)
return {"sharingAmountAverage": str(sharing_amount_average)}
@app.route('/determineSharingAmountPerUser', methods=['POST'])
def determine_sharing_amount_per_user():
"""
Get the sharing amount based on userAccountValue and cache by userID
`USER_ACCOUNT_VALUE * 0.01` per shar-202
This should be cached in the Database
---
# SPEC COLLISION WARNING!!!
this appears to also be defined in DJ_POWER_POINT#40: https://docs.google.com/presentation/d/15avMjQcwcBAqN1H_u-q6zwxBaAjvF9Vpcn3K_w1ZJQ4/edit#slide=id.g7e5529e358_5_10
# TODO: troubleshoot the difference in these specs
Update per-user accruals after daily data update
Variables used below:
UAV = user account value
D = days in current year (365 or 366)
W = withdrawal allowance (5.2%)
F = AUM fee (0.25% - may vary by user if tiered and/or waived for small accounts)
“Sharing payable” accrual: UAV * 1% / D
“Fees payable” accrual: UAV * F / D
“Effective net deposits” withdrawal allowance accrual: UAV * W / D
“Active days” - increase active days for account by 1
Used in allocation of the 1% of sharing
Alternatively - could allocate the 1% daily
"""
user_id = request.get_json()['userID']
user_account_value = request.get_json()['userAccountValue']
try:
user_account_value = Decimal(user_account_value)
except Exception as e:
print(e)
user_sharing_amount = user_account_value * SHARING_MULTIPLIER
user_sharing_amount = round_sharing_amount(user_sharing_amount)
cache_sharing_amount(user_id, user_sharing_amount)
return {"userSharingAmount": str(user_sharing_amount)}
@app.route('/determineNetSharingAmountPerUser', methods=['POST'])
def determine_net_sharing_amount_per_user():
""" Get the net sharing amount based on userSharingAmount and communitySharingAmount
`USER_NET_SHARING_AMOUNT = USER_SHARING_AMOUNT - COMMUNITY_SHARING_AMOUNT` per shar-203
---
# SPEC COLLISION WARNING!!!
this appears to also be defined in DJ_POWER_POINT#40: https://docs.google.com/presentation/d/15avMjQcwcBAqN1H_u-q6zwxBaAjvF9Vpcn3K_w1ZJQ4/edit#slide=id.g7e5529e358_5_10
# TODO: troubleshoot the difference in these specs
Update per-user accruals after daily data update
Variables used below:
UAV = user account value
D = days in current year (365 or 366)
W = withdrawal allowance (5.2%)
F = AUM fee (0.25% - may vary by user if tiered and/or waived for small accounts)
“Sharing payable” accrual: UAV * 1% / D
“Fees payable” accrual: UAV * F / D
“Effective net deposits” withdrawal allowance accrual: UAV * W / D
“Active days” - increase active days for account by 1
Used in allocation of the 1% of sharing
Alternatively - could allocate the 1% daily
"""
user_sharing_amount = request.get_json()['userSharingAmount']
community_sharing_amount = request.get_json()['communitySharingAmount']
try:
user_sharing_amount = Decimal(user_sharing_amount)
community_sharing_amount = Decimal(community_sharing_amount)
except Exception as e:
print(e)
user_net_sharing_amount = user_sharing_amount - community_sharing_amount
user_net_sharing_amount = round_sharing_amount(user_net_sharing_amount)
return {"userSharingAmount": str(user_net_sharing_amount)}
@app.route('/matchUsersForTransfers', methods=['POST'])
def match_users_for_transfers():
"""
Match Users For Transfers
---
# SPEC COLLISION WARNING!!!
this appears to also be defined in DJ_POWER_POINT#42: https://docs.google.com/presentation/d/15avMjQcwcBAqN1H_u-q6zwxBaAjvF9Vpcn3K_w1ZJQ4/edit#slide=id.g7e5529e358_5_15
# TODO: troubleshoot the difference in these specs
Calculate sharing pool to be allocated
Pool = sum of “sharing payable” - “sharing receivable” across all accounts in the system
NOTE: Always ensure that calculations preserve money
Don’t lose small bits though rounding or careless calculations
Divide total pool into two components (1% and 99%):
C = 1%
S = 99%
Allocate the 1% equally to all active accounts
AD_i = “active days” in current period for account i
AD = sum of AD_i over i = total “active days” in current period across all accounts
C_d = C / AD = account-day allocation of C
C_i = C_d * AD_i = allocation of C to account i
Increase “sharing receivable” of account i by C_i
"""
orders = []
user_transfers = {}
users = request.get_json()['users']
print(user_transfers)
for user in users:
user_id = user['userId']
transfer_amount = user['balanceDetails']['transferAmount']
# TODO:
# - This is disabled until we can discuss where this belongs architecturally
# - This is part of a larger discussion around "Sharing Filters"
# if not user_recently_registered(users, user_id):
user_transfers.update({user_id: transfer_amount})
print('user_transfers: ', user_transfers)
print('user_transfers type: ', type(user_transfers))
print('user_transfers.items() type: ', type(user_transfers.items()))
# Make sure the transfers balance to 0
if not does_sharing_zero(user_transfers.items()):
return {"exception": "Unbalanced Distribution: Positive and negative transfer amounts must balance to 0."}
# TODO: If Calculon can always count on well ordered input then this isn't needed
# - but if well ordered input can't be expected then implement this sort
# fastest dict sort by values proposed in PEP 265 .. this is the path to optimization when needed
# - source: https://writeonly.wordpress.com/2008/08/30/sorting-dictionaries-by-value-in-python-improved/
# user_transfers = sorted(user_transfers, key=itemgetter(1), reverse=True)
# sorted_dict = collections.OrderedDict(sorted_x)
positive_accounts = {}
negative_accounts = {}
try:
for user_id, transfer_amount in user_transfers.items():
if Decimal(transfer_amount) > 0:
positive_accounts[user_id] = abs(Decimal(transfer_amount))
elif Decimal(transfer_amount) < 0:
negative_accounts[user_id] = abs(Decimal(transfer_amount))
positive_user_id = 0
negative_user_id = 0
negative_user_id = get_next_negative_account(
negative_accounts, negative_user_id)
print('get_next_positive_account(positive_accounts, positive_user_id): ',
get_next_positive_account(positive_accounts, positive_user_id))
while get_next_positive_account(positive_accounts, positive_user_id):
positive_user_id = get_next_positive_account(
positive_accounts, positive_user_id)
print('positive_user_id: ', positive_user_id)
print('get_remaining_negative_need(negative_accounts, negative_user_id): ',
get_remaining_negative_need(negative_accounts, negative_user_id))
print('get_remaining_positive_transfer_amount(positive_accounts, positive_user_id): ',
get_remaining_positive_transfer_amount(positive_accounts, positive_user_id))
# sharing filters
# sharing filter 1 - make sure all transfers are larger than the minimum sharing amount
# - When a user has less than the minimum allowed for a transfer they will owe to the community.
if get_remaining_positive_transfer_amount(positive_accounts, positive_user_id) < SHARING_MINIMUM_AMOUNT or get_remaining_negative_need(negative_accounts, negative_user_id) < SHARING_MINIMUM_AMOUNT:
print('++cache++ transfer below SHARING_MINIMUM_AMOUNT: ',
get_remaining_positive_transfer_amount(positive_accounts, positive_user_id))
print('get_remaining_positive_transfer_amount(positive_accounts, positive_user_id)',
get_remaining_positive_transfer_amount(positive_accounts, positive_user_id))
print('get_remaining_negative_need(negative_accounts, negative_user_id)',
get_remaining_negative_need(negative_accounts, negative_user_id))
# TODO: cache this once the cache is defined
orders.append('cache ${:0,.2f} from {} to {}'.format(
abs(get_remaining_positive_transfer_amount(positive_accounts, positive_user_id)), positive_user_id, negative_user_id))
break
# Sharing Filter 2 - Skip people that registered recently
# if user_recently_registered(users, user_id):
# print('++cache++ time since registration is less than min amount')
# # TODO: how do we balance the sharing equation if they are part of the initial balancing
# break
while get_remaining_negative_need(negative_accounts, negative_user_id) > 0 and get_remaining_positive_transfer_amount(positive_accounts, positive_user_id) > 0:
# is there enough in the first positive account to fill the first negative account ?
if get_remaining_negative_need(negative_accounts, negative_user_id) <= get_remaining_positive_transfer_amount(positive_accounts, positive_user_id):
# execute transfer
positive_accounts[positive_user_id] = get_remaining_positive_transfer_amount(
positive_accounts, positive_user_id) - get_remaining_negative_need(negative_accounts, negative_user_id)
# move the full available negative_fill_need
print('move ${} from {} to {}'.format(
abs(get_remaining_negative_need(negative_accounts, negative_user_id)), positive_user_id, negative_user_id))
orders.append('move ${:0,.2f} from {} to {}'.format(
abs(get_remaining_negative_need(negative_accounts, negative_user_id)), positive_user_id, negative_user_id))
# finalize transfer execution by zeroing the transferred balance
negative_accounts[negative_user_id] = 0
# get next negative fill need
if get_next_negative_account(negative_accounts, negative_user_id):
negative_user_id = get_next_negative_account(
negative_accounts, negative_user_id)
negative_accounts[negative_user_id] = negative_accounts[negative_user_id]
# not enough in the first positive account to fill the first negative account
# execute partial transfer!!!
elif get_remaining_positive_transfer_amount(positive_accounts, positive_user_id) > 0:
print('about to execute partial get_remaining_negative_need(negative_accounts, negative_user_id): ',
get_remaining_negative_need(negative_accounts, negative_user_id))
print('with partial get_remaining_positive_transfer_amount(positive_accounts, positive_user_id): ',
get_remaining_positive_transfer_amount(positive_accounts, positive_user_id))
negative_accounts[negative_user_id] = get_remaining_negative_need(
negative_accounts, negative_user_id) - get_remaining_positive_transfer_amount(positive_accounts, positive_user_id)
print('executed partial get_remaining_negative_need(negative_accounts, negative_user_id): ',
get_remaining_negative_need(negative_accounts, negative_user_id))
print('executed partial get_remaining_positive_transfer_amount(positive_accounts, positive_user_id): ',
get_remaining_positive_transfer_amount(positive_accounts, positive_user_id))
# TODO:
# should we cache partial transfers < SHARING_MINIMUM_AMOUNT
# - This is disabled until we can discuss where this belongs architecturally
if abs(get_remaining_negative_need(negative_accounts, negative_user_id)) < SHARING_MINIMUM_AMOUNT:
# move the partial negative_transfer_amount
print(
'partial ++cache++ transfer below SHARING_MINIMUM_AMOUNT: ')
print('partial get_remaining_positive_transfer_amount(positive_accounts, positive_user_id)',
get_remaining_positive_transfer_amount(positive_accounts, positive_user_id))
print('partial get_remaining_negative_need(negative_accounts, negative_user_id)',
get_remaining_negative_need(negative_accounts, negative_user_id))
orders.append('partial transfer below cache threshold ${:0,.2f} from {} to {}'.format(
abs(get_remaining_negative_need(negative_accounts, negative_user_id)), positive_user_id, negative_user_id))
positive_accounts[positive_user_id] = get_remaining_positive_transfer_amount(
positive_accounts, positive_user_id) - abs(get_remaining_negative_need(negative_accounts, negative_user_id))
else:
# move the full available positive_transfer_amount
orders.append('partial move ${:0,.2f} from {} to {}'.format(
abs(get_remaining_positive_transfer_amount(positive_accounts, positive_user_id)), positive_user_id, negative_user_id))
# finalize transfer execution by zeroing the transferred balance
positive_accounts[positive_user_id] = 0
print('get_remaining_positive_transfer_amount(positive_accounts, positive_user_id): ',
get_remaining_positive_transfer_amount(positive_accounts, positive_user_id))
print('get_remaining_negative_need(negative_accounts, negative_user_id): ',
get_remaining_negative_need(negative_accounts, negative_user_id))
print('positive_accounts: ', positive_accounts)
print('negative_accounts: ', negative_accounts)
except Exception as e:
print(e)
raise
return {"orders": orders}
def estimate_sharing_payable_cash_need(total_account_value, number_of_accounts):
"""
- Sharing Payable is the sum of .1% of the **average** account value of each person in sharing
- and .1% per week for the total community amount
TODO: what is the difference between the total account value of each person and the total community amount?
Aren't those the same thing?
---
# SPEC COLLISION WARNING!!!
this appears to also be defined in DJ_POWER_POINT#39: https://docs.google.com/presentation/d/15avMjQcwcBAqN1H_u-q6zwxBaAjvF9Vpcn3K_w1ZJQ4/edit#slide=id.g7db0d288e7_0_102
# TODO: troubleshoot the difference in these specs
Used to help maintain minimal but sufficient cash to cover expected flows over the near term
Estimate cash needs over the next time interval (3 months)
Estimate is a total of the following items estimated for the time period:
Estimated fees over time period
Estimated sharing payable
Withdrawals scheduled and pending
"""
average_user_account_value = (
Decimal(total_account_value) / int(number_of_accounts))
sum_of_average_account_values = Decimal(
average_user_account_value) * int(number_of_accounts)
reserve_for_sharing_users_amount = Decimal(
sum_of_average_account_values) * Decimal(CASH_NEED_MULTIPLIER)
print('reserve_for_sharing_users_amount: ',
reserve_for_sharing_users_amount)
reserve_for_community_amount = Decimal(
total_account_value) * Decimal(CASH_NEED_MULTIPLIER)
print('reserve_for_community_amount: ', reserve_for_community_amount)
return round_sharing_amount(reserve_for_sharing_users_amount + reserve_for_community_amount)
def estimate_withdrawal_cash_need(total_account_value):
"""
- The Withdrawal Estimate is based on .1% per week if it's weekly, 5.2% per year annually
- What is the difference between this and reserve_for_community_amount above?
---
# SPEC COLLISION WARNING!!!
this appears to also be defined in DJ_POWER_POINT#39: https://docs.google.com/presentation/d/15avMjQcwcBAqN1H_u-q6zwxBaAjvF9Vpcn3K_w1ZJQ4/edit#slide=id.g7db0d288e7_0_102
# TODO: troubleshoot the difference in these specs
Used to help maintain minimal but sufficient cash to cover expected flows over the near term
Estimate cash needs over the next time interval (3 months)
Estimate is a total of the following items estimated for the time period:
Estimated fees over time period
Estimated sharing payable
Withdrawals scheduled and pending
"""
reserve_for_sharing_users_amount = Decimal(
total_account_value) * Decimal(CASH_NEED_MULTIPLIER)
return round_sharing_amount(reserve_for_sharing_users_amount)
@app.route('/estimateCashNeed', methods=['POST'])
def estimate_cash_need():
""" A helper function to determine cash need for each individual customer on the system.
Used to help maintain minimal but sufficient cash to cover expected flows over the near term
Determine a time period to estimate cash need (~ 3 months?)
Estimate is a total of the following items estimated for the time period:
- Estimated fees over time period
- Estimated sharing payable
- Withdrawals scheduled and pending
shar-443
---
# SPEC COLLISION WARNING!!!
this appears to also be defined in DJ_POWER_POINT#39: https://docs.google.com/presentation/d/15avMjQcwcBAqN1H_u-q6zwxBaAjvF9Vpcn3K_w1ZJQ4/edit#slide=id.g7db0d288e7_0_102
# TODO: troubleshoot the difference in these specs
Used to help maintain minimal but sufficient cash to cover expected flows over the near term
Estimate cash needs over the next time interval (3 months)
Estimate is a total of the following items estimated for the time period:
Estimated fees over time period
Estimated sharing payable
Withdrawals scheduled and pending
"""
total_account_value = request.get_json()['totalAccountValue']
number_of_accounts = request.get_json()['numberOfAccounts']
# old, silly, attempt .. delete this after I vreify that I'm doing this right
# average_account_value_per_user = (Decimal(total_account_value) / int(number_of_accounts))
# estimated_cash_need_per_user_per_week = round_sharing_amount(Decimal(average_account_value_per_user) * Decimal(0.001))
# # this is relevant per brandon's notes .. why is this relevant?
# point_one_percent_of_total_community_amount = round_sharing_amount(Decimal(total_account_value) * Decimal(0.001))
# # estimated_cash_need = sharing_amount_per_user + point_one_percent_of_total_community_amount
sharing_payable_cash_need = estimate_sharing_payable_cash_need(
total_account_value, number_of_accounts)
withdrawals_cash_need = estimate_withdrawal_cash_need(total_account_value)
estimated_cash_need_per_week = sharing_payable_cash_need + withdrawals_cash_need
return {"estimatedCashNeedPerWeek": str(estimated_cash_need_per_week)}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment