├── home ├── __init__.py ├── migrations │ └── __init__.py ├── views.py └── templates │ └── home │ ├── index.html │ └── base.html ├── analysis ├── __init__.py ├── migrations │ └── __init__.py ├── models.py ├── urls.py ├── views.py ├── templates │ └── analysis │ │ └── index.html └── analysis_utils.py ├── positions ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0004_auto_20190524_2321.py │ ├── 0002_auto_20171029_1710.py │ ├── 0003_auto_20171029_1819.py │ └── 0001_initial.py ├── static │ └── positions │ │ └── favicon.png ├── urls.py ├── admin.py ├── views.py ├── models.py ├── portfolio_utils.py └── templates │ └── positions │ └── index.html ├── snapshot-finance ├── __init__.py ├── urls.py ├── wsgi.py └── settings.py ├── requirements.txt ├── README.md ├── manage.py └── .gitignore /home/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /analysis/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /positions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /home/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /snapshot-finance/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /analysis/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /positions/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /analysis/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /positions/static/positions/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdcolema/snapshot-finance/HEAD/positions/static/positions/favicon.png -------------------------------------------------------------------------------- /analysis/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | url(r'^$', views.index, name='index'), 7 | ] 8 | -------------------------------------------------------------------------------- /positions/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | url(r'^$', views.index, name='index'), 7 | ] 8 | -------------------------------------------------------------------------------- /home/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | 4 | def index(request): 5 | """Home page view""" 6 | return render(request, "home/index.html", {}) 7 | -------------------------------------------------------------------------------- /positions/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Account, Position 3 | 4 | 5 | admin.site.register(Account) 6 | admin.site.register(Position) 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | backports-abc==0.5 2 | bokeh==0.11.1 3 | Django==4.2.27 4 | django-environ==0.4.5 5 | futures==3.1.1 6 | Jinja2>=2.10.1 7 | MarkupSafe==1.0 8 | numpy==1.22.0 9 | pandas==0.24.2 10 | python-dateutil==2.6.1 11 | pytz==2017.2 12 | requests==2.32.4 13 | simplejson==3.10.0 14 | singledispatch==3.4.0.3 15 | six==1.11.0 16 | tornado==6.5.1 17 | -------------------------------------------------------------------------------- /snapshot-finance/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include, url 2 | from django.contrib import admin 3 | 4 | from home import views 5 | 6 | 7 | urlpatterns = [ 8 | url(r'^$', views.index, name='index'), 9 | url(r'^analysis/', include('analysis.urls')), 10 | url(r'^positions/', include('positions.urls')), 11 | url(r'^admin/', admin.site.urls), 12 | ] 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Snapshot Finance 2 | 3 | Track and analyze investments in real time across all your accounts. See this post for a more in-depth guide. 4 | 5 | ### Usage 6 | Add new accounts and positions through Django admin 7 | 8 | ### Apps 9 | - Positions: summary of tracked positions, filterable by account 10 | - Analysis: derived metrics, visualizations 11 | 12 | -------------------------------------------------------------------------------- /snapshot-finance/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for snapshot-finance project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "snapshot-finance.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /home/templates/home/index.html: -------------------------------------------------------------------------------- 1 | {% extends "home/base.html" %} 2 | {% load static %} 3 | 4 | {% block title %}Snapshot{% endblock %} 5 | 6 | 7 | {% block extra_head %} 8 | 9 | 16 | 17 | {% endblock %} 18 | 19 | {% block content %} 20 | 21 |

Welcome to Snapshot Finance

22 |

Track your positions in real time across multiple accounts

23 | 24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /positions/migrations/0004_auto_20190524_2321.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.6 on 2019-05-24 23:21 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('positions', '0003_auto_20171029_1819'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='position', 17 | name='shares', 18 | field=models.FloatField(), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /positions/migrations/0002_auto_20171029_1710.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.6 on 2017-10-29 17:10 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('positions', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='account', 17 | name='account_type', 18 | field=models.CharField(choices=[('TRADITIONAL', 'Traditional IRA or 401(k)'), ('ROTH', 'Roth IRA or 401(k)'), ('STANDARD', 'Standard Brokerage')], max_length=100), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /positions/migrations/0003_auto_20171029_1819.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.6 on 2017-10-29 18:19 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('positions', '0002_auto_20171029_1710'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='account', 17 | name='cash_balance', 18 | field=models.FloatField(default=0.0), 19 | ), 20 | migrations.AlterField( 21 | model_name='position', 22 | name='cost_basis', 23 | field=models.FloatField(default=0.0), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "snapshot-finance.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError: 10 | # The above import may fail for some other reason. Ensure that the 11 | # issue is really that Django is missing to avoid masking other 12 | # exceptions on Python 2. 13 | try: 14 | import django 15 | except ImportError: 16 | raise ImportError( 17 | "Couldn't import Django. Are you sure it's installed and " 18 | "available on your PYTHONPATH environment variable? Did you " 19 | "forget to activate a virtual environment?" 20 | ) 21 | raise 22 | execute_from_command_line(sys.argv) 23 | -------------------------------------------------------------------------------- /positions/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | from .models import Account, Position 4 | from . import portfolio_utils as pu 5 | 6 | 7 | def index(request): 8 | """Primary view of positions held""" 9 | 10 | positions_summary = pu.get_position_summary(Position.objects.all()) 11 | accounts = Account.objects.all() 12 | total_cash = sum((acct.cash_balance for acct in accounts)) 13 | 14 | context = { 15 | "positions": positions_summary.to_html(index=False, float_format=lambda x: '%.2f' % x), 16 | "accounts": [acct.name for acct in accounts], 17 | "cash_balances": {acct: acct.cash_balance for acct in accounts}, 18 | "total_cash": "{:,.2f}".format(total_cash), 19 | "total_value": "{:,.2f}".format(total_cash + positions_summary["Market Value ($)"].iloc[0]), 20 | "num_positions": positions_summary.shape[0] - 1, 21 | } 22 | 23 | return render(request, "positions/index.html", context) 24 | -------------------------------------------------------------------------------- /positions/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Account(models.Model): 5 | """A brokerage account""" 6 | 7 | ACCOUNT_TYPES = ( 8 | ('TRADITIONAL', 'Traditional IRA or 401(k)'), 9 | ('ROTH', 'Roth IRA or 401(k)'), 10 | ('STANDARD', 'Standard Brokerage'), 11 | ) 12 | 13 | name = models.CharField(max_length=100) 14 | account_type = models.CharField(max_length=100, choices=ACCOUNT_TYPES) 15 | cash_balance = models.FloatField(default=0.0) 16 | 17 | def __str__(self): 18 | return self.name 19 | 20 | 21 | class Position(models.Model): 22 | """A position, such as an equity holding""" 23 | 24 | name = models.CharField(max_length=100) 25 | symbol = models.CharField(max_length=10) 26 | shares = models.FloatField() 27 | cost_basis = models.FloatField(default=0.0) 28 | account = models.ForeignKey("Account", on_delete=models.CASCADE) 29 | 30 | def __str__(self): 31 | return self.symbol 32 | -------------------------------------------------------------------------------- /analysis/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | from positions.models import Account, Position 4 | from . import analysis_utils as au 5 | 6 | 7 | def index(request): 8 | """Primary view for portfolio analysis""" 9 | 10 | positions_data = au.get_position_data(Position.objects.all()) 11 | accounts = Account.objects.all() 12 | total_cash = sum((acct.cash_balance for acct in accounts)) 13 | 14 | concentration_bar_chart = au.get_concentration_bar_chart(positions_data) 15 | concentration_area_chart = au.get_concentration_area_chart(positions_data) 16 | 17 | context = { 18 | "positions": positions_data.to_html(index=False, float_format=lambda x: '%10.2f' % x), 19 | "accounts": [acct.name for acct in accounts], 20 | "cash_balances": {acct: acct.cash_balance for acct in accounts}, 21 | "total_cash": "{:,.2f}".format(total_cash), 22 | "total_value": "{:,.2f}".format(total_cash + positions_data["Market Value ($)"].sum()), 23 | "num_positions": positions_data.shape[0], 24 | "concentration_bar_chart": concentration_bar_chart, 25 | "concentration_area_chart": concentration_area_chart, 26 | } 27 | 28 | return render(request, "analysis/index.html", context) 29 | -------------------------------------------------------------------------------- /positions/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.6 on 2017-10-29 16:48 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Account', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('name', models.CharField(max_length=100)), 22 | ('account_type', models.CharField(choices=[('TRADITIONAL', 'Traditional IRA or 401(k)'), ('ROTH', 'Roth IRA or 401(k)'), ('STANDARD', 'Standard Brokerage')], max_length=1)), 23 | ('cash_balance', models.DecimalField(decimal_places=2, default=0.0, max_digits=12)), 24 | ], 25 | ), 26 | migrations.CreateModel( 27 | name='Position', 28 | fields=[ 29 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 30 | ('name', models.CharField(max_length=100)), 31 | ('symbol', models.CharField(max_length=10)), 32 | ('shares', models.IntegerField()), 33 | ('cost_basis', models.DecimalField(decimal_places=2, default=0.0, max_digits=12)), 34 | ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='positions.Account')), 35 | ], 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /analysis/templates/analysis/index.html: -------------------------------------------------------------------------------- 1 | {% extends "home/base.html" %} 2 | {% load static %} 3 | 4 | {% block title %}Analysis{% endblock %} 5 | 6 | 7 | {% block extra_head %} 8 | 9 | 24 | 25 | {% endblock %} 26 | 27 | 28 | {% block content %} 29 | 30 | 31 |
32 | 33 | 34 |

${{ total_cash }}

35 | 36 |
37 | 38 | 39 |

${{ total_value }}

40 | 41 |
42 | 43 | 44 |

{{ num_positions }}

45 | 46 |
47 | 48 |
49 | 50 |

Analysis

51 | 52 |
53 | 54 | {{ concentration_bar_chart|safe }} 55 | {{ concentration_area_chart|safe }} 56 | 57 | 75 | 76 | 77 | 78 | {% endblock %} 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | *.ipynb 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | snapshot-finance/.env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | 104 | # csv 105 | *.csv 106 | 107 | # sqlite 108 | *.sqlite3 109 | 110 | # ide 111 | .idea 112 | 113 | # DS Store 114 | .DS_Store 115 | 116 | /static 117 | -------------------------------------------------------------------------------- /analysis/analysis_utils.py: -------------------------------------------------------------------------------- 1 | from bokeh.charts import Bar, Area 2 | from bokeh.embed import file_html 3 | from bokeh.resources import INLINE 4 | from bokeh.charts.attributes import cat 5 | from positions import portfolio_utils as pu 6 | 7 | 8 | def get_position_data(positions): 9 | """Fetches metrics for given Position objects to be used for analysis views""" 10 | 11 | # columns to return 12 | cols = ["Name", "Symbol", "Shares", "Market Value ($)", "Last Price ($)", "Day's Change ($)", "Day's Change (%)", 13 | "Day's Gain/Loss ($)", "Cost Basis ($)", "Total Gain/Loss ($)", "Overall Return (%)", "Account"] 14 | 15 | df = pu.get_positions_dataframe(positions, nthreads=10) 16 | 17 | # derived values 18 | df["Market Value ($)"] = df["Shares"] * df["Last Price ($)"] 19 | df["Day's Gain/Loss ($)"] = df["Shares"] * df["Day's Change ($)"] 20 | df["Total Gain/Loss ($)"] = df["Market Value ($)"] - df["Cost Basis ($)"] 21 | df["Overall Return (%)"] = 100. * df["Total Gain/Loss ($)"] / df["Cost Basis ($)"] 22 | df["Concentration"] = 100. * df["Market Value ($)"] / df["Market Value ($)"].sum() 23 | 24 | return df 25 | 26 | 27 | def get_concentration_bar_chart(df): 28 | """Creates bar chart visualizing portfolio concentration by position""" 29 | df = df[["Symbol", "Concentration"]].sort_values("Concentration", ascending=False) 30 | 31 | chart = Bar( 32 | df, 33 | label=cat(columns='Symbol', sort=False), 34 | values='Concentration', 35 | title='Portfolio Concentration By Position', 36 | ylabel='Concentration (%)', 37 | plot_width=1200, 38 | plot_height=400, 39 | legend=False, 40 | color='#4285f4' 41 | ) 42 | 43 | return file_html(chart, INLINE) 44 | 45 | 46 | def get_concentration_area_chart(df): 47 | """Creates area chart visualizing portfolio concentration by position""" 48 | df = ( 49 | df[["Symbol", "Concentration"]] 50 | .sort_values("Concentration") 51 | .set_index("Symbol") 52 | .cumsum() 53 | .reset_index() 54 | .drop("Symbol", axis=1) 55 | ) 56 | 57 | chart = Area( 58 | df['Concentration'].astype(float), 59 | title='Cumulative Portfolio Concentration', 60 | ylabel='% of Total Value', 61 | xlabel='Number of Positions', 62 | plot_width=1200, 63 | plot_height=400, 64 | legend=False, 65 | color='#4285f4' 66 | ) 67 | 68 | chart.x_range.start, chart.x_range.end = 0, df.shape[0] 69 | 70 | return file_html(chart, INLINE) 71 | -------------------------------------------------------------------------------- /snapshot-finance/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for snapshot-finance project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.10.5. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.10/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.10/ref/settings/ 11 | """ 12 | 13 | import os 14 | import environ 15 | 16 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 17 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 18 | 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/ 22 | 23 | env = environ.Env( 24 | DEFAULT_DB_URL=(str, ''), 25 | SECRET_KEY=(str, 'dev-project-secret-key-override-in-dot-env'), 26 | ) 27 | 28 | environ.Env.read_env() 29 | 30 | DEBUG = env('DEBUG') 31 | SECRET_KEY = env('SECRET_KEY') 32 | 33 | ALLOWED_HOSTS = [] 34 | 35 | 36 | # Application definition 37 | 38 | INSTALLED_APPS = [ 39 | 'django.contrib.admin', 40 | 'django.contrib.auth', 41 | 'django.contrib.contenttypes', 42 | 'django.contrib.sessions', 43 | 'django.contrib.messages', 44 | 'django.contrib.staticfiles', 45 | 'analysis', 46 | 'home', 47 | 'positions' 48 | ] 49 | 50 | MIDDLEWARE = [ 51 | 'django.middleware.security.SecurityMiddleware', 52 | 'django.contrib.sessions.middleware.SessionMiddleware', 53 | 'django.middleware.common.CommonMiddleware', 54 | 'django.middleware.csrf.CsrfViewMiddleware', 55 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 56 | 'django.contrib.messages.middleware.MessageMiddleware', 57 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 58 | ] 59 | 60 | ROOT_URLCONF = 'snapshot-finance.urls' 61 | 62 | TEMPLATES = [ 63 | { 64 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 65 | 'DIRS': [], 66 | 'APP_DIRS': True, 67 | 'OPTIONS': { 68 | 'context_processors': [ 69 | 'django.template.context_processors.debug', 70 | 'django.template.context_processors.request', 71 | 'django.template.context_processors.static', 72 | 'django.contrib.auth.context_processors.auth', 73 | 'django.contrib.messages.context_processors.messages', 74 | ], 75 | }, 76 | }, 77 | ] 78 | 79 | 80 | WSGI_APPLICATION = 'snapshot-finance.wsgi.application' 81 | 82 | 83 | # Database 84 | # https://docs.djangoproject.com/en/1.10/ref/settings/#databases 85 | 86 | DATABASES = { 87 | 'default': { 88 | 'ENGINE': 'django.db.backends.sqlite3', 89 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 90 | } 91 | } 92 | 93 | 94 | # Password validation 95 | # https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators 96 | 97 | AUTH_PASSWORD_VALIDATORS = [ 98 | { 99 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 100 | }, 101 | { 102 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 103 | }, 104 | { 105 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 106 | }, 107 | { 108 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 109 | }, 110 | ] 111 | 112 | 113 | # Internationalization 114 | # https://docs.djangoproject.com/en/1.10/topics/i18n/ 115 | 116 | LANGUAGE_CODE = 'en-us' 117 | 118 | TIME_ZONE = 'UTC' 119 | 120 | USE_I18N = True 121 | 122 | USE_L10N = True 123 | 124 | USE_TZ = True 125 | 126 | 127 | # Static files (CSS, JavaScript, Images) 128 | # https://docs.djangoproject.com/en/1.10/howto/static-files/ 129 | 130 | 131 | STATIC_URL = '/static/' 132 | STATIC_ROOT = os.path.join(BASE_DIR, "static") 133 | 134 | MEDIA_URL = '/media/' 135 | MEDIA_ROOT = os.path.join(BASE_DIR, "media") 136 | 137 | STATICFILES_FINDERS = ( 138 | 'django.contrib.staticfiles.finders.FileSystemFinder', 139 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 140 | ) 141 | -------------------------------------------------------------------------------- /positions/portfolio_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import requests 4 | import pandas as pd 5 | from threading import Thread 6 | 7 | 8 | def make_iex_request(ticker): 9 | secret = os.environ["IEX_API_TOKEN"] 10 | response = requests.get("https://cloud-sse.iexapis.com/stable/stock/{}/quote?token={}".format(ticker, secret)) 11 | return response 12 | 13 | 14 | def get_position_updates(position, max_retries=10, retry_after=1): 15 | """Gets metric updates from IEX API for a single Position object""" 16 | 17 | ticker = position.symbol 18 | response = make_iex_request(ticker) 19 | status_code = response.status_code 20 | 21 | while status_code == 429 and max_retries: 22 | time.sleep(retry_after) 23 | response = make_iex_request(ticker) 24 | status_code = response.status_code 25 | max_retries -= 1 26 | retry_after += 1 27 | 28 | if status_code != 200: 29 | raise Exception(f"IEX api failed with with status code: {status_code}") 30 | 31 | data = response.json() 32 | 33 | # parse price data 34 | price = data['latestPrice'] 35 | change = data['change'] 36 | pct_change = 100. * (data['changePercent'] or 0.00) 37 | 38 | record = { 39 | "Name": position.name, 40 | "Symbol": ticker, 41 | "Shares": position.shares, 42 | "Cost Basis ($)": position.cost_basis, 43 | "Last Price ($)": price, 44 | "Day's Change ($)": change, 45 | "Day's Change (%)": pct_change, 46 | "Account": position.account.name 47 | } 48 | 49 | return record 50 | 51 | 52 | def process_range(pos_range, store=None): 53 | """Process a number of positions, storing the results in a list""" 54 | if store is None: 55 | store = [] 56 | for p in pos_range: 57 | store.append(get_position_updates(p)) 58 | return store 59 | 60 | 61 | def get_positions_dataframe(positions, nthreads=10): 62 | """ 63 | Constructs updated dataframe for a list of Position objects 64 | using threaded requests to IEX API 65 | """ 66 | 67 | store = [] 68 | threads = [] 69 | 70 | # create the threads 71 | for i in range(nthreads): 72 | pos_range = positions[i::nthreads] 73 | t = Thread(target=process_range, args=(pos_range, store)) 74 | threads.append(t) 75 | 76 | # start the threads 77 | [t.start() for t in threads] 78 | 79 | # wait for the threads to finish 80 | [t.join() for t in threads] 81 | 82 | return pd.DataFrame(store) 83 | 84 | 85 | def get_totals(df): 86 | """Creates a totals row as a dataframe""" 87 | 88 | totals_df = pd.DataFrame({ 89 | "Name": "Totals", 90 | "Cost Basis ($)": df["Cost Basis ($)"].sum(), 91 | "Day's Change (%)": (100. * df["Day's Gain/Loss ($)"].sum() / df["Market Value ($)"].sum()), 92 | "Market Value ($)": df["Market Value ($)"].sum(), 93 | "Day's Gain/Loss ($)": df["Day's Gain/Loss ($)"].sum(), 94 | "Account": "" 95 | }, index=[0]) 96 | 97 | return totals_df 98 | 99 | 100 | def format_positions_summary(df): 101 | """Formats report for final display""" 102 | 103 | # rounding 104 | df["Day's Change ($)"] = df["Day's Change ($)"].astype(float).round(2) 105 | df["Day's Change (%)"] = df["Day's Change (%)"].astype(float).round(2) 106 | df["Market Value ($)"] = df["Market Value ($)"].astype(float).round(2) 107 | df["Day's Gain/Loss ($)"] = df["Day's Gain/Loss ($)"].astype(float).round(2) 108 | df["Cost Basis ($)"] = df["Cost Basis ($)"].astype(float).round(2) 109 | 110 | # sort by largest position and fill null values with empty string (b/c I like it that way) 111 | df.sort_values("Market Value ($)", inplace=True, ascending=False) 112 | df.fillna("", inplace=True) 113 | 114 | return df 115 | 116 | 117 | def get_position_summary(positions): 118 | """Builds and formats summary of positions for given Position object input""" 119 | 120 | # columns to return 121 | cols = ["Name", "Symbol", "Shares", "Market Value ($)", "Last Price ($)", "Day's Change ($)", "Day's Change (%)", 122 | "Day's Gain/Loss ($)", "Cost Basis ($)", "Total Gain/Loss ($)", "Overall Return (%)", "Account"] 123 | 124 | df = get_positions_dataframe(positions, nthreads=10) 125 | 126 | # derived values needed before adding totals row 127 | df["Market Value ($)"] = df["Shares"] * df["Last Price ($)"] 128 | df["Day's Gain/Loss ($)"] = df["Shares"] * df["Day's Change ($)"] 129 | 130 | # create and append totals row 131 | totals_df = get_totals(df) 132 | df = df.append(totals_df, ignore_index=True) 133 | 134 | # derived values needed after adding totals row 135 | df["Total Gain/Loss ($)"] = (df["Market Value ($)"] - df["Cost Basis ($)"]).astype(float).round(2) 136 | df["Overall Return (%)"] = (100. * df["Total Gain/Loss ($)"] / df["Cost Basis ($)"]).astype(float).round(2) 137 | 138 | # final formatting 139 | df = format_positions_summary(df.loc[:, cols]) 140 | 141 | return df 142 | -------------------------------------------------------------------------------- /home/templates/home/base.html: -------------------------------------------------------------------------------- 1 | {% load staticfiles %} 2 | 3 | 4 | 5 | 6 | {% block meta_tags %}{% endblock %} 7 | 8 | 9 | {% block title %}Snapshot"{% endblock %} 10 | 11 | 12 | {% block stylesheets %} 13 | 14 | 15 | 16 | {% endblock %} 17 | 18 | {% block javascript %} 19 | 20 | 21 | 22 | 23 | 27 | 28 | 29 | {% endblock %} 30 | 31 | {% block extra_head %}{% endblock %} 32 | 33 | 34 | 35 | 36 | 37 | 38 | 152 | 153 |
177 | 178 |
179 | {% block content %}{% endblock %} 180 |
181 | 182 | 191 | 192 | 193 | 194 | 195 | 196 | -------------------------------------------------------------------------------- /positions/templates/positions/index.html: -------------------------------------------------------------------------------- 1 | {% extends "home/base.html" %} 2 | {% load static %} 3 | 4 | {% block title %}Positions{% endblock %} 5 | 6 | 7 | {% block extra_head %} 8 | 9 | 34 | 35 | {% endblock %} 36 | 37 | 38 | {% block content %} 39 | 40 | 41 |
42 | 43 | 44 | 49 | 50 |
51 | 52 | 53 |

${{ total_cash }}

54 | 55 |
56 | 57 | 58 |

${{ total_value }}

59 | 60 |
61 | 62 | 63 |

{{ num_positions }}

64 | 65 |
66 | 67 |
68 | 69 |

Positions

70 | 71 |
72 | 73 | 74 | {{ positions|safe }} 75 | 76 | 77 | 78 | 211 | 212 | {% endblock %} 213 | --------------------------------------------------------------------------------