diff --git a/.dockerignore b/.dockerignore index 4c3b87e1..f7ace482 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,6 +5,7 @@ .gitmodules docs tests +scripts docker-compose.yml readme.md diff --git a/.github/workflows/build-and-push-image.yaml b/.github/workflows/build-and-push-image.yaml index 7b0102c6..4d958e80 100644 --- a/.github/workflows/build-and-push-image.yaml +++ b/.github/workflows/build-and-push-image.yaml @@ -2,11 +2,12 @@ name: Create and publish Docker image on: push: - branches: ['main', 'release'] + branches: ['main', 'release', 'project_structure'] env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} + TEST_TAG: donbing/bitbot:test jobs: build-and-push-image: @@ -38,10 +39,23 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - - name: Build and push Docker image + - name: Build test image and export to Docker + uses: docker/build-push-action@v2 + with: + context: . + load: true + file: scripts/docker/dockerfile + tags: ${{ env.TEST_TAG }} + + - name: Run test image + run: | + docker run --rm --env TESTRUN=true --env BITBOT_OUTPUT=disk --env BITBOT_SHOWIMAGE=false ${{ env.TEST_TAG }} + + - name: Build and push real Docker image uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc with: context: . + file: scripts/docker/dockerfile platforms: linux/arm/v6,linux/arm/v7 push: true tags: ${{ steps.meta.outputs.tags }} diff --git a/.github/workflows/lint-and-test-python.yml b/.github/workflows/lint-and-test-python.yml new file mode 100644 index 00000000..57440c9a --- /dev/null +++ b/.github/workflows/lint-and-test-python.yml @@ -0,0 +1,32 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Lint and test python + +on: [push] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.9.2 + uses: actions/setup-python@v2 + with: + python-version: "3.9.2" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with unittest + run: | + python3 -m unittest discover -s tests -v diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..9a36b4d9 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Module", + "type": "python", + "request": "launch", + "module": "run", + "env" : { + "TESTRUN": "true", + "BITBOT_OUTPUT": "disk", + "BITBOT_SHOWIMAGE": "true" + }, + "justMyCode": false + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index ceb7365e..4ae35df4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,16 @@ { "files.exclude": { "**/__pycache__": true - } + }, + "python.testing.unittestArgs": [ + "-v", + "-s", + "./tests", + "-p", + "test*.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true, + "python.linting.enabled": true, + "python.linting.flake8Enabled": true } \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..f288702d --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/config.ini b/config.ini deleted file mode 100644 index 9764539c..00000000 --- a/config.ini +++ /dev/null @@ -1,14 +0,0 @@ -[currency] -exchange=bitmex -instrument=BTC/USD - -[display] -rotation=0 -colour=red -width=400 -height=300 -refresh_time_minutes=10 - -[comments] -up=moon,yolo,pump it,gentlemen -down=short the corn!,goblin town,bearish?,dooom,sell!! \ No newline at end of file diff --git a/config/base.mplstyle b/config/base.mplstyle new file mode 100644 index 00000000..b6094c95 --- /dev/null +++ b/config/base.mplstyle @@ -0,0 +1,56 @@ +figure.dpi: 100 +#figure.autolayout: True +#figure.constrained_layout.use: True +#figure.constrained_layout.h_pad: 0.04167 # Padding around axes objects. Float representing +#figure.constrained_layout.w_pad: 0.04167 # inches. Default is 3/72 inches (3 points) +#figure.constrained_layout.hspace: 0.02 # Space between subplot groups. Float representing +#figure.constrained_layout.wspace: 0.02 # a fraction of the subplot widths being separated. + +font.family: sans-serif +font.sans-serif: 04b03 +font.weight: light +savefig.transparent: True +#savefig.pad_inches: 0 +text.hinting_factor:1 +text.hinting: native +text.antialiased: False +patch.antialiased: False +lines.antialiased: False +figure.subplot.hspace: 0 + +axes.facecolor: white +axes.linewidth: 0.5 +axes.spines.left: True +axes.spines.bottom: True +axes.spines.top: False +axes.spines.right: False +axes.grid: False + +grid.linestyle: - +grid.linewidth: 0.5 +grid.color: black +axes.edgecolor: red + +ytick.major.pad: 0 + +xtick.color: red +ytick.color: red + +xtick.labelcolor: black +ytick.labelcolor: black + +xtick.labelsize: 12 +ytick.labelsize: 12 + +xtick.alignment: center +ytick.alignment: bottom + +ytick.major.size: 5 +xtick.major.size: 5 +xtick.minor.size: 3 + +xtick.direction: inout +ytick.direction: inout + +ytick.major.width: 0.5 +xtick.major.width: 0.5 \ No newline at end of file diff --git a/config/config.ini b/config/config.ini new file mode 100644 index 00000000..258ff153 --- /dev/null +++ b/config/config.ini @@ -0,0 +1,24 @@ +[currency] +exchange=bitmex +instrument=BTC/USD +stock_symbol= +holdings=0 + +[display] +rotation=0 +refresh_time_minutes=10 +# inky disk +output=inky +disk_file_name=last_display.png +# 1 2 +overlay_layout=1 +expanded_chart=false +# red black none +border=red +timestamp=true +show_volume=false +candle_width=random + +[comments] +up=moon,yolo,pump it,gentlemen +down=short the corn!,goblin town,bearish?,dooom,sell!! diff --git a/config/default.mplstyle b/config/default.mplstyle new file mode 100644 index 00000000..1aae355d --- /dev/null +++ b/config/default.mplstyle @@ -0,0 +1,3 @@ +axes.xmargin: 0.02 +axes.ymargin: 0.1 +axes.autolimit_mode: round_numbers \ No newline at end of file diff --git a/config/inset.mplstyle b/config/inset.mplstyle new file mode 100644 index 00000000..2f6e9418 --- /dev/null +++ b/config/inset.mplstyle @@ -0,0 +1,22 @@ +axes.spines.left: False +axes.spines.bottom: False +axes.spines.top: False +axes.spines.right: False + +axes.autolimit_mode: data +axes.xmargin: 0 +axes.ymargin: 0 +xtick.major.size: 5 +ytick.major.size: 5 +xtick.direction: in +ytick.direction: in + +ytick.minor.visible: False +xtick.major.pad: -15 +ytick.major.pad: -38 + +figure.subplot.left: 0 +figure.subplot.right: 1 +figure.subplot.bottom: 0 +figure.subplot.top: 1 + diff --git a/config/logging.ini b/config/logging.ini new file mode 100644 index 00000000..618b7ac7 --- /dev/null +++ b/config/logging.ini @@ -0,0 +1,32 @@ +[loggers] +keys=root + +[logger_root] +level=DEBUG +handlers=screen,file + +[formatters] +keys=simple,verbose + +[formatter_simple] +format=%(asctime)s [%(levelname)s] %(name)s: %(message)s + +[formatter_verbose] +format=[%(asctime)s] %(levelname)s [%(filename)s %(name)s %(funcName)s (%(lineno)d)]: %(message)s + +[handlers] +keys=file,screen + +[handler_file] +class=handlers.RotatingFileHandler +maxBytes=2000 +backupCount=0 +formatter=simple +level=INFO +args=('debug.log',) + +[handler_screen] +class=StreamHandler +formatter=simple +level=INFO +args=(sys.stdout,) \ No newline at end of file diff --git a/config/volume.mplstyle b/config/volume.mplstyle new file mode 100644 index 00000000..8984f971 --- /dev/null +++ b/config/volume.mplstyle @@ -0,0 +1,22 @@ +axes.spines.left: False +axes.spines.bottom: False +axes.spines.top: False +axes.spines.right: False + +ytick.major.width: 0 +ytick.minor.size: 0 +ytick.labelsize: 6 +ytick.color: red +ytick.labelcolor: black + +xtick.major.width: 0 +xtick.minor.size: 0 +xtick.color: red +xtick.labelsize: 1 +xtick.labelcolor: white + +axes.xmargin:0 +axes.ymargin:0 +xtick.major.pad: 0 +xtick.minor.pad: 0 +ytick.major.pad: 0 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 8fa2297b..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,22 +0,0 @@ -version: "2.1" - -services: - - wifi-connect: - image: balenablocks/wifi-connect:rpi - network_mode: "host" - restart: unless-stopped - labels: - io.balena.features.dbus: '1' - cap_add: - - NET_ADMIN - environment: - DBUS_SYSTEM_BUS_ADDRESS: "unix:path=/host/run/dbus/system_bus_socket" - - bit-bot: - image: ghcr.io/donbing/bitbot:main - privileged: true - restart: unless-stopped - - # config-editor: - # build: ./docker/config-editor.dockerfile \ No newline at end of file diff --git a/dockerfile b/dockerfile deleted file mode 100644 index 52d065ed..00000000 --- a/dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -FROM navikey/raspbian-buster - -# packages needed to run the app -RUN apt-get update && apt-get install -y --no-install-recommends\ - python3 python3-pip \ - python3-matplotlib \ - python3-rpi.gpio \ - python3-pil \ - && rm -rf /var/lib/apt/lists/* - -COPY requirements.txt . -RUN pip3 install --no-cache-dir -r requirements.txt - -WORKDIR /code -COPY . . - -CMD [ "python3", "./run.py" ] \ No newline at end of file diff --git a/docs/app_install.md b/docs/app_install.md index 997cf109..a74f53c1 100644 --- a/docs/app_install.md +++ b/docs/app_install.md @@ -1,21 +1,18 @@ # Setup Options -## A. Burn the Bitbot image to a new SD card ---- +## 🎴 A. Burn the Bitbot image to a new SD card > Simple installation that anyone can complete 1. download the latest release from [releases page](https://github.com/donbing/bitbot/releases) 2. use [Balena Etcher](https://www.balena.io/etcher/) to burn the zipped image to your SD card. 3. insert SD, power up and wait for the screen to refresh -## B. Add to an existing PiOS install -> For advanced users that want to modify an existing pi -buster image - -1. make sure python, pip, git and other dependancies are installed +## πŸ“B. Add Bitbot to an existing PiOS install +> tested on buster, seems to work on bullseye too +1. Make sure python, pip, git and other dependancies are installed ```sh sudo apt update -y sudo apt install -y git python3-pip python3-matplotlib python3-rpi.gpio python3-pil ``` -2. Clone this repo and setup requirements +2. Clone this repo and install [pip requirements](/requirements.txt) ```sh git clone https://github.com/donbing/bitbot cd bitbot @@ -30,13 +27,13 @@ sudo raspi-config nonint do_i2c 0 ```sh python3 -m run ``` -5. Add cron jobs to start the app and config server +5. Add cron jobs to start the [app](/run.py) and [config-server](/src/config_webserver.py) after reboot ```sh -(crontab -l 2>/dev/null; echo "@reboot sleep 30 && python3 /home/pi/bitbot/run.py 2>&1 | /usr/bin/logger -t bitbot")| crontab - -(crontab -l 2>/dev/null; echo "@reboot sleep 30 && python3 /home/pi/bitbot/src/config_webserver.py 2>&1 | /usr/bin/logger -t bitbot")| crontab - +(crontab -l 2>/dev/null; echo "@reboot sleep 30 && cd /home/pi/bitbot && python3 run.py 2>&1 | /usr/bin/logger -t bitbot.charts")| crontab - +(crontab -l 2>/dev/null; echo "@reboot sleep 30 && cd /home/pi/bitbot && python3 src/config_webserver.py 2>&1 | /usr/bin/logger -t bitbot.charts")| crontab - ``` -## C. Install in docker -> Highly flexible approach that allows for simple updates +## 🐳 C. Run in docker +> 1. ensure that `I2C`/`SPI` are enabled on the host pi ```sh sudo raspi-config nonint do_spi 0 @@ -44,5 +41,5 @@ sudo raspi-config nonint do_i2c 0 ``` 2. run the container ```sh -docker run --privileged -d ghcr.io/donbing/bitbot:main -``` \ No newline at end of file +docker run --privileged --restart unless-stopped -d ghcr.io/donbing/bitbot:release +``` diff --git a/docs/development.md b/docs/development.md index 8190fd3e..d6d3934f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,38 +1,63 @@ -# App Development +# Development > Bitbot is somewhat cobbled together, but is fairly carefully commented and has been factored with ease of change in mind. -## logging -BitBot will log to `StdOut` and a rolling `debug.log` file, i'm mildly concerned about writing to the SD card too much causing wear, it may be sensible to write these to a memory cache instead. +## βœ”οΈ Tests +> [python unittests](/tests) with the default test framework + + python3 -m unittest discover tests -v -Log level is defaulted to info, but there is some limited debug level logging if you wish to get more info. +## βœ‰οΈ Env vars +> `TESTRUN` loads one chart and then exits -Cron jobs were configured to output to syslog. +> `BITBOT_SHOWIMAGE` [opens the image in vscode](/run.py) after loading the chart +> `BITBOT_OUTPUT` may be set to `disk` to write to disk rather than the e-ink display + + export TESTRUN=true BITBOT_OUTPUT=disk BITBOT_SHOWIMAGE=true + +## πŸ“» Easy WiFi config +> [`comitup`](https://github.com/davesteele/comitup) is used for the ***disk image***, it creates a **config hotspot** on the Pi if it **cant connect** to any wifi itself. + +> The config file is located at `/etc/comitup.conf` +```sh +# show comitup info +sudo comitup -i +# open cli (easy to delete connections here) +sudo comitup-cli +``` + +## 🌳Logging +> BitBot will log to `StdOut` and a rolling `debug.log` file, configured in [πŸ“logging.ini](/logging.ini) + +> Log level is **defaulted to `INFO`**, but there is some ***limited debug level logging*** if you wish to get more info. + +> Cron jobs were configured to output to syslog. 😞 ```sh # python logging tail ~/bitbot/debug.log # syslog logging -more /var/log/syslog | grep bitbot +less /var/log/syslog | grep bitbot.charts ``` -## Packages - - Pimoroni's [`inky`](https://github.com/pimoroni/inky) lib is used to draw to the screen, - - [`CCXT`](https://github.com/ccxt/ccxt) is used to interact with currency exchanges - - [`MPL-Finance`](https://github.com/matplotlib/mpl-finance) is used to draw the graphs (and could do with updating to [`mplfinance`](https://github.com/matplotlib/mplfinance)) - - [`Pillow`](https://github.com/python-pillow/Pillow) aids in drawing overlay text onto the graph +## 🎁 Packages + - [Pimoroni](pimoroni.com) [`inky`](https://github.com/pimoroni/inky) does the **e-ink display**, + - [`CCXT`](https://github.com/ccxt/ccxt) talks to **crypto exchanges** + - [`MPL-Finance`](https://github.com/matplotlib/mpl-finance) **draws the graphs** (and could do with updating to [`mplfinance`](https://github.com/matplotlib/mplfinance)) + - [`Pillow`](https://github.com/python-pillow/Pillow) draws **drawing overlay** text onto the graph ![Package Interactions](http://www.plantuml.com/plantuml/svg/3Oon3KCX30NxFqMo0EvJ_LN0M7mhO11-LjOFrUckkDkHDsBqwwt6FQh4xgy7MFuXslcNckA94YwRfq4CYUUWEgseDIgACa4Zgvt6JcT5A_CtD_6qZbstM3ty0m00) -## Docker -> Build arm6 on x86 -```bash -docker buildx build --platform linux/armv6 . -t bitbot --progress string -# run it -docker run --privileged --platform linux/arm/v6 bitbot +## 🐳 Docker +> **Github actions** builds and tests and publishes a **container image** on each commit to `main` and `release` +### 🐳 Build +> building on `x86` is way faster than on the Pi +```sh +# remove the `--platform` args if building on a pi +docker buildx build --platform linux/arm/v6 . -t bitbot -f scripts/docker/dockerfile --progress string ``` - -## Configuration -[`RaspiWifi`](https://github.com/jasbur/RaspiWiFi) is installed seperately in order to facilitate easy end-user setup. Unfortunately the lack of region in wpa_supplicant causes problems on newer pi hardware. they could do with a PR to fix.. - -Alternativey [`txwifi`](https://github.com/txn2/txwifi) may be worth a look as a replacement, and is hosted in docker for cleanliness and consistency. \ No newline at end of file +### 🐳 Run +> **Priviledged access** is needed for `GPIO`, this looks to be fixable thru bind mounts +```sh +docker run --privileged --platform linux/arm/v6 bitbot +``` \ No newline at end of file diff --git a/docs/device_assembly.md b/docs/device_assembly.md index fa20a14a..2998a590 100644 --- a/docs/device_assembly.md +++ b/docs/device_assembly.md @@ -1,18 +1,22 @@ -# Assembly Instructions -> The screen is an e-ink glass display and is quite delicate, we recommend holding it by the edges, and avoiding applying pressure to the glass covered side. +# πŸ“ Assembly Instructions +> The screen is an e-ink glass display and is quite delicate, we recommend holding it by the edges, and avoiding applying much pressure to the glass covered side. -## Your BitBot consists of 3 parts - - Screen - - Raspberry Pi - - Stand +## πŸ€– Your BitBot consists of 3 parts +> Screen, Raspberry Pi, Stand ![All Three parts arranged on a mat](images/Assembly/BitBot_assembly_1.jpg "All Parts") -## Steps -1. Line the screen up with the stand, so that the screw holes are visible, and the bottom corner connector is seated in it's rectangular hole. +## πŸ‘ž Steps +**1**. Line the screen up with the stand, so that the screw holes are visible, and the bottom corner connector is seated in it's rectangular hole. + Screen lined up with stand -2. Screw the stand to the screen using the supplied standoff screws. (you may also wish to remove the screen protector at this point) + +**2**. Screw the stand to the screen using the supplied standoff screws. (you may also wish to remove the screen protector at this point) + screen screwed into stand with standoff screws -4. Gently push the raspberry pi pins into the connector at the top of the screen, ensuring that it is correctly lined up, and that the incuded Micro-SD card is securely seated in the Raspberry Pi. + +**3**. Gently push the raspberry pi pins into the connector at the top of the screen, ensuring that it is correctly lined up, and that the incuded Micro-SD card is securely seated in the Raspberry Pi. + screen screwed into stand with standoffs -5. Attach the USB cable and wait for the screen to refresh, indicating that it needs a wifi connection. \ No newline at end of file + +**4**. Attach the USB cable and wait for the screen to refresh \ No newline at end of file diff --git a/docs/device_setup.md b/docs/device_setup.md index 798598b5..c3dfce83 100644 --- a/docs/device_setup.md +++ b/docs/device_setup.md @@ -1,31 +1,28 @@ -# How to configure your new crypto-watcher +# πŸ“ˆ How to configure your new crypto-watcher 1. Optionally, remove the screen protoector that is covering the e-paper display (there is a red tab at the bottom-left) 2. **Connect a micro-usb** cable to the raspberry pi board on your crypto-watcher 3. **Wait a minute** or so for it to boot up -4. The device will display a `**NO INTERNET CONNECTION**` message -5. From another device, **connect** to the `RaspiWiFi Setup` access point (ignoring any warnings about it having no internet) -6. **Go to `RaspPiWifiSetup.com` or `10.0.0.1`** in the browser on your device -7. Select your internet-connected **wifi access point name** -8. Enter your **wifi password** -9. **Wait** for the device to reboot (this may take 1-2 mins) - * Your crypto-watcher will refresh the screen once it has loaded up and connected to the internet. - * The device is set up to refresh on the hour and every ten minutes thereafter. - * The current bitcoin price defaults to **Bitmex BTC/USD**. - -> Source code for the application can be found at: https://github.com/donbing/bitbot -> For technical assistance please contact us via the Etsy shop. +4. The device will display a **`NO INTERNET CONNECTION`** message +5. From another device, **connect** to the `bitbot-{nnn}` access point +6. Select your home **wifi access point name** +7. Enter your **wifi password** +8. **Wait** for the device to reboot (this may take 1-2 mins) + * Your crypto-watcher will **refresh** the screen once it has loaded up and connected to the internet. + * The device is set up to refresh every **ten minutes**. + * The dispayed instrument defaults to **Bitmex BTC/USD**. -# Advanced Configuration -> Config settings for your crypto-watcher are stored in a config-file on the raspberry pi, -> in order to access the data, you will need SSH access using the following command. -```sh -ssh pi@bitbot -# password is raspberry -``` -> Once you have connected, the config can be opened for editing by issuing the following command -```sh -nano bitbot/config.ini -``` -> The only values I recommend altering are `exchange`, `instrument` and `comments` -> A list of supported crypto-exchanges can be found here https://github.com/ccxt/ccxt/wiki/Exchange-Markets -> Please see your selected exchange for the instruments that it supports +> More detailed [instructions with screenshots can be found here](wifi_setup.md) + +# βš™οΈ Advanced Configuration +Configuration for your crypto-watcher is stored in a config.ini file on the raspberry Pi + +> visit [http://bitbot:8080](http://bitbot:8080) in your browser to **edit the configuration** file + +> A list of **supported crypto-exchanges** can be found here https://github.com/ccxt/ccxt/wiki/Exchange-Markets - Please see your selected exchange for the ***instruments that it supports*** + +> Bitbot uses [Style Files](../config/base.mplstyle) to generate the charts. If you're feeling experimental.. you can edit these! Examples of the ***styling*** options can be [found here](https://matplotlib.org/stable/tutorials/introductory/customizing.html#the-default-matplotlibrc-file) + +# πŸ’» Help +> **Source code** for the application can be found at: https://github.com/donbing/bitbot + +> For **technical assistance** please contact us via the [Etsy shop](https://www.etsy.com/uk/shop/TurtlefishDesigns), or raise a [github issue](https://github.com/donbing/bitbot/issues) diff --git a/docs/docker_installation.md b/docs/docker_installation.md index 71c10ca0..761a5bef 100644 --- a/docs/docker_installation.md +++ b/docs/docker_installation.md @@ -1,4 +1,4 @@ -# Docker setup instructions +# πŸ‹ How to install Docker 1. ### Update the host package manager ```sh @@ -24,13 +24,13 @@ sudo shutdown -r now ``` 6. ### Run bitbot image - - `main` - ```shell - docker run --restart unless-stopped --privileged ghcr.io/donbing/bitbot:main - docker run -it --privileged ghcr.io/donbing/bitbot:main - docker-compose up -d - ``` - - `release` (stable) - ```shell - docker run --restart unless-stopped --privileged ghcr.io/donbing/bitbot:release - ``` \ No newline at end of file +- `main` + ```shell + docker run --restart unless-stopped --privileged ghcr.io/donbing/bitbot:main + docker run -it --privileged ghcr.io/donbing/bitbot:main + docker-compose -f scripts/docker/docker-compose.yml up + ``` +- `release` (stable) + ```shell + docker run --restart unless-stopped --privileged ghcr.io/donbing/bitbot:release + ``` \ No newline at end of file diff --git a/docs/etsy_blurb.md b/docs/etsy_blurb.md index 32ed1b7d..adf39231 100644 --- a/docs/etsy_blurb.md +++ b/docs/etsy_blurb.md @@ -1,25 +1,26 @@ # Etsy store blurb: -Meet BitBot. a modern, elegant and minimalist e-ink crypto-chart ticker. This device makes for an attractive desk toy and convenient means to track your favourite crypto-currencies. +Meet BitBot. a modern, elegant and minimalist e-ink financial chart ticker. This device makes for an attractive desk toy and convenient means to track your favourite CRYPTO-CURRENCIES or STOCKS. BitBit is built using open-source software. This makes it very easy to tinker with, and function as a fun learning experience for more technically minded individuals. Contents: - Hand-crafted aluminium base, brushed and polished to an attractive satin finish. - 4.2" 3 colour (black, white, red) e-paper display (similar to a kindle display). - - Low-power integrated linux single-board-computer, with HDMI, USB and SDCard. + - Efficient Linux single-board-computer, with HDMI, USB and SDCard. - Convenient magnetically attaching micro-USB power cable. - 16GB micro-sd card, with custom software designed for ease of use, low maintainance and reliability. Features: + - Bitbot is continually being developed, we are very receptive to new feature suggestions! + - Configuration web-page, allows you to select you preferred currency or stock, as well as full access to styling the chart plots and other fun enhancements. - Easy to connect to your home WiFi. Bit-Bot has a bitlt-in WiFi access point that will allow you to easily connect it from any WiFi enabled device. - - Attractive candle graph of varying timeframes/candle widths - - Current price displayed in large easy to read text - - Instrument name next to price (e.g. **BTC/USD**) - - Percentage change within visible time period - - A short comment related to current price-action is displayed with randomly chosen refreshes - - Ten minute refresh. To ensure that your information is up to date, your chart will be updated every 10 minutes and 30 seconds after any restart - - Four different chart views. Each refresh will display a random view chasen from the following set + - Attractive candle graph of varying timeframes/candle widths. + - Current price displayed in large easy to read text. + - Instrument name next to price (e.g. **BTC/USD**). + - Percentage change within visible time period. + - A short comment related to current price-action is displayed with randomly chosen refreshes. + - Ten minute refresh. To ensure that your information is up to date, your chart will be updated every 10 minutes and 30 seconds after any restart. + - Four different chart views. Each refresh will display a random view chasen from the following set: - 5 min candles over 5 hours - 1 hour candles over the last day - - 1 hour candles over the last 3 days - 1 day candles over 3 months diff --git a/docs/features.md b/docs/features.md index 0fec2720..b2b80834 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,55 +1,111 @@ +# ✨ Features -# Features - +## βœ”οΈ Bitbot creates it's own config hotspot when it cant connect to WiFi >**As** Marketing -**In order that** customers give bitbot **glowing reviews** about the easy setup process ->**I want** new users to have a simple means to **connect bitbot to their wifi** +**In order that** customers give Bitbot **glowing reviews** about the easy setup process +>**I want** new users to have a simple means to **connect Bitbot to their wifi** **So that** they can **easily link** the device to their network - - *Scenario:* `No wifi is available, bitbot creates it's own configuration hostspot.` - - *Scenario:* `Wifi connection is lost, bitbot creates it's own configuration hostspot after 5 mins.` - - *Scenario:* `A user connects to the configuration hotspot, and sets bitbot to connect to an existing wifi access point.` + - *Scenario:* `βœ… No wifi is available, Bitbot creates it's own configuration hostspot.` + - *Scenario:* `βœ… Wifi connection is lost, Bitbot creates it's own configuration hostspot after 5 mins.` + - *Scenario:* `βœ… A user connects to the configuration hotspot, and sets Bitbot to connect to an existing wifi access point.` +## βœ”οΈ Config allows changing exchange and instrument >**As** Marketing -**In order that** bitbot appeals to as **many people as possible** +**In order that** Bitbot appeals to as **many people as possible** **I want** the **displayed instrument** to be configurable **So that** users can follow their **preferred cryptocurrency** - - *Scenario:* `Bitbot defaults to showing bitmex BTC/USD ticker.` - - *Scenario:* `Bitbot is re-configured to show an ETH/USD chart.` + - *Scenario:* `βœ… Bitbot defaults to showing bitmex BTC/USD ticker.` + - *Scenario:* `βœ… Bitbot is re-configured to show an ETH/USD chart.` + +## βœ”οΈ Support stocks and shares +>**As** Marketing +**In order that** to appeal to a broad user-base +**I want** Bitbot to support regular stocks and shares +**So that** people who follow non-crypto markets may also purchase a device + - *Scenario:* `βœ… Bitbot is configured with a stock symbol and then shows stock price.` + - *Scenario:* `🚧 Bitbot is configured with a stock symbol and shows logo for the stock.` + - *Scenario:* `🚧 Bitbot is configured with a stock symbol and shows news for the stock.` +## βœ”οΈ Current price should not overlap the chart >**As** Marketing -**In order that** bitbot looks **aesthetically pleasing** +**In order that** Bitbot looks **aesthetically pleasing** **I want** the price display to **avoid overlapping** the chart **So that** users can **clearly see** both chart and current price - - *Scenario:* `Current price is displayed in a large font, and avoids covering the chart.` - - *Scenario:* `Current price has a white background when it has to cover some of the chart.` - + - *Scenario:* `βœ… Current price is displayed in a large font, and avoids covering the chart.` + - *Scenario:* `βœ… Current price has a white background when it has to cover some of the chart.` +## βœ”οΈ Show error page when no internet connection >**As** Support **In order** to **minimise support work** generated by networking problems -**I want** users to see a **connection error screen** when bitbot has no internet connection +**I want** users to see a **connection error screen** when Bitbot has no internet connection **So that** that they know when their device is disconnected and **cannot update the chart** - - *Scenario:* `Wifi is connected, but bitbot cannot connect to google, so an error is shown.` - - *Scenario:* `Wifi is not connected, so an error is shown.` - - *Scenario:* `Wifi is connected, and bitbot can ping google, so loads the chart.` + - *Scenario:* `βœ… Wifi is connected, but Bitbot cannot connect to google, so an error is shown.` + - *Scenario:* `βœ… Wifi is not connected, so an error is shown.` + - *Scenario:* `βœ… Wifi is connected, and Bitbot can ping google, so loads the chart.` -INCOMPLETE ----------- +## βœ”οΈ Configurable Volume chart +>**AS** Marketing +**In order** that pro traders be interested in Bitbot +**I want** bit bot to show an optional volume graph below the prive chart +**So that** the validity of price movements can be better assessed + - *Scenario:* `βœ… Bitbot defaults to showing no volume chart` + - *Scenario:* `βœ… Bitbots config is altered to enable the volume chart` + - *Scenario:* `βœ… Bitbots config allows styling of the volume chart` ->**As** Marketing -**In order that** we can promote the device as a trading bot -**I want** bit bot to be configurable to make orders at regular intervals -**So that** users can use DCA trading strategies - - *Scenario:* `bit bot is configured with trading account details, buy frequencey and amount.` - - *Scenario:* `bit bot used configured trading info to automatically place orders for the customer.` +## βœ”οΈ Config is editable via a built-in webserver +>**AS** Marketing +**In order** to avoid having to use ssh to edit configs +**I want** Bitbot to run a web-server, hosting a config editor page +**So that** that non-technical users can customise their Bitbot + - *Scenario:* `βœ… Config server defaults to port 8080` + - *Scenario:* `βœ… Display is starts updating immediately after any config change` + - *Scenario:* `βœ… Logs can be viewed in the config-server web-page` +## βœ”οΈ Chart styles are editable in the config-server >**AS** Marketing -**In order** to avoid sending printed setup instructions with each device -**I want** bit bots to guide the user through settting up the device when if first powers on -**So that** users have an easy on-boarding experience and leave glowing reviews - - *Scenario:* `on first power on, bitbot displays a friendly welcome message and explains how to configure the wifi` +**In order** we can advertise bit bot as 'infinately customisable' +**I want** users to be able to edit the matplot lib style sheets +**So that** they can personalise their chart to their own tastes + - *Scenario:* `βœ… The config editor allows direct editing of existing MPL style sheet files` + - *Scenario:* `🚧 Incomplete: The config editor allows new MPL style sheets to be added, and referenced in the config.ini` + +## βœ”οΈ Total value of holdings can be tracked +>**AS** Marketing +**In order** that users find using Bitbot friction-free +**I want** a config entry for the users holdings, and a total value to be drawn to screen +**So that** users dont have to calculate totals themselves + - *Scenario:* `βœ… holdings are entered intot e cnfig file, and total value is displayed below the price.` + +# 🚧 INCOMPLETE +## πŸ’‘ Show friendly welcome screen(s) on first load >**AS** Marketing **In order** that users leave glowing reviews **I want** bit bot to show a nice welcome screen before first power on **So that** users feel their device is personalised to them - - *scenario:* `bitbot shows a personaliused message and bingsbots logo before first powering up` + - *Scenario:* `Bitbot shows a personalised message and logo before first powering up` + +## πŸ’‘Show setup instructions on first load +>**AS** Marketing +**In order** to avoid sending printed setup instructions with each device +**I want** bit bot to guide the user through settting up the device when if first powers on +**So that** users have an easy on-boarding experience and leave glowing reviews + - *Scenario:* `on first power on, Bitbot displays a friendly welcome message and explains how to configure the wifi` + +## πŸ’‘ Support muiltiple chart plots on one display +>**AS** Marketing +**In order** to appeal to a broad user-base +**I want** multiple charts to be displayed on one screen +**So that** that people can accurately track multiple currencies with one device + - *Scenario:* `two currencies may be added to config, and both have charts displayed on-screen` + +## πŸ’‘ Show Market indicators (macd, rsi, bbands, fibs) +> worth it? + +## πŸ’‘Make bitbot capable of buying/selling +>**As** Marketing +**In order that** we can promote the device as a trading bot +**I want** bit bot to be configurable to make orders at regular intervals +**So that** users can use DCA trading strategies + - *Scenario:* `Bitbot is configured with trading account details, buy frequencey and amount.` + - *Scenario:* `Bitbot used configured trading info to automatically place orders for the customer.` diff --git a/docs/images/WifiSetup/1_connect.png b/docs/images/WifiSetup/1_connect.png new file mode 100644 index 00000000..531dadde Binary files /dev/null and b/docs/images/WifiSetup/1_connect.png differ diff --git a/docs/images/WifiSetup/2_sign_in.png b/docs/images/WifiSetup/2_sign_in.png new file mode 100644 index 00000000..c9f66ad7 Binary files /dev/null and b/docs/images/WifiSetup/2_sign_in.png differ diff --git a/docs/images/WifiSetup/3_select_your_wifi.png b/docs/images/WifiSetup/3_select_your_wifi.png new file mode 100644 index 00000000..de9e54e1 Binary files /dev/null and b/docs/images/WifiSetup/3_select_your_wifi.png differ diff --git a/docs/images/WifiSetup/4_enter_your_password.png b/docs/images/WifiSetup/4_enter_your_password.png new file mode 100644 index 00000000..e6b5dc12 Binary files /dev/null and b/docs/images/WifiSetup/4_enter_your_password.png differ diff --git a/docs/notes b/docs/notes new file mode 100644 index 00000000..36bd437c --- /dev/null +++ b/docs/notes @@ -0,0 +1,70 @@ +todo: + - config candle colours + - config volume colours + - intro + - instructions + - multi-plot-figure + - moving averages + - overlapping multi-coin charts + - allow selecting style in config + - indicators + + +> Build arm6 on x86 +```bash + docker run -e QEMU_CPU=arm1176 --privileged --rm -it --platform linux/arm/v6 balenalib/raspberry-pi:buster bash +# build container atrm6 +docker buildx build --platform linux/arm/v6 . -t bitbot --progress string +# run it, have to specify which chip QEMU should emulate +docker run -e QEMU_CPU=arm1176 --privileged --rm -t --platform linux/arm/v6 navikey/raspbian-buster:latest bash + +# remove all containers +docker container rm $(docker container ls -q -a) +#' which cpus to use for the build +--cpuset-cpus=0-3' +# wifi-connect docker pull balenablocks/wifi-connect:rpi +docker run --network=host -v /run/dbus/:/run/dbus/ balenablocks/wifi-connect:rpi + +# error: failed to solve: failed to solve with frontend dockerfile.v0: failed to create LLB definition: rpc error: code = Unknown desc = error getting credentials - err: exit status 255, out: `` += In ~/.docker/config.json `change credsStore to credStore` + +# error exec "--env" "executable file not found in $PATH: unknown" += badly ordered docker args, envs must come before image name +``` + +> get linux os version +```sh +cat /etc/os-release +``` + +> enable vnc raspiconfig +```sh +sudo raspi-config nonint do_vnc 0 +``` + +> setup my git +```sh +# setup user +git config --global user.email ccbing@gmail.com +git config --global user.name donbing +# tell git to cache creds after first auth +git config --global credential.helper store +# remove creds +git config --global --unset user.password +# aliases + git config --global alias.co checkout + git config --global alias.br branch + git config --global alias.ci commit + git config --global alias.st status + +> check cpu arch +`dpkg --print-architecture` + + +## fonts +> place in `~/.fonts` or `/usr/local/share/fonts` for system wide access + mkdir ~/.fonts && cp ~/bitbot/src/resources/04B_03__.TTF ~/.fonts/04B_03__.TTF +> manually rebuild the font cache with `fc-cache -f -v` + + +> list fonts with `fc-list` \ No newline at end of file diff --git a/docs/wifi_setup.md b/docs/wifi_setup.md new file mode 100644 index 00000000..1fe39488 --- /dev/null +++ b/docs/wifi_setup.md @@ -0,0 +1,27 @@ +# πŸ€– Bitbot WiFi Setup + * After powering up for the **first time**, your Bitbot will show a message indicating that it has **no wifi connection**. + + * While in this state, Bitbot will ***run it's own wifi access-point*** that you may connect to. + + * ***Use your phone***, or other **wifi device** to connect to this access-point, where you will be **guided** through the process of connecting Bitbot to your **home wifi**. +--- +## Detailed guide + * Here we demonstrate connecting bitbot to my home wifi (NETGEAR85) using an android phone + + + + + + + + +
1. 2. 3. 4.
+ +* **Once completed** your phone will disconnect, Bitbot will then reboot and ***show a chart*** a minute or so later +--- +## Troubleshooting + > ### I dont see my home wifi in step 3 + - Bitbots raspberry pi has quite a ***small antenna***, and it may be struggling to get a **good signal**. Try moving your Bitbot to a location closer to your home WiFi access-point + > ### I dont see the Bitbot WiFi access-point + - Ensure that your Bitbot is **powered up** and has refreshed its screen to show the ***connection warning message***, there should be a **green light** on the back of the raspberry pi. + - In case of a software failure, you can ***use a computer*** to [write your wifi details **directly to the SD-Card**](https://www.raspberrypi-spy.co.uk/2017/04/manually-setting-up-pi-wifi-using-wpa_supplicant-conf/). diff --git a/readme.md b/readme.md index 240901f9..cbd87636 100644 --- a/readme.md +++ b/readme.md @@ -1,28 +1,32 @@ -## **BitBot**, *A Raspberry Pi powered e-ink screen with crypto price chart* +# πŸ€– **BitBot** +> A Raspberry Pi powered e-ink crypto price chart
- - - + + +
-# Basic features - - Shows the current price - - Shows instrument details (e,g, ```(XBTUSD, +12%)```) - - Displays some AI text comment/message depending on price action - - Capable of charting and trading on many different crypto-exchanges - - Reddit discussion [here](https://www.reddit.com/r/raspberry_pi/comments/mrne5p/my_eink_cryptowatcher/) - - Warns on connection errors - - Config and log are available via webserver running on port **8080** +# ✨ Features + - 🏦 Capable of charting **any crypto-currency** from **many different exchanges** + - πŸ›οΈ Supports regular **stock prices** + - πŸ’² Large **current price** header (avoids chart overlap) + - 🎲 randomly selected **time frames**, or configured to **your preference** + - πŸ’° Supports **tracking** your current **holdings** + - πŸ“ˆ Shows instrument details (e,g, ```(XBT/USD, +12%)```) + - πŸ“Š Optional **volume chart** + - πŸ’¬ Displays ***configurable AI commentry*** depending on **price action** + - πŸ“‘ Warns on **connection errors** + - βš™οΈ **Config webserver** running on port **8080** allows easy configuration + - ♻️ Display **refreshes after config changes** + - πŸ‘½ Reddit discussion [here](https://www.reddit.com/r/raspberry_pi/comments/mrne5p/my_eink_cryptowatcher/) and [here](https://old.reddit.com/r/raspberry_pi/comments/s3dnnn/i_made_an_aluminium_stand_for_an_eink_display/) -# Requested Features - - Show value of your portfolio - - Display Transaction fees - - Smaller/cheaper display - - Regular stocks +# πŸ’‘ Requested Features + - πŸ’Έ Display **Transaction fees** + - πŸ“Ί Smaller/cheaper display -# Docs - - [How To Install](docs/app_install.md) - - [Device Setup](docs/device_setup.md) - - [Device Assembly](docs/device_assembly.md) - - [Dev Notes](docs/development.md) - - [Docker Setup](docs/docker_installation.md) \ No newline at end of file +# πŸ“ Docs + - [πŸ’» How To **Install**](docs/app_install.md) + - [βš™οΈ Device **Setup**](docs/device_setup.md) + - [πŸ”— Device **Assembly**](docs/device_assembly.md) + - [πŸ“’ Dev **Notes**](docs/development.md) + - [πŸ‹ **Docker** Setup](docs/docker_installation.md) diff --git a/requirements.txt b/requirements.txt index dde72089..5c7a8ba7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,9 @@ -mpl-finance==0.10.0 -tzlocal -ccxt==1.66.90 -inky==1.3.1 \ No newline at end of file +mplfinance==0.12.8b6 +matplotlib==3.5.1 +tzlocal +ccxt==1.66.90 +inky==1.3.1 +watchdog==2.1.6 +numpy==1.22 +Pillow==7.0.0 +yfinance==0.1.69 \ No newline at end of file diff --git a/run.py b/run.py index 5a94a7e4..998bf345 100644 --- a/run.py +++ b/run.py @@ -1,41 +1,67 @@ -from src import update_chart -import configparser import pathlib -import sched, time -import logging, logging.handlers - -# setup our logger for std out and rolling file -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s [%(levelname)s] %(message)s", - handlers=[ - logging.handlers.RotatingFileHandler("debug.log", maxBytes=2000, backupCount=0), - logging.StreamHandler() - ]) -logging.info("Running") - -# get the config file data -filePath = pathlib.Path(__file__).parent.absolute() -config = configparser.ConfigParser() -config.read(str(filePath)+'/config.ini') -logging.info("Loaded config") - -# schedule chart updates -scheduler = sched.scheduler(time.time, time.sleep) +import logging +import logging.config +import sched +import time +import os +from src.configuration.bitbot_files import use_config_dir +from src.configuration.bitbot_config import load_config_ini +from src.configuration.bitbot_logging import initialise_logger +from src.configuration.config_observer import watch_config_dir +from src.log_decorator import info_log +from src.bitbot import BitBot + +# declare config files +config_files = use_config_dir(pathlib.Path(__file__).parent.resolve()) +# load logging config +initialise_logger(config_files.logging_ini) +# load app config +config = load_config_ini(config_files.config_ini) +# create bitbot chart updater +app = BitBot(config, config_files) + + +@info_log +def refresh_chart(sc): + app.run() + # show image in vscode for debug + if config.shoud_show_image_in_vscode(): + os.system("code last_display.png") + # dont reschedule if testing + if not config.is_test_run(): + refresh_minutes = config.refresh_rate_minutes() + logging.info("Next refresh in: " + str(refresh_minutes) + " mins") + sc.enter(refresh_minutes * 60, 1, refresh_chart, (sc,)) + -def get_refresh_rate_minutes(): - return float(config['display']['refresh_time_minutes']) - -bb = update_chart.bitbot(config) +@info_log +def cancel_schedule(sc): + for event in sc.queue: + try: + sc.cancel(event) + except ValueError: + # This is OK because the event may have been just canceled + pass + + +@info_log +def config_changed(sc): + # reload the app config + config.reload(config_files.config_ini) + # cancel current schedule + cancel_schedule(sc) + # new schedule + refresh_chart(sc) + + +# scheduler for regular chart updates +scheduler = sched.scheduler(time.time, time.sleep) -def refresh_chart(sc): - bb.run() - logging.info("Screen update complete") - refresh_minutes = get_refresh_rate_minutes() - logging.info("Next refresh in: " + str(refresh_minutes) + " mins") - sc.enter(refresh_minutes * 60, 1, refresh_chart, (sc,)) +# refresh chart on config file change +watch_config_dir( + config_files.config_folder, + on_changed=lambda: config_changed(scheduler)) -# update chart immediately and begin update schedule +# update chart immediately and begin schedule refresh_chart(scheduler) scheduler.run() -logging.info("Scheduler running") \ No newline at end of file diff --git a/scripts/docker/docker-compose.yml b/scripts/docker/docker-compose.yml new file mode 100644 index 00000000..a58678af --- /dev/null +++ b/scripts/docker/docker-compose.yml @@ -0,0 +1,11 @@ +version: "2.1" + +services: + + bit-bot: + image: ghcr.io/donbing/bitbot:main + privileged: true + restart: unless-stopped + + # config-editor: + # build: ./docker/config-editor.dockerfile \ No newline at end of file diff --git a/scripts/docker/dockerfile b/scripts/docker/dockerfile new file mode 100644 index 00000000..ec942163 --- /dev/null +++ b/scripts/docker/dockerfile @@ -0,0 +1,19 @@ +FROM navikey/raspbian-bullseye:latest + +ENV TESTRUN='false' +ENV BITBOT_OUTPUT='inky' +ENV BITBOT_SHOWIMAGE='false' + +RUN apt update && \ + apt install -y \ + --no-install-recommends \ + python3-pip python3-rpi.gpio libatlas-base-dev libopenjp2-7 libtiff5 libxcb1 libfreetype6-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY ../../requirements.txt . +RUN pip3 install --prefer-binary --no-cache-dir -r requirements.txt + +WORKDIR /code +COPY ../../ . + +CMD [ "python3", "./run.py" ] \ No newline at end of file diff --git a/src/bitbot.py b/src/bitbot.py new file mode 100644 index 00000000..80002adb --- /dev/null +++ b/src/bitbot.py @@ -0,0 +1,81 @@ +from PIL import Image +import io +import socket +import time +from src import crypto_exchanges, stock_exchanges, kinky +from src.market_chart import MarketChart +from src.log_decorator import info_log +from src.chart_overlay import ChartOverlay + + +class Cartographer(): + def __init__(self, config, display, files): + self.market = MarketChart(config, display, files) + + @info_log + def draw_to(self, chart_data, file_stream): + self.market.create_plot(chart_data).write_to_stream(file_stream) + + def __repr__(self): + return 'Cartographer' + + +class BitBot(): + def __init__(self, config, files): + self.config = config + self.files = files + self.display = self.create_display() + self.plot = Cartographer(self.config, self.display, self.files) + + # πŸ›οΈ stock or crypto exchange + def market_exchange(self): + if self.config.stock_symbol(): + return stock_exchanges.Exchange(self.config) + else: + return crypto_exchanges.Exchange(self.config) + + # βœ’οΈ select inky display or file output (nice for testing) + def create_display(self): + if self.config.use_inky(): + return kinky.Inker(self.config) + else: + return kinky.Disker(self.config) + + def run(self): + # πŸ“‘ await internet connection + self.wait_for_internet_connection(self.display) + # πŸ“ˆ fetch chart data + chart_data = self.market_exchange().fetch_history() + # πŸ–ŠοΈ draw the chart on the display + with io.BytesIO() as file_stream: + # πŸ–ŠοΈ draw chart plot to image + self.plot.draw_to(chart_data, file_stream) + chart_image = Image.open(file_stream) + # πŸ–ŠοΈ draw overlay on image + overlay = ChartOverlay(self.config, self.display, chart_data) + overlay.draw_on(chart_image) + # πŸ“Ί display the image + self.display.show(chart_image) + + @info_log + def wait_for_internet_connection(self, display): + # πŸ“‘ test if internet is available + def network_connected(hostname="google.com"): + try: + host = socket.gethostbyname(hostname) + socket.create_connection((host, 80), 2).close() + return True + except: + time.sleep(1) + return False + + connection_error_shown = False + while network_connected() is False: + # 🚫 draw error message if not already drawn + if connection_error_shown is False: + connection_error_shown = True + display.draw_connection_error() + time.sleep(10) + + def __repr__(self): + return 'BitBot inky:' + str(self.config.use_inky()) diff --git a/src/chart_overlay.py b/src/chart_overlay.py new file mode 100644 index 00000000..d0db231a --- /dev/null +++ b/src/chart_overlay.py @@ -0,0 +1,120 @@ +from datetime import datetime +from PIL import Image, ImageDraw +import random +from src import price_humaniser +from src.log_decorator import info_log + + +class ChartOverlay(): + + # 🏳️ select image area with the most white pixels + @staticmethod + def least_intrusive_position(img, possibleTextPositions): + # πŸ”’ count the white pixels in an area of the image + def count_white_pixels(x, y, n, image): + count = 0 + for s in range(x, x+(n*3)+1): + for t in range(y, y+n+1): + pix = image.getpixel((s, t)) + count += 1 if pix == (255, 255, 255) else 0 + return count + + rgb_im = img.convert('RGB') + height_of_section = 60 + ordredByAveColour = sorted(possibleTextPositions, key=lambda item: (count_white_pixels(*item, height_of_section, rgb_im), item[0])) + return ordredByAveColour[-1] + + def flatten(t): + return [item for sublist in t for item in sublist] + + possible_title_positions = flatten(map(lambda y: map(lambda x: (x, y), range(60, 200, 10)), [6, 200])) + + def __init__(self, config, display, chart_data): + self.config = config + self.display = display + self.chart_data = chart_data + + @info_log + def draw_on(self, chart_image): + # πŸ–ŠοΈ handles drawing over our chart image + draw_plot_image = ImageDraw.Draw(chart_image) + # 🏳️ find some empty space in the image to place our text + selectedArea = ChartOverlay.least_intrusive_position(chart_image, self.possible_title_positions) + # πŸ–ŠοΈ draw configured overlay + if self.config.overlay_type() == "2": + self.draw_overlay2(draw_plot_image, self.chart_data, selectedArea, chart_image) + else: + self.draw_overlay1(draw_plot_image, self.chart_data, selectedArea, chart_image) + + # πŸ•’ add the time if configured + def draw_current_time(self, draw_plot_image): + if self.config.show_timestamp() == 'true': + formatted_time = datetime.now().strftime("%b %-d %-H:%M") + text_width, text_height = draw_plot_image.textsize(formatted_time, self.display.tiny_font) + draw_plot_image.text( + (self.display.WIDTH - text_width - 1, self.display.HEIGHT - text_height - 2), + formatted_time, + 'black', + self.display.tiny_font) + + # πŸ”² add a border if configured + def draw_border(self, draw_plot_image): + border_type = self.config.border_type() + if border_type != 'none': + draw_plot_image.rectangle( + [(0, 0), (self.display.WIDTH - 1, self.display.HEIGHT - 1)], + outline=border_type) + + # πŸ’¬ draw a random comment depending on price action + def draw_price_comment(self, draw_plot_image, chartdata, selectedArea): + if self.config.portfolio_size(): + messages = "{:,}".format(self.config.portfolio_size() * chartdata.last_close()) + draw_plot_image.text((selectedArea[0], selectedArea[1]+52), messages, 'black', self.display.title_font) + elif random.random() < 0.5: + direction = 'up' if chartdata.start_price() < chartdata.last_close() else 'down' + messages = self.config.get_price_action_comments(direction) + draw_plot_image.text((selectedArea[0], selectedArea[1]+52), random.choice(messages), 'red', self.display.title_font) + + # πŸ–ŠοΈ draw current price text + def draw_current_price(self, draw_plot_image, chartdata, selectedArea): + price = price_humaniser.format_title_price(chartdata.last_close()) + draw_plot_image.text((selectedArea[0], selectedArea[1]+11), price, 'black', self.display.price_font) + + def draw_overlay1(self, draw_plot_image, chartdata, selectedArea, base_plot_image): + # 🎹 πŸ•Ž draw instrument / candle width + title = chartdata.instrument + ' (' + chartdata.candle_width + ') ' + draw_plot_image.text(selectedArea, title, 'black', self.display.title_font) + # πŸ–ŠοΈ draw % change text + title_width, title_height = draw_plot_image.textsize(title, self.display.title_font) + change = ((chartdata.last_close() - chartdata.start_price()) / chartdata.last_close())*100 + change_colour = ('red' if change < 0 else 'black') + draw_plot_image.text((selectedArea[0]+title_width, selectedArea[1]), '{:+.2f}'.format(change) + '%', change_colour, self.display.title_font) + + self.draw_current_price(draw_plot_image, chartdata, selectedArea) + self.draw_price_comment(draw_plot_image, chartdata, selectedArea) + self.draw_border(draw_plot_image) + self.draw_current_time(draw_plot_image) + + def draw_overlay2(self, draw_plot_image, chartdata, selectedArea, base_plot_image): + # 🎹 draw instrument name + title = chartdata.instrument + title_width, title_height = draw_plot_image.textsize(title, self.display.medium_font) + txt = Image.new('RGBA', (title_width, title_height), (0, 0, 0, 0)) + d = ImageDraw.Draw(txt) + d.text((0, 0), title, 'black', self.display.medium_font) + w = txt.rotate(270, expand=True) + title_paste_pos = (self.display.WIDTH-title_height - 2, int((self.display.HEIGHT - title_width) / 2)) + base_plot_image.paste(w, title_paste_pos, w) + # πŸ•Ž candle width + candle_width_right_padding = 2 + candle_width_width, candle_width_height = draw_plot_image.textsize(chartdata.candle_width, self.display.medium_font) + draw_plot_image.text((self.display.WIDTH-candle_width_width, candle_width_right_padding), chartdata.candle_width, 'red', self.display.medium_font) + # πŸ–ŠοΈ draw % change text + change = chartdata.percentage_change() + change_colour = ('red' if change < 0 else 'black') + draw_plot_image.text((selectedArea[0], selectedArea[1]), '{:+.2f}'.format(change) + '%', change_colour, self.display.title_font) + + self.draw_current_price(draw_plot_image, chartdata, selectedArea) + self.draw_price_comment(draw_plot_image, chartdata, selectedArea) + self.draw_border(draw_plot_image) + self.draw_current_time(draw_plot_image) diff --git a/src/config_webserver.py b/src/config_webserver.py index 1ef46992..b2507fd3 100644 --- a/src/config_webserver.py +++ b/src/config_webserver.py @@ -1,66 +1,101 @@ -import pathlib -import os.path -from os.path import join as pjoin -import cgi -from http.server import BaseHTTPRequestHandler, HTTPServer - -class StoreHandler(BaseHTTPRequestHandler): - curdir = pathlib.Path(__file__).parent.resolve() - store_path = pjoin(curdir, '../', 'config.ini') - log_path = pjoin(curdir, '../', 'debug.log') - - def do_GET(self): - with open(self.store_path) as store_file: - # html for config editor - html = ''' - - - - - - - - -

BitBot crypto-ticker config

-
- ''' - html += '' - html += ''' -
-
- ''' - # display log info if it exists - if os.path.isfile(self.log_path): - with open(self.log_path) as log_file: - html += '

LOG

' - - html += ''' - - - ''' - # html response - self.send_response(200) - self.send_header("Content-type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(len(html))) - self.end_headers() - self.wfile.write(bytes(html, "utf8")) - - def do_POST(self): - # form vars - form = cgi.FieldStorage( - fp=self.rfile, - headers=self.headers, - environ={'REQUEST_METHOD':'POST'}) - - # write config file to disk - with open(self.store_path, 'w') as fh: - fh.write(form.getvalue('configfile')) - - # redirect to get action - self.send_response(302) - self.send_header('Location', self.path) - self.end_headers() - -# start the webserver -server = HTTPServer(('', 8080), StoreHandler) -server.serve_forever() \ No newline at end of file +import pathlib +import os +import os.path +from os.path import join as pjoin +import cgi +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib import parse as urlparse +from configuration.bitbot_files import BitBotFiles + + +base_dir = pjoin(pathlib.Path(__file__).parent.resolve(), '../') + +files_config = BitBotFiles(base_dir) + +editable_files = { + "config_ini": files_config.config_ini, + "base_style": files_config.base_style, + "inset_style": files_config.inset_style, + "default_style": files_config.default_style, + "volume_style": files_config.volume_style +} + + +class StoreHandler(BaseHTTPRequestHandler): + + def create_editor_form(self, fileKey, current_file_key): + with open(editable_files[fileKey]) as file_handle: + html = '

βš™οΈ ' + fileKey + '

' + html += '
' + html += '' + html += '
' + return html + + def do_GET(self): + param = urlparse.parse_qs(urlparse.urlparse(self.path).query).get('fileKey', []) + fileKey = next((x for x in param), None) + # html for config editor + html = ''' + + + + + + + + + + +

πŸ€– BitBot Crypto-Ticker Config

+ ''' + for file in editable_files: + html += self.create_editor_form(file, fileKey) + + # display log info if it exists + if os.path.isfile(files_config.log_file_path): + with open(files_config.log_file_path) as log_file: + html += '

πŸͺ΅ LOG

' + + html += '' + # html response + self.send_response(200) + self.send_header("Content-type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(html))) + self.end_headers() + self.wfile.write(bytes(html, "utf8")) + + def do_POST(self): + fileKey = urlparse.parse_qs(urlparse.urlparse(self.path).query).get('fileKey', None)[0] + # form vars + form = cgi.FieldStorage( + fp=self.rfile, + headers=self.headers, + environ={'REQUEST_METHOD': 'POST'}) + + # write config file to disk + with open(editable_files[fileKey], 'w') as fh: + fh.write(form.getvalue('fileContent')) + + # redirect to get action + self.send_response(302) + self.send_header('Location', self.path) + self.end_headers() + + +# start the webserver +server = HTTPServer(('', 8080), StoreHandler) +server.serve_forever() diff --git a/src/configuration/__init__.py b/src/configuration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/configuration/bitbot_config.py b/src/configuration/bitbot_config.py new file mode 100644 index 00000000..5f3f2eaf --- /dev/null +++ b/src/configuration/bitbot_config.py @@ -0,0 +1,78 @@ +import os +import configparser +from src.log_decorator import info_log + + +@info_log +def load_config_ini(config_ini_path): + config = configparser.ConfigParser() + config.read(config_ini_path, encoding='utf-8') + return BitBotConfig(config) + + +# encapsulate horrid config vars +class BitBotConfig(): + def __init__(self, config): + self.config = config + + def exchange_name(self): + return self.config["currency"]["exchange"] + + def instrument_name(self): + return self.config["currency"]["instrument"] + + def use_inky(self): + dont_write_to_disk = os.getenv('BITBOT_OUTPUT') != 'disk' + do_write_to_inky = self.config["display"]["output"] == "inky" + return dont_write_to_disk and do_write_to_inky + + def get_price_action_comments(self, direction): + return self.config.get('comments', direction).split(',') + + def border_type(self): + return self.config["display"]["border"] + + def overlay_type(self): + return self.config["display"]["overlay_layout"] + + def show_timestamp(self): + return self.config["display"]["timestamp"] + + def expand_chart(self): + return self.config["display"]["expanded_chart"] == 'true' + + def show_volume(self): + return self.config["display"]["show_volume"] == 'true' + + def set(self, section, key, value): + self.config.set(section, key, value) + + def reload(self, config_ini_path): + self.config.read(config_ini_path, encoding='utf-8') + + def refresh_rate_minutes(self): + return float(self.config['display']['refresh_time_minutes']) + + def display_rotation(self): + return int(self.config['display']['rotation']) + + def shoud_show_image_in_vscode(self): + return os.getenv('BITBOT_SHOWIMAGE') == 'true' + + def is_test_run(self): + return os.getenv('TESTRUN') == 'true' + + def stock_symbol(self): + return self.config['currency']['stock_symbol'] + + def portfolio_size(self): + try: + return self.config.getfloat('currency', 'holdings', fallback=0) + except ValueError: + return 0 + + def output_file_name(self): + return self.config['display']['disk_file_name'] + + def candle_width(self): + return self.config['display']['candle_width'] diff --git a/src/configuration/bitbot_files.py b/src/configuration/bitbot_files.py new file mode 100644 index 00000000..31175a37 --- /dev/null +++ b/src/configuration/bitbot_files.py @@ -0,0 +1,32 @@ + +from os.path import join as pjoin, exists +import errno +import os + + +def use_config_dir(base_config_path): + return BitBotFiles(base_config_path) + + +class BitBotFiles(): + def __init__(self, base_path): + self.log_file_path = pjoin(base_path, 'debug.log') + + self.config_folder = pjoin(base_path, 'config/') + self.fonts_folder = pjoin(base_path, 'src/resources') + + self.logging_ini = self.existing_file_path('logging.ini') + self.config_ini = self.existing_file_path('config.ini') + self.base_style = self.existing_file_path('base.mplstyle') + self.inset_style = self.existing_file_path('inset.mplstyle') + self.default_style = self.existing_file_path('default.mplstyle') + self.volume_style = self.existing_file_path('volume.mplstyle') + + def existing_file_path(self, file_name): + file_path = pjoin(self.config_folder, file_name) + if not exists(file_path): + raise FileNotFoundError( + errno.ENOENT, + os.strerror(errno.ENOENT), + file_name) + return file_path diff --git a/src/configuration/bitbot_logging.py b/src/configuration/bitbot_logging.py new file mode 100644 index 00000000..15c045dc --- /dev/null +++ b/src/configuration/bitbot_logging.py @@ -0,0 +1,20 @@ +import logging +import logging.config +import sys + + +def initialise_logger(logging_ini_path): + # load log file + logging.config.fileConfig(logging_ini_path) + + # log unhandled exceptions + def handle_exception(exc_type, exc_value, exc_traceback): + if issubclass(exc_type, KeyboardInterrupt): + sys.__excepthook__(exc_type, exc_value, exc_traceback) + return + logging.error( + "Uncaught exception", + exc_info=(exc_type, exc_value, exc_traceback)) + + # register system exception handler + sys.excepthook = handle_exception diff --git a/src/configuration/config_observer.py b/src/configuration/config_observer.py new file mode 100644 index 00000000..ee136822 --- /dev/null +++ b/src/configuration/config_observer.py @@ -0,0 +1,33 @@ + +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler, FileModifiedEvent +import os.path as path +from src.log_decorator import info_log + + +@info_log +def watch_config_dir(config_dir_path, on_changed): + event_handler = ConfigChangeHandler(on_changed) + observer = Observer() + observer.schedule(event_handler, config_dir_path) + observer.start() + + +class ConfigChangeHandler(FileSystemEventHandler): + def __init__(self, on_changed): + self.on_changed = on_changed + self.watched_files = {} + + def on_modified(self, event): + if isinstance(event, FileModifiedEvent): + file_path = event.src_path + + last_modified = path.getmtime(file_path) + cached_last_modified = self.watched_files.get(file_path) + + new_change = file_path not in self.watched_files + file_changed = last_modified != cached_last_modified + + if new_change or file_changed: + self.watched_files[file_path] = last_modified + self.on_changed() diff --git a/src/crypto_exchanges.py b/src/crypto_exchanges.py new file mode 100644 index 00000000..8540a2ad --- /dev/null +++ b/src/crypto_exchanges.py @@ -0,0 +1,104 @@ +import ccxt +from datetime import datetime +import random +import collections +import matplotlib.dates as mdates +from src.log_decorator import info_log + + +class Exchange(): + CandleConfig = collections.namedtuple('CandleConfig', 'width count') + candle_configs = [ + CandleConfig("5m", 60), + CandleConfig("1h", 24), + CandleConfig("1d", 60), + ] + + def __init__(self, config): + self.config = config + + def fetch_history(self): + configred_candle_width = self.config.candle_width() + instrument = self.config.instrument_name() + + if(configred_candle_width == "random"): + random_index = random.randrange(len(self.candle_configs)) + candle_config = self.candle_configs[random_index] + else: + candle_config, = ( + conf for conf in self.candle_configs + if conf.width == configred_candle_width) + + candle_data = fetch_OHLCV( + candle_config.width, + candle_config.count, + self.config.exchange_name(), + self.config.instrument_name() + ) + return CandleData(instrument, candle_config.width, candle_data) + + def __repr__(self): + return '' + + +def fetch_OHLCV(candle_freq, num_candles, exchange_name, instrument): + exchange = load_exchange(exchange_name) + dirty_chart_data = fetch_market_data( + exchange, + instrument, + candle_freq, + num_candles) + return list(map(make_matplotfriendly_date, dirty_chart_data)) + + +@info_log +def fetch_market_data(exchange, instrument, candle_freq, num_candles): + return exchange.fetchOHLCV(instrument, candle_freq, limit=num_candles) + + +@info_log +def load_exchange(exchange_name): + exchange = getattr(ccxt, exchange_name)({ + # 'apiKey': '', + # 'secret': '', + 'enableRateLimit': True, + }) + exchange.loadMarkets() + return exchange + + +def make_matplotfriendly_date(element): + datetime_field = element[0]/1000 + datetime_utc = datetime.utcfromtimestamp(datetime_field) + datetime_num = mdates.date2num(datetime_utc) + return replace_at_index(element, 0, datetime_num) + + +def replace_at_index(tup, ix, val): + lst = list(tup) + lst[ix] = val + return tuple(lst) + + +class CandleData(): + def __init__(self, instrument, candle_width, candle_data): + self.instrument = instrument + self.candle_width = candle_width + self.candle_data = candle_data + + def percentage_change(self): + current_price = self.last_close() + start_price = self.start_price() + return ((current_price - start_price) / current_price) * 100 + + def last_close(self): + return self.candle_data[-1][4] + + def end_price(self): + return self.candle_data[0][3] + + def start_price(self): + return self.candle_data[0][4] + + def __repr__(self): + return f'<{self.instrument} {self.candle_width} candle data>' diff --git a/src/currency_chart.py b/src/currency_chart.py deleted file mode 100644 index 6c10543b..00000000 --- a/src/currency_chart.py +++ /dev/null @@ -1,133 +0,0 @@ - -import matplotlib -import matplotlib.pyplot as plt -import matplotlib.dates as mdates -matplotlib.use('Agg') -import ccxt -from datetime import datetime, timedelta, timezone -import mpl_finance -import random -import tzlocal -import logging - -def fetch_OHLCV_chart_data(candleFreq, num_candles, config): - - exchange_name = config["currency"]["exchange"] - instrument = config["currency"]["instrument"] - # create exchange wrapper based on user exchange config - exchange = getattr(ccxt, exchange_name)({ - #'apiKey': '', - #'secret': '', - 'enableRateLimit': True, - }) - exchange.loadMarkets() - - logging.debug("Supported exchanges: \n" + "\n".join(ccxt.exchanges)) - logging.debug("Supported time frames: \n" + "\n".join(exchange.timeframes)) - logging.debug("Supported markets: \n" + "\n".join(exchange.markets.keys())) - logging.info("Fetching "+ str(num_candles) + " " + candleFreq + " " + instrument + " candles from " + exchange_name) - - candleData = exchange.fetchOHLCV(instrument, candleFreq, limit=num_candles) - cleaned_candle_data = list(map(lambda x: make_matplotfriendly_date(x), candleData)) - logging.debug("Candle data: " + "\n".join(map(str, cleaned_candle_data))) - - return cleaned_candle_data - -def make_matplotfriendly_date(element): - datetime_field = element[0]/1000 - datetime_utc = datetime.utcfromtimestamp(datetime_field) - datetime_num = mdates.date2num(datetime_utc) - return replace_at_index(element, 0, datetime_num) - -def replace_at_index(tup, ix, val): - lst = list(tup) - lst[ix] = val - return tuple(lst) - -def make_sell_order(instrument): - order = exchange.create_order(instrument, 'Market', 'sell', 2.0, None) - logging.info(order['side'] + ':' + str(order['amount']) + '@' + str(order['price'])) - -#DejaVu Sans Mono, Bitstream Vera Sans Mono, Andale Mono, Nimbus Mono L, Courier New, Courier, Fixed, Terminal, monospace -def get_plot(display): - # pyplot setup for 4X3 100dpi screen - fig, ax = plt.subplots(figsize=(display.WIDTH / 100, display.HEIGHT / 100), dpi=100) - # fills screen with graph - #fig.subplots_adjust(top=1, bottom=0, left=0, right=1) - # faied attempt at mpl fonts - plt.rcParams["font.family"] = "monospace" - plt.rcParams["font.monospace"] = "Terminal" - plt.rcParams['text.antialiased'] = False - plt.rcParams['lines.antialiased'] = False - plt.rcParams['patch.antialiased'] = False - plt.rcParams['timezone'] = tzlocal.get_localzone_name() - - # human readable short-format y-axis currency amount - ax.yaxis.set_major_formatter(matplotlib.ticker.FuncFormatter(human_format)) - - # this will hide the axis/labels - ax.autoscale_view(tight=False) - - # style axis ticks - ax.tick_params(labelsize='8', color='red', which='both', labelcolor='black') - - # hide the top/right border - ax.spines['bottom'].set_color('red') - ax.spines['left'].set_color('red') - ax.spines['bottom'].set_linewidth(1) - ax.spines['left'].set_linewidth(1) - ax.spines['top'].set_visible(False) - ax.spines['right'].set_visible(False) - - return (fig, ax) - -def human_format(num, pos): - num = float('{:.3g}'.format(num)) - magnitude = 0 - while abs(num) >= 1000: - magnitude += 1 - num /= 1000.0 - return '{}{}'.format('{:f}'.format(num).rstrip('0').rstrip('.'), ['', 'K', 'M', 'B', 'T'][magnitude]) - -def configure_axes(ax, minor_label_locator, minor_label_format, major_label_locator, major_label_format): - # format/locate x axis labels - ax.xaxis.set_minor_locator(minor_label_locator) - #ax.xaxis.set_minor_formatter(minor_label_format) - ax.xaxis.set_major_locator(major_label_locator) - ax.xaxis.set_major_formatter(major_label_format) - -class crypto_chart: - def __init__(self, config, display): - self.config = config - self.display = display - self.fig, self.ax = get_plot(display) - - def createChart(self): - return chart_data(self.config, self.fig, self.ax) - -class chart_data: - def __init__(self, config, fig, ax): - layouts = [ - ('1d', 60, 0.01, mdates.DayLocator(interval=7), mdates.DateFormatter('%d'), mdates.MonthLocator(), mdates.DateFormatter('%B')), - ('1h', 40, 0.005, mdates.HourLocator(interval=4), mdates.DateFormatter(''), mdates.DayLocator(), mdates.DateFormatter('%a %d %b')), - ('1h', 24, 0.01, mdates.HourLocator(interval=1), mdates.DateFormatter(''), mdates.HourLocator(interval=4), mdates.DateFormatter('%I %p')), - ('5m', 60, 0.0005, mdates.MinuteLocator(interval=30), mdates.DateFormatter(''), mdates.HourLocator(interval=1), mdates.DateFormatter('%I%p')) - ] - self.fig = fig - self.layout = layouts[random.randrange(len(layouts))] - self.candle_width = self.layout[0] - self.candleData = fetch_OHLCV_chart_data(self.layout[0], self.layout[1], config) - mpl_finance.candlestick_ohlc(ax, self.candleData, width=self.layout[2], colorup='black', colordown='red') - configure_axes(ax, self.layout[3], self.layout[4], self.layout[5], self.layout[6]) - - def last_close(self): - return self.candleData[-1][4] - - def end_price(self): - return self.candleData[0][3] - - def start_price(self): - return self.candleData[0][4] - - def write_to_stream(self, stream): - self.fig.savefig(stream, dpi=self.fig.dpi, pad_inches=0) \ No newline at end of file diff --git a/src/kinky.py b/src/kinky.py index 4bb5ae7d..0e5879b9 100644 --- a/src/kinky.py +++ b/src/kinky.py @@ -1,89 +1,119 @@ from inky.auto import auto import pathlib from PIL import Image, ImageFont, ImageDraw -import logging +from src.log_decorator import info_log filePath = pathlib.Path(__file__).parent.absolute() fontPath = str(filePath)+'/resources/04B_03__.TTF' price_font = ImageFont.truetype(fontPath, 48) title_font = ImageFont.truetype(fontPath, 16) +medium_font = ImageFont.truetype(fontPath, 32) +tiny_font = ImageFont.truetype(fontPath, 8) -connection_error_message = """ +connection_error_message = """ NO INTERNET CONNECTION ---------------------------- Please check your WIFI ---------------------------- -To configure WiFi access, -connect to 'RaspPiSetup' WiFi AP -then visit raspiwifisetup.com""" +To configure WiFi access, +connect to 'bitbot-' WiFi AP +and follow the instructions""" -class disker: - def __init__(self): + +class Disker: + def __init__(self, config): self.WIDTH = 400 self.HEIGHT = 300 self.title_font = title_font self.price_font = price_font - + self.tiny_font = tiny_font + self.medium_font = medium_font + self.config = config + + @info_log def draw_connection_error(self): - logging.info("No connection") - - def show(self, display_image): - display_image.save('last_display.png') - -class inker: + None + + def show(self, image): + rotated_image = image.rotate(self.config.display_rotation()) + quatised_image = quantise_inky(rotated_image) + self.save_image(self.config.output_file_name(), quatised_image) + + @info_log + def save_image(self, path, image): + image.save(path) + + def __repr__(self): + return f'' + + +# 🎨 create a limited pallete image for converting our chart image +def quantise_inky(display_image): + palette_img = Image.new("P", (1, 1)) + white_black_red = (255, 255, 255, 0, 0, 0, 255, 0, 0) + palette_img.putpalette(white_black_red + (0, 0, 0) * 252) + return display_image.convert('RGB').quantize(palette=palette_img) + + +class Inker: def __init__(self, config): - self.display_config = config["display"] - self.inky_display = auto() - self.WIDTH = self.inky_display.WIDTH - self.HEIGHT = self.inky_display.HEIGHT + self.config = config + self.display = auto() + self.WIDTH = self.display.WIDTH + self.HEIGHT = self.display.HEIGHT self.title_font = title_font self.price_font = price_font - + self.tiny_font = tiny_font + self.medium_font = medium_font + + @info_log def draw_connection_error(self): - logging.info("No connection") - img = Image.new("P", (self.inky_display.WIDTH, self.inky_display.HEIGHT)) + img = Image.new("P", (self.WIDTH, self.HEIGHT)) draw = ImageDraw.Draw(img) - # calculate space needed for message - message_width, message_height = draw.textsize(connection_error_message, title_font) - - # where to position the message - message_y = (self.inky_display.HEIGHT - message_height) / 2 - message_x = (self.inky_display.WIDTH - message_width) / 2 - - # draw the message at position - draw.multiline_text((message_x, message_y), connection_error_message, fill=self.inky_display.BLACK, font=title_font, align="center") - - # position for surrounding box + # 🌌 calculate space needed for message + message_width, message_height = draw.textsize( + connection_error_message, + title_font) + # πŸ“ where to position the message + message_y = (self.HEIGHT - message_height) / 2 + message_x = (self.WIDTH - message_width) / 2 + # πŸ–ŠοΈ draw the message at position + draw.multiline_text( + (message_x, message_y), + connection_error_message, + fill=self.display.BLACK, + font=title_font, + align="center") + # πŸ“ position for surrounding box padding = 10 - x0 = message_x - padding - y0 = message_y - padding + x0, y0 = (message_x - padding, message_y - padding) x1 = message_x + message_width + padding y1 = message_y + message_height + padding + # πŸ–ŠοΈ draw box at position + draw.rectangle([(x0, y0), (x1, y1)], outline=self.display.RED) + # πŸ“Ί show the image + self.display.set_image(img) + self.display.show() - # draw box at position - draw.rectangle([(x0, y0), (x1, y1)], outline=self.inky_display.RED) - - # show the image - self.inky_display.set_image(img) - self.inky_display.show() - + @info_log def show(self, image): - logging.info("Displaying image") - # rotate the image - image_rotation = self.display_config.getint("rotation") + # πŸŒ€ rotate the image + image_rotation = self.config.display_rotation() display_image = image.rotate(image_rotation) three_colour_screen_types = ["yellow", "red"] - if self.inky_display.colour in three_colour_screen_types: - # create a limited pallete image for converting our chart image to. - palette_img = Image.new("P", (1, 1)) - palette_img.putpalette((255, 255, 255, 0, 0, 0, 255, 0, 0) + (0, 0, 0) * 252) - display_image = display_image.convert('RGB').quantize(palette=palette_img) - - # show the image - self.inky_display.set_image(display_image) + if self.display.colour in three_colour_screen_types: + display_image = quantise_inky(display_image) + + # πŸ“Ί show the image + self.display.set_image(display_image) try: - self.inky_display.show() + self.display.show() except RuntimeError: - pass # current lib has a bug that spits out RuntimeError("Timeout waiting for busy signal to clear.") + # πŸͺ³ inky 1.3.0 bug: + # RuntimeError("Timeout waiting for busy signal to clear.") + pass + + def __repr__(self): + return f'<{self.display.colour} Inky: @{(self.WIDTH, self.HEIGHT)}>' diff --git a/src/log_decorator.py b/src/log_decorator.py new file mode 100644 index 00000000..1e14ad83 --- /dev/null +++ b/src/log_decorator.py @@ -0,0 +1,16 @@ +import logging + + +def info_log(func): + def wrapper(*args, **kwargs): + args_repr = [repr(a) for a in args] + kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()] + signature = ", ".join(args_repr + kwargs_repr) + # πŸͺ΅ log method call to info + logging.info(f"{func.__name__}: {signature}") + # πŸ”¨ do the real work + result = func(*args, **kwargs) + # πŸͺ΅ log result to debug + logging.debug(result) + return result + return wrapper diff --git a/src/market_chart.py b/src/market_chart.py new file mode 100644 index 00000000..e20f4ac5 --- /dev/null +++ b/src/market_chart.py @@ -0,0 +1,83 @@ +import matplotlib +import tzlocal +import matplotlib.pyplot as plt +import matplotlib.dates as mdates +import matplotlib.font_manager as font_manager +from mplfinance.original_flavor import candlestick_ohlc, volume_overlay +from src import price_humaniser + +matplotlib.use('Agg') +local_timezone = tzlocal.get_localzone() + + +# ☝️ single instance for lifetime of app +class MarketChart: + def __init__(self, config, display, files): + self.config = config + self.display = display + self.files = files + for font_file in font_manager.findSystemFonts(fontpaths=files.fonts_folder): + font_manager.fontManager.addfont(font_file) + + def create_plot(self, chart_data): + return PlottedChart(self.config, self.display, self.files, chart_data) + + +class PlottedChart: + layouts = { + '3mo': (20, mdates.YearLocator(), plt.NullFormatter(), mdates.YearLocator(1), mdates.DateFormatter('%Y'), local_timezone), + '1mo': (0.01, mdates.MonthLocator(), plt.NullFormatter(), mdates.YearLocator(1), mdates.DateFormatter('%Y'), local_timezone), + '1d': (0.01, mdates.DayLocator(bymonthday=range(1, 31, 7)), plt.NullFormatter(), mdates.MonthLocator(), mdates.DateFormatter('%b'), local_timezone), + '1h': (0.005, mdates.HourLocator(byhour=range(0, 23, 4)), plt.NullFormatter(), mdates.DayLocator(), mdates.DateFormatter('%a %d %b', local_timezone)), + "5m": (0.0005, mdates.MinuteLocator(byminute=[0, 30]), plt.NullFormatter(), mdates.HourLocator(interval=1), mdates.DateFormatter('%-I.%p', local_timezone)), + } + + def __init__(self, config, display, files, chart_data): + self.candle_width = chart_data.candle_width + # πŸ–¨οΈ create MPL plot + self.fig, ax = self.create_chart_figure(config, display, files) + # πŸ“ find suiteable layout for timeframe + layout = self.layouts[self.candle_width] + # βž– locate/format x axis ticks for chosen layout + ax[0].xaxis.set_minor_locator(layout[1]) + ax[0].xaxis.set_minor_formatter(layout[2]) + ax[0].xaxis.set_major_locator(layout[3]) + ax[0].xaxis.set_major_formatter(layout[4]) + # πŸ’²currency amount uses custom formatting + ax[0].yaxis.set_major_formatter(matplotlib.ticker.FuncFormatter(price_humaniser.format_scale_price)) + + self.plot_chart(config, layout, ax, chart_data.candle_data) + + def plot_chart(self, config, layout, ax, candle_data): + # βœ’οΈ draw candles to MPL plot + candlestick_ohlc(ax[0], candle_data, colorup='green', colordown='red', width=layout[0]) + # βœ’οΈ draw volumes to MPL plot + if config.show_volume(): + ax[1].yaxis.set_major_formatter(matplotlib.ticker.FuncFormatter(price_humaniser.format_scale_price)) + dates, opens, highs, lows, closes, volumes = list(zip(*candle_data)) + volume_overlay(ax[1], opens, closes, volumes, colorup='white', colordown='red', width=1) + + def create_chart_figure(self, config, display, files): + # πŸ“ apply global base style + plt.style.use(files.base_style) + # πŸ“ select mpl style + stlye = files.inset_style if config.expand_chart() else files.default_style + num_plots = 2 if config.show_volume() else 1 + heights = [4, 1] if config.show_volume() else [1] + plt.tight_layout() + # πŸ“ scope styles to just this plot + with plt.style.context(stlye): + fig = plt.figure(figsize=(display.WIDTH / 100, display.HEIGHT / 100)) + gs = fig.add_gridspec(num_plots, hspace=0, height_ratios=heights) + ax1 = fig.add_subplot(gs[0], zorder=1) + ax2 = None + if config.show_volume(): + with plt.style.context(files.volume_style): + ax2 = fig.add_subplot(gs[1], zorder=0) + + return (fig, (ax1, ax2)) + + def write_to_stream(self, stream): + self.fig.savefig(stream, dpi=self.fig.dpi, pad_inches=0) + stream.seek(0) + plt.close(self.fig) diff --git a/src/price_humaniser.py b/src/price_humaniser.py new file mode 100644 index 00000000..2f6f9696 --- /dev/null +++ b/src/price_humaniser.py @@ -0,0 +1,21 @@ + + +def format_title_price(price): + price_format = '{:,.0f}' if price > 100 else '{:,.2f}' if price > 10 else '{:,.3f}' + return price_format.format(price) + + +def format_scale_price(num, pos): + + if num < 1: + return "{:.3f}".format(num).lstrip('0') + + if num < 10: + return "{:.2f}".format(num) + + num = float('{:.3g}'.format(num)) + magnitude = 0 + while abs(num) >= 1000: + magnitude += 1 + num /= 1000.0 + return '{}{}'.format('{:f}'.format(num).rstrip('0').rstrip('.'), ['', 'K', 'M', 'B', 'T'][magnitude]) diff --git a/src/resources/Pixel12x10.ttf b/src/resources/Pixel12x10.ttf new file mode 100644 index 00000000..8f740731 Binary files /dev/null and b/src/resources/Pixel12x10.ttf differ diff --git a/src/resources/Pixel12x10Mono.ttf b/src/resources/Pixel12x10Mono.ttf new file mode 100644 index 00000000..53a2cdc9 Binary files /dev/null and b/src/resources/Pixel12x10Mono.ttf differ diff --git a/src/stock_exchanges.py b/src/stock_exchanges.py new file mode 100644 index 00000000..185608ea --- /dev/null +++ b/src/stock_exchanges.py @@ -0,0 +1,107 @@ +import yfinance +import collections +import random +from datetime import datetime, timedelta +import matplotlib.dates as mdates +from src.log_decorator import info_log + + +class Exchange(): + CandleConfig = collections.namedtuple('CandleConfig', 'width duration') + candle_configs = [ + CandleConfig('1mo', timedelta(weeks=4*24)), + CandleConfig('1h', timedelta(hours=40)), + CandleConfig('1wk', timedelta(weeks=60)), + CandleConfig('3mo', timedelta(weeks=12*24)) + ] + + def __init__(self, config): + self.config = config + + def fetch_history(self): + instrument = self.config.stock_symbol() + ticker = yfinance.Ticker(instrument) + candle_config = self.select_candle_config() + candle_width = candle_config.width + chart_duration = candle_config.duration + + end_date = datetime.utcnow() + start_date = end_date - chart_duration + + history = self.get_stock_history( + ticker, + candle_width, + start_date, + end_date) + + return CandleData(instrument, candle_width, history, ticker) + + @info_log + def get_stock_history(self, ticker, candle_width, start_date, end_date): + return ticker.history( + interval=candle_width, + start=start_date.strftime("%Y-%m-%d"), + end=end_date.strftime("%Y-%m-%d")) + + def select_candle_config(self): + candle_width = self.config.candle_width() + if(candle_width == "random"): + return self.get_random_candle_config() + else: + candle_config = self.get_candle_config_matching(candle_width) + return candle_config + + def get_candle_config_matching(self, configred_candle_width): + candle_config, = ( + conf for conf in self.candle_configs + if conf.width == configred_candle_width + ) + return candle_config + + def get_random_candle_config(self): + randomised_index = random.randrange(len(self.candle_configs)) + new_var = self.candle_configs[randomised_index] + return new_var + + def __repr__(self): + return '' + + +def make_matplotfriendly_date(element): + datetime_field = element[0] + datetime_num = mdates.date2num(datetime_field) + return replace_at_index(element, 0, datetime_num) + + +def replace_at_index(tup, ix, val): + lst = list(tup) + lst[ix] = val + return tuple(lst) + + +class CandleData(): + def __init__(self, instrument, candle_width, candle_data, ticker): + self.instrument = f'{instrument}/{ticker.info["currency"]}' + self.candle_width = candle_width + candle_data.reset_index(level=0, inplace=True) + self.candle_data = self.clean_candle_data(candle_data) + + def clean_candle_data(self, candle_data): + return list(map(make_matplotfriendly_date, candle_data.to_numpy())) + + def percentage_change(self): + current_price = self.last_close() + starting_price = self.start_price() + return ((current_price - starting_price) / current_price) * 100 + + def last_close(self): + return float(self.candle_data[-1][4]) + + def end_price(self): + return float(self.candle_data[0][3]) + + def start_price(self): + return float(self.candle_data[0][4]) + + def __repr__(self): + return f'<{self.instrument} {self.candle_width} candle data>' diff --git a/src/update_chart.py b/src/update_chart.py deleted file mode 100644 index 228cf6f2..00000000 --- a/src/update_chart.py +++ /dev/null @@ -1,107 +0,0 @@ -from src import currency_chart -from src import kinky -from PIL import Image, ImageDraw -import io -import random -import socket -import time -import logging - -def network_connected(hostname="google.com"): - try: - host = socket.gethostbyname(hostname) - socket.create_connection((host, 80), 2).close() - return True - except: - time.sleep(1) - return False - -# sort positions by average colour and then by random -def least_intrusive_position(img, possibleTextPositions): - rgb_im = img.convert('RGB') - height_of_section = 60 - ordredByAveColour = sorted(possibleTextPositions, key=lambda item: (count_white_pixels(*item, height_of_section, rgb_im), item[0])) - return ordredByAveColour[-1] - -# count the white pixels in an area of the image -def count_white_pixels(x, y, n, image): - count = 0 - for s in range(x, x+(n*3)+1): - for t in range(y, y+n+1): - pix = image.getpixel((s, t)) - count += 1 if pix == (255,255,255) else 0 - return count - -# wait for network connection -def wait_for_internet_connection(display): - logging.info('Await network') - connection_error_shown = False - while network_connected() == False: - # draw error message if not already drawn - if connection_error_shown == False: - connection_error_shown = True - display.draw_connection_error() - time.sleep(10) - -class bitbot: - def __init__(self, config): - self.config = config - self.display = kinky.inker(self.config) - # below is for testing without an inky display. saves to disk - #display = kinky.disker() - self.chart = currency_chart.crypto_chart(self.config, self.display) - - def get_comments(self, direction): - return self.config.get('comments', direction).split(',') - - def configured_instrument(self): - return self.config["currency"]["instrument"] - - def run(self): - # check internet connection - wait_for_internet_connection(self.display) - - # fetch the chart data - chartdata = self.chart.createChart() - #chartdata = currency_chart.chart_data(self.config, self.display) - - with io.BytesIO() as file_stream: - logging.info('Formatting chart for display') - - # write mathplot fig to stream and open as a PIL image - chartdata.write_to_stream(file_stream) - file_stream.seek(0) - plot_image = Image.open(file_stream) - - # find some empty graph space to place our text - title_positions = [(60, 5), (210, 5), (140, 5), (60, 200), (210, 200), (140, 200)] - selectedArea = least_intrusive_position(plot_image, title_positions) - - # write our text to the image - draw_plot_image = ImageDraw.Draw(plot_image) - - # instrument / time text - title = self.configured_instrument() + ' (' + chartdata.candle_width + ') ' - draw_plot_image.text(selectedArea, title, 'black', self.display.title_font) - - # % change text - title_width, title_height = draw_plot_image.textsize(title, self.display.title_font) - change = ((chartdata.last_close() - chartdata.start_price()) / chartdata.last_close())*100 - change_colour = ('red' if change < 0 else 'black') - draw_plot_image.text((selectedArea[0]+title_width, selectedArea[1]), '{:+.2f}'.format(change) + '%', change_colour, self.display.title_font) - - # current price text - price = '{:,.0f}'.format(chartdata.last_close()) - price_width, price_height = draw_plot_image.textsize(price, self.display.price_font) - draw_plot_image.text((selectedArea[0], selectedArea[1]+11), price, 'black', self.display.price_font) - - # select some random comment depending on price action - if random.random() < 0.5: - direction = 'up' if chartdata.start_price() < chartdata.last_close() else 'down' - messages=self.get_comments(direction) - draw_plot_image.text((selectedArea[0], selectedArea[1]+52), random.choice(messages), 'red', self.display.title_font) - - # add a border to the display - draw_plot_image.rectangle([(0, 0), (self.display.WIDTH -1, self.display.HEIGHT-1)], outline='red') - - self.display.show(plot_image) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_chart_rendering.py b/tests/test_chart_rendering.py new file mode 100644 index 00000000..a296b58a --- /dev/null +++ b/tests/test_chart_rendering.py @@ -0,0 +1,78 @@ +import unittest +import pathlib +import os +import sys +from os.path import join as pjoin +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), 'src'))) +from src import bitbot +from src.configuration.bitbot_files import use_config_dir +from src.configuration.bitbot_config import load_config_ini + +# check config files +curdir = pathlib.Path(__file__).parent.resolve() +files = use_config_dir(pjoin(curdir, "../")) + + +def load_config(): + config = load_config_ini(files.config_ini) + config.set('display', 'output', 'disk') + return config + + +# load config +test_params = [ + ("APPLE 1mo defaults", "", "", "AAPL", "1", "false", "false", "1mo", ""), + ("APPLE 3mo defaults", "", "", "AAPL", "1", "false", "false", "3mo", ""), + + ("bitmex BTC 5m defaults", "bitmex", "BTC/USD", "", "1", "false", "false", "5m", ""), + ("bitmex BTC 1h defaults", "bitmex", "BTC/USD", "", "1", "false", "false", "1h", ""), + ("bitmex BTC 1d defaults", "bitmex", "BTC/USD", "", "1", "false", "false", "1d", ""), + + ("BTC HOLDINGS", "bitmex", "BTC/USD", "", "1", "false", "false", "1d", "100"), + ("BTC VOLUME", "bitmex", "BTC/USD", "", "1", "false", "true", "1d", ""), + ("BTC VOLUME EXPANDED", "bitmex", "BTC/USD", "", "1", "true", "true", "1d", ""), + ("BTC VOLUME OVERLAY2", "bitmex", "BTC/USD", "", "2", "false", "true", "1d", ""), + ("BTC OVERLAY2", "bitmex", "BTC/USD", "", "2", "false", "false", "1d", ""), + + ("bitmex ETH 5m defaults", "bitmex", "ETH/USD", "", "1", "false", "false", "5m", ""), + ("bitmex ETH 1h defaults", "bitmex", "ETH/USD", "", "1", "false", "false", "1h", ""), + ("bitmex ETH 1d defaults", "bitmex", "ETH/USD", "", "1", "false", "false", "1d", ""), + + ("cryptocom CRO 5m defaults", "cryptocom", "CRO/USDC", "", "1", "false", "false", "5m", ""), + ("cryptocom CRO 1h defaults", "cryptocom", "CRO/USDC", "", "1", "false", "false", "1h", ""), + ("cryptocom CRO 1d defaults", "cryptocom", "CRO/USDC", "", "1", "false", "false", "1d", ""), +] + +os.makedirs('tests/images/', exist_ok=True) + + +class TestRenderingMeta(type): + def __new__(mcs, name, bases, dict): + + def gen_test(name, exch, token, stock, overlay, expand, volume, candle_width, holdings): + def test(self): + config = load_config() + image_file_name = f'tests/images/{name}.png' + config.set('currency', 'stock_symbol', stock) + config.set('currency', 'exchange', exch) + config.set('currency', 'instrument', token) + config.set('currency', 'holdings', holdings) + config.set('display', 'overlay_layout', overlay) + config.set('display', 'expanded_chart', expand) + config.set('display', 'show_volume', volume) + config.set('display', 'candle_width', candle_width) + config.set('display', 'disk_file_name', image_file_name) + app = bitbot.BitBot(config, files) + app.run() + # os.system(f"code {image_file_name}") + + return test + + for test_param in test_params: + test_name = "test_%s" % test_param[0] + dict[test_name] = gen_test(*test_param) + return type.__new__(mcs, name, bases, dict) + + +class ChartRenderingTests(unittest.TestCase, metaclass=TestRenderingMeta): + __metaclass__ = TestRenderingMeta diff --git a/tests/test_price_humaniser.py b/tests/test_price_humaniser.py new file mode 100644 index 00000000..eb8f081c --- /dev/null +++ b/tests/test_price_humaniser.py @@ -0,0 +1,52 @@ +import unittest +import sys +import os +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), 'src'))) +from src.price_humaniser import format_title_price, format_scale_price + + +class test_title_price_humaniser(unittest.TestCase): + def test_uses_2dp_if_lessthan_100(self): + self.assertEqual(format_title_price(1), "1.000") + self.assertEqual(format_title_price(9.99), "9.990") + self.assertEqual(format_title_price(11), "11.00") + self.assertEqual(format_title_price(11.1), "11.10") + self.assertEqual(format_title_price(99.99), "99.99") + self.assertEqual(format_title_price(99.999), "100.00") + + def test_uses_0dp_if_greaterthan_100(self): + self.assertEqual(format_title_price(100.1), "100") + + +class test_scale_price_humaiser(unittest.TestCase): + def test_less_than_one(self): + self.assertEqual(format_scale_price(0.9, 0), ".900") + self.assertEqual(format_scale_price(0.99, 0), ".990") + self.assertEqual(format_scale_price(1.11, 0), "1.11") + self.assertEqual(format_scale_price(0.432, 0), ".432") + self.assertEqual(format_scale_price(0.4324, 0), ".432") + + def test_decimal(self): + self.assertEqual(format_scale_price(1, 0), "1.00") + self.assertEqual(format_scale_price(9.99, 0), "9.99") + self.assertEqual(format_scale_price(11, 0), "11") + self.assertEqual(format_scale_price(11.1, 0), "11.1") + self.assertEqual(format_scale_price(11.11, 0), "11.1") + self.assertEqual(format_scale_price(100.11, 0), "100") + + def test_kilo(self): + self.assertEqual(format_scale_price(1000, 0), "1K") + self.assertEqual(format_scale_price(1100, 0), "1.1K") + self.assertEqual(format_scale_price(11100, 0), "11.1K") + + def test_mega(self): + self.assertEqual(format_scale_price(1000000, 0), "1M") + self.assertEqual(format_scale_price(1100000, 0), "1.1M") + self.assertEqual(format_scale_price(1110000, 0), "1.11M") + self.assertEqual(format_scale_price(1111000, 0), "1.11M") + + def test_giga(self): + self.assertEqual(format_scale_price(1000000000, 0), "1B") + self.assertEqual(format_scale_price(1100000000, 0), "1.1B") + self.assertEqual(format_scale_price(1110000000, 0), "1.11B") + self.assertEqual(format_scale_price(1111000000, 0), "1.11B") diff --git a/tests/test_stock_exchange.py b/tests/test_stock_exchange.py new file mode 100644 index 00000000..92204004 --- /dev/null +++ b/tests/test_stock_exchange.py @@ -0,0 +1,33 @@ +import sys +import os +import unittest +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), 'src'))) +from src import stock_exchanges +from src.configuration import bitbot_config + +# πŸͺ³ ''1h',' <- fails on weekends due to short chart duration +test_params = [] # ["1mo", '1h', '1wk', 'random'] + + +class TestStockExchange(unittest.TestCase): + def test_fetching_history(self): + for candle_width in test_params: + with self.subTest(msg=candle_width): + self.run_test(candle_width) + + def run_test(self, candle_width): + stock = "TSLA" + mock_config = { + "currency": { + "stock_symbol": stock + }, + "display": { + "candle_width": candle_width, + "disk_file_name": "last_display.png" + } + } + config = bitbot_config.BitBotConfig(mock_config) + excange = stock_exchanges.Exchange(config) + data = excange.fetch_history() + num_candles = len(data.candle_data) + self.assertTrue(num_candles > 0, msg=f'got {num_candles} candles for {stock}')