From d8cd651939e7f819e7b21593171a99cab6066b20 Mon Sep 17 00:00:00 2001 From: Matthew Mitchell Date: Sat, 20 Jan 2018 17:11:12 +0000 Subject: [PATCH] Initial commit with v1.0.0 release --- .gitignore | 10 + LICENSE | 22 ++ README.md | 149 ++++++++++++++ examples/README.md | 97 +++++++++ examples/gains/exampleTrades.csv | 32 +++ examples/income/exampleIncome.csv | 8 + examples/prices/bar_gbp.csv | 3 + examples/prices/foo_gbp.csv | 3 + ez_setup.py | 332 ++++++++++++++++++++++++++++++ pycryptax/__init__.py | 0 pycryptax/__main__.py | 152 ++++++++++++++ pycryptax/csvdata.py | 143 +++++++++++++ pycryptax/datemap.py | 78 +++++++ pycryptax/gains.py | 307 +++++++++++++++++++++++++++ pycryptax/income.py | 95 +++++++++ pycryptax/output.py | 54 +++++ pycryptax/prices.py | 50 +++++ pycryptax/util.py | 18 ++ setup.py | 31 +++ 19 files changed, 1584 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 examples/README.md create mode 100644 examples/gains/exampleTrades.csv create mode 100644 examples/income/exampleIncome.csv create mode 100644 examples/prices/bar_gbp.csv create mode 100644 examples/prices/foo_gbp.csv create mode 100644 ez_setup.py create mode 100644 pycryptax/__init__.py create mode 100644 pycryptax/__main__.py create mode 100644 pycryptax/csvdata.py create mode 100644 pycryptax/datemap.py create mode 100644 pycryptax/gains.py create mode 100644 pycryptax/income.py create mode 100644 pycryptax/output.py create mode 100644 pycryptax/prices.py create mode 100644 pycryptax/util.py create mode 100644 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d9e821 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.sw* +Session.vim +*.egg-info/ +__pycache__/ +build/ +dist/ +*.egg +*.zip +tags +*.~lock* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6cf651f --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2020 Matthew Mitchell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed60208 --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +# PyCryptax + +Pycryptax calculates income and capital gains using transactions and price +data from CSV files which can include cryptoassets such as Bitcoin. Capital +gains are calculated according to section 104 holding; 30-day bed and +breakfasting; and same-day rules. Guidance is available through an [HMRC +publication +online](https://www.gov.uk/government/publications/tax-on-cryptoassets/cryptoassets-for-individuals). + +To install the program you must have Python 3. You can install using pip +(remember to use `sudo` if needed): + + pip3 install pycryptax + +Alternatively you can run the following command in the project's root directory: + + python3 setup.py install + +After installation, the `pycryptax` command should be available and you may run +`pycryptax --help` to view an overview of the usage. See below for more details +on how to use the software. + +## Disclaimer + +**Do not rely on this software for accuracy. Anything provided by this software +does not constitute advice in any form. The software is provided "as is", +without warranty of any kind. Use at your own risk. See the LICENSE file for +more details.** + +## Providing Data + +Transaction data for income and gains need to be provided in CSV files contained +within particular directories. Prices are kept in `./prices`, capital gain/loss +trades in `./gains` and income transactions in `./income`. These directories +should be found within the present working directory or the directory provided +by the `--dir` command line option. + +CSV files can be produced by any decent spreadsheet software. Spreadsheet +software can be used to manipulate exported price, exchange and wallet data into +the correct format. + +Empty rows are allowed. Additional columns for comments etc. are allowed and +ignored. + +Please see the `./examples` directory which contains an example of how data +should be provided. + +### Price data + +Inside the `./prices` directory, CSV files containing price data for asset pairs +can be provided. This price data is used to convert asset amounts into the +reporting/account currency for which the tax calculation is being done (`gbp` by +default). + +Each file should be formatted as `XXX_YYY.csv` where `XXX` is the base currency, +and `YYY` is the quote currency. For example `btc_gbp.csv` would contain the GBP +prices of 1 bitcoin. + +These files can be chained together to combine conversions. For example +`btc_usd.csv` and `usd_gbp.csv` would allow conversion of bitcoin to GBP by +converting to USD first. The software only allows conversions to be done through +a single chain and they can only be done from the base currency to the quoted +currency so `gbp_btc.csv` would allow conversions of GBP to bitcoin but not +bitcoin to GBP. + +Each file should contain a list of daily prices for the asset pair. If a price +is not available for a specifc date, then the soonest earlier date available is +used instead. + +The price csv files should use the following columns: + +| Column | Description | +| ------ | ------------------------------------------------------------------- | +| DATE | The date of the price formatted as YYYY-MM-DD | +| PRICE | A decimal number of the price of the base asset in the quoted asset | + +### Income data + +Transactions for all revenues (positive amounts) and expenses (negative amounts) +can be provided under the `./income` directory in as many CSV files as desired. +The CSV files can be named anything as long as they end in `.csv`. Transactional +data should be provided with the following columns: + +| Column | Description | +| ------ | ---------------------------------------------------------------------------- | +| DATE | The date of the transaction formatted as YYYY-MM-DD | +| ASSET | The asset transacted, in the same format provided by the prices CSV filename | +| AMOUNT | The amount of the asset received/debited (positive) or sent/credited (negative) | +| NOTE | A note to be provided when outputing transactions | + +### Capital Gain/Loss data + +Trades between assets, and other acquisitions or disposals can be provided in +the `./gains` directory in as many CSV files as desired. The CSV files can be +named anything as long as they end in `.csv`. + +If one asset is being traded for another, then they should be provided on the +same row. Sometimes assets are acquired or disposed without a counter asset. In +this case, only the single asset should be provided and the other cells should be +empty. + +If you are selling or buying an asset against the reporting currency (GBP by +default), then the amount of the reporting currency should be provided as a +corresponding buy/sell asset like any other asset would. For example if you sold +2 bitcoin for £12,000, then put `btc` as the sell asset, `gbp` as the buy asset, +`2` as the sell amount and `12000` as the buy amount. + +Asset names should be in the same format as in the price CSV filenames. + +Trades should be provided with the following columns: + +| Column | Description | +| ----------- | ------------------------------------------------------------------------- | +| DATE | The date of the trade, acquistion and/or disposal formatted as YYYY-MM-DD | +| SELL ASSET | The asset being sold/sent/disposed, or empty if none | +| BUY ASSET | The asset being bought/received/acquired or empty if none | +| SELL AMOUNT | The amount of the SELL ASSET being disposed or empty if none | +| BUY ASSET | The amount of the BUY ASSET being acquired or empty if none | + +## Running Calculations + +Please run `pycryptax -h` for usage details. + +When running a calculation you must either be in the directory containing the +`prices`, `income` and/or `gains` directories, or provide it using the `--dir` +option. + +Calcuations are done for a particular period of time. The start and end dates +need to be provided in the `YYYY-MM-DD` format. For example, to calculate income +for the 2009-2010 tax year in the `./examples` directory: + + pycryptax income 2009-04-06 2010-04-05 -d ./examples + +The following actions are allowed: + +- **income:** Produces the revenue and expenditure for each asset and in + total. +- **gain:** Produces the gain and loss for each asset and in total. Also + displays the status of the section 104 holding at the end of the +calculation period. +- **txs:** Outputs in CSV format each income tax transaction with revenue and + expenditure calculations shown in the reporting asset (GBP by default). +- **disposals:** Outputs in CSV format each disposal, including the + calculated costs and proceeds which HMRC may ask for. + +If you do not want to report calculations in GBP or have named GBP something +other than `gbp`, then the `--reportingcurrency` option can be used to specify a +different asset. + diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..79ac096 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,97 @@ +# Calculation Examples + +The capital gains example is included with the `./gains/exampleTrades.csv` file, +and the income example is included with the `./income/exampleIncome.csv` file. +Multiple CSV files can be given in the `./gains/` and `./income/` directories. +Prices are found within `./prices` for the example `foo` and `bar` assets. + +The income for the 2009 to 2010 (Apr 6 to Apr 5) tax year should equate to a +profit of 55. The 2010 to 2011 tax year should equate to a loss of 22.5. + +## Capital Gains Calculation Walkthrough + +On the 1st of January 2010, a total of 800 foo coins are purchased for a total +of £1,600 with the following transactions: + +1. 200 for £550 +2. 400 for £750 +3. 200 at a market price of £1.5 + +The transactions are considered as one, ie. 800 coins for £1,600 @ £2. + +On the same day, a disposal is made of 300 coins for £700. This presents a gain +of £100. The remaining 500 coins for £1,000 are added to a section 104 holding. + +On the 1st of February 150 coins are purchased for £450 at £3. On the 1st of +February 50 coins are disposed for £200 at £4. On the 2nd of January 300 coins +are disposed for £450 at £1.5 + +Firstly the disposal on the 1st of Feb is matched against the acquisition. A +gain of £50 is made on 50 coins, leaving 100 coins purchased at £3. Next the +disposal on the 2nd of Jan is matched to the acquisition on the 1st of Feb due +to it being within 30 days afterwards. This matches 100 coins at a cost of £3, +sold at £1.5, leaving a loss of £150, and 200 coins remaining. These 200 coins +are matched against the section 104 holding at a cost of £2, leaving a loss of +£100. The holding is reduced to 300 coins for £600. + +On the 2nd of February a total of 300 coins are purchased for £1200 at £4. + +The section 104 holding is increased to 600 coins for £1,800. This is outside +the 30 day period of the disposal made in January, so it does not get matched. + +On the 1st of April 300 coins are disposed for £1,200 at £4. On the 1st of +April 100 coins are purchased for £300 at £3. On the 1st of May 600 coins are +purchased for £3,000 at £5. On the 1st of May 200 coins are disposed for £1,200 +at £6. + +Firstly 100 coins disposed in April are matched against the coins purchased on +the same day, at a £100 gain. The 200 coins disposed in May are matched against +the same-day purchase for a £200 gain. The remaining 200 coins disposed in April +are matched against the remaining 400 purchased in May, leaving a loss of £200. +Of the coins purchased in May, 200 are added to the holding with £1,000. The new +holding equals 800 coins and £2,800. + +On the 1st of July 250 coins are disposed for £2,500 at £10. On the 1st of July +50 coins are purchased for £600 at £12. On the 2nd of July 200 coins are +purchased for £800 at £4. On the 2nd of July 100 coins are disposed for £1,100 +at £11. On the 3rd of July 300 coins are purchased for £1,800 at £6. + +The disposal is matched against all three acquisitions but the same-day disposal +on the 2nd must be matched first, and the most recent purchases are matched +first. + +1. The disposal on the 2nd is matched to 100 coins on the same day, leaving a + gain of £700 and 100 purchased coins remaining on the 2nd. +2. The 50 coins purchased on the 1st is matched to the coins disposed on that + day, leaving a £100 loss, and a £200 disposal remaining. +3. The remaining 100 coins from the 2nd are matched to the remaining 200 on the + 1st for a £600 gain, leaving 100 coins from the 1st. +4. The remaining 100 coins is matched to the 300 coins on the 3rd for a gain of + £400. +5. This leaves 200 remaining on the 3rd, increasing the holding to 1,000 coins + and £4,000. + +On the 1st of September 100 coins are disposed for £1,000 at £10 On the 2nd of +September 300 coins are disposed for £1,800 at £6 On the 3rd of September 50 +coins are purchased for £200 at £4 On the 4th of September 150 coins are +purchased for £300 at £2 + +The acquisitions are matched against the earlier disposal on the 1st. 50 from +the 3rd are matched at £4 for a £300 gain. 50 from the 4th are matched for a +gain of £400. Then the remaining 100 from the 4th is matched to the disposal on +the 2nd, for a gain of £400. The remaining 200 on the 2nd are matched against +the holding at a cost of £4, leaving a gain of £400. The holding is reduced to +800 coins and £3,200. + +300 coins are sold on the 5th of September for £1,000, making a loss of £200. +This is done in three transactions: + +1. 100 coins for £300 +2. 150 coins for £500 +3. 50 coins at a market price of £4 + +The final holding is at 500 coins and £2,000. + +The total gain throughout 2010 is £2,900. However, in the UK the tax year starts +on the 6th of April. The 09/10 tax year would have a loss of £200 and the 10/11 +tax year would have a gain of £3,100. diff --git a/examples/gains/exampleTrades.csv b/examples/gains/exampleTrades.csv new file mode 100644 index 0000000..8f28397 --- /dev/null +++ b/examples/gains/exampleTrades.csv @@ -0,0 +1,32 @@ +DATE,SELL ASSET,SELL AMOUNT,BUY ASSET,BUY AMOUNT,EXP GAIN +,,,,, +2010-01-01,gbp,550,foo,200, +2010-01-01,gbp,750,foo,400, +2010-01-01,,,foo,200, +2010-01-01,foo,300,gbp,700,100 +,,,,, +2010-01-02,foo,300,gbp,450,-250 +2010-02-01,gbp,450,foo,150, +2010-02-01,foo,50,gbp,200,50 +,,,,, +2010-02-02,gbp,1200,foo,300, +,,,,, +2010-04-01,foo,300,gbp,1200,-100 +2010-04-01,gbp,300,foo,100, +2010-05-01,gbp,3000,foo,600, +2010-05-01,foo,200,gbp,1200,200 +,,,,, +2010-07-01,foo,250,gbp,2500,900 +2010-07-01,gbp,600,foo,50, +2010-07-02,gbp,800,foo,200, +2010-07-02,foo,100,gbp,1100,700 +2010-07-03,gbp,1800,foo,300, +,,,,, +2010-09-01,foo,100,gbp,1000,700 +2010-09-02,foo,300,gbp,1800,800 +2010-09-03,gbp,200,foo,50, +2010-09-04,gbp,300,foo,150, +,,,,, +2010-09-05,foo,100,gbp,300,-200 +2010-09-05,foo,150,gbp,500, +2010-09-05,foo,50,,, diff --git a/examples/income/exampleIncome.csv b/examples/income/exampleIncome.csv new file mode 100644 index 0000000..5aca488 --- /dev/null +++ b/examples/income/exampleIncome.csv @@ -0,0 +1,8 @@ +DATE,ASSET,AMOUNT,NOTE +2010-01-01,foo,100,100*1.5 = +150 +2010-02-01,bar,2,2*10 = +20 +2010-02-02,bar,-5,-5*20 = -100 +2010-04-05,foo,-10,-10*1.5 = -15 +2010-05-06,bar,0.5,0.5*20 = +10 +2010-08-31,foo,5,5*1.5 = +7.5 +2010-09-01,foo,-10,-10*4 = -40 diff --git a/examples/prices/bar_gbp.csv b/examples/prices/bar_gbp.csv new file mode 100644 index 0000000..764aa5b --- /dev/null +++ b/examples/prices/bar_gbp.csv @@ -0,0 +1,3 @@ +DATE,PRICE +2010-01-01,10 +2010-02-02,20 diff --git a/examples/prices/foo_gbp.csv b/examples/prices/foo_gbp.csv new file mode 100644 index 0000000..7639c94 --- /dev/null +++ b/examples/prices/foo_gbp.csv @@ -0,0 +1,3 @@ +DATE,PRICE +2010-01-01,1.5 +2010-09-01,4 diff --git a/ez_setup.py b/ez_setup.py new file mode 100644 index 0000000..a523401 --- /dev/null +++ b/ez_setup.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python +"""Bootstrap setuptools installation + +To use setuptools in your package's setup.py, include this +file in the same directory and add this to the top of your setup.py:: + + from ez_setup import use_setuptools + use_setuptools() + +To require a specific version of setuptools, set a download +mirror, or use an alternate download directory, simply supply +the appropriate options to ``use_setuptools()``. + +This file can also be run as a script to install or upgrade setuptools. +""" +import os +import shutil +import sys +import tempfile +import zipfile +import optparse +import subprocess +import platform +import textwrap +import contextlib + +from distutils import log + +try: + from urllib.request import urlopen +except ImportError: + from urllib2 import urlopen + +try: + from site import USER_SITE +except ImportError: + USER_SITE = None + +DEFAULT_VERSION = "7.0" +DEFAULT_URL = "https://pypi.python.org/packages/source/s/setuptools/" + +def _python_cmd(*args): + """ + Return True if the command succeeded. + """ + args = (sys.executable,) + args + return subprocess.call(args) == 0 + + +def _install(archive_filename, install_args=()): + with archive_context(archive_filename): + # installing + log.warn('Installing Setuptools') + if not _python_cmd('setup.py', 'install', *install_args): + log.warn('Something went wrong during the installation.') + log.warn('See the error message above.') + # exitcode will be 2 + return 2 + + +def _build_egg(egg, archive_filename, to_dir): + with archive_context(archive_filename): + # building an egg + log.warn('Building a Setuptools egg in %s', to_dir) + _python_cmd('setup.py', '-q', 'bdist_egg', '--dist-dir', to_dir) + # returning the result + log.warn(egg) + if not os.path.exists(egg): + raise IOError('Could not build the egg.') + + +class ContextualZipFile(zipfile.ZipFile): + """ + Supplement ZipFile class to support context manager for Python 2.6 + """ + + def __enter__(self): + return self + + def __exit__(self, type, value, traceback): + self.close() + + def __new__(cls, *args, **kwargs): + """ + Construct a ZipFile or ContextualZipFile as appropriate + """ + if hasattr(zipfile.ZipFile, '__exit__'): + return zipfile.ZipFile(*args, **kwargs) + return super(ContextualZipFile, cls).__new__(cls) + + +@contextlib.contextmanager +def archive_context(filename): + # extracting the archive + tmpdir = tempfile.mkdtemp() + log.warn('Extracting in %s', tmpdir) + old_wd = os.getcwd() + try: + os.chdir(tmpdir) + with ContextualZipFile(filename) as archive: + archive.extractall() + + # going in the directory + subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0]) + os.chdir(subdir) + log.warn('Now working in %s', subdir) + yield + + finally: + os.chdir(old_wd) + shutil.rmtree(tmpdir) + + +def _do_download(version, download_base, to_dir, download_delay): + egg = os.path.join(to_dir, 'setuptools-%s-py%d.%d.egg' + % (version, sys.version_info[0], sys.version_info[1])) + if not os.path.exists(egg): + archive = download_setuptools(version, download_base, + to_dir, download_delay) + _build_egg(egg, archive, to_dir) + sys.path.insert(0, egg) + + # Remove previously-imported pkg_resources if present (see + # https://bitbucket.org/pypa/setuptools/pull-request/7/ for details). + if 'pkg_resources' in sys.modules: + del sys.modules['pkg_resources'] + + import setuptools + setuptools.bootstrap_install_from = egg + + +def use_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL, + to_dir=os.curdir, download_delay=15): + to_dir = os.path.abspath(to_dir) + rep_modules = 'pkg_resources', 'setuptools' + imported = set(sys.modules).intersection(rep_modules) + try: + import pkg_resources + except ImportError: + return _do_download(version, download_base, to_dir, download_delay) + try: + pkg_resources.require("setuptools>=" + version) + return + except pkg_resources.DistributionNotFound: + return _do_download(version, download_base, to_dir, download_delay) + except pkg_resources.VersionConflict as VC_err: + if imported: + msg = textwrap.dedent(""" + The required version of setuptools (>={version}) is not available, + and can't be installed while this script is running. Please + install a more recent version first, using + 'easy_install -U setuptools'. + + (Currently using {VC_err.args[0]!r}) + """).format(VC_err=VC_err, version=version) + sys.stderr.write(msg) + sys.exit(2) + + # otherwise, reload ok + del pkg_resources, sys.modules['pkg_resources'] + return _do_download(version, download_base, to_dir, download_delay) + +def _clean_check(cmd, target): + """ + Run the command to download target. If the command fails, clean up before + re-raising the error. + """ + try: + subprocess.check_call(cmd) + except subprocess.CalledProcessError: + if os.access(target, os.F_OK): + os.unlink(target) + raise + +def download_file_powershell(url, target): + """ + Download the file at url to target using Powershell (which will validate + trust). Raise an exception if the command cannot complete. + """ + target = os.path.abspath(target) + ps_cmd = ( + "[System.Net.WebRequest]::DefaultWebProxy.Credentials = " + "[System.Net.CredentialCache]::DefaultCredentials; " + "(new-object System.Net.WebClient).DownloadFile(%(url)r, %(target)r)" + % vars() + ) + cmd = [ + 'powershell', + '-Command', + ps_cmd, + ] + _clean_check(cmd, target) + +def has_powershell(): + if platform.system() != 'Windows': + return False + cmd = ['powershell', '-Command', 'echo test'] + with open(os.path.devnull, 'wb') as devnull: + try: + subprocess.check_call(cmd, stdout=devnull, stderr=devnull) + except Exception: + return False + return True + +download_file_powershell.viable = has_powershell + +def download_file_curl(url, target): + cmd = ['curl', url, '--silent', '--output', target] + _clean_check(cmd, target) + +def has_curl(): + cmd = ['curl', '--version'] + with open(os.path.devnull, 'wb') as devnull: + try: + subprocess.check_call(cmd, stdout=devnull, stderr=devnull) + except Exception: + return False + return True + +download_file_curl.viable = has_curl + +def download_file_wget(url, target): + cmd = ['wget', url, '--quiet', '--output-document', target] + _clean_check(cmd, target) + +def has_wget(): + cmd = ['wget', '--version'] + with open(os.path.devnull, 'wb') as devnull: + try: + subprocess.check_call(cmd, stdout=devnull, stderr=devnull) + except Exception: + return False + return True + +download_file_wget.viable = has_wget + +def download_file_insecure(url, target): + """ + Use Python to download the file, even though it cannot authenticate the + connection. + """ + src = urlopen(url) + try: + # Read all the data in one block. + data = src.read() + finally: + src.close() + + # Write all the data in one block to avoid creating a partial file. + with open(target, "wb") as dst: + dst.write(data) + +download_file_insecure.viable = lambda: True + +def get_best_downloader(): + downloaders = ( + download_file_powershell, + download_file_curl, + download_file_wget, + download_file_insecure, + ) + viable_downloaders = (dl for dl in downloaders if dl.viable()) + return next(viable_downloaders, None) + +def download_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL, + to_dir=os.curdir, delay=15, downloader_factory=get_best_downloader): + """ + Download setuptools from a specified location and return its filename + + `version` should be a valid setuptools version number that is available + as an sdist for download under the `download_base` URL (which should end + with a '/'). `to_dir` is the directory where the egg will be downloaded. + `delay` is the number of seconds to pause before an actual download + attempt. + + ``downloader_factory`` should be a function taking no arguments and + returning a function for downloading a URL to a target. + """ + # making sure we use the absolute path + to_dir = os.path.abspath(to_dir) + zip_name = "setuptools-%s.zip" % version + url = download_base + zip_name + saveto = os.path.join(to_dir, zip_name) + if not os.path.exists(saveto): # Avoid repeated downloads + log.warn("Downloading %s", url) + downloader = downloader_factory() + downloader(url, saveto) + return os.path.realpath(saveto) + +def _build_install_args(options): + """ + Build the arguments to 'python setup.py install' on the setuptools package + """ + return ['--user'] if options.user_install else [] + +def _parse_args(): + """ + Parse the command line for options + """ + parser = optparse.OptionParser() + parser.add_option( + '--user', dest='user_install', action='store_true', default=False, + help='install in user site package (requires Python 2.6 or later)') + parser.add_option( + '--download-base', dest='download_base', metavar="URL", + default=DEFAULT_URL, + help='alternative URL from where to download the setuptools package') + parser.add_option( + '--insecure', dest='downloader_factory', action='store_const', + const=lambda: download_file_insecure, default=get_best_downloader, + help='Use internal, non-validating downloader' + ) + parser.add_option( + '--version', help="Specify which version to download", + default=DEFAULT_VERSION, + ) + options, args = parser.parse_args() + # positional arguments are ignored + return options + +def main(): + """Install or upgrade setuptools and EasyInstall""" + options = _parse_args() + archive = download_setuptools( + version=options.version, + download_base=options.download_base, + downloader_factory=options.downloader_factory, + ) + return _install(archive, _build_install_args(options)) + +if __name__ == '__main__': + sys.exit(main()) diff --git a/pycryptax/__init__.py b/pycryptax/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pycryptax/__main__.py b/pycryptax/__main__.py new file mode 100644 index 0000000..ff72863 --- /dev/null +++ b/pycryptax/__main__.py @@ -0,0 +1,152 @@ +# Copyright 2019 Matthew Mitchell + +import argparse, sys +from contextlib import contextmanager +from pycryptax import csvdata, prices, income, gains, util + +GAINS_DIR = "/gains" +INCOME_DIR = "/income" +PRICES_DIR = "/prices" +ERR_NOTICE = """ + +Please read the README.md and see the ./examples directory for an example \ +working directory +""" + +BEFORE_MSG = """ +Do not rely on this software for accuracy. Anything provided by this software +does not constitute advice in any form. The software is provided "as is", +without warranty of any kind. Please see the LICENSE and README.md files.""" + +AFTER_MSG = """\ +Thank you for using PyCryptax. If you found this program useful, please consider +giving a small donation to one of the following crypto addresses: + + Bitcoin : 1Fj5hKbEfb2ndBgBsyErT5WJv7jyaSSZKW + Ethereum : 0xdE77869FF85084e0020061ac9bf858f1722e8448 + Litecoin : MRuio1MGdDPmh5Wxk5nApDyraP9qKSdy6D + Peercoin : PPJLqRggLFEPkSTt1AhytJWUxsBSLNA7E9 + Monero : 848JNJDYRF9ZBXb3FAKkcMWYXbppcv8MaM3YYw4cCt1E7RKwCtUUaCbjUDjgkuxZYgFu5qCNfW5KJddrFz1hpCZR8cS2jCD +""" + +def fail(message): + print("\n" + message + ERR_NOTICE, file=sys.stderr) + sys.exit(1) + +@contextmanager +def csvErrorHandler(what, directory, reportAsset): + + try: + yield + except FileNotFoundError: + fail( + "You need to provide {} in the {} directory".format(what, directory) + ) + except csvdata.CSVNotOpenable as e: + fail("Cannot open CSV file {}".format(str(e))) + except csvdata.CSVKeyError as e: + fail("Missing column(s) for {}".format(e.filename)) + except csvdata.CSVDateError as e: + fail( +"Incorrect date \"{}\" in {} on line {}. An example date is 2020-01-05" + .format(e.date, e.filename, e.line) + ) + except csvdata.CSVNumberError as e: + fail( +"A non numeric value found in {} on line {} where a number is expected" + .format(e.filename, e.line) + ) + except prices.AssetPricesNotFound as e: + fail("""\ +Cannot find a {1} price for {0}. Please provide a {0}_{1}.csv file in the \ +prices directory""".format(e.asset, reportAsset)) + except prices.PriceNotFoundForDate as e: + fail( + "Cannot find a {} price for {}" + .format(e.asset, util.getPrettyDate(e.date)) + ) + +def main(): + + # Arguments + + parser = argparse.ArgumentParser( + formatter_class=argparse.RawDescriptionHelpFormatter, + description="""\ +Calculate UK Income and Capital Gain for Tax. Uses Section 104 holding rules and +30-day bed and breakfasting rules. Automatically converts asset amounts into a +specified reporting asset given price data.\ + """, + epilog="""\ +Use the 'income' command to produce a summary of revenue and +expenses. + +Use the 'txs' command to output income calculations for each +transaction to CSV, containing the original asset amount, the price +and the reporting asset amount. + +Use the 'gain' command to produce a summary of asset gains and +losses, and a summary of current section 104 holdings. + +Use the 'disposals' command to output a list of asset disposal +information to CSV. + """ + ) + + parser.add_argument( + "action", choices=["income", "txs", "gain", "disposals"] + ) + parser.add_argument("start", type=str, help="Starting date of calculation") + parser.add_argument("end", type=str, help="End date of calculation") + parser.add_argument( + "--reportingcurrency", "-c", type=str, default="gbp", + help="The reporting currency (default \"gbp\") to present calculations" + ) + parser.add_argument( + "--dir", "-d", type=str, default="./", + help="The root directory of the CSV data (default \"./\")" + ) + + args = parser.parse_args() + + reportAsset = args.reportingcurrency + action = args.action + rootDir = args.dir + start = util.dateFromString(args.start) + end = util.dateFromString(args.end) + + # Load price data + with csvErrorHandler("prices", rootDir + PRICES_DIR, reportAsset): + priceData = prices.Prices(reportAsset, rootDir + PRICES_DIR) + + def getCGCalc(**kwargs): + with csvErrorHandler( + "capital gains information", rootDir + GAINS_DIR, reportAsset + ): + return gains.CapitalGainCalculator( + csvdata.CSVGains(rootDir + GAINS_DIR), priceData, start, end, + **kwargs + ) + + def getIncomeCalc(): + with csvErrorHandler("income information", rootDir + INCOME_DIR, reportAsset): + return income.IncomeCalculator( + csvdata.CSVIncome(rootDir + INCOME_DIR), priceData, start, end + ) + + if action == "income": + print(BEFORE_MSG) + getIncomeCalc().printSummary() + print(AFTER_MSG) + elif action == "txs": + getIncomeCalc().printTxs() + elif action == "gain": + print(BEFORE_MSG) + getCGCalc(summary=True).printSummary() + print(AFTER_MSG) + elif action == "disposals": + getCGCalc(disposals=True).printDisposals() + +if __name__ == '__main__': + main() + diff --git a/pycryptax/csvdata.py b/pycryptax/csvdata.py new file mode 100644 index 0000000..de7f3ae --- /dev/null +++ b/pycryptax/csvdata.py @@ -0,0 +1,143 @@ +import csv, bisect, os +from decimal import Decimal, InvalidOperation +from pycryptax import util, datemap + +class CSVNotOpenable(Exception): + pass + +class CSVKeyError(KeyError): + def __init__(self, filename): + self.filename = filename + +class CSVDateError(ValueError): + def __init__(self, filename, date, line): + self.filename = filename + self.date = date + self.line = line + +class CSVNumberError(ValueError): + def __init__(self, filename, line): + self.filename = filename + self.line = line + +def getDateFromRow(row): + return util.dateFromString(row["DATE"]) + +def isEmpty(v): + return not v or v.isspace() + +class CSVDateMap(datemap.DateMap): + + def _processFile(self, filename): + + try: + f = open(filename, newline='') + except FileNotFoundError as e: + raise e + except (IOError, OSError) as e: + raise CSVNotOpenable(str(e)) + + with f: + + dialect = csv.Sniffer().sniff(f.read(1024)) + f.seek(0) + + reader = csv.DictReader(f, dialect=dialect) + + for line, row in enumerate(reader): + + line += 2 + + try: + + if isEmpty(row["DATE"]): + continue + + try: + date = getDateFromRow(row) + except ValueError: + raise CSVDateError(filename, row["DATE"], line) + + data = self._processRow(row) + self.insert(date, data) + + except KeyError as e: + raise CSVKeyError(filename) + except InvalidOperation as e: + raise CSVNumberError(filename, line) + + def __init__(self, path, requireDir=True): + + super().__init__() + + if os.path.isdir(path) != requireDir: + raise FileNotFoundError + + if requireDir: + for f in os.listdir(path): + self._processFile(path + "/" + f) + else: + self._processFile(path) + +class IncomeTx(): + + def __init__(self, asset, amount, note): + self.asset = asset + self.amount = Decimal(amount) + self.note = note + +class CSVIncome(CSVDateMap): + + def __init__(self, filename): + super().__init__(filename) + + def _processRow(self, row): + return IncomeTx(row["ASSET"], row["AMOUNT"], row.get("NOTE")) + +class GainTx(): + + def __init__(self, sellAsset, buyAsset, sellAmount, buyAmount): + + if not isEmpty(sellAsset): + self.sellAsset = sellAsset + self.sellAmount = Decimal(sellAmount) + else: + self.sellAsset = None + self.sellAmount = None + + if not isEmpty(buyAsset): + self.buyAsset = buyAsset + self.buyAmount = Decimal(buyAmount) + else: + self.buyAsset = None + self.buyAmount = None + +class CSVGains(CSVDateMap): + + def __init__(self, filename): + super().__init__(filename) + + def _processRow(self, row): + return GainTx( + row["SELL ASSET"], row["BUY ASSET"], + row["SELL AMOUNT"], row["BUY AMOUNT"] + ) + +class CSVPrices(CSVDateMap): + + def __init__(self, filename, quoted): + super().__init__(filename, False) + self._quoted = quoted + + def _processRow(self, row): + return Decimal(row["PRICE"]) + + def quotedAsset(self): + return self._quoted + + def __getitem__(self, ind): + i = bisect.bisect(self._dates, ind) - 1 + if i < 0: + raise KeyError + return self._values[i] + diff --git a/pycryptax/datemap.py b/pycryptax/datemap.py new file mode 100644 index 0000000..578fd32 --- /dev/null +++ b/pycryptax/datemap.py @@ -0,0 +1,78 @@ +import bisect, datetime + +class DateMapIterator(): + + def __init__(self, dateMap, start=None, end=None): + + self._dateMap = dateMap + + self._pos = 0 if start is None else bisect.bisect_left(dateMap._dates, start) + + self._end = len(dateMap) if end is None else \ + bisect.bisect_right(dateMap._dates, end) + + def __next__(self): + + if self._pos == self._end: + raise StopIteration + + self._pos += 1 + + return self._dateMap[self._pos - 1] + +class DateMapIterable(): + + def __init__(self, dateMap, start, end): + self._dateMap = dateMap + self._start = start + self._end = end + + def __iter__(self): + return DateMapIterator(self._dateMap, self._start, self._end) + +class DateMap(): + + def __init__(self): + + self._dates = [] + self._values = [] + + def range(self, start, end): + return DateMapIterable(self, start, end) + + def _indexOf(self, date): + return bisect.bisect_left(self._dates, date) + + def _indexHasDate(self, ind, date): + return ind != len(self) and self._dates[ind] == date + + def __contains__(self, date): + return self._indexHasDate(self._indexOf(date), date) + + def __getitem__(self, ind): + + if type(ind) is datetime.datetime: + + intInd = self._indexOf(ind) + + if not self._indexHasDate(intInd, ind): + raise IndexError + + return self._values[intInd] + + return self._dates[ind], self._values[ind] + + def __len__(self): + return len(self._dates) + + def __iter__(self): + return DateMapIterator(self) + + def insert(self, date, value): + + ind = bisect.bisect(self._dates, date) + self._dates.insert(ind, date) + self._values.insert(ind, value) + + return value + diff --git a/pycryptax/gains.py b/pycryptax/gains.py new file mode 100644 index 0000000..40a44c2 --- /dev/null +++ b/pycryptax/gains.py @@ -0,0 +1,307 @@ +import copy, datetime +from decimal import Decimal +from pycryptax import util, output, datemap + +class AssetPool(): + + def __init__(self): + self.totalQuantity = 0 + self.totalCost = 0 + + def add(self, quantity, cost): + self.totalQuantity += quantity + self.totalCost += cost + + def dispose(self, quantity): + + if quantity > self.totalQuantity: + raise ValueError( + "Quantity of asset being disposed, is more than existing. {} > {}" + .format(quantity, self.totalQuantity) + ) + + cost = self.totalCost * quantity / self.totalQuantity + self.totalQuantity -= quantity + self.totalCost -= cost + + return cost + + def __repr__(self): + return "AssetPool({}, {})".format(self.totalQuantity, self.totalCost) + +class AggregateDayTxs(): + + def __init__(self): + + self.acquireAmt = 0 + self.acquireVal = 0 + + self.disposeAmt = 0 + self.disposeVal = 0 + + def acquire(self, amt, val): + self.acquireAmt += amt + self.acquireVal += val + + def dispose(self, amt, val): + self.disposeAmt += amt + self.disposeVal += val + +class Gain(): + + def __init__(self, cost=0, value=0): + self._value = value + self._cost = cost + + def __iadd__(self, b): + self._value += b._value + self._cost += b._cost + return self + + def cost(self): + return self._cost + + def value(self): + return self._value + + def gain(self): + return self._value - self._cost + +class CapitalGainCalculator(): + + def __init__( + self, gainData, priceData, start, end, summary=True, disposals=False + ): + + self._start = start + self._end = end + self._includeSummary = summary + self._includeDisposals = disposals + + if summary: + self._assetGain = {} + self._totalGain = Gain() + + if disposals: + self._disposals = datemap.DateMap() + + self._assetPoolsAtEnd = {} + self._assetPools = {} + + self._priceData = priceData + + reportAsset = priceData.reportAsset() + + def isNonReportAsset(asset): + return asset and asset != reportAsset + + # Obtain total acquisition and disposal values for each day for every + # asset + + assetTxs = {} + + def getDayTxForAsset(asset, date): + + if asset not in assetTxs: + assetTxs[asset] = datemap.DateMap() + + dayTxs = assetTxs[asset] + + if date not in dayTxs: + return dayTxs.insert(date, AggregateDayTxs()) + + return dayTxs[date] + + def getValueOfAssetAmount(asset, amount, otherAsset, otherAmount, date): + + if otherAsset: + # Use the value of the other asset in exchange according to CG78310 + return otherAmount * priceData.get(otherAsset, date) + else: + # Use market value + return amount * priceData.get(asset, date) + + for date, tx in gainData: + + if isNonReportAsset(tx.buyAsset): + # Acquisition + + getDayTxForAsset(tx.buyAsset, date).acquire( + tx.buyAmount, + getValueOfAssetAmount( + tx.buyAsset, tx.buyAmount, tx.sellAsset, + tx.sellAmount, date + ) + ) + + if isNonReportAsset(tx.sellAsset): + # Disposal + + getDayTxForAsset(tx.sellAsset, date).dispose( + tx.sellAmount, + getValueOfAssetAmount( + tx.sellAsset, tx.sellAmount, tx.buyAsset, + tx.buyAmount, date + ) + ) + + def applyGain(asset, gain, date): + + if date < start or date > end: + return + + if self._includeSummary: + self._totalGain += gain + util.addToDictKey(self._assetGain, asset, gain) + + if self._includeDisposals: + self._disposals.insert(date, (asset, gain)) + + def match(asset, date, disposeTx, acquireTx): + + # Get amount that can be matched + amount = min(disposeTx.disposeAmt, acquireTx.acquireAmt) + + if amount == 0: + # Cannot match nothing + return + + # Get proportion of cost + cost = acquireTx.acquireVal * amount / acquireTx.acquireAmt + + # Get proportion of disposal value + value = disposeTx.disposeVal * amount / disposeTx.disposeAmt + + # Apply gain/loss + applyGain(asset, Gain(cost, value), date) + + # Adjust data to remove amounts and report asset values that have + # been accounted for + + disposeTx.disposeAmt -= amount + acquireTx.acquireAmt -= amount + + disposeTx.disposeVal -= value + acquireTx.acquireVal -= cost + + for asset, dayTxs in assetTxs.items(): + + # Same-day rule: Match disposals to acquisitions that happen on the same day + + for date, tx in dayTxs: + match(asset, date, tx, tx) + + # Bed and breakfasting rule + # Match disposals to nearest acquisitions from 1->30 days afterwards + + for date, tx in dayTxs: + + # Only process disposals + if tx.disposeAmt == 0: + continue + + # Loop though tranactions in range to match against + for matchDate, matchTx in dayTxs.range( + date + datetime.timedelta(days=1), + date + datetime.timedelta(days=30) + ): + match(asset, date, tx, matchTx) + + # Process section 104 holdings from very beginning but only count gains + # realised between start and end. + + for date, tx in dayTxs: + + # Only an acquisation or disposal, not both allowed. + # Should have been previously matched + assert(not (tx.acquireAmt != 0 and tx.disposeAmt != 0)) + + if tx.acquireAmt != 0: + + # Adjust section 104 holding + + if asset not in self._assetPools: + self._assetPools[asset] = AssetPool() + + self._assetPools[asset].add(tx.acquireAmt, tx.acquireVal) + + if tx.disposeAmt != 0: + + if asset not in self._assetPools: + raise ValueError("Disposing of an asset not acquired") + + # Adjust section 104 holding and get cost + try: + cost = self._assetPools[asset].dispose(tx.disposeAmt) + except ValueError as e: + print(util.getPrettyDate(date) + " (" + asset + "): " + str(e)) + raise e + + # Apply gain/loss + applyGain(asset, Gain(cost, tx.disposeVal), date) + + if date <= end: + # Update asset pools up until the end of the range to get the + # section 104 holdings at the point of the end of the range + self._assetPoolsAtEnd[asset] = copy.deepcopy(self._assetPools[asset]) + + def printSummary(self): + + output.printCalculationTitle("CAPITAL GAIN", self._start, self._end) + + table = output.OutputTable(4) + table.appendRow("ASSET", "ACQUISITION COST", "DISPOSAL VALUE", "GAIN / LOSS") + table.appendGap() + + for k, v in self._assetGain.items(): + table.appendRow(k, v.cost(), v.value(), v.gain()) + + table.appendGap() + table.appendRow( + "TOTAL", self._totalGain.cost(), self._totalGain.value(), + self._totalGain.gain() + ) + + table.print() + + print("SECTION 104 HOLDINGS AS OF {}:\n".format(util.getPrettyDate(self._end))) + + table = output.OutputTable(5) + table.appendRow("ASSET", "AMOUNT", "COST", "VALUE", "UNREALISED GAIN") + table.appendGap() + + totalCost = Decimal(0) + totalValue = Decimal(0) + + for asset, pool in self._assetPoolsAtEnd.items(): + + value = pool.totalQuantity * self._priceData.get(asset, self._end) + + totalCost += pool.totalCost + totalValue += value + + table.appendRow( + asset, pool.totalQuantity, pool.totalCost, value, value - pool.totalCost + ) + + table.appendGap() + table.appendRow("", "TOTAL", totalCost, totalValue, totalValue - totalCost) + + table.print() + + def printDisposals(self): + + print("Date,Asset,Cost,Proceeds,Gain") + + def numFormat(n): + return "{:.2f}".format(n) + + for date, (asset, gain) in self._disposals: + print("{},\"{}\",{},{},{}".format( + util.getPrettyDate(date), + asset.replace('"', '""'), + numFormat(gain.cost()), + numFormat(gain.value()), + numFormat(gain.gain()), + )) + diff --git a/pycryptax/income.py b/pycryptax/income.py new file mode 100644 index 0000000..d140eb2 --- /dev/null +++ b/pycryptax/income.py @@ -0,0 +1,95 @@ +from decimal import Decimal +from pycryptax import util, output + +class IncomeValue(): + + def __init__(self, value=Decimal(0)): + if value > 0: + self._in = value + self._out = Decimal(0) + else: + self._out = -value + self._in = Decimal(0) + + def __iadd__(self, amount): + self._in += amount._in + self._out += amount._out + return self + + def revenue(self): + return self._in + + def expenditure(self): + return self._out + + def total(self): + return self._in - self._out + +class IncomeTx(): + + def __init__(self, asset, date, amount, price, incomeValue, note): + self.asset = asset + self.date = date + self.amount = amount + self.price = price + self.incomeValue = incomeValue + self.note = note + +class IncomeCalculator(): + + def __init__(self, incomeData, priceData, start, end): + + self._start = start + self._end = end + + self._assetIncome = {} + self._txs = [] + self._total = IncomeValue() + + for date, tx in incomeData.range(start, end): + price = priceData.get(tx.asset, date) + incomeValue = IncomeValue(tx.amount * price) + self._txs.append( + IncomeTx( + tx.asset, date, tx.amount, price, incomeValue, tx.note + ) + ) + self._total += incomeValue + util.addToDictKey(self._assetIncome, tx.asset, incomeValue) + + def printSummary(self): + + output.printCalculationTitle("INCOME", self._start, self._end) + + table = output.OutputTable(4) + table.appendRow("ASSET", "REVENUE", "EXPENDITURE", "TOTAL") + table.appendGap() + + for k, v in self._assetIncome.items(): + table.appendRow(k, v.revenue(), v.expenditure(), v.total()) + + table.appendGap() + table.appendRow( + "TOTAL", self._total.revenue(), self._total.expenditure(), self._total.total() + ) + + table.print() + + def printTxs(self): + + print("Date,Asset,Amount,Price,Revenue,Expense,Note") + + def numFormat(n): + return "{:.2f}".format(n) if n != 0 else "" + + for tx in self._txs: + print("{},\"{}\",{},{},{},{},\"{}\"".format( + util.getPrettyDate(tx.date), + tx.asset.replace('"', '""'), + numFormat(tx.amount), + numFormat(tx.price), + numFormat(tx.incomeValue.revenue()), + numFormat(tx.incomeValue.expenditure()), + tx.note.replace('"', '""') + )) + diff --git a/pycryptax/output.py b/pycryptax/output.py new file mode 100644 index 0000000..017aa31 --- /dev/null +++ b/pycryptax/output.py @@ -0,0 +1,54 @@ +import string, decimal +from pycryptax import util + +class OutputTable(): + + def __init__(self, cols): + self._data = [] + self._cols = cols + self._colWidths = (0,) * cols + + def appendRow(self, *row): + + # Convert to strings + + row = tuple( + "{:,.2f}".format(cell) if type(cell) is decimal.Decimal \ + else str(cell) \ + for cell in row + ) + + # Pad with extra cells + + self._data.append( + row + ("",) * (self._cols - len(row)) + ) + + # Calculate new width from maximum width of cell strings + + self._colWidths = tuple( + max(last, new+2) for last, new in zip( + self._colWidths, + (len(cell) for cell in row) + ) + ) + + def appendGap(self): + self._data.append(("",) * self._cols) + + def print(self): + + for row in self._data: + for cell, width in zip(row, self._colWidths): + padding = width-len(cell) + print(cell, end = " " * padding) + print() + + print() + + +def printCalculationTitle(title, start, end): + print("\n{} CALCULATION {} - {}:\n".format( + title, util.getPrettyDate(start), util.getPrettyDate(end) + )) + diff --git a/pycryptax/prices.py b/pycryptax/prices.py new file mode 100644 index 0000000..571c9dd --- /dev/null +++ b/pycryptax/prices.py @@ -0,0 +1,50 @@ +import os, re +from decimal import Decimal +from pycryptax import csvdata + +FILENAME_PATTERN = r"^(.+)_(.+)\.csv$" + +class AssetPricesNotFound(Exception): + def __init__(self, asset): + self.asset = asset + +class PriceNotFoundForDate(Exception): + def __init__(self, asset, date): + self.asset = asset + self.date = date + +class Prices(): + + def __init__(self, reportAsset, dirpath): + + self._d = {} + self._reportAsset = reportAsset + + for f in os.listdir(dirpath): + + match = re.match(FILENAME_PATTERN, f) + + if match: + base, quoted = match.groups() + self._d[base.lower()] = csvdata.CSVPrices( + dirpath + "/" + f, quoted.lower() + ) + + def get(self, asset, date): + + if asset == self._reportAsset: + return Decimal(1) + + try: + assetPrices = self._d[asset] + except KeyError: + raise AssetPricesNotFound(asset) + + try: + return assetPrices[date] * self.get(assetPrices.quotedAsset(), date) + except KeyError: + raise PriceNotFoundForDate(asset, date) + + def reportAsset(self): + return self._reportAsset + diff --git a/pycryptax/util.py b/pycryptax/util.py new file mode 100644 index 0000000..ab79e6d --- /dev/null +++ b/pycryptax/util.py @@ -0,0 +1,18 @@ +import datetime, copy + +def dateFromString(s): + try: + return datetime.datetime.strptime(s, "%Y-%m-%d") + except ValueError: + return datetime.datetime.strptime(s, "%d %b %Y") + +def getPrettyDate(d): + return d.strftime("%d/%m/%Y") + +def addToDictKey(d, k, v): + + if k in d: + d[k] += v + else: + d[k] = copy.deepcopy(v) + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..a663b89 --- /dev/null +++ b/setup.py @@ -0,0 +1,31 @@ +from ez_setup import use_setuptools +use_setuptools() + +from setuptools import setup + +setup( + name = "PyCryptax", + version = "1.0.0", + description = \ + "UK Income and Capital Gains Tax Calculator for Cryptocurrencies", + long_description=open("README.md", "r").read(), + long_description_content_type="text/markdown", + author = "Matthew Mitchell", + author_email = "pycryptax@thelibertyportal.com", + url = "https://github.com/MatthewLM/PyCryptax", + packages = ["pycryptax"], + license = "MIT", + classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: POSIX", + "Topic :: Office/Business :: Financial :: Accounting", + "Intended Audience :: End Users/Desktop", + "Natural Language :: English", + "Environment :: Console" + ], + entry_points = { + "console_scripts" : ["pycryptax = pycryptax.__main__:main"] + } +) +