├── 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 |
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 |
70 |
71 |
72 |
73 |
74 | {{ positions|safe }}
75 |
76 |
77 |
78 |
211 |
212 | {% endblock %}
213 |
--------------------------------------------------------------------------------