Compare commits
303 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34223960f8 | |||
| 5f583de857 | |||
| bb0a0ef928 | |||
| 2d4c7002a7 | |||
| 939e46eb61 | |||
| 04f1d8cbcd | |||
| c6032c5b66 | |||
| 8908e2da02 | |||
| 892d5a30e0 | |||
| b896513be9 | |||
| 013ccba050 | |||
| a10f94a570 | |||
| 5b8b9ae39e | |||
| 3e84ed7572 | |||
| 39458ef44e | |||
| 0e47b7538e | |||
| 0aaa51e736 | |||
| e6f38d9481 | |||
| 910d426ad4 | |||
| 72ad02de4b | |||
| 50285a3d51 | |||
| ff31e3d48b | |||
| 3d944afeb4 | |||
| 8e625107c1 | |||
| df034863c7 | |||
| 70cdfc9fd8 | |||
| a0bd776abb | |||
| afcafa6031 | |||
| 07c85697f3 | |||
| a882b5aadb | |||
| bad82fb41b | |||
| 5aca9008cb | |||
| 712a4c6c57 | |||
| 78f0d21b73 | |||
| 19cac58692 | |||
| 7d77b6fbc8 | |||
| e4e08091af | |||
| 292d43b154 | |||
| eae4422ad8 | |||
| 53d463b8b5 | |||
| 827644bb32 | |||
| 21e8672a12 | |||
| 70910c2f6d | |||
| 9ddea4c6e1 | |||
| 576b22e4c9 | |||
| 0035879a87 | |||
| 97298bcd39 | |||
| 0504058c01 | |||
| 750ccbd68f | |||
| d815700e58 | |||
| 9d809bbbe4 | |||
| 74a26e004f | |||
| 65710e2791 | |||
| ac310735df | |||
| 5611de0e2e | |||
| 4196539169 | |||
| 08cfcceb6a | |||
| e427d5802c | |||
| fc5cc1fee2 | |||
| fb3c19d3bf | |||
| 24aeb72549 | |||
| c799da58e1 | |||
| e24f932c0f | |||
| 7e2bf3430e | |||
| e1c8c2c515 | |||
| ae1e59ce30 | |||
| 03089ed1b3 | |||
| 97b13063d9 | |||
| 9260de5f25 | |||
| 505a24bf99 | |||
| 0e88b8c6f5 | |||
| 519486fe57 | |||
| 4086168515 | |||
| a13bd9f0dc | |||
| 2c3950b522 | |||
| 653f54add6 | |||
| 8e0d792d26 | |||
| 81af737204 | |||
| 81845d47f2 | |||
| cf475657cf | |||
| 90a15ceddb | |||
| 981ce0d62f | |||
| 154b679464 | |||
| ee51cb7e2a | |||
| 40120c7027 | |||
| cfd5b8a4f3 | |||
| 3b93e328d5 | |||
| 1fd858287d | |||
| e370f5bbb7 | |||
| 3e492475c0 | |||
| c454e85ad4 | |||
| 487322abb5 | |||
| f78c521dc4 | |||
| ff9bcd782f | |||
| 1ccf515ca2 | |||
| 1b0f9c134c | |||
| 3589242996 | |||
| 689aa4d50b | |||
| 26370c03c4 | |||
| 80b043219a | |||
| de54b6843d | |||
| 17e5d8b665 | |||
| bd9c828c68 | |||
| f72cd6f5a7 | |||
| 3593697cce | |||
| d53e71dcd5 | |||
| 71e79cfb40 | |||
| 38a65f99c9 | |||
| 26e54fb357 | |||
| 224ed104b9 | |||
| 2702fe27e4 | |||
| dd21227f8f | |||
| 1ef8dd9378 | |||
| eae345f243 | |||
| 6d6f968f42 | |||
| 261c848ffd | |||
| 9bcc80078e | |||
| c4b7d399ea | |||
| ffe53e91c0 | |||
| aeb1b12afe | |||
| fe81ec7ee7 | |||
| f0ecc0fd3d | |||
| 03b75fb683 | |||
| dc93621547 | |||
| 7ab6f79e56 | |||
| 9e48f21c8d | |||
| 10e6de8df4 | |||
| 00fbdec6f1 | |||
| 730903c383 | |||
| 5fc9455908 | |||
| 28e0ad68fc | |||
| ca48d702a7 | |||
| 812b9ed075 | |||
| 93a0595652 | |||
| 8a357e8cab | |||
| 22e12977f8 | |||
| 732cf02317 | |||
| 6dea75651b | |||
| 6cff252813 | |||
| 0d06ca6a04 | |||
| a3f875270b | |||
| 00a1312ee3 | |||
| 1195faca0f | |||
| a39f255e52 | |||
| cac2460153 | |||
| 894da4ef9b | |||
| a705b794fd | |||
| 37da6885ee | |||
| 219018b1d9 | |||
| 4b780fd6d2 | |||
| 1faa22897b | |||
| 7e1899d8ff | |||
| 878c668696 | |||
| 8c94fbf299 | |||
| 4ece09368e | |||
| 0f135f4024 | |||
| eac5de0d4a | |||
| 399858d09b | |||
| 7694d8a241 | |||
| 9bd406c5b1 | |||
| d23d28afd8 | |||
| 0a6b2d844f | |||
| be325d31b6 | |||
| e08c1880c6 | |||
| 5f9f6f01c5 | |||
| 65388238c3 | |||
| cdce46b6df | |||
| 8320b54332 | |||
| e8ef0921ad | |||
| c4736fae70 | |||
| 1748f49ee6 | |||
| c32641ec34 | |||
| 53ebe28b14 | |||
| 465686dbaf | |||
| 58604c1e5a | |||
| 3e4f055a4a | |||
| 92586d7466 | |||
| 94c90b8a7c | |||
| f866baa37a | |||
| da72c17cd0 | |||
| 1c5c4af477 | |||
| 83d5ad213b | |||
| ea22c27710 | |||
| 32bf256c84 | |||
| e498e7668e | |||
| f58fbf9d6d | |||
| 5e56c97bf9 | |||
| 000c459d76 | |||
| 307f65c898 | |||
| 5db54adfb7 | |||
| 19fb9a85fc | |||
| 9d48ebbad9 | |||
| 077b5257e8 | |||
| b84602a5ed | |||
| 43541c1af2 | |||
| 8c4d0fa1a1 | |||
| 16fed7a8ca | |||
| c1009a19fb | |||
| be189cf899 | |||
| 8116d1d4de | |||
| ab698c8903 | |||
| 74b16f2165 | |||
| fafbbe9b3a | |||
| 04b32c3f33 | |||
| 0babcbfac4 | |||
| 2da57d95b7 | |||
| d317c03819 | |||
| 2e187b5e08 | |||
| 064343c1ff | |||
| efc67c63d8 | |||
| a978377501 | |||
| 1bf05a1b87 | |||
| 5e3c993a15 | |||
| 4220bb629f | |||
| ea4602abc7 | |||
| bdd30c238c | |||
| 778d799113 | |||
| 47cd1b6a91 | |||
| 118232e906 | |||
| 64c84fe708 | |||
| cff3c02851 | |||
| 60577d02c7 | |||
| 99749bd9c9 | |||
| b3ca2e5927 | |||
| b71e9e2e80 | |||
| 72a8aacabe | |||
| a0e9cfb40d | |||
| 46707c1149 | |||
| 497efcfa76 | |||
| 1201c248ee | |||
| 395eb31801 | |||
| b27edd9818 | |||
| 51c43e9893 | |||
| ec2019430e | |||
| 05174e93ad | |||
| e8ec94bfa8 | |||
| c6642e028c | |||
| 6d5a5f46b9 | |||
| e651eb86ca | |||
| 84171da29b | |||
| d463ec689b | |||
| 416a82058b | |||
| 6f2324ad1b | |||
| c19f13edc1 | |||
| 390b137e0b | |||
| 0c7d4a83f1 | |||
| 25112cb03a | |||
| 5ade4b35a0 | |||
| 00067c56d4 | |||
| 620566490b | |||
| 7245f4cc69 | |||
| 575fecb163 | |||
| 4120b1abfa | |||
| 801d3739fc | |||
| 92bdf14508 | |||
| fa25a82693 | |||
| 1684f3e0cb | |||
| a31f807da8 | |||
| 6d92b49f3d | |||
| 11cdf975bc | |||
| 7bacc28e3b | |||
| 4bbb71d434 | |||
| 8da153a476 | |||
| 1189325638 | |||
| e93459ae55 | |||
| b1fcf51546 | |||
| 75716368bb | |||
| ec15e2bb63 | |||
| 9a3e030ce7 | |||
| 4f5894ef4a | |||
| e0b5610d90 | |||
| bc34519a26 | |||
| dc69bfa8c7 | |||
| cf7c5fc23a | |||
| 16d5b80657 | |||
| 169eabd800 | |||
| 62dcae48bb | |||
| b8f24d4b67 | |||
| 6d9e0008b8 | |||
| b9d41f9ac0 | |||
| f724f450f2 | |||
| cc447c5fb0 | |||
| b3f0f89d16 | |||
| 8dd153fb53 | |||
| 89bfb28019 | |||
| 1215e47297 | |||
| 4016899179 | |||
| 1cad9b83fb | |||
| 780ee76dc3 | |||
| 4d8e17f59f | |||
| 21c27e22da | |||
| 2e978089b5 | |||
| 803fe7147e | |||
| 6490364a5d | |||
| 2ad773952e | |||
| 138e71107e | |||
| bde399f589 | |||
| 8a43602363 | |||
| 5a56790fd4 | |||
| 892f681174 | |||
| 997b5420ee | |||
| 643bbe3af2 | |||
| f85f0f19b9 |
@@ -0,0 +1,16 @@
|
|||||||
|
.git
|
||||||
|
.env
|
||||||
|
node_modules
|
||||||
|
packages
|
||||||
|
vendor
|
||||||
|
tests
|
||||||
|
.DS_Store
|
||||||
|
vapor.yml
|
||||||
|
.vapor
|
||||||
|
storage/app/livewire-tmp/*
|
||||||
|
storage/app/public/profile-photos/*
|
||||||
|
storage/framework/cache/*
|
||||||
|
storage/framework/sessions/*
|
||||||
|
storage/framework/testing/*
|
||||||
|
storage/framework/views/*
|
||||||
|
storage/logs/*
|
||||||
+27
-27
@@ -1,24 +1,38 @@
|
|||||||
APP_NAME=Investbrain
|
# Generate a secure key using `openssl rand -base64 32`
|
||||||
APP_ENV=production
|
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=true
|
|
||||||
APP_TIMEZONE=UTC
|
# Port for NGINX to listen on
|
||||||
APP_PORT=8000
|
APP_PORT=8000
|
||||||
|
|
||||||
|
# Used internally to generate absolute links
|
||||||
APP_URL="http://localhost:${APP_PORT}"
|
APP_URL="http://localhost:${APP_PORT}"
|
||||||
SELF_HOSTED=true
|
|
||||||
|
# Webroot for static assets (css, js, images, etc)
|
||||||
|
ASSET_URL="${APP_URL}"
|
||||||
|
|
||||||
|
# Enables or disables new user registration
|
||||||
REGISTRATION_ENABLED=true
|
REGISTRATION_ENABLED=true
|
||||||
|
|
||||||
# ASSET_URL="http://localhost:8000" # (optional) webroot for static assets (css, js, images, etc)
|
# Enable or disable AI chat feature
|
||||||
|
|
||||||
AI_CHAT_ENABLED=false
|
AI_CHAT_ENABLED=false
|
||||||
|
|
||||||
|
# API key for OpenAI (for Llama support, see docs)
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
OPENAI_ORGANIZATION=
|
OPENAI_ORGANIZATION=
|
||||||
|
|
||||||
|
# Market data provider to use (comma separated list)
|
||||||
MARKET_DATA_PROVIDER=yahoo
|
MARKET_DATA_PROVIDER=yahoo
|
||||||
MARKET_DATA_REFRESH=30
|
|
||||||
ALPHAVANTAGE_API_KEY=
|
ALPHAVANTAGE_API_KEY=
|
||||||
FINNHUB_API_KEY=
|
FINNHUB_API_KEY=
|
||||||
|
ALPACA_API_KEY=
|
||||||
|
ALPACA_API_SECRET=
|
||||||
|
TWELVEDATA_API_SECRET=
|
||||||
|
|
||||||
|
# Cadence to refresh market data (in minutes)
|
||||||
|
MARKET_DATA_REFRESH=30
|
||||||
|
DAILY_CHANGE_TIME=
|
||||||
|
|
||||||
|
#### Advanced configurations ####
|
||||||
ENABLED_LOGIN_PROVIDERS=
|
ENABLED_LOGIN_PROVIDERS=
|
||||||
GITHUB_CLIENT_ID=
|
GITHUB_CLIENT_ID=
|
||||||
GITHUB_CLIENT_SECRET=
|
GITHUB_CLIENT_SECRET=
|
||||||
@@ -29,9 +43,10 @@ LINKEDIN_CLIENT_SECRET=
|
|||||||
FACEBOOK_CLIENT_ID=
|
FACEBOOK_CLIENT_ID=
|
||||||
FACEBOOK_CLIENT_SECRET=
|
FACEBOOK_CLIENT_SECRET=
|
||||||
|
|
||||||
APP_LOCALE=en
|
FILESYSTEM_DISK=local
|
||||||
APP_FALLBACK_LOCALE=en
|
SESSION_DRIVER=redis
|
||||||
APP_FAKER_LOCALE=en_US
|
QUEUE_CONNECTION=redis
|
||||||
|
CACHE_STORE=redis
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=mysql
|
||||||
DB_HOST=investbrain-mysql
|
DB_HOST=investbrain-mysql
|
||||||
@@ -40,20 +55,7 @@ DB_DATABASE=investbrain
|
|||||||
DB_USERNAME=investbrain
|
DB_USERNAME=investbrain
|
||||||
DB_PASSWORD=investbrain
|
DB_PASSWORD=investbrain
|
||||||
|
|
||||||
SESSION_DRIVER=redis
|
REDIS_HOST=investbrain-redis
|
||||||
SESSION_LIFETIME=120
|
|
||||||
SESSION_ENCRYPT=false
|
|
||||||
SESSION_PATH=/
|
|
||||||
SESSION_DOMAIN=null
|
|
||||||
|
|
||||||
FILESYSTEM_DISK=local
|
|
||||||
QUEUE_CONNECTION=redis
|
|
||||||
|
|
||||||
CACHE_STORE=redis
|
|
||||||
|
|
||||||
REDIS_CLIENT=predis
|
|
||||||
REDIS_HOST=127.0.0.1
|
|
||||||
REDIS_PATH=/tmp/database_server.sock
|
|
||||||
REDIS_PASSWORD=null
|
REDIS_PASSWORD=null
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|
||||||
@@ -71,5 +73,3 @@ AWS_SECRET_ACCESS_KEY=
|
|||||||
AWS_DEFAULT_REGION=us-east-1
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
AWS_BUCKET=
|
AWS_BUCKET=
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
name: Build and push Docker images
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-22.04 #ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Increase swap space
|
||||||
|
run: sudo /bin/dd if=/dev/zero of=/var/swap.1 bs=1M count=5120 && sudo chmod 600 /var/swap.1 && sudo /sbin/mkswap /var/swap.1 && sudo /sbin/swapon /var/swap.1
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GIT_HUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Cache Docker layers
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /tmp/.buildx-cache
|
||||||
|
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-buildx-
|
||||||
|
|
||||||
|
- name: Extract version from tag
|
||||||
|
id: extract-version
|
||||||
|
run: |
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
|
||||||
|
TAGS="investbrainapp/investbrain:${VERSION},ghcr.io/investbrainapp/investbrain:${VERSION}"
|
||||||
|
|
||||||
|
# Conditionally add 'latest' tags unless 'pre-release' is in the version
|
||||||
|
if [[ "${GITHUB_REF_NAME}" != *alpha* && "${GITHUB_REF_NAME}" != *beta* && "${GITHUB_REF_NAME}" != *rc* ]]; then
|
||||||
|
TAGS="$TAGS,investbrainapp/investbrain:latest,ghcr.io/investbrainapp/investbrain:latest"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
file: ./docker/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.extract-version.outputs.tags }}
|
||||||
|
build-args: |
|
||||||
|
VERSION=${{ github.ref_name }}
|
||||||
|
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
|
|
||||||
|
|
||||||
<p align="center"><a href="https://investbra.in" target="_blank"><img src="https://raw.githubusercontent.com/investbrainapp/investbrain/main/investbrain-logo.png" width="400" alt="Investbrain Logo"></a></p>
|
<p align="center"><a href="https://investbra.in" target="_blank"><img src="https://raw.githubusercontent.com/investbrainapp/investbrain/main/investbrain-logo.png" width="400" alt="Investbrain Logo"></a></p>
|
||||||
|
|
||||||
|
[](https://github.com/investbrainapp/investbrain/)
|
||||||
|
[](https://github.com/investbrainapp/investbrain/)
|
||||||
|
[](https://github.com/investbrainapp/investbrain/issues)
|
||||||
|
[](https://hub.docker.com/r/investbrainapp/investbrain/)
|
||||||
|
|
||||||
|
|
||||||
## About Investbrain
|
## About Investbrain
|
||||||
|
|
||||||
Investbrain is a smart open-source investment tracker that helps you manage, track, and make informed decisions about your investments.
|
Investbrain is a smart open-source investment tracker that helps you manage, track, and make informed decisions about your investments.
|
||||||
@@ -20,31 +28,31 @@ Investbrain is a smart open-source investment tracker that helps you manage, tra
|
|||||||
|
|
||||||
## Under the hood
|
## Under the hood
|
||||||
|
|
||||||
Investbrain is a Laravel PHP web application that leverages Livewire and Tailwind for its frontend. Most databases should work, including MySQL and SQLite. Out of the box, we feature three market data providers: [Yahoo Finance](https://finance.yahoo.com/), [Finnhub](https://finnhub.io/pricing-stock-api-market-data), and [Alpha Vantage](https://www.alphavantage.co/support/). But we also offer an extensible market data provider interface for intrepid developers to create their own! We also offer an integration with OpenAI for our ["chat with your holdings"](#chat-with-your-holdings) capability. Finally, of course we have robust support for i18n, a11y, and dark mode.
|
Investbrain is a Laravel PHP web application that has an extensible market data provider interface. Out of the box, we feature many market data providers. But intrepid developers can [create their own providers](#custom-providers)! We also offer integrations with OpenAI and Ollama for our ["chat with your holdings"](#chat-with-your-holdings) capability. Finally, of course we have robust support for i18n, a11y, and dark mode.
|
||||||
|
|
||||||
## Self hosting
|
## Self hosting
|
||||||
|
|
||||||
For ease of installation, we _highly recommend_ installing Investbrain using the provided [Docker Compose](https://github.com/investbrainapp/investbrain/blob/main/docker-compose.yml) file, which downloads all the necessary dependencies and seamlessly builds everything you need to get started quickly!
|
For ease of installation, we _highly recommend_ installing Investbrain using the provided [Docker Compose](https://github.com/investbrainapp/investbrain/blob/main/docker-compose.yml) file, which uses the official Investbrain Docker image and includes all the necessary dependencies to seamlessly build everything you need to get started quickly!
|
||||||
|
|
||||||
Before getting started, you should already have the following installed on your machine: [Docker Engine](https://docs.docker.com/engine/install/), [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git), and a wild sense of adventure.
|
Before getting started, you should already have [Docker Engine](https://docs.docker.com/engine/install/) installed on your machine.
|
||||||
|
|
||||||
Ready? Let's get started!
|
Ready? Let's get started!
|
||||||
|
|
||||||
First, you can clone this repository:
|
**1. Download copy of Docker Compose file**
|
||||||
|
|
||||||
|
Grab a copy of the [docker-compose.yml](https://github.com/investbrainapp/investbrain/blob/main/docker-compose.yml) using `wget`, `curl` or similar:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/investbrainapp/investbrain.git && cd investbrain
|
curl -O https://raw.githubusercontent.com/investbrainapp/investbrain/main/docker-compose.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, build the Docker image and bring up the container (this will take a few minutes):
|
**2. Set your environment**
|
||||||
|
|
||||||
```bash
|
Adjust the `environment` properties in the compose file to your preferences.
|
||||||
docker compose up
|
|
||||||
```
|
|
||||||
|
|
||||||
In the previous step, all of the default configurations are set automatically. This includes creating a .env file and setting the required Laravel `APP_KEY`.
|
**3. Run `docker compose up`**
|
||||||
|
|
||||||
If everything worked as expected, you should now be able to access Investbrain in the browser at. You should create an account by visiting:
|
It might take a few minutes to pull the Docker images. But assuming everything worked as expected, you should now be able to access Investbrain in the browser by visiting:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
http://localhost:8000/register
|
http://localhost:8000/register
|
||||||
@@ -64,11 +72,11 @@ Always keep in mind the limitations of LLMs. When in doubt, consult a licensed i
|
|||||||
|
|
||||||
## Market data providers
|
## Market data providers
|
||||||
|
|
||||||
Investbrain includes an extensible market data provider interface that allows you to retrieve stock market data from multiple providers, such as Yahoo Finance, Alpha Vantage, or Finnhub. The interface includes a built-in fallback mechanism to ensure reliable data access, even if a provider fails.
|
Investbrain includes an extensible market data provider interface that allows you to retrieve stock market data from multiple providers, such as [Yahoo Finance](https://finance.yahoo.com/), [Twelve Data](https://twelvedata.com), [Finnhub](https://finnhub.io/pricing-stock-api-market-data), [Alpaca](https://alpaca.markets/), and [Alpha Vantage](https://www.alphavantage.co/support/). The interface includes a built-in fallback mechanism to ensure reliable data access, even if a provider fails.
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
You can specify the market data provider you want to use in your .env file:
|
You can specify the market data provider you want to use in your environment variables:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
MARKET_DATA_PROVIDER=yahoo
|
MARKET_DATA_PROVIDER=yahoo
|
||||||
@@ -76,13 +84,13 @@ MARKET_DATA_PROVIDER=yahoo
|
|||||||
|
|
||||||
You can also use Investbrain's built-in fallback mechanism to ensure reliable data access. If any provider fails, Investbrain will automatically attempt to retrieve data from the next available provider, continuing through your configured providers until one returns successfully.
|
You can also use Investbrain's built-in fallback mechanism to ensure reliable data access. If any provider fails, Investbrain will automatically attempt to retrieve data from the next available provider, continuing through your configured providers until one returns successfully.
|
||||||
|
|
||||||
Your selected providers should be listed in your .env file. Each should be separated by a comma:
|
Your selected providers should be listed in your environment variables. Each should be separated by a comma:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
MARKET_DATA_PROVIDER=yahoo,alphavantage
|
MARKET_DATA_PROVIDER=yahoo,alphavantage
|
||||||
```
|
```
|
||||||
|
|
||||||
In the above example, Yahoo Finance will be attempted first and the Alpha Vantage provider will be used as the fallback. If Yahoo Finance fails to retrieve market data, the application will automatically try Alpha Vantage.
|
In the above example, Yahoo Finance will be attempted first. If Yahoo Finance fails to retrieve market data, the application will automatically try Alpha Vantage.
|
||||||
|
|
||||||
### Custom providers
|
### Custom providers
|
||||||
|
|
||||||
@@ -109,11 +117,11 @@ Feel free to submit a PR with any custom providers you create.
|
|||||||
|
|
||||||
## Import / Export
|
## Import / Export
|
||||||
|
|
||||||
Investbrain includes a convenient feature which allows you to import and export portfolios and transaction data.
|
Investbrain includes a convenient feature which allows you to maintain the portability of your portfolios and transaction data.
|
||||||
|
|
||||||
### Import
|
### Import
|
||||||
|
|
||||||
Imports are "upserted" to the database. If the record does not already exist in the database, the record will be created. However, when a portfolio or transaction exists (the record's ID matches an existing record), the record will be updated. This way, you can simultaneously create new records, but also bulk update records.
|
Imports are "upserted" to the database. If the record does not already exist in the database, the record will be created. However, when a portfolio or transaction exists (i.e. the record's ID matches an existing record), the record will be updated. This way, you can simultaneously create new records, but also bulk update records.
|
||||||
|
|
||||||
### Export
|
### Export
|
||||||
|
|
||||||
@@ -121,15 +129,19 @@ Exporting your portfolios and transactions is a convenient way to back-up your I
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
There are several optional configurations available when installing using the recommended [Docker method](#self-hosting). These options are configurable using an environment file. Changes can be made in your [.env](https://github.com/investbrainapp/investbrain/blob/main/.env.example) file before installation.
|
There are several optional configurations available when installing using the recommended [Docker method](#self-hosting). These options are configurable using an environment file. Configurations can be added to your [.env](https://github.com/investbrainapp/investbrain/blob/main/.env.example) file or to the `environment` property in the [docker-compose.yml](https://github.com/investbrainapp/investbrain/blob/main/docker-compose.yml) file.
|
||||||
|
|
||||||
| Option | Description | Default |
|
| Option | Description | Default |
|
||||||
| ------------- | ------------- | ------------- |
|
| ------------- | ------------- | ------------- |
|
||||||
| APP_URL | The URL where your Investbrain installation will be accessible | http://localhost |
|
| APP_URL | The URL where your Investbrain installation will be accessible | http://localhost |
|
||||||
| APP_PORT | The HTTP port exposed by the NGINX container | 8000 |
|
| APP_PORT | The HTTP port exposed by the NGINX container | 8000 |
|
||||||
| MARKET_DATA_PROVIDER | The market data provider to use (either `yahoo`, `alphavantage`, or `finnhub`) | yahoo |
|
| APP_KEY | Encryption key for various security-related functions | Set automatically during install |
|
||||||
|
| MARKET_DATA_PROVIDER | The market data provider to use (either `yahoo`, `twelvedata`, `alphavantage`, `alpaca`, or `finnhub`) | yahoo |
|
||||||
| ALPHAVANTAGE_API_KEY | If using the Alpha Vantage provider | `null` |
|
| ALPHAVANTAGE_API_KEY | If using the Alpha Vantage provider | `null` |
|
||||||
| FINNHUB_API_KEY | If using the Finnhub provider | `null` |
|
| FINNHUB_API_KEY | If using the Finnhub provider | `null` |
|
||||||
|
| ALPACA_API_KEY | If using the Alpaca provider | `null` |
|
||||||
|
| ALPACA_API_SECRET | If using the Alpaca provider | `null` |
|
||||||
|
| TWELVEDATA_API_SECRET | If using the Twelve Data provider | `null` |
|
||||||
| MARKET_DATA_REFRESH | Cadence to refresh market data in minutes | 30 |
|
| MARKET_DATA_REFRESH | Cadence to refresh market data in minutes | 30 |
|
||||||
| APP_TIMEZONE | Timezone for the application, including daily change captures | UTC |
|
| APP_TIMEZONE | Timezone for the application, including daily change captures | UTC |
|
||||||
| AI_CHAT_ENABLED | Whether to enable AI chat features | `false` |
|
| AI_CHAT_ENABLED | Whether to enable AI chat features | `false` |
|
||||||
@@ -141,7 +153,7 @@ There are several optional configurations available when installing using the re
|
|||||||
| REGISTRATION_ENABLED | Whether to enable registration of new users | `true` |
|
| REGISTRATION_ENABLED | Whether to enable registration of new users | `true` |
|
||||||
|
|
||||||
|
|
||||||
> Note: These options affect the [docker-compose.yml](https://github.com/investbrainapp/investbrain/blob/main/docker-compose.yml) file, so if you decide to make any changes to these default configurations, you'll have to restart the Docker containers before your changes take effect.
|
> Note: These options affect the [docker-compose.yml](https://github.com/investbrainapp/investbrain/blob/main/docker-compose.yml) file and are cached during run-time. If change any environment configurations, you'll have to restart the container before your changes take effect.
|
||||||
|
|
||||||
## Updating
|
## Updating
|
||||||
|
|
||||||
@@ -151,10 +163,10 @@ To update Investbrain using the recommended [Docker installation](#self-hosting)
|
|||||||
docker compose stop
|
docker compose stop
|
||||||
```
|
```
|
||||||
|
|
||||||
Then pull the latest updates from this repository using git:
|
Then pull the latest Docker image:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git pull
|
docker image pull investbrainapp/investbrain:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Finally bring the containers back up!
|
Finally bring the containers back up!
|
||||||
@@ -167,7 +179,7 @@ Easy as that!
|
|||||||
|
|
||||||
## Command line utilities
|
## Command line utilities
|
||||||
|
|
||||||
Investbrain comes bundled with several helpful command line utilities to make managing your portfolios and holdings more efficient. Keep in mind these commands are extremely powerful and can make irreversable changes to your holdings.
|
Investbrain comes bundled with several helpful command line utilities to make managing your portfolios and holdings more efficient. Keep in mind these commands are extremely powerful and can make irreversable changes to your holdings. Just to be safe, we recommend backing up your portfolios before using these commands.
|
||||||
|
|
||||||
To run these commands, you can use `docker exec` like this:
|
To run these commands, you can use `docker exec` like this:
|
||||||
|
|
||||||
@@ -175,16 +187,23 @@ To run these commands, you can use `docker exec` like this:
|
|||||||
docker exec -it investbrain-app php artisan <replace with command you want to run>
|
docker exec -it investbrain-app php artisan <replace with command you want to run>
|
||||||
```
|
```
|
||||||
|
|
||||||
Just to be safe, we recommend backing up your portfolios before using these commands:
|
If you need more details on what the command does, you can take a look at the options available using the `help` option:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
<command you want to run> --help
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ------------- | ------------- |
|
| ------------- | ------------- |
|
||||||
| refresh:market-data | Refreshes market data with your configured market data provider. |
|
| refresh:market-data | Refreshes market data with your configured market data provider. |
|
||||||
| refresh:dividend-data | Refreshes dividend data with your configured market data provider. Will also re-calculate your total dividends earned for each holding. |
|
| refresh:dividend-data | Refreshes dividend data with your configured market data provider. Will also re-calculate your total dividends earned for each holding. |
|
||||||
| refresh:split-data | Refreshes splits data with your configured market data provider. Will also create new transactions to account for any splits. |
|
| refresh:split-data | Refreshes splits data with your configured market data provider. Will also create new transactions to account for any splits. |
|
||||||
|
| refresh:currency-data | Grabs the latest daily currency exchange rate data and persists to the database. |
|
||||||
| capture:daily-change | Captures a snapshot of each portfolio's daily performance. |
|
| capture:daily-change | Captures a snapshot of each portfolio's daily performance. |
|
||||||
| sync:daily-change | Re-calculates daily snapshots of your portfolio's daily performance. Useful to fill in gaps in your portfolio charts. (Note: this is an extremely resource intensive query.) |
|
| sync:daily-change | Syncs daily snapshots of your portfolio's daily performance. Useful to fill in gaps in your portfolio charts. (Note: this is an extremely resource intensive query.) |
|
||||||
| sync:holdings | Re-calculates performance of holdings with related transactions (i.e. dividends, realized gains, etc). |
|
| sync:holdings | Syncs performance of holdings with related transactions (i.e. dividends, realized gains, etc). |
|
||||||
|
| fix:cost-basis-for-sales | Utility to automatically re-calculates cost basis for sale transactions. |
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
@@ -203,6 +222,14 @@ docker exec -it investbrain-app tail -f storage/logs/laravel.log
|
|||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|
||||||
|
**<summary>Application styling is broken and images are too big</summary>**
|
||||||
|
|
||||||
|
If you're serving Investbrain from a DNS name (e.g. example.com), it's likely that you haven't updated the `ASSET_URL` environment yet. The URL provided there will be used to generate absolute URLs for images, JS, and CSS assets on the front end of the application.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
|
||||||
**<summary>Market data not refreshing on fresh install</summary>**
|
**<summary>Market data not refreshing on fresh install</summary>**
|
||||||
|
|
||||||
If you're unable to refresh market data out of the box (i.e. your market data provider is set to Yahoo), there is a chance Yahoo is being blocked by a firewall or adblocker. Pihole is known to block `fc.yahoo.com` which is the domain used to query Yahoo.
|
If you're unable to refresh market data out of the box (i.e. your market data provider is set to Yahoo), there is a chance Yahoo is being blocked by a firewall or adblocker. Pihole is known to block `fc.yahoo.com` which is the domain used to query Yahoo.
|
||||||
|
|||||||
+3
-1
@@ -4,7 +4,9 @@
|
|||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
| ------- | ------------------ |
|
| ------- | ------------------ |
|
||||||
| 1.0.x | :white_check_mark: |
|
| 1.2.x | :white_check_mark: |
|
||||||
|
| 1.1.x | :x: |
|
||||||
|
| 1.0.x | :x: |
|
||||||
| < 1.0.0 | :x: |
|
| < 1.0.0 | :x: |
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Actions;
|
||||||
|
|
||||||
|
use App\Models\Currency;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class ConvertToMarketDataCurrency
|
||||||
|
{
|
||||||
|
public function __invoke(Model $model, callable $next)
|
||||||
|
{
|
||||||
|
if (is_null($model?->market_data)) {
|
||||||
|
|
||||||
|
$model->loadMarketData();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_null($model->currency) && $model->currency !== $model->market_data->currency) {
|
||||||
|
|
||||||
|
// convert to market data currency
|
||||||
|
$model->cost_basis = Currency::convert(
|
||||||
|
value: $model->cost_basis,
|
||||||
|
from: $model->currency,
|
||||||
|
to: $model->market_data->currency,
|
||||||
|
date: $model->date
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($model->transaction_type == 'SELL') {
|
||||||
|
|
||||||
|
$model->sale_price = Currency::convert(
|
||||||
|
value: $model->sale_price,
|
||||||
|
from: $model->currency,
|
||||||
|
to: $model->market_data->currency,
|
||||||
|
date: $model->date
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// currency cannot be saved to the database - we already know market_data.currency anyway
|
||||||
|
unset($model->currency);
|
||||||
|
|
||||||
|
return $next($model);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Actions;
|
||||||
|
|
||||||
|
use App\Casts\BaseCurrency;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class CopyToBaseCurrency
|
||||||
|
{
|
||||||
|
public function __invoke(Model $model, callable $next)
|
||||||
|
{
|
||||||
|
foreach ($model->getCasts() as $key => $value) {
|
||||||
|
if ($value === BaseCurrency::class) {
|
||||||
|
|
||||||
|
$model[$key] = $model[Str::beforeLast($key, '_base')];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($model);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Actions;
|
||||||
|
|
||||||
|
use App\Models\Transaction;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class EnsureCostBasisAddedToSale
|
||||||
|
{
|
||||||
|
public function __invoke(Model $model, callable $next)
|
||||||
|
{
|
||||||
|
// cost basis is required for sales to calculate realized gains
|
||||||
|
if ($model->transaction_type == 'SELL') {
|
||||||
|
|
||||||
|
$cost_basis = Transaction::where([
|
||||||
|
'portfolio_id' => $model->portfolio_id,
|
||||||
|
'symbol' => $model->symbol,
|
||||||
|
'transaction_type' => 'BUY',
|
||||||
|
])->whereDate('date', '<=', $model->date)
|
||||||
|
->selectRaw('SUM(transactions.cost_basis * transactions.quantity) as total_cost_basis')
|
||||||
|
->selectRaw('SUM(transactions.quantity) as total_quantity')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$average_cost_basis = empty($cost_basis->total_quantity)
|
||||||
|
? 0
|
||||||
|
: $cost_basis->total_cost_basis / $cost_basis->total_quantity;
|
||||||
|
|
||||||
|
$model->cost_basis = $average_cost_basis ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($model);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Actions;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
use function Illuminate\Support\defer;
|
||||||
|
|
||||||
|
class EnsureDailyChangeIsSynced
|
||||||
|
{
|
||||||
|
public function __invoke(Model $model, callable $next)
|
||||||
|
{
|
||||||
|
if (config('app.env') != 'testing') {
|
||||||
|
|
||||||
|
$cacheKey = 'daily_change_synced'.$model->portfolio_id;
|
||||||
|
|
||||||
|
if (
|
||||||
|
! Cache::has($cacheKey)
|
||||||
|
&& $model->date->lessThan(now())
|
||||||
|
&& ($model->date->lessThan($model->portfolio->daily_change()->min('date') ?? now())
|
||||||
|
|| $model->date->lessThan($model->portfolio->transactions()->where('id', '!=', $model->id)->max('date') ?? now())
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
defer(fn () => $model->portfolio->syncDailyChanges());
|
||||||
|
|
||||||
|
Cache::put($cacheKey, now(), now()->addMinutes(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($model);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Traits\WithTrimStrings;
|
use App\Traits\WithTrimStrings;
|
||||||
use Laravel\Jetstream\Jetstream;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||||
@@ -30,13 +31,22 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
|
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
|
||||||
'password' => $this->passwordRules(),
|
'password' => $this->passwordRules(),
|
||||||
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',
|
'terms' => config('investbrain.self_hosted') ? '' : ['accepted', 'required'],
|
||||||
])->validate();
|
])->validate();
|
||||||
|
|
||||||
return User::create([
|
$user = User::make([
|
||||||
'name' => $input['name'],
|
'name' => $input['name'],
|
||||||
'email' => $input['email'],
|
'email' => $input['email'],
|
||||||
'password' => Hash::make($input['password']),
|
'password' => Hash::make($input['password']),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// ensure first user is flagged as an admin
|
||||||
|
if (User::count() === 0) {
|
||||||
|
$user->admin = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
return $user;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Traits\WithTrimStrings;
|
use App\Traits\WithTrimStrings;
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
use Illuminate\Support\Facades\Validator;
|
|
||||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
||||||
|
|
||||||
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||||
{
|
{
|
||||||
use WithTrimStrings;
|
use WithTrimStrings;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate and update the given user's profile information.
|
* Validate and update the given user's profile information.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Actions\Jetstream;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Laravel\Jetstream\Contracts\DeletesUsers;
|
|
||||||
|
|
||||||
class DeleteUser implements DeletesUsers
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Delete the given user.
|
|
||||||
*/
|
|
||||||
public function delete(User $user): void
|
|
||||||
{
|
|
||||||
$user->deleteProfilePhoto();
|
|
||||||
$user->tokens->each->delete();
|
|
||||||
$user->delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Casts;
|
||||||
|
|
||||||
|
use App\Models\Currency;
|
||||||
|
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class BaseCurrency implements CastsAttributes
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Cast the given value to user's display currency
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $attributes
|
||||||
|
*/
|
||||||
|
public function get(Model $model, string $key, mixed $value, array $attributes): mixed
|
||||||
|
{
|
||||||
|
|
||||||
|
return (float) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare the given value for storage in base currency
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $attributes
|
||||||
|
*/
|
||||||
|
public function set(Model $model, string $key, mixed $value, array $attributes): mixed
|
||||||
|
{
|
||||||
|
|
||||||
|
// for market data and transactions the `currency` attribute is available...
|
||||||
|
// but for dividends and other types, need to make sure `market_data` is loaded
|
||||||
|
if (is_null($model?->currency)) {
|
||||||
|
|
||||||
|
$model->loadMarketData();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Currency::convert(
|
||||||
|
(float) $value,
|
||||||
|
$model?->currency ?? $model->market_data?->currency,
|
||||||
|
config('investbrain.base_currency'),
|
||||||
|
$model?->date
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Holding;
|
||||||
use App\Models\Portfolio;
|
use App\Models\Portfolio;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
@@ -38,27 +41,17 @@ class CaptureDailyChange extends Command
|
|||||||
*/
|
*/
|
||||||
public function handle()
|
public function handle()
|
||||||
{
|
{
|
||||||
Portfolio::with('holdings.market_data')->get()->each(function($portfolio){
|
Portfolio::with('holdings.market_data')->get()->each(function ($portfolio) {
|
||||||
|
|
||||||
$this->line('Capturing daily change for ' . $portfolio->title);
|
$this->line('Capturing daily change for '.$portfolio->title);
|
||||||
|
|
||||||
$total_cost_basis = $portfolio->holdings->sum('total_cost_basis');
|
$metrics = Holding::query()
|
||||||
|
->portfolio($portfolio->id)
|
||||||
$total_dividends = $portfolio->holdings->sum('dividends_earned');
|
->getPortfolioMetrics(config('investbrain.base_currency'));
|
||||||
|
|
||||||
$realized_gains = $portfolio->holdings->sum('realized_gain_dollars');
|
|
||||||
|
|
||||||
$total_market_value = $portfolio->holdings->sum(function($holding) {
|
|
||||||
return $holding->market_data->market_value * $holding->quantity;
|
|
||||||
});
|
|
||||||
|
|
||||||
$portfolio->daily_change()->create([
|
$portfolio->daily_change()->create([
|
||||||
'date' => now(),
|
'date' => now(),
|
||||||
'total_market_value' => $total_market_value,
|
'total_market_value' => $metrics->get('total_market_value'),
|
||||||
'total_cost_basis' => $total_cost_basis,
|
|
||||||
'total_gain' => $total_market_value - $total_cost_basis,
|
|
||||||
'total_dividends_earned' => $total_dividends,
|
|
||||||
'realized_gains' => $realized_gains
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Portfolio;
|
||||||
|
use App\Models\Transaction;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class FixCostBasisForSales extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'fix:cost-basis-for-sales
|
||||||
|
{--portfolio= : The ID of the portfolio to fix.}
|
||||||
|
{--user= : The user ID of transactions to fix.}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Fixes broken costs basis for sale transactions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new command instance.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
|
||||||
|
if (empty($this->option('user')) && empty($this->option('portfolio'))) {
|
||||||
|
|
||||||
|
$this->error('Must provide at least a user or portfolio.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$transactions = Transaction::where(['transaction_type' => 'SELL']);
|
||||||
|
|
||||||
|
if ($this->option('user')) {
|
||||||
|
|
||||||
|
$portfolios = Portfolio::fullAccess($this->option('user'))->get('id')
|
||||||
|
->pluck('id')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$transactions->whereIn('portfolio_id', $portfolios);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
$transactions->where(['portfolio_id' => $this->option('portfolio')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$transactions = $transactions->get();
|
||||||
|
|
||||||
|
$this->line("Fixing cost basis for {$transactions->count()} sale transactions...");
|
||||||
|
|
||||||
|
$transactions->chunk(10)->each(function ($chunk) {
|
||||||
|
|
||||||
|
dispatch(function () use ($chunk) {
|
||||||
|
|
||||||
|
$chunk->each(function ($transaction) {
|
||||||
|
|
||||||
|
$cost_basis = Transaction::where([
|
||||||
|
'portfolio_id' => $transaction->portfolio_id,
|
||||||
|
'symbol' => $transaction->symbol,
|
||||||
|
'transaction_type' => 'BUY',
|
||||||
|
])->whereDate('date', '<=', $transaction->date)
|
||||||
|
->selectRaw('SUM(transactions.cost_basis * transactions.quantity) as total_cost_basis')
|
||||||
|
->selectRaw('SUM(transactions.quantity) as total_quantity')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$average_cost_basis = empty($cost_basis->total_quantity)
|
||||||
|
? 0
|
||||||
|
: $cost_basis->total_cost_basis / $cost_basis->total_quantity;
|
||||||
|
|
||||||
|
$transaction->cost_basis = $average_cost_basis ?? 0;
|
||||||
|
|
||||||
|
$transaction->save();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->line('Done!');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\CurrencyRate;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class RefreshCurrencyData extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'refresh:currency-data
|
||||||
|
{--force : Refresh of currency data}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Refresh currency data from data provider';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new command instance.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
|
||||||
|
CurrencyRate::refreshCurrencyData($this->option('force') ?? false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\Holding;
|
|
||||||
use App\Models\Dividend;
|
use App\Models\Dividend;
|
||||||
|
use App\Models\Holding;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class RefreshDividendData extends Command
|
class RefreshDividendData extends Command
|
||||||
@@ -43,17 +45,17 @@ class RefreshDividendData extends Command
|
|||||||
{
|
{
|
||||||
$holdings = Holding::distinct();
|
$holdings = Holding::distinct();
|
||||||
|
|
||||||
if (!($this->option('force') ?? false)) {
|
if (! ($this->option('force') ?? false)) {
|
||||||
$holdings->where('quantity', '>', 0);
|
$holdings->where('quantity', '>', 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->option('user')) {
|
if ($this->option('user')) {
|
||||||
$holdings->myHoldings($this->option('user'));
|
$holdings->myHoldings($this->option('user'));
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($holdings->get(['symbol']) as $holding) {
|
foreach ($holdings->get(['symbol']) as $holding) {
|
||||||
$this->line('Refreshing ' . $holding->symbol);
|
$this->line('Refreshing '.$holding->symbol);
|
||||||
|
|
||||||
Dividend::refreshDividendData($holding->symbol);
|
Dividend::refreshDividendData($holding->symbol);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\Holding;
|
use App\Models\Holding;
|
||||||
@@ -42,20 +44,24 @@ class RefreshMarketData extends Command
|
|||||||
public function handle()
|
public function handle()
|
||||||
{
|
{
|
||||||
$force = $this->option('force') ?? false;
|
$force = $this->option('force') ?? false;
|
||||||
|
|
||||||
// get all symbols from market data
|
// get all symbols from market data
|
||||||
$holdings = Holding::where('quantity', '>', 0)
|
$holdings = Holding::where('quantity', '>', 0)
|
||||||
->select(['symbol'])
|
->select(['symbol'])
|
||||||
->distinct();
|
->distinct();
|
||||||
|
|
||||||
if ($this->option('user')) {
|
if ($this->option('user')) {
|
||||||
$holdings->myHoldings($this->option('user'));
|
$holdings->myHoldings($this->option('user'));
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($holdings->get() as $holding) {
|
foreach ($holdings->get() as $holding) {
|
||||||
$this->line('Refreshing ' . $holding->symbol);
|
$this->line('Refreshing '.$holding->symbol);
|
||||||
|
|
||||||
MarketData::getMarketData($holding->symbol, $force);
|
try {
|
||||||
|
MarketData::getMarketData($holding->symbol, $force);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->line('Could not refresh '.$holding->symbol.' ('.$e->getMessage().')');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\Split;
|
|
||||||
use App\Models\Holding;
|
use App\Models\Holding;
|
||||||
|
use App\Models\Split;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class RefreshSplitData extends Command
|
class RefreshSplitData extends Command
|
||||||
@@ -42,14 +44,14 @@ class RefreshSplitData extends Command
|
|||||||
{
|
{
|
||||||
$holdings = Holding::distinct();
|
$holdings = Holding::distinct();
|
||||||
|
|
||||||
if (!($this->option('force') ?? false)) {
|
if (! ($this->option('force') ?? false)) {
|
||||||
$holdings->where('quantity', '>', 0);
|
$holdings->where('quantity', '>', 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($holdings->get(['symbol']) as $holding) {
|
foreach ($holdings->get(['symbol']) as $holding) {
|
||||||
$this->line('Refreshing ' . $holding->symbol);
|
$this->line('Refreshing '.$holding->symbol);
|
||||||
|
|
||||||
Split::refreshSplitData($holding->symbol);
|
Split::refreshSplitData($holding->symbol);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\Portfolio;
|
use App\Models\Portfolio;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Contracts\Console\PromptsForMissingInput;
|
use Illuminate\Contracts\Console\PromptsForMissingInput;
|
||||||
|
|
||||||
use function Laravel\Prompts\search;
|
use function Laravel\Prompts\search;
|
||||||
|
|
||||||
class SyncDailyChange extends Command implements PromptsForMissingInput
|
class SyncDailyChange extends Command implements PromptsForMissingInput
|
||||||
@@ -61,14 +64,14 @@ class SyncDailyChange extends Command implements PromptsForMissingInput
|
|||||||
public function handle()
|
public function handle()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|
||||||
$portfolio = Portfolio::findOrFail($this->argument('portfolio_id'));
|
$portfolio = Portfolio::findOrFail($this->argument('portfolio_id'));
|
||||||
|
|
||||||
$this->line('Syncing daily change history... This may take a moment.');
|
$this->line('Syncing daily change history... This may take a moment.');
|
||||||
|
|
||||||
$portfolio->syncDailyChanges();
|
$portfolio->syncDailyChanges();
|
||||||
|
|
||||||
$this->line('Awesome! Daily change history for '. $portfolio->title .' has been completed.');
|
$this->line('Awesome! Daily change history for '.$portfolio->title.' has been completed.');
|
||||||
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\Holding;
|
use App\Models\Holding;
|
||||||
@@ -47,7 +49,7 @@ class SyncHoldingData extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($holdings->get() as $holding) {
|
foreach ($holdings->get() as $holding) {
|
||||||
$this->line('Refreshing ' . $holding->symbol);
|
$this->line('Refreshing '.$holding->symbol);
|
||||||
|
|
||||||
$holding->syncTransactionsAndDividends();
|
$holding->syncTransactionsAndDividends();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Exports;
|
namespace App\Exports;
|
||||||
|
|
||||||
|
use App\Exports\Sheets\ConfigSheet;
|
||||||
use App\Exports\Sheets\DailyChangesSheet;
|
use App\Exports\Sheets\DailyChangesSheet;
|
||||||
use App\Exports\Sheets\PortfoliosSheet;
|
use App\Exports\Sheets\PortfoliosSheet;
|
||||||
use App\Exports\Sheets\TransactionsSheet;
|
use App\Exports\Sheets\TransactionsSheet;
|
||||||
@@ -14,18 +17,15 @@ class BackupExport implements WithMultipleSheets
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public bool $empty = false
|
public bool $empty = false
|
||||||
)
|
) {}
|
||||||
{ }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array
|
|
||||||
*/
|
|
||||||
public function sheets(): array
|
public function sheets(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
new PortfoliosSheet($this->empty),
|
new PortfoliosSheet($this->empty),
|
||||||
new TransactionsSheet($this->empty),
|
new TransactionsSheet($this->empty),
|
||||||
new DailyChangesSheet($this->empty)
|
new DailyChangesSheet($this->empty),
|
||||||
];
|
new ConfigSheet($this->empty),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exports\Sheets;
|
||||||
|
|
||||||
|
use App\Models\Holding;
|
||||||
|
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||||
|
|
||||||
|
class ConfigSheet implements FromCollection, WithHeadings, WithTitle
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public bool $empty = false
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function headings(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Key',
|
||||||
|
'Value',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \Illuminate\Support\Collection
|
||||||
|
*/
|
||||||
|
public function collection()
|
||||||
|
{
|
||||||
|
$configs = collect();
|
||||||
|
|
||||||
|
if ($this->empty) {
|
||||||
|
return $configs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// collect user settings
|
||||||
|
$configs->push([
|
||||||
|
'key' => 'name',
|
||||||
|
'value' => auth()->user()->name,
|
||||||
|
], [
|
||||||
|
'key' => 'locale',
|
||||||
|
'value' => auth()->user()->getLocale(),
|
||||||
|
], [
|
||||||
|
'key' => 'display_currency',
|
||||||
|
'value' => auth()->user()->getCurrency(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// reinvested holdings
|
||||||
|
$reinvested_holdings = Holding::myHoldings()->where('reinvest_dividends', true)->get(['portfolio_id', 'symbol']);
|
||||||
|
if ($reinvested_holdings->isNotEmpty()) {
|
||||||
|
$configs->push([
|
||||||
|
'key' => 'reinvested_dividends',
|
||||||
|
'value' => $reinvested_holdings->toJson(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $configs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function title(): string
|
||||||
|
{
|
||||||
|
return 'Config';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Exports\Sheets;
|
namespace App\Exports\Sheets;
|
||||||
|
|
||||||
use App\Models\DailyChange;
|
use App\Models\DailyChange;
|
||||||
@@ -11,7 +13,7 @@ class DailyChangesSheet implements FromCollection, WithHeadings, WithTitle
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public bool $empty = false
|
public bool $empty = false
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
public function headings(): array
|
public function headings(): array
|
||||||
{
|
{
|
||||||
@@ -20,24 +22,37 @@ class DailyChangesSheet implements FromCollection, WithHeadings, WithTitle
|
|||||||
'Portfolio ID',
|
'Portfolio ID',
|
||||||
'Total Market Value',
|
'Total Market Value',
|
||||||
'Total Cost Basis',
|
'Total Cost Basis',
|
||||||
'Total Gain',
|
|
||||||
'Total Dividends Earned',
|
|
||||||
'Realized Gains',
|
'Realized Gains',
|
||||||
'Annotation'
|
'Total Dividends Earned',
|
||||||
|
'Annotation',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return \Illuminate\Support\Collection
|
* @return \Illuminate\Support\Collection
|
||||||
*/
|
*/
|
||||||
public function collection()
|
public function collection()
|
||||||
{
|
{
|
||||||
return $this->empty ? collect() : DailyChange::myDailyChanges()->get();
|
if ($this->empty) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return DailyChange::myDailyChanges()
|
||||||
|
->withDailyPerformance()
|
||||||
|
->get()
|
||||||
|
->map(function ($daily_change) {
|
||||||
|
return [
|
||||||
|
'date' => date_format($daily_change->date, 'Y-m-d'),
|
||||||
|
'portfolio_id' => $daily_change->portfolio_id,
|
||||||
|
'total_market_value' => $daily_change->total_market_value,
|
||||||
|
'total_cost_basis' => $daily_change->total_cost_basis,
|
||||||
|
'realized_gains' => $daily_change->realized_gain_dollars,
|
||||||
|
'total_dividends_earned' => $daily_change->total_dividends_earned,
|
||||||
|
'annotation' => $daily_change->annotation,
|
||||||
|
];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
public function title(): string
|
public function title(): string
|
||||||
{
|
{
|
||||||
return 'Daily Changes';
|
return 'Daily Changes';
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Exports\Sheets;
|
namespace App\Exports\Sheets;
|
||||||
|
|
||||||
use App\Models\Portfolio;
|
use App\Models\Portfolio;
|
||||||
@@ -11,8 +13,8 @@ class PortfoliosSheet implements FromCollection, WithHeadings, WithTitle
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public bool $empty = false
|
public bool $empty = false
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
public function headings(): array
|
public function headings(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -21,21 +23,18 @@ class PortfoliosSheet implements FromCollection, WithHeadings, WithTitle
|
|||||||
'Notes',
|
'Notes',
|
||||||
'Wishlist',
|
'Wishlist',
|
||||||
'Created',
|
'Created',
|
||||||
'Updated'
|
'Updated',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return \Illuminate\Support\Collection
|
* @return \Illuminate\Support\Collection
|
||||||
*/
|
*/
|
||||||
public function collection()
|
public function collection()
|
||||||
{
|
{
|
||||||
return $this->empty ? collect() : Portfolio::myPortfolios()->get();
|
return $this->empty ? collect() : Portfolio::myPortfolios()->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
public function title(): string
|
public function title(): string
|
||||||
{
|
{
|
||||||
return 'Portfolios';
|
return 'Portfolios';
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Exports\Sheets;
|
namespace App\Exports\Sheets;
|
||||||
|
|
||||||
use App\Models\Transaction;
|
use App\Models\Transaction;
|
||||||
use Maatwebsite\Excel\Concerns\WithTitle;
|
|
||||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
|
||||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||||
|
|
||||||
class TransactionsSheet implements FromCollection, WithHeadings, WithTitle
|
class TransactionsSheet implements FromCollection, WithHeadings, WithTitle
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public bool $empty = false
|
public bool $empty = false
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
public function headings(): array
|
public function headings(): array
|
||||||
{
|
{
|
||||||
@@ -23,25 +25,46 @@ class TransactionsSheet implements FromCollection, WithHeadings, WithTitle
|
|||||||
'Quantity',
|
'Quantity',
|
||||||
'Cost Basis',
|
'Cost Basis',
|
||||||
'Sale Price',
|
'Sale Price',
|
||||||
|
'Currency',
|
||||||
'Split',
|
'Split',
|
||||||
'Reinvested Dividend',
|
'Reinvested Dividend',
|
||||||
'Date',
|
'Date',
|
||||||
'Created',
|
'Created',
|
||||||
'Updated'
|
'Updated',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return \Illuminate\Support\Collection
|
* @return \Illuminate\Support\Collection
|
||||||
*/
|
*/
|
||||||
public function collection()
|
public function collection()
|
||||||
{
|
{
|
||||||
return $this->empty ? collect() : Transaction::myTransactions()->get();
|
if ($this->empty) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Transaction::myTransactions()
|
||||||
|
->withMarketData()
|
||||||
|
->get()
|
||||||
|
->map(function ($transaction) {
|
||||||
|
return [
|
||||||
|
'id' => $transaction->id,
|
||||||
|
'symbol' => $transaction->symbol,
|
||||||
|
'portfolio_id' => $transaction->portfolio_id,
|
||||||
|
'transaction_type' => $transaction->transaction_type,
|
||||||
|
'quantity' => $transaction->quantity,
|
||||||
|
'cost_basis' => $transaction->cost_basis,
|
||||||
|
'sale_price' => $transaction->sale_price,
|
||||||
|
'currency' => $transaction->market_data_currency,
|
||||||
|
'split' => $transaction->split,
|
||||||
|
'reinvested_dividend' => $transaction->reinvested_dividend,
|
||||||
|
'date' => date_format($transaction->date, 'Y-m-d'),
|
||||||
|
'created_at' => $transaction->created_at,
|
||||||
|
'updated_at' => $transaction->updated_at,
|
||||||
|
];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
public function title(): string
|
public function title(): string
|
||||||
{
|
{
|
||||||
return 'Transactions';
|
return 'Transactions';
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\ApiControllers;
|
||||||
|
|
||||||
|
abstract class Controller
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\ApiControllers;
|
||||||
|
|
||||||
|
use App\Http\ApiControllers\Controller as ApiController;
|
||||||
|
use App\Http\Requests\HoldingRequest;
|
||||||
|
use App\Http\Resources\HoldingResource;
|
||||||
|
use App\Models\Holding;
|
||||||
|
use App\Models\Portfolio;
|
||||||
|
use HackerEsq\FilterModels\FilterModels;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
|
class HoldingController extends ApiController
|
||||||
|
{
|
||||||
|
public function index(FilterModels $filters)
|
||||||
|
{
|
||||||
|
|
||||||
|
$filters->setQuery(Holding::query());
|
||||||
|
$filters->setScopes(['myHoldings']);
|
||||||
|
$filters->setEagerRelations(['market_data', 'transactions']);
|
||||||
|
$filters->setSearchableColumns(['symbol']);
|
||||||
|
|
||||||
|
return HoldingResource::collection($filters->paginated());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Portfolio $portfolio, string $symbol)
|
||||||
|
{
|
||||||
|
|
||||||
|
Gate::authorize('readOnly', $portfolio);
|
||||||
|
|
||||||
|
$holding = $portfolio->holdings()->symbol($symbol)->firstOrFail();
|
||||||
|
|
||||||
|
return HoldingResource::make($holding);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(HoldingRequest $request, Portfolio $portfolio, string $symbol)
|
||||||
|
{
|
||||||
|
|
||||||
|
Gate::authorize('fullAccess', $portfolio);
|
||||||
|
|
||||||
|
$holding = $portfolio->holdings()->symbol($symbol)->firstOrFail();
|
||||||
|
|
||||||
|
$holding->update($request->validated());
|
||||||
|
|
||||||
|
return HoldingResource::make($holding);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\ApiControllers;
|
||||||
|
|
||||||
|
use App\Http\ApiControllers\Controller as ApiController;
|
||||||
|
use App\Http\Resources\MarketDataResource;
|
||||||
|
use App\Models\MarketData;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class MarketDataController extends ApiController
|
||||||
|
{
|
||||||
|
public function show(Request $request, string $symbol)
|
||||||
|
{
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
return MarketDataResource::make(
|
||||||
|
MarketData::getMarketData($symbol)
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
|
||||||
|
return response([
|
||||||
|
'message' => 'Symbol '.$symbol.' not found.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\ApiControllers;
|
||||||
|
|
||||||
|
use App\Http\ApiControllers\Controller as ApiController;
|
||||||
|
use App\Http\Requests\PortfolioRequest;
|
||||||
|
use App\Http\Resources\PortfolioResource;
|
||||||
|
use App\Models\Portfolio;
|
||||||
|
use HackerEsq\FilterModels\FilterModels;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
|
class PortfolioController extends ApiController
|
||||||
|
{
|
||||||
|
public function index(FilterModels $filters)
|
||||||
|
{
|
||||||
|
$filters->setQuery(Portfolio::query());
|
||||||
|
$filters->setScopes(['myPortfolios']);
|
||||||
|
$filters->setEagerRelations(['users', 'transactions', 'holdings']);
|
||||||
|
$filters->setFilterableRelations(['holdings.symbol']);
|
||||||
|
$filters->setSearchableColumns(['title', 'notes']);
|
||||||
|
|
||||||
|
return PortfolioResource::collection($filters->paginated());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(PortfolioRequest $request)
|
||||||
|
{
|
||||||
|
$portfolio = Portfolio::create($request->validated());
|
||||||
|
|
||||||
|
return PortfolioResource::make($portfolio);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Portfolio $portfolio)
|
||||||
|
{
|
||||||
|
Gate::authorize('readOnly', $portfolio);
|
||||||
|
|
||||||
|
return PortfolioResource::make($portfolio);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(PortfolioRequest $request, Portfolio $portfolio)
|
||||||
|
{
|
||||||
|
Gate::authorize('fullAccess', $portfolio);
|
||||||
|
|
||||||
|
$portfolio->update($request->validated());
|
||||||
|
|
||||||
|
return PortfolioResource::make($portfolio);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Portfolio $portfolio)
|
||||||
|
{
|
||||||
|
Gate::authorize('fullAccess', $portfolio);
|
||||||
|
|
||||||
|
$portfolio->delete();
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\ApiControllers;
|
||||||
|
|
||||||
|
use App\Http\ApiControllers\Controller as ApiController;
|
||||||
|
use App\Http\Requests\TransactionRequest;
|
||||||
|
use App\Http\Resources\TransactionResource;
|
||||||
|
use App\Models\Transaction;
|
||||||
|
use HackerEsq\FilterModels\FilterModels;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
|
class TransactionController extends ApiController
|
||||||
|
{
|
||||||
|
public function index(FilterModels $filters)
|
||||||
|
{
|
||||||
|
|
||||||
|
$filters->setQuery(Transaction::query());
|
||||||
|
$filters->setScopes(['myTransactions']);
|
||||||
|
$filters->setEagerRelations(['market_data']);
|
||||||
|
$filters->setSearchableColumns(['symbol']);
|
||||||
|
|
||||||
|
return TransactionResource::collection($filters->paginated());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(TransactionRequest $request)
|
||||||
|
{
|
||||||
|
Gate::authorize('fullAccess', $request->portfolio);
|
||||||
|
|
||||||
|
$transaction = Transaction::create($request->validated());
|
||||||
|
|
||||||
|
return TransactionResource::make($transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Transaction $transaction)
|
||||||
|
{
|
||||||
|
Gate::authorize('readOnly', $transaction->portfolio);
|
||||||
|
|
||||||
|
return TransactionResource::make($transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(TransactionRequest $request, Transaction $transaction)
|
||||||
|
{
|
||||||
|
Gate::authorize('fullAccess', $transaction->portfolio);
|
||||||
|
|
||||||
|
$transaction->update($request->validated());
|
||||||
|
|
||||||
|
return TransactionResource::make($transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Transaction $transaction)
|
||||||
|
{
|
||||||
|
Gate::authorize('fullAccess', $transaction->portfolio);
|
||||||
|
|
||||||
|
$transaction->delete();
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\ApiControllers;
|
||||||
|
|
||||||
|
use App\Http\ApiControllers\Controller as ApiController;
|
||||||
|
use App\Http\Resources\UserResource;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class UserController extends ApiController
|
||||||
|
{
|
||||||
|
public function me(Request $request)
|
||||||
|
{
|
||||||
|
return UserResource::make($request->user());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Routing\Controller;
|
||||||
|
|
||||||
|
class ApiTokenController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Show the user API token screen.
|
||||||
|
*
|
||||||
|
* @return \Illuminate\View\View
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
return view('api.index', [
|
||||||
|
'request' => $request,
|
||||||
|
'user' => $request->user(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,22 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use Exception;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\ConnectedAccount;
|
use App\Models\ConnectedAccount;
|
||||||
use Illuminate\Support\MessageBag;
|
use App\Models\User;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Notifications\VerifyConnectedAccountNotification;
|
||||||
|
use Exception;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
use Illuminate\Support\MessageBag;
|
||||||
use Laravel\Socialite\Facades\Socialite;
|
use Laravel\Socialite\Facades\Socialite;
|
||||||
use App\Notifications\VerifyConnectedAccountNotification;
|
|
||||||
|
|
||||||
class ConnectedAccountController extends Controller
|
class ConnectedAccountController extends Controller
|
||||||
{
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redirect the user to the GitHub authentication page.
|
* Redirect the user to the GitHub authentication page.
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
public function redirectToProvider(string $provider)
|
public function redirectToProvider(string $provider)
|
||||||
{
|
{
|
||||||
@@ -28,7 +27,6 @@ class ConnectedAccountController extends Controller
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Obtain the user information from GitHub.
|
* Obtain the user information from GitHub.
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
public function handleProviderCallback(string $provider)
|
public function handleProviderCallback(string $provider)
|
||||||
{
|
{
|
||||||
@@ -45,21 +43,21 @@ class ConnectedAccountController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// check if this account is already linked
|
// check if this account is already linked
|
||||||
$connected_account = ConnectedAccount::firstOrNew([
|
$connected_account = ConnectedAccount::firstOrNew([
|
||||||
'provider' => $provider,
|
'provider' => $provider,
|
||||||
'provider_id' => $providerUser->id
|
'provider_id' => $providerUser->id,
|
||||||
], [
|
], [
|
||||||
'token' => $providerUser->token,
|
'token' => $providerUser->token,
|
||||||
'secret' => $providerUser->tokenSecret,
|
'secret' => $providerUser->tokenSecret,
|
||||||
'refresh_token' => $providerUser->refreshToken,
|
'refresh_token' => $providerUser->refreshToken,
|
||||||
'expires_at' => $providerUser->expiresIn,
|
'expires_at' => $providerUser->expiresIn,
|
||||||
'verified_at' => false
|
'verified_at' => false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// already linked and verified, let's go login!
|
// already linked and verified, let's go login!
|
||||||
if (
|
if (
|
||||||
$connected_account->exists
|
$connected_account->exists
|
||||||
&& !is_null($connected_account->verified_at)
|
&& ! is_null($connected_account->verified_at)
|
||||||
) {
|
) {
|
||||||
|
|
||||||
Auth::login($connected_account->user, true);
|
Auth::login($connected_account->user, true);
|
||||||
@@ -68,20 +66,20 @@ class ConnectedAccountController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// new user, let's create one
|
// new user, let's create one
|
||||||
if (!$user = User::where('email', $providerUser->email)->first()) {
|
if (! $user = User::where('email', $providerUser->email)->first()) {
|
||||||
|
|
||||||
$user = User::create([
|
$user = User::create([
|
||||||
'name' => $providerUser->name,
|
'name' => $providerUser->name,
|
||||||
'email' => $providerUser->email,
|
'email' => $providerUser->email,
|
||||||
'email_verified_at' => now()
|
'email_verified_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$connected_account->user_id = $user->id;
|
$connected_account->user_id = $user->id;
|
||||||
$connected_account->verified_at = now();
|
$connected_account->verified_at = now();
|
||||||
$connected_account->save();
|
$connected_account->save();
|
||||||
|
|
||||||
Auth::login($user, true);
|
Auth::login($user, true);
|
||||||
|
|
||||||
return redirect(route('dashboard'));
|
return redirect(route('dashboard'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,23 +90,23 @@ class ConnectedAccountController extends Controller
|
|||||||
$user->notify(new VerifyConnectedAccountNotification($connected_account->id));
|
$user->notify(new VerifyConnectedAccountNotification($connected_account->id));
|
||||||
|
|
||||||
return redirect(route('login'))
|
return redirect(route('login'))
|
||||||
->with('status', __(
|
->with('status', __(
|
||||||
'Account already exists. Check your email to connect your :provider account.',
|
'Account already exists. Check your email to connect your :provider account.',
|
||||||
['provider' => config("services.$provider.name")]
|
['provider' => config("services.$provider.name")]
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function validateProvider($provider): void
|
protected function validateProvider($provider): void
|
||||||
{
|
{
|
||||||
if (!in_array($provider, explode(',', config('services.enabled_login_providers')))) {
|
if (! in_array($provider, explode(',', config('services.enabled_login_providers')))) {
|
||||||
|
|
||||||
throw new Exception('Please provide a valid social provider.');
|
throw new Exception('Please provide a valid social provider.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function verify(ConnectedAccount $connected_account)
|
public function verify(ConnectedAccount $connected_account)
|
||||||
{
|
{
|
||||||
if (!$connected_account->verified_at) {
|
if (! $connected_account->verified_at) {
|
||||||
|
|
||||||
// mark request as verified
|
// mark request as verified
|
||||||
$connected_account->verified_at = now();
|
$connected_account->verified_at = now();
|
||||||
@@ -126,10 +124,10 @@ class ConnectedAccountController extends Controller
|
|||||||
'title' => __('Your :provider account has been connected.', ['provider' => config("services.{$connected_account->provider}.name")]),
|
'title' => __('Your :provider account has been connected.', ['provider' => config("services.{$connected_account->provider}.name")]),
|
||||||
'description' => null,
|
'description' => null,
|
||||||
'css' => 'alert-success',
|
'css' => 'alert-success',
|
||||||
'icon' => Blade::render("<x-mary-icon class='w-7 h-7' name='o-check-circle' />"),
|
'icon' => Blade::render("<x-ui.icon class='w-7 h-7' name='o-check-circle' />"),
|
||||||
'position' => 'toast-top toast-end',
|
'position' => 'toast-top toast-end',
|
||||||
'timeout' => '5000'
|
'timeout' => '5000',
|
||||||
]
|
],
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
abstract class Controller
|
abstract class Controller
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Holding;
|
use App\Models\Holding;
|
||||||
@@ -15,16 +17,14 @@ class DashboardController extends Controller
|
|||||||
$user = $request->user()->load(['portfolios', 'holdings', 'transactions']);
|
$user = $request->user()->load(['portfolios', 'holdings', 'transactions']);
|
||||||
|
|
||||||
// get portfolio metrics
|
// get portfolio metrics
|
||||||
$metrics = cache()->remember(
|
$metrics = cache()->tags(['metrics-'.$user->id])->remember(
|
||||||
'dashboard-metrics-' . $user->id,
|
'dashboard-metrics-'.$user->id,
|
||||||
10,
|
10,
|
||||||
function () {
|
function () {
|
||||||
return
|
return Holding::query()
|
||||||
Holding::query()
|
|
||||||
->myHoldings()
|
->myHoldings()
|
||||||
->withoutWishlists()
|
->withoutWishlists()
|
||||||
->withPortfolioMetrics()
|
->getPortfolioMetrics();
|
||||||
->first();
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Holding;
|
use App\Models\Holding;
|
||||||
@@ -8,21 +10,20 @@ use Illuminate\Http\Request;
|
|||||||
|
|
||||||
class HoldingController extends Controller
|
class HoldingController extends Controller
|
||||||
{
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display the specified resource.
|
* Display the specified resource.
|
||||||
*/
|
*/
|
||||||
public function show(Request $request, Portfolio $portfolio, String $symbol)
|
public function show(Request $request, Portfolio $portfolio, string $symbol)
|
||||||
{
|
{
|
||||||
$holding = Holding::with([
|
$holding = Holding::with([
|
||||||
'market_data',
|
'market_data',
|
||||||
'transactions' => function ($query) use ($symbol) {
|
'transactions' => function ($query) use ($symbol) {
|
||||||
$query->where('transactions.symbol', $symbol);
|
$query->where('transactions.symbol', $symbol);
|
||||||
}
|
},
|
||||||
])
|
])
|
||||||
->symbol($symbol)
|
->symbol($symbol)
|
||||||
->portfolio($portfolio->id)
|
->portfolio($portfolio->id)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
$formattedTransactions = $holding->getFormattedTransactions();
|
$formattedTransactions = $holding->getFormattedTransactions();
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Portfolio;
|
use App\Models\Portfolio;
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
|
|
||||||
class InvitedOnboardingController extends Controller
|
class InvitedOnboardingController extends Controller
|
||||||
{
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the invited user needs a password?
|
* Check if the invited user needs a password?
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
public function __invoke(Request $request, Portfolio $portfolio, User $user)
|
public function __invoke(Request $request, Portfolio $portfolio, User $user)
|
||||||
{
|
{
|
||||||
|
|
||||||
if (!$request->hasValidSignature()) {
|
if (! $request->hasValidSignature()) {
|
||||||
abort(401, 'Invalid signature');
|
abort(401, 'Invalid signature');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +26,7 @@ class InvitedOnboardingController extends Controller
|
|||||||
// route to create password form
|
// route to create password form
|
||||||
return view('auth.invited-onboarding', [
|
return view('auth.invited-onboarding', [
|
||||||
'portfolio' => $portfolio,
|
'portfolio' => $portfolio,
|
||||||
'user' => $user
|
'user' => $user,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Holding;
|
use App\Models\Holding;
|
||||||
use App\Models\Portfolio;
|
use App\Models\Portfolio;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class PortfolioController extends Controller
|
class PortfolioController extends Controller
|
||||||
{
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show the form for creating a new resource.
|
* Show the form for creating a new resource.
|
||||||
*/
|
*/
|
||||||
@@ -22,26 +24,23 @@ class PortfolioController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function show(Request $request, Portfolio $portfolio)
|
public function show(Request $request, Portfolio $portfolio)
|
||||||
{
|
{
|
||||||
if ($request->user()->cannot('readOnly', $portfolio)) {
|
Gate::authorize('readOnly', $portfolio);
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$portfolio->load(['transactions', 'holdings']);
|
$portfolio->load(['transactions', 'holdings']);
|
||||||
|
|
||||||
// get portfolio metrics
|
// get portfolio metrics
|
||||||
$metrics = cache()->remember(
|
$metrics = cache()->tags(['metrics-'.$request->user()->id])->remember(
|
||||||
'portfolio-metrics-' . $portfolio->id,
|
'portfolio-metrics-'.$portfolio->id,
|
||||||
60,
|
60,
|
||||||
function () use ($portfolio) {
|
function () use ($portfolio) {
|
||||||
return Holding::query()
|
return Holding::query()
|
||||||
->portfolio($portfolio->id)
|
->portfolio($portfolio->id)
|
||||||
->withPortfolioMetrics()
|
->getPortfolioMetrics();
|
||||||
->first();
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
$formattedHoldings = $portfolio->getFormattedHoldings();
|
$formattedHoldings = $portfolio->getFormattedHoldings();
|
||||||
|
|
||||||
return view('portfolio.show', compact(['portfolio', 'metrics', 'formattedHoldings']));
|
return view('portfolio.show', compact(['portfolio', 'metrics', 'formattedHoldings']));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Traits\HasLocalizedMarkdown;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Routing\Controller;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class PrivacyPolicyController extends Controller
|
||||||
|
{
|
||||||
|
use HasLocalizedMarkdown;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the privacy policy for the application.
|
||||||
|
*
|
||||||
|
* @return \Illuminate\View\View
|
||||||
|
*/
|
||||||
|
public function show(Request $request)
|
||||||
|
{
|
||||||
|
$policyFile = $this->localizedMarkdownPath('policy.md');
|
||||||
|
|
||||||
|
return view('policy', [
|
||||||
|
'policy' => Str::markdown(file_get_contents($policyFile)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Traits\HasLocalizedMarkdown;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Routing\Controller;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class TermsOfServiceController extends Controller
|
||||||
|
{
|
||||||
|
use HasLocalizedMarkdown;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the terms of service for the application.
|
||||||
|
*
|
||||||
|
* @return \Illuminate\View\View
|
||||||
|
*/
|
||||||
|
public function show(Request $request)
|
||||||
|
{
|
||||||
|
$termsFile = $this->localizedMarkdownPath('terms.md');
|
||||||
|
|
||||||
|
return view('terms', [
|
||||||
|
'terms' => Str::markdown(file_get_contents($termsFile)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
class TransactionController extends Controller
|
class TransactionController extends Controller
|
||||||
{
|
{
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display the specified resource.
|
* Display the specified resource.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Routing\Controller;
|
||||||
|
|
||||||
|
class UserProfileController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Show the user profile screen.
|
||||||
|
*
|
||||||
|
* @return \Illuminate\View\View
|
||||||
|
*/
|
||||||
|
public function show(Request $request)
|
||||||
|
{
|
||||||
|
return view('profile.show', [
|
||||||
|
'request' => $request,
|
||||||
|
'user' => $request->user(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Number;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class LocalizationMiddleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next)
|
||||||
|
{
|
||||||
|
if (Auth::check()) {
|
||||||
|
|
||||||
|
$locale = auth()->user()->getLocale();
|
||||||
|
|
||||||
|
app()->setLocale(Str::before($locale, '_'));
|
||||||
|
|
||||||
|
Number::useLocale($locale);
|
||||||
|
Number::useCurrency(auth()->user()->getCurrency());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
|
||||||
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class SetLocale
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Handle an incoming request.
|
|
||||||
*
|
|
||||||
* @return mixed
|
|
||||||
*/
|
|
||||||
public function handle(Request $request, Closure $next)
|
|
||||||
{
|
|
||||||
if (!session()->has('locale')) {
|
|
||||||
session()->put('locale', $request->getPreferredLanguage(
|
|
||||||
config('app.available_locales')
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
app()->setLocale(session('locale'));
|
|
||||||
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest as BaseFormRequest;
|
||||||
|
|
||||||
|
class FormRequest extends BaseFormRequest
|
||||||
|
{
|
||||||
|
public function requestOrModelValue($key, $model): mixed
|
||||||
|
{
|
||||||
|
return $this->request->get($key) ?? $this->{$model}?->{$key};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
class HoldingRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
|
||||||
|
$rules = [
|
||||||
|
'reinvest_dividends' => ['sometimes', 'boolean'],
|
||||||
|
];
|
||||||
|
|
||||||
|
return $rules;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
class PortfolioRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
|
||||||
|
$rules = [
|
||||||
|
'title' => ['required', 'string', 'min:5', 'max:255'],
|
||||||
|
'notes' => ['sometimes', 'nullable', 'string'],
|
||||||
|
'wishlist' => ['sometimes', 'nullable', 'boolean'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (! is_null($this->portfolio)) {
|
||||||
|
$rules['title'][0] = 'sometimes';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rules;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\Portfolio;
|
||||||
|
use App\Rules\QuantityValidationRule;
|
||||||
|
use App\Rules\SymbolValidationRule;
|
||||||
|
|
||||||
|
class TransactionRequest extends FormRequest
|
||||||
|
{
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
|
||||||
|
$this->merge([
|
||||||
|
'portfolio' => Portfolio::find($this->requestOrModelValue('portfolio_id', 'transaction')),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
|
||||||
|
$rules = [
|
||||||
|
'portfolio_id' => ['required', 'exists:portfolios,id'],
|
||||||
|
'symbol' => ['required', 'string', new SymbolValidationRule],
|
||||||
|
'transaction_type' => ['required', 'string', 'in:BUY,SELL'],
|
||||||
|
'date' => ['required', 'date_format:Y-m-d', 'before_or_equal:'.now()->toDateString()],
|
||||||
|
'quantity' => [
|
||||||
|
'required',
|
||||||
|
'numeric',
|
||||||
|
'gt:0',
|
||||||
|
new QuantityValidationRule(
|
||||||
|
$this->input('portfolio'),
|
||||||
|
$this->requestOrModelValue('symbol', 'transaction'),
|
||||||
|
$this->requestOrModelValue('transaction_type', 'transaction'),
|
||||||
|
$this->requestOrModelValue('date', 'transaction'),
|
||||||
|
$this->transaction
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'currency' => ['required', 'exists:currencies,currency'],
|
||||||
|
'cost_basis' => ['exclude_if:transaction_type,SELL', 'min:0', 'numeric'],
|
||||||
|
'sale_price' => ['exclude_if:transaction_type,BUY', 'min:0', 'numeric'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (! is_null($this->transaction)) {
|
||||||
|
$rules['portfolio_id'][0] = 'sometimes';
|
||||||
|
$rules['symbol'][0] = 'sometimes';
|
||||||
|
$rules['transaction_type'][0] = 'sometimes';
|
||||||
|
$rules['currency'][0] = 'sometimes';
|
||||||
|
$rules['date'][0] = 'sometimes';
|
||||||
|
$rules['quantity'][0] = 'sometimes';
|
||||||
|
|
||||||
|
if (
|
||||||
|
$this->requestOrModelValue('transaction_type', 'transaction') == 'SELL'
|
||||||
|
&& $this->requestOrModelValue('sale_price', 'transaction') == null
|
||||||
|
) {
|
||||||
|
$rules['sale_price'][0] = 'required';
|
||||||
|
} elseif (
|
||||||
|
$this->requestOrModelValue('transaction_type', 'transaction') == 'BUY'
|
||||||
|
&& $this->requestOrModelValue('cost_basis', 'transaction') == null
|
||||||
|
) {
|
||||||
|
$rules['cost_basis'][0] = 'required';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rules;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class HoldingResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'portfolio_id' => $this->portfolio_id,
|
||||||
|
'symbol' => $this->symbol,
|
||||||
|
'quantity' => $this->quantity,
|
||||||
|
'currency' => $this->market_data->currency,
|
||||||
|
'reinvest_dividends' => $this->reinvest_dividends,
|
||||||
|
'average_cost_basis' => $this->average_cost_basis,
|
||||||
|
'total_cost_basis' => $this->total_cost_basis,
|
||||||
|
'realized_gain_dollars' => $this->realized_gain_dollars,
|
||||||
|
'dividends_earned' => $this->dividends_earned,
|
||||||
|
'splits_synced_at' => $this->splits_synced_at,
|
||||||
|
'total_market_value' => $this->total_market_value,
|
||||||
|
'market_gain_dollars' => $this->market_gain_dollars,
|
||||||
|
'market_gain_percent' => $this->market_gain_percent,
|
||||||
|
'created_at' => $this->created_at,
|
||||||
|
'updated_at' => $this->updated_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class MarketDataResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'symbol' => $this->symbol,
|
||||||
|
'name' => $this->name,
|
||||||
|
'market_value' => $this->market_value,
|
||||||
|
'fifty_two_week_low' => $this->fifty_two_week_low,
|
||||||
|
'fifty_two_week_high' => $this->fifty_two_week_high,
|
||||||
|
'last_dividend_date' => $this->last_dividend_date,
|
||||||
|
'last_dividend_amount' => $this->last_dividend_amount,
|
||||||
|
'dividend_yield' => $this->dividend_yield,
|
||||||
|
'market_cap' => $this->market_cap,
|
||||||
|
'trailing_pe' => $this->trailing_pe,
|
||||||
|
'forward_pe' => $this->forward_pe,
|
||||||
|
'book_value' => $this->book_value,
|
||||||
|
'created_at' => $this->created_at,
|
||||||
|
'updated_at' => $this->updated_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class PortfolioResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'title' => $this->title,
|
||||||
|
'notes' => $this->notes,
|
||||||
|
'wishlist' => $this->wishlist,
|
||||||
|
'owner' => UserResource::make($this->owner),
|
||||||
|
'transactions' => TransactionResource::collection($this->whenLoaded('transactions')),
|
||||||
|
'holdings' => HoldingResource::collection($this->whenLoaded('holdings')),
|
||||||
|
'created_at' => $this->created_at,
|
||||||
|
'updated_at' => $this->updated_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class TransactionResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'symbol' => $this->symbol,
|
||||||
|
'portfolio_id' => $this->portfolio_id,
|
||||||
|
'transaction_type' => $this->transaction_type,
|
||||||
|
'quantity' => $this->quantity,
|
||||||
|
'currency' => $this->market_data->currency,
|
||||||
|
'cost_basis' => $this->cost_basis,
|
||||||
|
'sale_price' => $this->sale_price,
|
||||||
|
'split' => $this->split,
|
||||||
|
'reinvested_dividend' => $this->reinvested_dividend,
|
||||||
|
'date' => date_format($this->date, 'Y-m-d'),
|
||||||
|
'created_at' => $this->created_at,
|
||||||
|
'updated_at' => $this->updated_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class UserResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'name' => $this->name,
|
||||||
|
'email' => $this->email,
|
||||||
|
'profile_photo_url' => $this->profile_photo_url,
|
||||||
|
'options' => [
|
||||||
|
'display_currency' => $this->getCurrency(),
|
||||||
|
'locale' => $this->getLocale(),
|
||||||
|
],
|
||||||
|
'created_at' => $this->created_at,
|
||||||
|
'updated_at' => $this->updated_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,40 +1,39 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Imports;
|
namespace App\Imports;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Console\Commands\RefreshDividendData;
|
||||||
use App\Imports\Sheets\PortfoliosSheet;
|
use App\Console\Commands\RefreshMarketData;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
|
||||||
use App\Console\Commands\SyncDailyChange;
|
use App\Console\Commands\SyncDailyChange;
|
||||||
use App\Console\Commands\SyncHoldingData;
|
use App\Console\Commands\SyncHoldingData;
|
||||||
|
use App\Imports\Sheets\ConfigSheet;
|
||||||
use App\Imports\Sheets\DailyChangesSheet;
|
use App\Imports\Sheets\DailyChangesSheet;
|
||||||
|
use App\Imports\Sheets\PortfoliosSheet;
|
||||||
use App\Imports\Sheets\TransactionsSheet;
|
use App\Imports\Sheets\TransactionsSheet;
|
||||||
use Maatwebsite\Excel\Events\AfterImport;
|
use App\Models\BackupImport as BackupImportModel;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Maatwebsite\Excel\Concerns\Importable;
|
use Maatwebsite\Excel\Concerns\Importable;
|
||||||
use Maatwebsite\Excel\Concerns\WithEvents;
|
use Maatwebsite\Excel\Concerns\WithEvents;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
|
||||||
|
use Maatwebsite\Excel\Events\AfterImport;
|
||||||
use Maatwebsite\Excel\Events\BeforeImport;
|
use Maatwebsite\Excel\Events\BeforeImport;
|
||||||
use Maatwebsite\Excel\Events\ImportFailed;
|
use Maatwebsite\Excel\Events\ImportFailed;
|
||||||
use App\Console\Commands\RefreshMarketData;
|
|
||||||
use App\Console\Commands\RefreshDividendData;
|
|
||||||
use App\Models\BackupImport as BackupImportModel;
|
|
||||||
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
|
|
||||||
|
|
||||||
class BackupImport implements WithMultipleSheets, WithEvents
|
class BackupImport implements WithEvents, WithMultipleSheets
|
||||||
{
|
{
|
||||||
|
|
||||||
use Importable;
|
use Importable;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public BackupImportModel $backupImportModel
|
public BackupImportModel $backupImportModel
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array
|
|
||||||
*/
|
|
||||||
public function registerEvents(): array
|
public function registerEvents(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
BeforeImport::class => fn() => $this->backupImportModel->update([
|
BeforeImport::class => fn () => $this->backupImportModel->update([
|
||||||
'status' => 'in_progress',
|
'status' => 'in_progress',
|
||||||
'message' => __('Import is in progress...'),
|
'message' => __('Import is in progress...'),
|
||||||
]),
|
]),
|
||||||
@@ -43,24 +42,24 @@ class BackupImport implements WithMultipleSheets, WithEvents
|
|||||||
$this->backupImportModel->update([
|
$this->backupImportModel->update([
|
||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
'message' => 'Import completed successfully!',
|
'message' => 'Import completed successfully!',
|
||||||
'completed_at' => now()
|
'completed_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Artisan::queue(RefreshMarketData::class, ['--user' => $this->backupImportModel->user_id, '--force' => true])
|
Artisan::queue(RefreshMarketData::class, ['--user' => $this->backupImportModel->user_id, '--force' => true])
|
||||||
->chain([
|
->chain([
|
||||||
fn() => Artisan::call(RefreshDividendData::class, ['--user' => $this->backupImportModel->user_id, '--force' => true]),
|
fn () => Artisan::call(RefreshDividendData::class, ['--user' => $this->backupImportModel->user_id, '--force' => true]),
|
||||||
fn() => Artisan::call(SyncHoldingData::class, ['--user' => $this->backupImportModel->user_id]),
|
fn () => Artisan::call(SyncHoldingData::class, ['--user' => $this->backupImportModel->user_id]),
|
||||||
fn() => User::find($this->backupImportModel->user_id)->portfolios->each(function($portfolio) {
|
fn () => User::find($this->backupImportModel->user_id)->portfolios->each(function ($portfolio) {
|
||||||
|
|
||||||
Artisan::queue(SyncDailyChange::class, ['portfolio_id' => $portfolio->id]);
|
Artisan::queue(SyncDailyChange::class, ['portfolio_id' => $portfolio->id]);
|
||||||
})
|
}),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
ImportFailed::class => fn(ImportFailed $event) => $this->backupImportModel->update([
|
ImportFailed::class => fn (ImportFailed $event) => $this->backupImportModel->update([
|
||||||
'status' => 'failed',
|
'status' => 'failed',
|
||||||
'message' => 'Error: '. substr($event->getException()->getMessage(), 0, 220),
|
'message' => 'Error: '.substr($event->getException()->getMessage(), 0, 220),
|
||||||
'has_errors' => true,
|
'has_errors' => true,
|
||||||
'completed_at' => now()
|
'completed_at' => now(),
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -71,6 +70,7 @@ class BackupImport implements WithMultipleSheets, WithEvents
|
|||||||
'Portfolios' => new PortfoliosSheet($this->backupImportModel),
|
'Portfolios' => new PortfoliosSheet($this->backupImportModel),
|
||||||
'Transactions' => new TransactionsSheet($this->backupImportModel),
|
'Transactions' => new TransactionsSheet($this->backupImportModel),
|
||||||
'Daily Changes' => new DailyChangesSheet($this->backupImportModel),
|
'Daily Changes' => new DailyChangesSheet($this->backupImportModel),
|
||||||
|
'Config' => new ConfigSheet($this->backupImportModel),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Imports\Sheets;
|
||||||
|
|
||||||
|
use App\Models\BackupImport;
|
||||||
|
use App\Models\Holding;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
||||||
|
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithEvents;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithValidation;
|
||||||
|
use Maatwebsite\Excel\Events\BeforeSheet;
|
||||||
|
|
||||||
|
class ConfigSheet implements SkipsEmptyRows, ToCollection, WithEvents, WithHeadingRow, WithValidation
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public BackupImport $backupImport
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function registerEvents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
BeforeSheet::class => function (BeforeSheet $event) {
|
||||||
|
DB::commit();
|
||||||
|
$this->backupImport->update([
|
||||||
|
'message' => __('Importing configurations...'),
|
||||||
|
]);
|
||||||
|
DB::beginTransaction();
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function collection(Collection $configs)
|
||||||
|
{
|
||||||
|
foreach ($configs as $config) {
|
||||||
|
|
||||||
|
switch ($config['key']) {
|
||||||
|
case 'name':
|
||||||
|
$this->backupImport->user->setAttribute('name', $config['value']);
|
||||||
|
$this->backupImport->user->save();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'locale':
|
||||||
|
$this->backupImport->user->setOption('locale', $config['value']);
|
||||||
|
$this->backupImport->user->save();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'display_currency':
|
||||||
|
$this->backupImport->user->setOption('display_currency', $config['value']);
|
||||||
|
$this->backupImport->user->save();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'reinvested_dividends':
|
||||||
|
if (json_validate($config['value'])) {
|
||||||
|
foreach (json_decode($config['value'], true) as $reinvest) {
|
||||||
|
Holding::myHoldings($this->backupImport->user->id)
|
||||||
|
->where('portfolio_id', $reinvest['portfolio_id'])
|
||||||
|
->where('symbol', $reinvest['symbol'])
|
||||||
|
->update([
|
||||||
|
'reinvest_dividends' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'key' => ['required', 'string'],
|
||||||
|
'value' => ['required', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,62 +1,62 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Imports\Sheets;
|
namespace App\Imports\Sheets;
|
||||||
|
|
||||||
use App\Imports\ValidatesPortfolioAccess;
|
use App\Imports\ValidatesPortfolioAccess;
|
||||||
use App\Models\DailyChange;
|
|
||||||
use App\Models\BackupImport;
|
use App\Models\BackupImport;
|
||||||
|
use App\Models\DailyChange;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Maatwebsite\Excel\Events\BeforeSheet;
|
|
||||||
use Maatwebsite\Excel\Concerns\WithEvents;
|
|
||||||
use Maatwebsite\Excel\Concerns\ToCollection;
|
|
||||||
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
||||||
|
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithEvents;
|
||||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||||
use Maatwebsite\Excel\Concerns\WithValidation;
|
use Maatwebsite\Excel\Concerns\WithValidation;
|
||||||
|
use Maatwebsite\Excel\Events\BeforeSheet;
|
||||||
|
|
||||||
class DailyChangesSheet implements ToCollection, WithHeadingRow, WithValidation, SkipsEmptyRows, WithEvents
|
class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, WithHeadingRow, WithValidation
|
||||||
{
|
{
|
||||||
use ValidatesPortfolioAccess;
|
use ValidatesPortfolioAccess;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public BackupImport $backupImport
|
public BackupImport $backupImport
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array
|
|
||||||
*/
|
|
||||||
public function registerEvents(): array
|
public function registerEvents(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
BeforeSheet::class => function(BeforeSheet $event) {
|
BeforeSheet::class => function (BeforeSheet $event) {
|
||||||
DB::commit();
|
DB::commit();
|
||||||
$this->backupImport->update([
|
$this->backupImport->update([
|
||||||
'message' => __('Importing daily changes...'),
|
'message' => __('Preparing to import daily changes...'),
|
||||||
]);
|
]);
|
||||||
DB::beginTransaction();
|
DB::beginTransaction();
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function collection(Collection $dailyChanges)
|
public function collection(Collection $dailyChanges)
|
||||||
{
|
{
|
||||||
$dailyChanges->chunk($this->batchSize())->each(function ($chunk) {
|
$totalBatches = count($dailyChanges) / $this->batchSize();
|
||||||
|
|
||||||
|
$dailyChanges->chunk($this->batchSize())->each(function ($chunk, $index) use ($totalBatches) {
|
||||||
|
|
||||||
$this->validatePortfolioAccess($chunk);
|
$this->validatePortfolioAccess($chunk);
|
||||||
|
|
||||||
|
$this->backupImport->update([
|
||||||
|
'message' => __('Importing daily changes (Batch :currentBatch of :totalBatches)...', ['currentBatch' => $index + 1, 'totalBatches' => $totalBatches]),
|
||||||
|
]);
|
||||||
|
|
||||||
// have to cast to native values
|
// have to cast to native values
|
||||||
$chunk = $chunk->map(function ($dailyChange) {
|
$chunk = $chunk->map(function ($dailyChange) {
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'total_market_value' => $dailyChange['total_market_value'],
|
|
||||||
'total_cost_basis' => $dailyChange['total_cost_basis'],
|
|
||||||
'total_gain' => $dailyChange['total_gain'],
|
|
||||||
'total_dividends_earned' => $dailyChange['total_dividends_earned'],
|
|
||||||
'realized_gains' => $dailyChange['realized_gains'],
|
|
||||||
'annotation' => $dailyChange['annotation'],
|
'annotation' => $dailyChange['annotation'],
|
||||||
'portfolio_id' => $dailyChange['portfolio_id'],
|
'portfolio_id' => $dailyChange['portfolio_id'],
|
||||||
'date' => Carbon::parse($dailyChange['date'])->format('Y-m-d')
|
'date' => Carbon::parse($dailyChange['date'])->toDateString(),
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,14 +64,9 @@ class DailyChangesSheet implements ToCollection, WithHeadingRow, WithValidation,
|
|||||||
$chunk->toArray(),
|
$chunk->toArray(),
|
||||||
['portfolio_id', 'date'],
|
['portfolio_id', 'date'],
|
||||||
[
|
[
|
||||||
'total_market_value',
|
|
||||||
'total_cost_basis',
|
|
||||||
'total_gain',
|
|
||||||
'total_dividends_earned',
|
|
||||||
'realized_gains',
|
|
||||||
'annotation',
|
'annotation',
|
||||||
'portfolio_id',
|
'portfolio_id',
|
||||||
'date'
|
'date',
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -85,13 +80,8 @@ class DailyChangesSheet implements ToCollection, WithHeadingRow, WithValidation,
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'portfolio_id' => ['required', 'uuid'],
|
'portfolio_id' => ['required', 'uuid'],
|
||||||
'date' => ['required', 'date'],
|
'date' => ['required', 'date'],
|
||||||
'total_market_value' => ['sometimes', 'nullable', 'numeric'],
|
|
||||||
'total_cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
|
||||||
'total_gain' => ['sometimes', 'nullable', 'numeric'],
|
|
||||||
'total_dividends_earned' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
|
||||||
'realized_gains' => ['sometimes', 'nullable', 'numeric'],
|
|
||||||
'annotation' => ['sometimes', 'nullable', 'string'],
|
'annotation' => ['sometimes', 'nullable', 'string'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,36 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Imports\Sheets;
|
namespace App\Imports\Sheets;
|
||||||
|
|
||||||
use App\Models\Portfolio;
|
|
||||||
use App\Models\BackupImport;
|
use App\Models\BackupImport;
|
||||||
|
use App\Models\Portfolio;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Maatwebsite\Excel\Events\BeforeSheet;
|
|
||||||
use Maatwebsite\Excel\Concerns\WithEvents;
|
|
||||||
use Maatwebsite\Excel\Concerns\ToCollection;
|
|
||||||
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
||||||
|
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithEvents;
|
||||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||||
use Maatwebsite\Excel\Concerns\WithValidation;
|
use Maatwebsite\Excel\Concerns\WithValidation;
|
||||||
|
use Maatwebsite\Excel\Events\BeforeSheet;
|
||||||
|
|
||||||
class PortfoliosSheet implements ToCollection, WithValidation, WithHeadingRow, SkipsEmptyRows, WithEvents
|
class PortfoliosSheet implements SkipsEmptyRows, ToCollection, WithEvents, WithHeadingRow, WithValidation
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public BackupImport $backupImport
|
public BackupImport $backupImport
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array
|
|
||||||
*/
|
|
||||||
public function registerEvents(): array
|
public function registerEvents(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
BeforeSheet::class => function(BeforeSheet $event) {
|
BeforeSheet::class => function (BeforeSheet $event) {
|
||||||
DB::commit();
|
DB::commit();
|
||||||
$this->backupImport->update([
|
$this->backupImport->update([
|
||||||
'message' => __('Importing portfolios...'),
|
'message' => __('Importing portfolios...'),
|
||||||
]);
|
]);
|
||||||
DB::beginTransaction();
|
DB::beginTransaction();
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +41,7 @@ class PortfoliosSheet implements ToCollection, WithValidation, WithHeadingRow, S
|
|||||||
Portfolio::unguard(); // ensures we can set an owner for the portfolio
|
Portfolio::unguard(); // ensures we can set an owner for the portfolio
|
||||||
|
|
||||||
$portfolio = Portfolio::fullAccess($this->backupImport->user_id)->updateOrCreate([
|
$portfolio = Portfolio::fullAccess($this->backupImport->user_id)->updateOrCreate([
|
||||||
'id' => $portfolio['portfolio_id']
|
'id' => $portfolio['portfolio_id'],
|
||||||
], [
|
], [
|
||||||
'id' => $portfolio['portfolio_id'] ?? null,
|
'id' => $portfolio['portfolio_id'] ?? null,
|
||||||
'title' => $portfolio['title'],
|
'title' => $portfolio['title'],
|
||||||
|
|||||||
@@ -1,57 +1,81 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Imports\Sheets;
|
namespace App\Imports\Sheets;
|
||||||
|
|
||||||
use App\Imports\ValidatesPortfolioAccess;
|
use App\Imports\ValidatesPortfolioAccess;
|
||||||
|
use App\Models\BackupImport;
|
||||||
|
use App\Models\Currency;
|
||||||
|
use App\Models\CurrencyRate;
|
||||||
use App\Models\Holding;
|
use App\Models\Holding;
|
||||||
use App\Models\Transaction;
|
use App\Models\Transaction;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use App\Models\BackupImport;
|
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Maatwebsite\Excel\Events\BeforeSheet;
|
use Illuminate\Support\Str;
|
||||||
use Maatwebsite\Excel\Concerns\WithEvents;
|
|
||||||
use Maatwebsite\Excel\Concerns\ToCollection;
|
|
||||||
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
|
||||||
|
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithEvents;
|
||||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||||
use Maatwebsite\Excel\Concerns\WithValidation;
|
use Maatwebsite\Excel\Concerns\WithValidation;
|
||||||
|
use Maatwebsite\Excel\Events\BeforeSheet;
|
||||||
|
|
||||||
class TransactionsSheet implements ToCollection, WithHeadingRow, WithValidation, SkipsEmptyRows, WithEvents
|
class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, WithHeadingRow, WithValidation
|
||||||
{
|
{
|
||||||
|
|
||||||
use ValidatesPortfolioAccess;
|
use ValidatesPortfolioAccess;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public BackupImport $backupImport
|
public BackupImport $backupImport
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array
|
|
||||||
*/
|
|
||||||
public function registerEvents(): array
|
public function registerEvents(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
BeforeSheet::class => function(BeforeSheet $event) {
|
BeforeSheet::class => function (BeforeSheet $event) {
|
||||||
DB::commit();
|
DB::commit();
|
||||||
$this->backupImport->update([
|
$this->backupImport->update([
|
||||||
'message' => __('Importing transactions...'),
|
'message' => __('Preparing to import transactions...'),
|
||||||
]);
|
]);
|
||||||
DB::beginTransaction();
|
DB::beginTransaction();
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function collection(Collection $transactions)
|
public function collection(Collection $transactions)
|
||||||
{
|
{
|
||||||
|
|
||||||
$transactions->chunk($this->batchSize())->each(function ($chunk) {
|
// if has any transactions not in base currency, need to sync timeseries conversion rates
|
||||||
|
if ($transactions->where('currency', '!=', config('investbrain.base_currency'))->isNotEmpty()) {
|
||||||
|
|
||||||
|
CurrencyRate::timeSeriesRates('', $transactions->min('date'), $transactions->max('date'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalBatches = count($transactions) / $this->batchSize();
|
||||||
|
|
||||||
|
// chunk transactions
|
||||||
|
$transactions->chunk($this->batchSize())->each(function ($chunk, $index) use ($totalBatches) {
|
||||||
|
|
||||||
|
$this->backupImport->update([
|
||||||
|
'message' => __('Importing transactions (Batch :currentBatch of :totalBatches)...', ['currentBatch' => $index + 1, 'totalBatches' => $totalBatches]),
|
||||||
|
]);
|
||||||
|
|
||||||
$this->validatePortfolioAccess($chunk);
|
$this->validatePortfolioAccess($chunk);
|
||||||
|
|
||||||
// have to cast to native values
|
// have to cast to native values
|
||||||
$chunk = $chunk->map(function ($transaction) {
|
$chunk = $chunk->map(function ($transaction) {
|
||||||
|
|
||||||
|
$date = Carbon::parse($transaction['date'])->toDateString();
|
||||||
|
|
||||||
|
// if transaction not in base currency, need to convert
|
||||||
|
if ($transaction['currency'] == config('investbrain.base_currency')) {
|
||||||
|
$cost_basis_base = $transaction['cost_basis'] ?? 0;
|
||||||
|
$sale_price_base = $transaction['sale_price'];
|
||||||
|
} else {
|
||||||
|
$cost_basis_base = Currency::convert($transaction['cost_basis'], $transaction['currency'], date: $date);
|
||||||
|
$sale_price_base = Currency::convert($transaction['sale_price'], $transaction['currency'], date: $date);
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $transaction['transaction_id'] ?? Str::uuid()->toString(),
|
'id' => $transaction['transaction_id'] ?? Str::uuid()->toString(),
|
||||||
'symbol' => strtoupper($transaction['symbol']),
|
'symbol' => strtoupper($transaction['symbol']),
|
||||||
@@ -60,9 +84,11 @@ class TransactionsSheet implements ToCollection, WithHeadingRow, WithValidation,
|
|||||||
'quantity' => $transaction['quantity'],
|
'quantity' => $transaction['quantity'],
|
||||||
'cost_basis' => $transaction['cost_basis'] ?? 0,
|
'cost_basis' => $transaction['cost_basis'] ?? 0,
|
||||||
'sale_price' => $transaction['sale_price'],
|
'sale_price' => $transaction['sale_price'],
|
||||||
|
'cost_basis_base' => $cost_basis_base,
|
||||||
|
'sale_price_base' => $sale_price_base,
|
||||||
'split' => boolval($transaction['split']) ? 1 : 0,
|
'split' => boolval($transaction['split']) ? 1 : 0,
|
||||||
'reinvested_dividend' => boolval($transaction['reinvested_dividend']) ? 1 : 0,
|
'reinvested_dividend' => boolval($transaction['reinvested_dividend']) ? 1 : 0,
|
||||||
'date' => Carbon::parse($transaction['date'])->format('Y-m-d')
|
'date' => $date,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,23 +105,23 @@ class TransactionsSheet implements ToCollection, WithHeadingRow, WithValidation,
|
|||||||
'sale_price',
|
'sale_price',
|
||||||
'split',
|
'split',
|
||||||
'reinvested_dividend',
|
'reinvested_dividend',
|
||||||
'date'
|
'date',
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// stub out related holdings
|
// get unique symbol/portfolio id combination and stub out related holdings
|
||||||
$chunk->unique(fn($item) => $item['symbol'] . $item['portfolio_id'])
|
$chunk->unique(fn ($item) => $item['symbol'].$item['portfolio_id'])
|
||||||
->each(function($holding) {
|
->each(function ($holding) {
|
||||||
|
|
||||||
Holding::firstOrCreate([
|
Holding::firstOrCreate([
|
||||||
'symbol' => $holding['symbol'],
|
'symbol' => $holding['symbol'],
|
||||||
'portfolio_id' => $holding['portfolio_id']
|
'portfolio_id' => $holding['portfolio_id'],
|
||||||
], [
|
], [
|
||||||
'quantity' => 0,
|
'quantity' => 0,
|
||||||
'average_cost_basis' => 0,
|
'average_cost_basis' => 0,
|
||||||
'splits_synced_at' => now(),
|
'splits_synced_at' => now(),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +140,7 @@ class TransactionsSheet implements ToCollection, WithHeadingRow, WithValidation,
|
|||||||
'transaction_type' => ['required', 'in:BUY,SELL'],
|
'transaction_type' => ['required', 'in:BUY,SELL'],
|
||||||
'date' => ['required', 'date'],
|
'date' => ['required', 'date'],
|
||||||
'quantity' => ['required', 'min:0', 'numeric'],
|
'quantity' => ['required', 'min:0', 'numeric'],
|
||||||
|
'currency' => ['required', 'string'],
|
||||||
'split' => ['sometimes', 'nullable', 'boolean'],
|
'split' => ['sometimes', 'nullable', 'boolean'],
|
||||||
'reinvested_dividend' => ['sometimes', 'nullable', 'boolean'],
|
'reinvested_dividend' => ['sometimes', 'nullable', 'boolean'],
|
||||||
'cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
'cost_basis' => ['sometimes', 'nullable', 'min:0', 'numeric'],
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Imports;
|
namespace App\Imports;
|
||||||
|
|
||||||
use App\Models\Portfolio;
|
use App\Models\Portfolio;
|
||||||
|
|
||||||
trait ValidatesPortfolioAccess
|
trait ValidatesPortfolioAccess
|
||||||
{
|
{
|
||||||
|
|
||||||
public function validatePortfolioAccess($collection)
|
public function validatePortfolioAccess($collection)
|
||||||
{
|
{
|
||||||
|
|
||||||
$uniquePortfolios = $collection->unique('portfolio_id')->pluck('portfolio_id');
|
$importingPortfolios = $collection->unique('portfolio_id')->pluck('portfolio_id');
|
||||||
$countPortfoliosWithAccess = Portfolio::fullAccess($this->backupImport->user_id)
|
$portfoliosWithAccess = Portfolio::fullAccess($this->backupImport->user_id)
|
||||||
->whereIn('id', $uniquePortfolios)
|
->whereIn('id', $importingPortfolios)
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
$countPortfoliosWithAccess < $uniquePortfolios->count()
|
$importingPortfolios->count() > $portfoliosWithAccess
|
||||||
) {
|
) {
|
||||||
throw new \Exception(__("You do not have access to that portfolio."));
|
throw new \Exception(__('You do not have access to that portfolio.'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Interfaces\MarketData;
|
||||||
|
|
||||||
|
use App\Interfaces\MarketData\Types\Dividend;
|
||||||
|
use App\Interfaces\MarketData\Types\Ohlc;
|
||||||
|
use App\Interfaces\MarketData\Types\Quote;
|
||||||
|
use App\Interfaces\MarketData\Types\Split;
|
||||||
|
use Carbon\CarbonInterval;
|
||||||
|
use Illuminate\Http\Client\PendingRequest;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
class AlpacaMarketData implements MarketDataInterface
|
||||||
|
{
|
||||||
|
public PendingRequest $client;
|
||||||
|
|
||||||
|
public string $dataBaseUrl = 'https://data.alpaca.markets/';
|
||||||
|
|
||||||
|
public string $apiBaseUrl = 'https://api.alpaca.markets/';
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createNewClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createNewClient()
|
||||||
|
{
|
||||||
|
$this->client = Http::withOptions([
|
||||||
|
'headers' => [
|
||||||
|
'content-type' => 'application/json',
|
||||||
|
'accept' => 'application/json',
|
||||||
|
'Apca-Api-Key-Id' => config('alpaca.key'),
|
||||||
|
'Apca-Api-Secret-Key' => config('alpaca.secret'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exists(string $symbol): bool
|
||||||
|
{
|
||||||
|
return (bool) $this->quote($symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function quote(string $symbol): Quote
|
||||||
|
{
|
||||||
|
$response = $this->client->baseUrl($this->dataBaseUrl)->get("v2/stocks/{$symbol}/trades/latest");
|
||||||
|
|
||||||
|
$quote = $response->json('trade');
|
||||||
|
|
||||||
|
throw_if(empty(Arr::get($quote, 'p')), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
|
||||||
|
|
||||||
|
$fundamental = cache()->remember(
|
||||||
|
'ap-symbol-'.$symbol,
|
||||||
|
1440,
|
||||||
|
function () use ($symbol) {
|
||||||
|
|
||||||
|
$this->createNewClient();
|
||||||
|
|
||||||
|
$basic = $this->client->baseUrl($this->apiBaseUrl)->get("v2/assets/{$symbol}")->json();
|
||||||
|
$fifty_two_week = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
|
||||||
|
'timeframe' => '12M',
|
||||||
|
'start' => now()->subWeeks(53)->format('Y-m-d'),
|
||||||
|
'end' => now()->subWeeks(1)->format('Y-m-d'), // todo: can't query recent SIP data
|
||||||
|
])->get("v2/stocks/{$symbol}/bars")->json();
|
||||||
|
|
||||||
|
return array_merge($fifty_two_week, $basic);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Quote([
|
||||||
|
'name' => Arr::get($fundamental, 'name'),
|
||||||
|
'symbol' => $symbol,
|
||||||
|
'currency' => 'USD', // Alpaca only has US equitities
|
||||||
|
'market_value' => Arr::get($quote, 'p'),
|
||||||
|
'fifty_two_week_high' => Arr::get($fundamental, 'bars.0.h'),
|
||||||
|
'fifty_two_week_low' => Arr::get($fundamental, 'bars.0.l'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dividends(string $symbol, $startDate, $endDate): Collection
|
||||||
|
{
|
||||||
|
$response = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
|
||||||
|
'symbols' => $symbol,
|
||||||
|
'limit' => 1000,
|
||||||
|
'sort' => 'asc',
|
||||||
|
'types' => 'cash_dividend',
|
||||||
|
'start' => $startDate->format('Y-m-d'),
|
||||||
|
'end' => $endDate->format('Y-m-d'),
|
||||||
|
])->get('v1/corporate-actions');
|
||||||
|
|
||||||
|
$dividends = $response->json('corporate_actions.cash_dividends');
|
||||||
|
|
||||||
|
return collect($dividends)
|
||||||
|
->map(function ($dividend) use ($symbol) {
|
||||||
|
|
||||||
|
return new Dividend([
|
||||||
|
'symbol' => $symbol,
|
||||||
|
'date' => Carbon::parse(Arr::get($dividend, 'ex_date')),
|
||||||
|
'dividend_amount' => Arr::get($dividend, 'rate'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function splits(string $symbol, $startDate, $endDate): Collection
|
||||||
|
{
|
||||||
|
$response = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
|
||||||
|
'symbols' => $symbol,
|
||||||
|
'limit' => 1000,
|
||||||
|
'sort' => 'asc',
|
||||||
|
'types' => 'forward_split',
|
||||||
|
'start' => $startDate->format('Y-m-d'),
|
||||||
|
'end' => $endDate->format('Y-m-d'),
|
||||||
|
])->get('v1/corporate-actions');
|
||||||
|
|
||||||
|
$splits = $response->json('corporate_actions.forward_splits');
|
||||||
|
|
||||||
|
return collect($splits)
|
||||||
|
->map(function ($split) use ($symbol) {
|
||||||
|
|
||||||
|
return new Split([
|
||||||
|
'symbol' => $symbol,
|
||||||
|
'date' => Carbon::parse(Arr::get($split, 'ex_date')),
|
||||||
|
'split_amount' => Arr::get($split, 'new_rate') / Arr::get($split, 'old_rate'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function history(string $symbol, $startDate, $endDate): Collection
|
||||||
|
{
|
||||||
|
$startDate = Carbon::parse($startDate);
|
||||||
|
$endDate = Carbon::parse($endDate)->subHours(36); // alpaca has sip data limits
|
||||||
|
|
||||||
|
$allHistory = collect();
|
||||||
|
|
||||||
|
$chunks = 1000;
|
||||||
|
|
||||||
|
$period = CarbonInterval::days($chunks)->toPeriod($startDate, $endDate);
|
||||||
|
foreach ($period as $startDate) {
|
||||||
|
|
||||||
|
$chunkEnd = $startDate->copy()->addDays($chunks - 1);
|
||||||
|
|
||||||
|
if ($chunkEnd->gt($endDate)) {
|
||||||
|
$chunkEnd = $endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->createNewClient();
|
||||||
|
|
||||||
|
$response = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
|
||||||
|
'timeframe' => '1D',
|
||||||
|
'start' => $startDate->format('Y-m-d'),
|
||||||
|
'end' => $chunkEnd->format('Y-m-d'),
|
||||||
|
])->get("v2/stocks/{$symbol}/bars");
|
||||||
|
|
||||||
|
$history = $response->json('bars');
|
||||||
|
|
||||||
|
throw_if(empty($history), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
|
||||||
|
|
||||||
|
$chunkedHistory = collect($history)
|
||||||
|
->mapWithKeys(function ($history) use ($symbol) {
|
||||||
|
|
||||||
|
$date = Carbon::parse($history['t'])->format('Y-m-d');
|
||||||
|
|
||||||
|
return [$date => new Ohlc([
|
||||||
|
'symbol' => $symbol,
|
||||||
|
'date' => $date,
|
||||||
|
'close' => Arr::get($history, 'c'),
|
||||||
|
])];
|
||||||
|
});
|
||||||
|
|
||||||
|
$allHistory = $allHistory->merge($chunkedHistory);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $allHistory;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,45 +1,66 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Interfaces\MarketData;
|
namespace App\Interfaces\MarketData;
|
||||||
|
|
||||||
|
use App\Interfaces\MarketData\Types\Dividend;
|
||||||
|
use App\Interfaces\MarketData\Types\Ohlc;
|
||||||
|
use App\Interfaces\MarketData\Types\Quote;
|
||||||
|
use App\Interfaces\MarketData\Types\Split;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use App\Interfaces\MarketData\Types\Quote;
|
|
||||||
use App\Interfaces\MarketData\Types\Split;
|
|
||||||
use App\Interfaces\MarketData\Types\Dividend;
|
|
||||||
use App\Interfaces\MarketData\Types\Ohlc;
|
|
||||||
use Tschucki\Alphavantage\Facades\Alphavantage;
|
use Tschucki\Alphavantage\Facades\Alphavantage;
|
||||||
|
|
||||||
class AlphaVantageMarketData implements MarketDataInterface
|
class AlphaVantageMarketData implements MarketDataInterface
|
||||||
{
|
{
|
||||||
public function exists(String $symbol): Bool
|
public function exists(string $symbol): bool
|
||||||
{
|
{
|
||||||
|
|
||||||
return $this->quote($symbol)->isNotEmpty();
|
return (bool) $this->quote($symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function quote(String $symbol): Quote
|
public function quote(string $symbol): Quote
|
||||||
{
|
{
|
||||||
|
|
||||||
|
$search = Alphavantage::core()->search($symbol);
|
||||||
|
$search = Arr::get($search, 'bestMatches.0', null);
|
||||||
|
|
||||||
|
if (Arr::get($search, '9. matchScore') !== '1.0000') {
|
||||||
|
throw new \Exception('Could not find ticker on Alphavantage');
|
||||||
|
}
|
||||||
|
|
||||||
$quote = Alphavantage::core()->quoteEndpoint($symbol);
|
$quote = Alphavantage::core()->quoteEndpoint($symbol);
|
||||||
$quote = Arr::get($quote, 'Global Quote', []);
|
$quote = Arr::get($quote, 'Global Quote', []);
|
||||||
|
|
||||||
if (empty($quote)) return new Quote();
|
|
||||||
|
|
||||||
$fundamental = cache()->remember(
|
$fundamental = cache()->remember(
|
||||||
'av-symbol-'.$symbol,
|
'av-symbol-'.$symbol,
|
||||||
1440,
|
1440,
|
||||||
function () use ($symbol) {
|
function () use ($symbol, $search) {
|
||||||
return Alphavantage::fundamentals()->overview($symbol);
|
if (Arr::get($search, '3. type') === 'Equity') {
|
||||||
|
|
||||||
|
$fundamental = (array) Alphavantage::fundamentals()->overview($symbol);
|
||||||
|
} else {
|
||||||
|
|
||||||
|
$fundamental = (array) Alphavantage::fundamentals()->etfProfile($symbol);
|
||||||
|
|
||||||
|
Arr::set($fundamental, 'DividendYield', Arr::get($fundamental, 'dividend_yield'));
|
||||||
|
Arr::set($fundamental, 'MarketCapitalization', Arr::get($fundamental, 'net_assets'));
|
||||||
|
Arr::set($fundamental, 'InceptionDate', Arr::get($fundamental, 'inception_date'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fundamental;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return new Quote([
|
return new Quote([
|
||||||
'name' => Arr::get($fundamental, 'Name'),
|
'name' => Arr::get($search, '2. name'),
|
||||||
'symbol' => Arr::get($fundamental, 'Symbol'),
|
'symbol' => $symbol,
|
||||||
'market_value' => Arr::get($quote, '05. price'),
|
'market_value' => (float) Arr::get($quote, '05. price'),
|
||||||
'fifty_two_week_high' => Arr::get($fundamental, '52WeekHigh'),
|
'currency' => Arr::get($search, '8. currency'),
|
||||||
'fifty_two_week_low' => Arr::get($fundamental, '52WeekLow'),
|
'fifty_two_week_high' => (float) Arr::get($fundamental, '52WeekHigh'),
|
||||||
|
'fifty_two_week_low' => (float) Arr::get($fundamental, '52WeekLow'),
|
||||||
'forward_pe' => Arr::get($fundamental, 'ForwardPE'),
|
'forward_pe' => Arr::get($fundamental, 'ForwardPE'),
|
||||||
'trailing_pe' => Arr::get($fundamental, 'TrailingPE'),
|
'trailing_pe' => Arr::get($fundamental, 'TrailingPE'),
|
||||||
'market_cap' => Arr::get($fundamental, 'MarketCapitalization'),
|
'market_cap' => Arr::get($fundamental, 'MarketCapitalization'),
|
||||||
@@ -48,72 +69,84 @@ class AlphaVantageMarketData implements MarketDataInterface
|
|||||||
? Arr::get($fundamental, 'DividendDate')
|
? Arr::get($fundamental, 'DividendDate')
|
||||||
: null,
|
: null,
|
||||||
'dividend_yield' => Arr::get($fundamental, 'DividendYield') != 'None'
|
'dividend_yield' => Arr::get($fundamental, 'DividendYield') != 'None'
|
||||||
? Arr::get($fundamental, 'DividendYield')
|
? ((float) Arr::get($fundamental, 'DividendYield')) * 100
|
||||||
: null
|
: null,
|
||||||
]);
|
'meta_data' => [
|
||||||
|
'industry' => Arr::get($fundamental, 'Industry'),
|
||||||
|
'country' => Arr::get($search, '4. region'),
|
||||||
|
'exchange' => Arr::get($fundamental, 'Exchange'),
|
||||||
|
'description' => Arr::get($fundamental, 'Description'),
|
||||||
|
'asset_type' => Arr::get($search, '3. type'),
|
||||||
|
'sector' => Arr::get($fundamental, 'Sector'),
|
||||||
|
'first_trade_year' => Arr::get($fundamental, 'InceptionDate')
|
||||||
|
? Carbon::parse(Arr::get($fundamental, 'InceptionDate'))->format('Y')
|
||||||
|
: null,
|
||||||
|
'source' => 'alphavantage',
|
||||||
|
],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function dividends(String $symbol, $startDate, $endDate): Collection
|
public function dividends(string $symbol, $startDate, $endDate): Collection
|
||||||
{
|
{
|
||||||
$dividends = Alphavantage::fundamentals()->dividends($symbol);
|
$dividends = Alphavantage::fundamentals()->dividends($symbol);
|
||||||
$dividends = Arr::get($dividends, 'data', []);
|
$dividends = Arr::get($dividends, 'data', []);
|
||||||
|
|
||||||
return collect($dividends)
|
return collect($dividends)
|
||||||
->filter(function($dividend) use ($startDate, $endDate) {
|
->filter(function ($dividend) use ($startDate, $endDate) {
|
||||||
|
|
||||||
return Carbon::parse(Arr::get($dividend, 'ex_dividend_date'))->between($startDate, $endDate);
|
return Carbon::parse(Arr::get($dividend, 'ex_dividend_date'))->between($startDate, $endDate);
|
||||||
})
|
})
|
||||||
->map(function($dividend) use ($symbol) {
|
->map(function ($dividend) use ($symbol) {
|
||||||
|
|
||||||
return new Dividend([
|
return new Dividend([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => Carbon::parse(Arr::get($dividend, 'ex_dividend_date')),
|
'date' => Carbon::parse(Arr::get($dividend, 'ex_dividend_date')),
|
||||||
'dividend_amount' => Arr::get($dividend, 'amount'),
|
'dividend_amount' => (float) Arr::get($dividend, 'amount'),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function splits(String $symbol, $startDate, $endDate): Collection
|
public function splits(string $symbol, $startDate, $endDate): Collection
|
||||||
{
|
{
|
||||||
$splits = Alphavantage::fundamentals()->splits($symbol);
|
$splits = Alphavantage::fundamentals()->splits($symbol);
|
||||||
$splits = Arr::get($splits, 'data', []);
|
$splits = Arr::get($splits, 'data', []);
|
||||||
|
|
||||||
return collect($splits)
|
return collect($splits)
|
||||||
->filter(function($split) use ($startDate, $endDate) {
|
->filter(function ($split) use ($startDate, $endDate) {
|
||||||
|
|
||||||
return Carbon::parse(Arr::get($split, 'effective_date'))->between($startDate, $endDate);
|
return Carbon::parse(Arr::get($split, 'effective_date'))->between($startDate, $endDate);
|
||||||
})
|
})
|
||||||
->map(function($split) use ($symbol) {
|
->map(function ($split) use ($symbol) {
|
||||||
|
|
||||||
return new Split([
|
return new Split([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => Carbon::parse(Arr::get($split, 'effective_date')),
|
'date' => Carbon::parse(Arr::get($split, 'effective_date')),
|
||||||
'split_amount' => Arr::get($split, 'split_factor'),
|
'split_amount' => (float) Arr::get($split, 'split_factor'),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function history(String $symbol, $startDate, $endDate): Collection
|
public function history(string $symbol, $startDate, $endDate): Collection
|
||||||
{
|
{
|
||||||
|
|
||||||
$history = Alphavantage::timeSeries()->daily($symbol, 'full');
|
$history = Alphavantage::timeSeries()->daily($symbol, 'full');
|
||||||
|
|
||||||
$history = Arr::get($history, 'Time Series (Daily)', []);
|
$history = Arr::get($history, 'Time Series (Daily)', []);
|
||||||
|
|
||||||
return collect($history)
|
return collect($history)
|
||||||
->filter(function ($history, $date) use ($startDate, $endDate) {
|
->filter(function ($history, $date) use ($startDate, $endDate) {
|
||||||
|
|
||||||
return Carbon::parse($date)->between($startDate, $endDate);
|
return Carbon::parse($date)->between($startDate, $endDate);
|
||||||
})
|
})
|
||||||
->mapWithKeys(function($history, $date) use ($symbol) {
|
->mapWithKeys(function ($history, $date) use ($symbol) {
|
||||||
|
|
||||||
$date = Carbon::parse($date)->format('Y-m-d');
|
$date = Carbon::parse($date)->toDateString();
|
||||||
|
|
||||||
return [ $date => new Ohlc([
|
return [$date => new Ohlc([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => $date,
|
'date' => $date,
|
||||||
'close' => Arr::get($history, '4. close')
|
'close' => (float) Arr::get($history, '4. close'),
|
||||||
]) ];
|
])];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,32 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Interfaces\MarketData;
|
namespace App\Interfaces\MarketData;
|
||||||
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use App\Interfaces\MarketData\Types\Quote;
|
|
||||||
use App\Interfaces\MarketData\Types\Dividend;
|
use App\Interfaces\MarketData\Types\Dividend;
|
||||||
use App\Interfaces\MarketData\Types\Ohlc;
|
use App\Interfaces\MarketData\Types\Ohlc;
|
||||||
|
use App\Interfaces\MarketData\Types\Quote;
|
||||||
use App\Interfaces\MarketData\Types\Split;
|
use App\Interfaces\MarketData\Types\Split;
|
||||||
|
use Carbon\CarbonPeriod;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class FakeMarketData implements MarketDataInterface
|
class FakeMarketData implements MarketDataInterface
|
||||||
{
|
{
|
||||||
public function exists(String $symbol): Bool
|
public function exists(string $symbol): bool
|
||||||
{
|
{
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function quote(String $symbol): Quote
|
public function quote(string $symbol): Quote
|
||||||
{
|
{
|
||||||
|
|
||||||
return new Quote([
|
return new Quote([
|
||||||
'name' => 'ACME Company Ltd',
|
'name' => 'ACME Company Ltd',
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
|
'currency' => 'USD',
|
||||||
'market_value' => 230.19,
|
'market_value' => 230.19,
|
||||||
'fifty_two_week_high' => 512.90,
|
'fifty_two_week_high' => 512.90,
|
||||||
'fifty_two_week_low' => 341.20,
|
'fifty_two_week_low' => 341.20,
|
||||||
@@ -31,11 +35,12 @@ class FakeMarketData implements MarketDataInterface
|
|||||||
'market_cap' => 9800700600,
|
'market_cap' => 9800700600,
|
||||||
'book_value' => 4.7,
|
'book_value' => 4.7,
|
||||||
'last_dividend_date' => now()->subDays(45),
|
'last_dividend_date' => now()->subDays(45),
|
||||||
'dividend_yield' => 0.033
|
'dividend_yield' => 0.033,
|
||||||
|
'meta_data' => [],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function dividends(String $symbol, $startDate, $endDate): Collection
|
public function dividends(string $symbol, $startDate, $endDate): Collection
|
||||||
{
|
{
|
||||||
|
|
||||||
return collect([
|
return collect([
|
||||||
@@ -57,33 +62,44 @@ class FakeMarketData implements MarketDataInterface
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function splits(String $symbol, $startDate, $endDate): Collection
|
public function splits(string $symbol, $startDate, $endDate): Collection
|
||||||
{
|
{
|
||||||
|
|
||||||
return collect([
|
return collect([
|
||||||
new Split([
|
new Split([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => now()->subMonths(36),
|
'date' => now()->subMonths(12),
|
||||||
'split_amount' => 10,
|
'split_amount' => 10,
|
||||||
])
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function history(String $symbol, $startDate, $endDate): Collection
|
public function history(string $symbol, $startDate, $endDate): Collection
|
||||||
{
|
{
|
||||||
$numDays = Carbon::parse($startDate)->diffInDays($endDate, true);
|
$endDate = now()->isBefore(Carbon::parse(config('investbrain.daily_change_time_of_day')))
|
||||||
|
? now()->subDay()
|
||||||
|
: now();
|
||||||
|
|
||||||
for ($i = 0; $i < $numDays; $i++) {
|
$days = CarbonPeriod::create($startDate, $endDate)->filter('isWeekday');
|
||||||
|
|
||||||
$date = now()->subDays($i)->format('Y-m-d');
|
$countOfDays = $days->count();
|
||||||
|
|
||||||
|
foreach ($days as $index => $date) {
|
||||||
|
|
||||||
|
$date = $date->toDateString();
|
||||||
|
|
||||||
$series[$date] = new Ohlc([
|
$series[$date] = new Ohlc([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => $date,
|
'date' => $date,
|
||||||
'close' => rand(150, 400),
|
'open' => rand(150, 400),
|
||||||
|
'high' => rand(150, 400),
|
||||||
|
'low' => rand(150, 400),
|
||||||
|
'close' => $index == $countOfDays - 1
|
||||||
|
? 230.19 // most recent close should match current market value
|
||||||
|
: rand(150, 400),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return collect($series);
|
return collect($series);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,29 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Interfaces\MarketData;
|
namespace App\Interfaces\MarketData;
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class FallbackInterface
|
class FallbackInterface
|
||||||
{
|
{
|
||||||
|
|
||||||
protected string $latest_error;
|
protected string $latest_error;
|
||||||
|
|
||||||
public function __call($method, $arguments)
|
public function __call($method, $arguments)
|
||||||
{
|
{
|
||||||
|
|
||||||
$providers = explode(',', config('investbrain.provider', 'yahoo'));
|
$providers = explode(',', config('investbrain.provider', 'yahoo'));
|
||||||
|
|
||||||
foreach ($providers as $provider) {
|
foreach ($providers as $provider) {
|
||||||
|
|
||||||
$provider = trim($provider);
|
$provider = trim($provider);
|
||||||
|
$symbol = $arguments[0];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
Log::info("Calling method {$method} for {$symbol} ({$provider})");
|
||||||
|
|
||||||
if (!in_array($provider, array_keys(config('investbrain.interfaces', [])))) {
|
if (! in_array($provider, array_keys(config('investbrain.interfaces', [])))) {
|
||||||
|
|
||||||
throw new \Exception("Provider [{$provider}] is not a valid market data interface.");
|
throw new \Exception("Provider [{$provider}] is not a valid market data interface.");
|
||||||
}
|
}
|
||||||
@@ -30,13 +33,20 @@ class FallbackInterface
|
|||||||
return app()->make($provider_class_name)->$method(...$arguments);
|
return app()->make($provider_class_name)->$method(...$arguments);
|
||||||
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|
||||||
$this->latest_error = $e->getMessage();
|
$this->latest_error = $e->getMessage();
|
||||||
|
|
||||||
Log::warning("Failed calling method {$method} ({$provider}): {$this->latest_error}");
|
Log::error("Failed calling method {$method} for {$symbol} ({$provider}): {$this->latest_error}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new \Exception("Could not get market data: {$this->latest_error}");
|
// don't need to throw error if calling exists method...
|
||||||
|
if ($method == 'exists') {
|
||||||
|
|
||||||
|
// symbol prob just doesn't exist
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \Exception("Could not get market data calling method {$method}: {$this->latest_error}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Interfaces\MarketData;
|
namespace App\Interfaces\MarketData;
|
||||||
|
|
||||||
use Illuminate\Support\Arr;
|
use App\Interfaces\MarketData\Types\Dividend;
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use App\Interfaces\MarketData\Types\Ohlc;
|
use App\Interfaces\MarketData\Types\Ohlc;
|
||||||
use App\Interfaces\MarketData\Types\Quote;
|
use App\Interfaces\MarketData\Types\Quote;
|
||||||
use App\Interfaces\MarketData\Types\Split;
|
use App\Interfaces\MarketData\Types\Split;
|
||||||
use App\Interfaces\MarketData\Types\Dividend;
|
use Finnhub\ObjectSerializer;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class FinnhubMarketData implements MarketDataInterface
|
class FinnhubMarketData implements MarketDataInterface
|
||||||
{
|
{
|
||||||
@@ -16,53 +19,66 @@ class FinnhubMarketData implements MarketDataInterface
|
|||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
|
|
||||||
$this->client = new \Finnhub\Api\DefaultApi(
|
$this->client = new \Finnhub\Api\DefaultApi(
|
||||||
new \GuzzleHttp\Client(),
|
new \GuzzleHttp\Client,
|
||||||
\Finnhub\Configuration::getDefaultConfiguration()->setApiKey('token', config('finnhub.key'))
|
\Finnhub\Configuration::getDefaultConfiguration()->setApiKey('token', config('finnhub.key'))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
public function exists(String $symbol): Bool
|
|
||||||
|
public function exists(string $symbol): bool
|
||||||
{
|
{
|
||||||
|
|
||||||
return $this->quote($symbol)->isNotEmpty();
|
return (bool) $this->quote($symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function quote(string $symbol): Quote
|
public function quote(string $symbol): Quote
|
||||||
{
|
{
|
||||||
$quote = $this->client->quote($symbol);
|
$quote = $this->client->quote($symbol);
|
||||||
|
|
||||||
if (empty($quote)) return new Quote();
|
if (is_null(Arr::get($quote, 'd'))) {
|
||||||
|
throw new \Exception('Could not find ticker on Finnhub');
|
||||||
|
}
|
||||||
|
|
||||||
$fundamental = cache()->remember(
|
$fundamental = cache()->remember(
|
||||||
'fh-symbol-'.$symbol,
|
'fh-symbol-'.$symbol,
|
||||||
1440,
|
1440,
|
||||||
function () use ($symbol) {
|
function () use ($symbol) {
|
||||||
return $this->client->companyBasicFinancials($symbol, "all");
|
|
||||||
|
return array_merge(
|
||||||
|
(array) ObjectSerializer::sanitizeForSerialization($this->client->companyProfile2($symbol)),
|
||||||
|
(array) ObjectSerializer::sanitizeForSerialization($this->client->companyBasicFinancials($symbol, 'all')),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return new Quote([
|
return new Quote([
|
||||||
'name' => Arr::get($fundamental, 'metric.name'),
|
'name' => Arr::get($fundamental, 'name'),
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'market_value' => Arr::get($quote, 'c'),
|
'currency' => Arr::get($fundamental, 'currency'),
|
||||||
|
'market_value' => Arr::get($quote, 'c'),
|
||||||
'fifty_two_week_high' => Arr::get($fundamental, 'metric.52WeekHigh'),
|
'fifty_two_week_high' => Arr::get($fundamental, 'metric.52WeekHigh'),
|
||||||
'fifty_two_week_low' => Arr::get($fundamental, 'metric.52WeekLow'),
|
'fifty_two_week_low' => Arr::get($fundamental, 'metric.52WeekLow'),
|
||||||
'forward_pe' => Arr::get($fundamental, 'metric.forwardPE'), // confirm
|
'forward_pe' => Arr::get($fundamental, 'metric.peAnnual'),
|
||||||
'trailing_pe' => Arr::get($fundamental, 'metric.trailingPE'), // confirm
|
'trailing_pe' => Arr::get($fundamental, 'metric.peTTM'),
|
||||||
'market_cap' => Arr::get($fundamental, 'metric.marketCapitalization'), // confirm
|
'market_cap' => Arr::get($fundamental, 'metric.marketCapitalization', 0) * 1000000,
|
||||||
'book_value' => Arr::get($fundamental, 'metric.bookValuePerShare'), // confirm
|
'book_value' => Arr::get($fundamental, 'metric.bookValuePerShareAnnual'),
|
||||||
'last_dividend_date' => Arr::get($fundamental, 'metric.lastDivDate'), // confirm
|
'dividend_yield' => Arr::get($fundamental, 'metric.dividendYieldIndicatedAnnual'),
|
||||||
'dividend_yield' => Arr::get($fundamental, 'metric.dividendYield'), // confirm
|
'meta_data' => [
|
||||||
]);
|
'country' => Arr::get($fundamental, 'country'),
|
||||||
|
'exchange' => Arr::get($fundamental, 'exchange'),
|
||||||
|
'first_trade_year' => Arr::get($fundamental, 'ipo') ? Carbon::parse(Arr::get($fundamental, 'ipo'))->format('Y') : null,
|
||||||
|
'source' => 'finnhub',
|
||||||
|
],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function dividends($symbol, $startDate, $endDate): Collection
|
public function dividends($symbol, $startDate, $endDate): Collection
|
||||||
{
|
{
|
||||||
$dividends = $this->client->stockDividends($symbol, $startDate->format('Y-m-d'), $endDate->format('Y-m-d'));
|
$dividends = $this->client->stockDividends($symbol, $startDate->toDateString(), $endDate->toDateString());
|
||||||
|
|
||||||
return collect($dividends)->map(function($dividend) use ($symbol) {
|
return collect($dividends)->map(function ($dividend) use ($symbol) {
|
||||||
|
|
||||||
return new Dividend([
|
return new Dividend([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => Carbon::parse(Arr::get($dividend, 'date')),
|
'date' => Carbon::parse(Arr::get($dividend, 'date')),
|
||||||
@@ -72,12 +88,12 @@ class FinnhubMarketData implements MarketDataInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function splits($symbol, $startDate, $endDate): Collection
|
public function splits($symbol, $startDate, $endDate): Collection
|
||||||
{
|
{
|
||||||
|
|
||||||
$splits = $this->client->stockSplits($symbol, $startDate->format('Y-m-d'), $endDate->format('Y-m-d'));
|
$splits = $this->client->stockSplits($symbol, $startDate->toDateString(), $endDate->toDateString());
|
||||||
|
|
||||||
|
return collect($splits)->map(function ($split) use ($symbol) {
|
||||||
|
|
||||||
return collect($splits)->map(function($split) use ($symbol) {
|
|
||||||
|
|
||||||
return new Split([
|
return new Split([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => Carbon::parse(Arr::get($split, 'date')),
|
'date' => Carbon::parse(Arr::get($split, 'date')),
|
||||||
@@ -89,18 +105,19 @@ class FinnhubMarketData implements MarketDataInterface
|
|||||||
public function history($symbol, $startDate, $endDate): Collection
|
public function history($symbol, $startDate, $endDate): Collection
|
||||||
{
|
{
|
||||||
|
|
||||||
$history = $this->client->stockCandles($symbol, "D", $startDate->timestamp, $endDate->timestamp);
|
$history = $this->client->stockCandles($symbol, 'D', $startDate->timestamp, $endDate->timestamp);
|
||||||
|
|
||||||
$timestamps = Arr::get($history, 't', []);
|
$timestamps = Arr::get($history, 't', []);
|
||||||
$closes = Arr::get($history, 'c', []);
|
$closes = Arr::get($history, 'c', []);
|
||||||
|
|
||||||
return collect($timestamps)->mapWithKeys(function ($timestamp, $index) use ($symbol, $closes) {
|
return collect($timestamps)->mapWithKeys(function ($timestamp, $index) use ($symbol, $closes) {
|
||||||
$date = Carbon::createFromTimestamp($timestamp)->format('Y-m-d');
|
$date = Carbon::createFromTimestamp($timestamp)->toDateString();
|
||||||
return [ $date => new Ohlc([
|
|
||||||
|
return [$date => new Ohlc([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => $date,
|
'date' => $date,
|
||||||
'close' => $closes[$index],
|
'close' => $closes[$index],
|
||||||
]) ];
|
])];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +1,36 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Interfaces\MarketData;
|
namespace App\Interfaces\MarketData;
|
||||||
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use App\Interfaces\MarketData\Types\Quote;
|
use App\Interfaces\MarketData\Types\Quote;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
interface MarketDataInterface
|
interface MarketDataInterface
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Does this symbol actually exist?
|
* Does this symbol actually exist?
|
||||||
*
|
|
||||||
* @param String $symbol
|
|
||||||
*
|
|
||||||
* @return Bool
|
|
||||||
*/
|
*/
|
||||||
public function exists(String $symbol): Bool;
|
public function exists(string $symbol): bool;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get quote data
|
* Get quote data
|
||||||
*
|
|
||||||
* @param String $symbol
|
|
||||||
*
|
|
||||||
* @return Quote
|
|
||||||
*/
|
*/
|
||||||
public function quote(String $symbol): Quote;
|
public function quote(string $symbol): Quote;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get dividend data
|
* Get dividend data
|
||||||
*
|
|
||||||
* @param String $symbol
|
|
||||||
* @param \DateTimeInterface $startDate
|
|
||||||
* @param \DateTimeInterface $endDate
|
|
||||||
*
|
|
||||||
* @return Collection
|
|
||||||
*/
|
*/
|
||||||
public function dividends(String $symbol, \DateTimeInterface $startDate, \DateTimeInterface $endDate): Collection;
|
public function dividends(string $symbol, \DateTimeInterface $startDate, \DateTimeInterface $endDate): Collection;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get split data
|
* Get split data
|
||||||
*
|
|
||||||
* @param String $symbol
|
|
||||||
* @param \DateTimeInterface $startDate
|
|
||||||
* @param \DateTimeInterface $endDate
|
|
||||||
*
|
|
||||||
* @return Collection
|
|
||||||
*/
|
*/
|
||||||
public function splits(String $symbol, \DateTimeInterface $startDate, \DateTimeInterface $endDate): Collection;
|
public function splits(string $symbol, \DateTimeInterface $startDate, \DateTimeInterface $endDate): Collection;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get historical close data
|
* Get historical close data
|
||||||
*
|
|
||||||
* @param String $symbol
|
|
||||||
* @param \DateTimeInterface $startDate
|
|
||||||
* @param \DateTimeInterface $endDate
|
|
||||||
*
|
|
||||||
* @return Collection
|
|
||||||
*/
|
*/
|
||||||
public function history(String $symbol, \DateTimeInterface $startDate, \DateTimeInterface $endDate): Collection;
|
public function history(string $symbol, \DateTimeInterface $startDate, \DateTimeInterface $endDate): Collection;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Interfaces\MarketData;
|
||||||
|
|
||||||
|
use App\Interfaces\MarketData\Types\Dividend;
|
||||||
|
use App\Interfaces\MarketData\Types\Ohlc;
|
||||||
|
use App\Interfaces\MarketData\Types\Quote;
|
||||||
|
use App\Interfaces\MarketData\Types\Split;
|
||||||
|
use Illuminate\Http\Client\PendingRequest;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
class TwelveDataMarketData implements MarketDataInterface
|
||||||
|
{
|
||||||
|
public PendingRequest $client;
|
||||||
|
|
||||||
|
public string $apiBaseUrl = 'https://api.twelvedata.com/';
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createNewClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createNewClient()
|
||||||
|
{
|
||||||
|
$this->client = Http::withOptions([
|
||||||
|
'headers' => [
|
||||||
|
'content-type' => 'application/json',
|
||||||
|
'accept' => 'application/json',
|
||||||
|
],
|
||||||
|
])->withQueryParameters([
|
||||||
|
'apikey' => config('twelvedata.secret'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exists(string $symbol): bool
|
||||||
|
{
|
||||||
|
|
||||||
|
return (bool) $this->quote($symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function quote(string $symbol): Quote
|
||||||
|
{
|
||||||
|
|
||||||
|
$response = $this->client
|
||||||
|
->baseUrl($this->apiBaseUrl)
|
||||||
|
->withQueryParameters(['symbol' => $symbol])
|
||||||
|
->get('price');
|
||||||
|
|
||||||
|
$quote = $response->json();
|
||||||
|
|
||||||
|
throw_if(empty(Arr::get($quote, 'price')), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
|
||||||
|
|
||||||
|
$current_market_value = Arr::get($quote, 'price');
|
||||||
|
|
||||||
|
$fundamental = cache()->remember(
|
||||||
|
'twelve-data-symbol-'.$symbol,
|
||||||
|
1440,
|
||||||
|
function () use ($symbol) {
|
||||||
|
|
||||||
|
$this->createNewClient();
|
||||||
|
|
||||||
|
$response = $this->client
|
||||||
|
->baseUrl($this->apiBaseUrl)
|
||||||
|
->withQueryParameters(['symbol' => $symbol])
|
||||||
|
->get('quote');
|
||||||
|
|
||||||
|
return $response->json();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Quote([
|
||||||
|
'name' => Arr::get($fundamental, 'name'),
|
||||||
|
'symbol' => $symbol,
|
||||||
|
'currency' => Arr::get($fundamental, 'currency'),
|
||||||
|
'market_value' => (float) $current_market_value,
|
||||||
|
'fifty_two_week_high' => (float) Arr::get($fundamental, 'fifty_two_week.high'),
|
||||||
|
'fifty_two_week_low' => (float) Arr::get($fundamental, 'fifty_two_week.low'),
|
||||||
|
'meta_data' => [
|
||||||
|
'exchange' => Arr::get($fundamental, 'exchange'),
|
||||||
|
'source' => 'twelvedata',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dividends(string $symbol, $startDate, $endDate): Collection
|
||||||
|
{
|
||||||
|
|
||||||
|
$response = $this->client
|
||||||
|
->baseUrl($this->apiBaseUrl)
|
||||||
|
->withQueryParameters([
|
||||||
|
'symbol' => $symbol,
|
||||||
|
'start_date' => Carbon::parse($startDate)->toDateString(),
|
||||||
|
'end_date' => Carbon::parse($endDate)->toDateString(),
|
||||||
|
])
|
||||||
|
->get('dividends');
|
||||||
|
|
||||||
|
$dividends = $response->json('dividends');
|
||||||
|
|
||||||
|
return collect($dividends)
|
||||||
|
->map(function ($dividend) use ($symbol) {
|
||||||
|
|
||||||
|
return new Dividend([
|
||||||
|
'symbol' => $symbol,
|
||||||
|
'date' => Arr::get($dividend, 'ex_date'),
|
||||||
|
'dividend_amount' => Arr::get($dividend, 'amount'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function splits(string $symbol, $startDate, $endDate): Collection
|
||||||
|
{
|
||||||
|
|
||||||
|
$response = $this->client
|
||||||
|
->baseUrl($this->apiBaseUrl)
|
||||||
|
->withQueryParameters([
|
||||||
|
'symbol' => $symbol,
|
||||||
|
'start_date' => Carbon::parse($startDate)->toDateString(),
|
||||||
|
'end_date' => Carbon::parse($endDate)->toDateString(),
|
||||||
|
])
|
||||||
|
->get('splits');
|
||||||
|
|
||||||
|
$splits = $response->json('splits');
|
||||||
|
|
||||||
|
return collect($splits)
|
||||||
|
->map(function ($split) use ($symbol) {
|
||||||
|
|
||||||
|
return new Split([
|
||||||
|
'symbol' => $symbol,
|
||||||
|
'date' => Arr::get($split, 'date'),
|
||||||
|
'split_amount' => Arr::get($split, 'from_factor') / Arr::get($split, 'to_factor'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function history(string $symbol, $startDate, $endDate): Collection
|
||||||
|
{
|
||||||
|
|
||||||
|
$response = $this->client
|
||||||
|
->baseUrl($this->apiBaseUrl)
|
||||||
|
->withQueryParameters([
|
||||||
|
'symbol' => $symbol,
|
||||||
|
'interval' => '1day',
|
||||||
|
'start_date' => Carbon::parse($startDate)->toDateString(),
|
||||||
|
'end_date' => Carbon::parse($endDate)->toDateString(),
|
||||||
|
])
|
||||||
|
->get('time_series');
|
||||||
|
|
||||||
|
$history = $response->json('values');
|
||||||
|
|
||||||
|
throw_if(empty($history), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
|
||||||
|
|
||||||
|
return collect($history)
|
||||||
|
->mapWithKeys(function ($history) use ($symbol) {
|
||||||
|
|
||||||
|
$date = Carbon::parse(Arr::get($history, 'datetime'))->toDateString();
|
||||||
|
|
||||||
|
return [$date => new Ohlc([
|
||||||
|
'symbol' => $symbol,
|
||||||
|
'date' => $date,
|
||||||
|
'close' => (float) Arr::get($history, 'close'),
|
||||||
|
])];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Interfaces\MarketData\Types;
|
namespace App\Interfaces\MarketData\Types;
|
||||||
|
|
||||||
use DateTime;
|
use DateTime;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use App\Interfaces\MarketData\Types\MarketDataType;
|
|
||||||
|
|
||||||
class Dividend extends MarketDataType
|
class Dividend extends MarketDataType
|
||||||
{
|
{
|
||||||
public function setSymbol(string $symbol): self
|
public function setSymbol(string $symbol): self
|
||||||
{
|
{
|
||||||
$this->items['symbol'] = $symbol;
|
$this->items['symbol'] = $symbol;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,9 +21,10 @@ class Dividend extends MarketDataType
|
|||||||
return $this->items['symbol'] ?? '';
|
return $this->items['symbol'] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setDividendAmount($dividendAmount): self
|
public function setDividendAmount(int|float $dividendAmount): self
|
||||||
{
|
{
|
||||||
$this->items['dividend_amount'] = (float) $dividendAmount;
|
$this->items['dividend_amount'] = (float) $dividendAmount;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,9 +33,10 @@ class Dividend extends MarketDataType
|
|||||||
return $this->items['dividend_amount'] ?? 0.0;
|
return $this->items['dividend_amount'] ?? 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setDate(String|DateTime $date): self
|
public function setDate(string|DateTime $date): self
|
||||||
{
|
{
|
||||||
$this->items['date'] = Carbon::parse($date)->format('Y-m-d H:i:s');
|
$this->items['date'] = Carbon::parse($date)->format('Y-m-d H:i:s');
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,4 +44,4 @@ class Dividend extends MarketDataType
|
|||||||
{
|
{
|
||||||
return $this->items['date'] ?? null;
|
return $this->items['date'] ?? null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,91 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Interfaces\MarketData\Types;
|
namespace App\Interfaces\MarketData\Types;
|
||||||
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class MarketDataType extends Collection
|
class MarketDataType extends Collection
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
public function __construct($items = [])
|
public function __construct($items = [])
|
||||||
{
|
{
|
||||||
|
|
||||||
foreach($this->getArrayableItems($items) as $key => $value) {
|
$items = $this->getArrayableItems($items);
|
||||||
|
|
||||||
$this->{$key} = $value;
|
foreach ($items as $key => $value) {
|
||||||
|
|
||||||
|
$this->validateRequiredTypes($key, $value);
|
||||||
|
|
||||||
|
if (! is_null($value)) {
|
||||||
|
$this->{$key} = $value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function toArray()
|
|
||||||
{
|
|
||||||
return $this->items;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function __set($key, $value)
|
public function __set($key, $value)
|
||||||
{
|
{
|
||||||
$this->{'set'.Str::studly($key)}($value);
|
|
||||||
|
$this->{$this->getSetMethodName($key)}($value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function __get($key)
|
public function __get($key)
|
||||||
{
|
{
|
||||||
return $this->items[$key] ?? null;
|
return $this->items[$key] ?? null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
protected function getSetMethodName($key): string
|
||||||
|
{
|
||||||
|
return 'set'.Str::studly($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function validateRequiredTypes($key, $value, $type = null): void
|
||||||
|
{
|
||||||
|
$method = new \ReflectionMethod($this, $this->getSetMethodName($key));
|
||||||
|
$params = $method->getParameters();
|
||||||
|
|
||||||
|
// no required type
|
||||||
|
if (is_null($type) && is_null($type = $params[0]->getType())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// can`t validate a mixed type
|
||||||
|
if ($type == 'mixed') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// has a union type, let's iterate
|
||||||
|
if ($type instanceof \ReflectionUnionType) {
|
||||||
|
|
||||||
|
foreach ($type->getTypes() as $subType) {
|
||||||
|
$expected[] = $subType;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->validateRequiredTypes($key, $value, $subType);
|
||||||
|
|
||||||
|
return;
|
||||||
|
} catch (\InvalidArgumentException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check type
|
||||||
|
if ($type instanceof \ReflectionNamedType) {
|
||||||
|
$expected = $type->getName();
|
||||||
|
|
||||||
|
if (get_debug_type($value) == $expected || ($type->allowsNull() && $value === null)) {
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (class_exists($expected) && is_subclass_of(get_debug_type($value), $expected)) {
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \InvalidArgumentException("Invalid type for {$key}. Expected ".implode('|', array_map(fn ($t) => $t, Arr::wrap($expected))).' but got '.get_debug_type($value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Interfaces\MarketData\Types;
|
namespace App\Interfaces\MarketData\Types;
|
||||||
|
|
||||||
use DateTime;
|
use DateTime;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use App\Interfaces\MarketData\Types\MarketDataType;
|
|
||||||
|
|
||||||
class Ohlc extends MarketDataType
|
class Ohlc extends MarketDataType
|
||||||
{
|
{
|
||||||
public function setSymbol(string $symbol): self
|
public function setSymbol(string $symbol): self
|
||||||
{
|
{
|
||||||
$this->items['symbol'] = $symbol;
|
$this->items['symbol'] = $symbol;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,9 +21,10 @@ class Ohlc extends MarketDataType
|
|||||||
return $this->items['symbol'] ?? '';
|
return $this->items['symbol'] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setOpen($open): self
|
public function setOpen(int|float $open): self
|
||||||
{
|
{
|
||||||
$this->items['open'] = (float) $open;
|
$this->items['open'] = (float) $open;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,9 +33,10 @@ class Ohlc extends MarketDataType
|
|||||||
return $this->items['open'] ?? 0.0;
|
return $this->items['open'] ?? 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setHigh($high): self
|
public function setHigh(int|float $high): self
|
||||||
{
|
{
|
||||||
$this->items['high'] = (float) $high;
|
$this->items['high'] = (float) $high;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,9 +45,10 @@ class Ohlc extends MarketDataType
|
|||||||
return $this->items['high'] ?? 0.0;
|
return $this->items['high'] ?? 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setLow($low): self
|
public function setLow(int|float $low): self
|
||||||
{
|
{
|
||||||
$this->items['low'] = (float) $low;
|
$this->items['low'] = (float) $low;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,9 +57,10 @@ class Ohlc extends MarketDataType
|
|||||||
return $this->items['low'] ?? 0.0;
|
return $this->items['low'] ?? 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setClose($close): self
|
public function setClose(int|float $close): self
|
||||||
{
|
{
|
||||||
$this->items['close'] = (float) $close;
|
$this->items['close'] = (float) $close;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,9 +69,10 @@ class Ohlc extends MarketDataType
|
|||||||
return $this->items['close'] ?? 0.0;
|
return $this->items['close'] ?? 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setDate(String|DateTime $date): self
|
public function setDate(string|DateTime $date): self
|
||||||
{
|
{
|
||||||
$this->items['date'] = Carbon::parse($date)->format('Y-m-d H:i:s');
|
$this->items['date'] = Carbon::parse($date)->format('Y-m-d H:i:s');
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,4 +80,4 @@ class Ohlc extends MarketDataType
|
|||||||
{
|
{
|
||||||
return $this->items['date'] ?? null;
|
return $this->items['date'] ?? null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Interfaces\MarketData\Types;
|
namespace App\Interfaces\MarketData\Types;
|
||||||
|
|
||||||
use DateTime;
|
use DateTime;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use App\Interfaces\MarketData\Types\MarketDataType;
|
|
||||||
|
|
||||||
class Quote extends MarketDataType
|
class Quote extends MarketDataType
|
||||||
{
|
{
|
||||||
public function setName($name): self
|
public function setName($name): self
|
||||||
{
|
{
|
||||||
$this->items['name'] = (string) $name;
|
if (! empty($name)) {
|
||||||
|
$this->items['name'] = (string) $name;
|
||||||
|
}
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,9 +24,10 @@ class Quote extends MarketDataType
|
|||||||
return $this->items['name'] ?? '';
|
return $this->items['name'] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setSymbol($symbol): self
|
public function setSymbol(string $symbol): self
|
||||||
{
|
{
|
||||||
$this->items['symbol'] = (string) $symbol;
|
$this->items['symbol'] = (string) $symbol;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,9 +36,30 @@ class Quote extends MarketDataType
|
|||||||
return $this->items['symbol'] ?? '';
|
return $this->items['symbol'] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setMarketValue($marketValue): self
|
public function setCurrency(string $currency): self
|
||||||
|
{
|
||||||
|
// need to standardize to ISO 4217
|
||||||
|
$currency = match ($currency) {
|
||||||
|
'US' => 'USD',
|
||||||
|
'CA' => 'CAD',
|
||||||
|
'GBp' => 'GBX',
|
||||||
|
default => $currency
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->items['currency'] = strtoupper((string) $currency);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCurrency(): string
|
||||||
|
{
|
||||||
|
return $this->items['currency'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMarketValue(int|float $marketValue): self
|
||||||
{
|
{
|
||||||
$this->items['market_value'] = (float) $marketValue;
|
$this->items['market_value'] = (float) $marketValue;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,9 +68,10 @@ class Quote extends MarketDataType
|
|||||||
return $this->items['market_value'] ?? 0.0;
|
return $this->items['market_value'] ?? 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setFiftyTwoWeekHigh($high): self
|
public function setFiftyTwoWeekHigh($high): self
|
||||||
{
|
{
|
||||||
$this->items['fifty_two_week_high'] = (float) $high;
|
$this->items['fifty_two_week_high'] = (float) $high;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,9 +80,10 @@ class Quote extends MarketDataType
|
|||||||
return $this->items['fifty_two_week_high'] ?? 0.0;
|
return $this->items['fifty_two_week_high'] ?? 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setFiftyTwoWeekLow($low): self
|
public function setFiftyTwoWeekLow($low): self
|
||||||
{
|
{
|
||||||
$this->items['fifty_two_week_low'] = (float) $low;
|
$this->items['fifty_two_week_low'] = (float) $low;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,9 +92,10 @@ class Quote extends MarketDataType
|
|||||||
return $this->items['fifty_two_week_low'] ?? 0.0;
|
return $this->items['fifty_two_week_low'] ?? 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setForwardPE($pe): self
|
public function setForwardPE($pe): self
|
||||||
{
|
{
|
||||||
$this->items['forward_pe'] = (float) $pe;
|
$this->items['forward_pe'] = (float) $pe;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,9 +104,10 @@ class Quote extends MarketDataType
|
|||||||
return $this->items['forward_pe'] ?? 0.0;
|
return $this->items['forward_pe'] ?? 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setTrailingPE($pe): self
|
public function setTrailingPE($pe): self
|
||||||
{
|
{
|
||||||
$this->items['trailing_pe'] = (float) $pe;
|
$this->items['trailing_pe'] = (float) $pe;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +118,9 @@ class Quote extends MarketDataType
|
|||||||
|
|
||||||
public function setMarketCap($cap): self
|
public function setMarketCap($cap): self
|
||||||
{
|
{
|
||||||
|
// return $this;
|
||||||
$this->items['market_cap'] = (int) $cap;
|
$this->items['market_cap'] = (int) $cap;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,9 +129,10 @@ class Quote extends MarketDataType
|
|||||||
return $this->items['market_cap'] ?? 0;
|
return $this->items['market_cap'] ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setBookValue($value): self
|
public function setBookValue($value): self
|
||||||
{
|
{
|
||||||
$this->items['book_value'] = (float) $value;
|
$this->items['book_value'] = (float) $value;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,9 +141,22 @@ class Quote extends MarketDataType
|
|||||||
return $this->items['book_value'] ?? 0.0;
|
return $this->items['book_value'] ?? 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setLastDividendAmount($value): self
|
||||||
|
{
|
||||||
|
$this->items['last_dividend_amount'] = (float) $value;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLastDividendAmount(): float
|
||||||
|
{
|
||||||
|
return $this->items['last_dividend_amount'] ?? 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
public function setLastDividendDate(mixed $date): self
|
public function setLastDividendDate(mixed $date): self
|
||||||
{
|
{
|
||||||
$this->items['last_dividend_date'] = is_null($date) ? null : Carbon::parse($date)->format('Y-m-d H:i:s');
|
$this->items['last_dividend_date'] = is_null($date) ? null : Carbon::parse($date)->format('Y-m-d H:i:s');
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,9 +165,10 @@ class Quote extends MarketDataType
|
|||||||
return $this->items['last_dividend_date'] ?? null;
|
return $this->items['last_dividend_date'] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setDividendYield($yield): self
|
public function setDividendYield($yield): self
|
||||||
{
|
{
|
||||||
$this->items['dividend_yield'] = (float) $yield;
|
$this->items['dividend_yield'] = (float) $yield;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,4 +176,28 @@ class Quote extends MarketDataType
|
|||||||
{
|
{
|
||||||
return $this->items['dividend_yield'] ?? 0.0;
|
return $this->items['dividend_yield'] ?? 0.0;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
public function setMetaData(array $meta_data): self
|
||||||
|
{
|
||||||
|
$defaults = [
|
||||||
|
'sector' => null,
|
||||||
|
'industry' => null,
|
||||||
|
'country' => null,
|
||||||
|
'exchange' => null,
|
||||||
|
'description' => null,
|
||||||
|
'asset_type' => null,
|
||||||
|
'first_trade_year' => null,
|
||||||
|
'source' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
// merges the NEW values with highest priority over previous values and defaults
|
||||||
|
$this->items['meta_data'] = array_merge($defaults, $this->items['meta_data'] ?? [], Arr::skipEmptyValues($meta_data));
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMetaData(): array
|
||||||
|
{
|
||||||
|
return $this->items['meta_data'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Interfaces\MarketData\Types;
|
namespace App\Interfaces\MarketData\Types;
|
||||||
|
|
||||||
use DateTime;
|
use DateTime;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use App\Interfaces\MarketData\Types\MarketDataType;
|
|
||||||
|
|
||||||
class Split extends MarketDataType
|
class Split extends MarketDataType
|
||||||
{
|
{
|
||||||
public function setSymbol(string $symbol): self
|
public function setSymbol(string $symbol): self
|
||||||
{
|
{
|
||||||
$this->items['symbol'] = $symbol;
|
$this->items['symbol'] = $symbol;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,9 +21,10 @@ class Split extends MarketDataType
|
|||||||
return $this->items['symbol'] ?? '';
|
return $this->items['symbol'] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setSplitAmount($splitAmount): self
|
public function setSplitAmount(int|float $splitAmount): self
|
||||||
{
|
{
|
||||||
$this->items['split_amount'] = (float) $splitAmount;
|
$this->items['split_amount'] = (float) $splitAmount;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,9 +33,10 @@ class Split extends MarketDataType
|
|||||||
return $this->items['split_amount'] ?? 0.0;
|
return $this->items['split_amount'] ?? 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setDate(String|DateTime $date): self
|
public function setDate(string|DateTime $date): self
|
||||||
{
|
{
|
||||||
$this->items['date'] = Carbon::parse($date)->format('Y-m-d H:i:s');
|
$this->items['date'] = Carbon::parse($date)->format('Y-m-d H:i:s');
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,4 +44,4 @@ class Split extends MarketDataType
|
|||||||
{
|
{
|
||||||
return $this->items['date'] ?? null;
|
return $this->items['date'] ?? null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,95 +1,110 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Interfaces\MarketData;
|
namespace App\Interfaces\MarketData;
|
||||||
|
|
||||||
use Illuminate\Support\Collection;
|
use App\Interfaces\MarketData\Types\Dividend;
|
||||||
use Scheb\YahooFinanceApi\ApiClient;
|
|
||||||
use App\Interfaces\MarketData\Types\Ohlc;
|
use App\Interfaces\MarketData\Types\Ohlc;
|
||||||
use App\Interfaces\MarketData\Types\Quote;
|
use App\Interfaces\MarketData\Types\Quote;
|
||||||
use App\Interfaces\MarketData\Types\Split;
|
use App\Interfaces\MarketData\Types\Split;
|
||||||
use App\Interfaces\MarketData\Types\Dividend;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Scheb\YahooFinanceApi\ApiClient;
|
||||||
use Scheb\YahooFinanceApi\ApiClientFactory as YahooFinance;
|
use Scheb\YahooFinanceApi\ApiClientFactory as YahooFinance;
|
||||||
|
|
||||||
class YahooMarketData implements MarketDataInterface
|
class YahooMarketData implements MarketDataInterface
|
||||||
{
|
{
|
||||||
public ApiClient $client;
|
public ApiClient $client;
|
||||||
|
|
||||||
public function __construct() {
|
public function __construct()
|
||||||
|
|
||||||
// create yahoo finance client factory
|
|
||||||
$this->client = YahooFinance::createApiClient();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function exists(String $symbol): Bool
|
|
||||||
{
|
{
|
||||||
|
|
||||||
return $this->quote($symbol)->isNotEmpty();
|
// create yahoo finance client factory
|
||||||
|
$this->client = YahooFinance::createApiClient(
|
||||||
|
clientOptions: ['headers' => ['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36']],
|
||||||
|
cache: app('cache.psr6')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function quote(String $symbol): Quote
|
public function exists(string $symbol): bool
|
||||||
|
{
|
||||||
|
|
||||||
|
return (bool) $this->quote($symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function quote(string $symbol): Quote
|
||||||
{
|
{
|
||||||
|
|
||||||
$quote = $this->client->getQuote($symbol);
|
$quote = $this->client->getQuote($symbol);
|
||||||
|
|
||||||
if (empty($quote)) return collect();
|
if (is_null($quote?->getRegularMarketPrice())) {
|
||||||
|
throw new \Exception('Could not find ticker on Yahoo');
|
||||||
|
}
|
||||||
|
|
||||||
return new Quote([
|
return new Quote([
|
||||||
'name' => $quote->getLongName() ?? $quote->getShortName(),
|
'name' => $quote?->getLongName() ?? $quote?->getShortName(),
|
||||||
'symbol' => $quote->getSymbol(),
|
'symbol' => $symbol,
|
||||||
'market_value' => $quote->getRegularMarketPrice(),
|
'currency' => $quote?->getCurrency(),
|
||||||
'fifty_two_week_high' => $quote->getFiftyTwoWeekHigh(),
|
'market_value' => $quote?->getRegularMarketPrice(),
|
||||||
'fifty_two_week_low' => $quote->getFiftyTwoWeekLow(),
|
'fifty_two_week_high' => $quote?->getFiftyTwoWeekHigh(),
|
||||||
'forward_pe' => $quote->getForwardPE(),
|
'fifty_two_week_low' => $quote?->getFiftyTwoWeekLow(),
|
||||||
'trailing_pe' => $quote->getTrailingPE(),
|
'forward_pe' => $quote?->getForwardPE(),
|
||||||
'market_cap' => $quote->getMarketCap(),
|
'trailing_pe' => $quote?->getTrailingPE(),
|
||||||
'book_value' => $quote->getBookValue(),
|
'market_cap' => $quote?->getMarketCap(),
|
||||||
'last_dividend_date' => $quote->getDividendDate(),
|
'book_value' => $quote?->getBookValue(),
|
||||||
'dividend_yield' => $quote->getTrailingAnnualDividendYield() * 100
|
'last_dividend_date' => $quote?->getDividendDate(),
|
||||||
|
'dividend_yield' => $quote?->getTrailingAnnualDividendYield() * 100,
|
||||||
|
'meta_data' => [
|
||||||
|
'exchange' => $quote?->getExchange(),
|
||||||
|
'asset_type' => $quote?->getQuoteType(),
|
||||||
|
'source' => 'yahoo',
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function dividends(String $symbol, $startDate, $endDate): Collection
|
public function dividends(string $symbol, $startDate, $endDate): Collection
|
||||||
{
|
{
|
||||||
|
|
||||||
return collect($this->client->getHistoricalDividendData($symbol, $startDate, $endDate))
|
return collect($this->client->getHistoricalDividendData($symbol, $startDate, $endDate))
|
||||||
->map(function($dividend) use ($symbol) {
|
->map(function ($dividend) use ($symbol) {
|
||||||
|
|
||||||
return new Dividend([
|
return new Dividend([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => $dividend->getDate(),
|
'date' => $dividend->getDate(),
|
||||||
'dividend_amount' => $dividend->getDividends(),
|
'dividend_amount' => $dividend->getDividends(),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function splits(String $symbol, $startDate, $endDate): Collection
|
public function splits(string $symbol, $startDate, $endDate): Collection
|
||||||
{
|
{
|
||||||
|
|
||||||
return collect($this->client->getHistoricalSplitData($symbol, $startDate, $endDate))
|
return collect($this->client->getHistoricalSplitData($symbol, $startDate, $endDate))
|
||||||
->map(function($split) use ($symbol) {
|
->map(function ($split) use ($symbol) {
|
||||||
$split_amount = explode(':', $split->getStockSplits());
|
$split_amount = explode(':', $split->getStockSplits());
|
||||||
|
|
||||||
return new Split([
|
return new Split([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => $split->getDate(),
|
'date' => $split->getDate(),
|
||||||
'split_amount' => $split_amount[0] / $split_amount[1],
|
'split_amount' => $split_amount[0] / $split_amount[1],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function history(String $symbol, $startDate, $endDate): Collection
|
public function history(string $symbol, $startDate, $endDate): Collection
|
||||||
{
|
{
|
||||||
|
|
||||||
return collect($this->client->getHistoricalQuoteData($symbol, ApiClient::INTERVAL_1_DAY, $startDate, $endDate))
|
return collect($this->client->getHistoricalQuoteData($symbol, ApiClient::INTERVAL_1_DAY, $startDate, $endDate))
|
||||||
->mapWithKeys(function($history) use ($symbol) {
|
->mapWithKeys(function ($history) use ($symbol) {
|
||||||
|
|
||||||
$date = $history->getDate()->format('Y-m-d');
|
$date = Carbon::parse($history->getDate())->toDateString();
|
||||||
|
|
||||||
return [ $date => new Ohlc([
|
return [$date => new Ohlc([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => $date,
|
'date' => $date,
|
||||||
'close' => $history->getClose(),
|
'close' => $history->getClose(),
|
||||||
]) ];
|
])];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use Throwable;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\BackupImport;
|
|
||||||
use Maatwebsite\Excel\Facades\Excel;
|
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use App\Notifications\ImportSucceededNotification;
|
|
||||||
use App\Notifications\ImportFailedNotification;
|
|
||||||
use App\Imports\BackupImport as BackupImportExcel;
|
use App\Imports\BackupImport as BackupImportExcel;
|
||||||
|
use App\Models\BackupImport;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Notifications\ImportFailedNotification;
|
||||||
|
use App\Notifications\ImportSucceededNotification;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class BackupImportJob implements ShouldQueue
|
class BackupImportJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
@@ -19,7 +21,7 @@ class BackupImportJob implements ShouldQueue
|
|||||||
/**
|
/**
|
||||||
* The number of times the job may be attempted.
|
* The number of times the job may be attempted.
|
||||||
*/
|
*/
|
||||||
public $tries = 1;
|
public $tries = 1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The number of seconds the job can run before timing out.
|
* The number of seconds the job can run before timing out.
|
||||||
@@ -42,7 +44,7 @@ class BackupImportJob implements ShouldQueue
|
|||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public BackupImport $backupImport
|
public BackupImport $backupImport
|
||||||
) {
|
) {
|
||||||
$this->user = User::find($this->backupImport->user_id);
|
$this->user = User::find($this->backupImport->user_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +52,7 @@ class BackupImportJob implements ShouldQueue
|
|||||||
* Execute the job.
|
* Execute the job.
|
||||||
*/
|
*/
|
||||||
public function handle(): void
|
public function handle(): void
|
||||||
{
|
{
|
||||||
Excel::import(new BackupImportExcel($this->backupImport), $this->backupImport->path, config('livewire.temporary_file_upload.disk', null));
|
Excel::import(new BackupImportExcel($this->backupImport), $this->backupImport->path, config('livewire.temporary_file_upload.disk', null));
|
||||||
|
|
||||||
$this->user->notify(new ImportSucceededNotification);
|
$this->user->notify(new ImportSucceededNotification);
|
||||||
@@ -63,9 +65,9 @@ class BackupImportJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
$this->backupImport->update([
|
$this->backupImport->update([
|
||||||
'status' => 'failed',
|
'status' => 'failed',
|
||||||
'message' => 'Error: '. substr($e->getMessage(), 0, 220),
|
'message' => 'Error: '.substr($e->getMessage(), 0, 220),
|
||||||
'has_errors' => true,
|
'has_errors' => true,
|
||||||
'completed_at' => now()
|
'completed_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->user->notify(new ImportFailedNotification($e->getMessage()));
|
$this->user->notify(new ImportFailedNotification($e->getMessage()));
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\CurrencyRate;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
|
class QueuedCurrencyRateInsertJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of times the job may be attempted.
|
||||||
|
*/
|
||||||
|
public $tries = 3;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected array $chunk
|
||||||
|
) {
|
||||||
|
$this->chunk = $chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the job.
|
||||||
|
*/
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
|
||||||
|
CurrencyRate::insertOrIgnore($this->chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Livewire\Datatables;
|
||||||
|
|
||||||
|
use App\Models\Holding;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Number;
|
||||||
|
use Rappasoft\LaravelLivewireTables\DataTableComponent;
|
||||||
|
use Rappasoft\LaravelLivewireTables\Views\Column;
|
||||||
|
|
||||||
|
class HoldingsTable extends DataTableComponent
|
||||||
|
{
|
||||||
|
public $portfolio;
|
||||||
|
|
||||||
|
public array $hiddenColumns = [];
|
||||||
|
|
||||||
|
public function mount($portfolio): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public function builder(): Builder
|
||||||
|
{
|
||||||
|
return Holding::query()
|
||||||
|
->portfolio($this->portfolio->id)
|
||||||
|
->with(['market_data'])
|
||||||
|
->withCount(['transactions as num_transactions' => function ($query) {
|
||||||
|
return $query->whereRaw('transactions.symbol = holdings.symbol');
|
||||||
|
}])
|
||||||
|
->withPerformance();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configure(): void
|
||||||
|
{
|
||||||
|
$this->hiddenColumns = ['name', 'average_cost_basis', 'market_value', 'fifty_two_week_low', 'fifty_two_week_high'];
|
||||||
|
|
||||||
|
$this->setTableWrapperAttributes([
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => false,
|
||||||
|
'default-colors' => false,
|
||||||
|
'class' => 'overflow-scroll',
|
||||||
|
]);
|
||||||
|
$this->setTableAttributes([
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => false,
|
||||||
|
'default-colors' => false,
|
||||||
|
'class' => 'table',
|
||||||
|
]);
|
||||||
|
$this->setTheadAttributes([
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => true,
|
||||||
|
'default-colors' => false,
|
||||||
|
]);
|
||||||
|
$this->setThAttributes(function (Column $column) {
|
||||||
|
|
||||||
|
$attributes = [
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => false,
|
||||||
|
'default-colors' => false,
|
||||||
|
'class' => 'text-xs font-medium whitespace-nowrap uppercase tracking-wider text-nowrap',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (in_array($column->getField(), $this->hiddenColumns)) {
|
||||||
|
$attributes['class'] = $attributes['class'].' hidden md:table-cell';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $attributes;
|
||||||
|
});
|
||||||
|
$this->setThSortButtonAttributes(fn () => [
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => true,
|
||||||
|
'default-colors' => false,
|
||||||
|
'class' => 'cursor-pointer',
|
||||||
|
]);
|
||||||
|
$this->setTbodyAttributes([
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => true,
|
||||||
|
'default-colors' => false,
|
||||||
|
]);
|
||||||
|
$this->setTrAttributes(fn () => [
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => true,
|
||||||
|
'default-colors' => false,
|
||||||
|
'class' => 'cursor-pointer hover:bg-neutral/25',
|
||||||
|
]);
|
||||||
|
$this->setTdAttributes(function (Column $column) {
|
||||||
|
|
||||||
|
$attributes = [
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => false,
|
||||||
|
'default-colors' => false,
|
||||||
|
'class' => 'text-nowrap',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (in_array($column->getField(), $this->hiddenColumns)) {
|
||||||
|
$attributes['class'] = $attributes['class'].' hidden md:table-cell';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $attributes;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->setDefaultSort('symbol', 'asc');
|
||||||
|
|
||||||
|
$this->setToolsDisabled();
|
||||||
|
$this->setFooterDisabled();
|
||||||
|
$this->setPaginationDisabled();
|
||||||
|
$this->setDisplayPaginationDetailsDisabled();
|
||||||
|
|
||||||
|
$this->setPrimaryKey('id');
|
||||||
|
|
||||||
|
$this->setTableRowUrl(function ($row) {
|
||||||
|
return route('holding.show', ['portfolio' => $row->portfolio_id, 'symbol' => $row->symbol]);
|
||||||
|
|
||||||
|
})->setTableRowUrlTarget(function ($row) {
|
||||||
|
|
||||||
|
return 'navigate';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function columns(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Column::make(__('Symbol'), 'symbol')
|
||||||
|
->sortable(),
|
||||||
|
Column::make(__('Name'), 'market_data.name')
|
||||||
|
->sortable(),
|
||||||
|
Column::make(__('Quantity'), 'quantity')
|
||||||
|
->sortable(),
|
||||||
|
Column::make(__('Average Cost Basis'), 'average_cost_basis')
|
||||||
|
->sortable()
|
||||||
|
->format(fn ($value, $row) => Number::currency($value ?? 0, $row->market_data?->currency)),
|
||||||
|
Column::make(__('Total Cost Basis'), 'total_cost_basis')
|
||||||
|
->sortable()
|
||||||
|
->format(fn ($value, $row) => Number::currency($value ?? 0, $row->market_data?->currency)),
|
||||||
|
Column::make(__('Market Value'), 'market_data.market_value')
|
||||||
|
->sortable()
|
||||||
|
->format(fn ($value, $row) => Number::currency($value ?? 0, $row->market_data?->currency)),
|
||||||
|
Column::make(__('Total Market Value'))
|
||||||
|
->sortable(fn (Builder $query, string $direction) => $query->orderBy('total_market_value', $direction))
|
||||||
|
->label(fn ($row) => Number::currency($row->total_market_value ?? 0, $row->market_data?->currency)),
|
||||||
|
Column::make(__('Market Gain/Loss'))
|
||||||
|
->html()
|
||||||
|
->label(fn ($row) => Number::currency($row->market_gain_dollars ?? 0, $row->market_data?->currency).view('components.ui.gain-loss-arrow-badge', [
|
||||||
|
'costBasis' => $row->average_cost_basis,
|
||||||
|
'marketValue' => $row->market_data?->market_value,
|
||||||
|
'small' => true,
|
||||||
|
]))
|
||||||
|
->sortable(fn (Builder $query, string $direction) => $query->orderBy('market_gain_dollars', $direction)),
|
||||||
|
Column::make(__('Realized Gain/Loss'), 'realized_gain_dollars')
|
||||||
|
->sortable()
|
||||||
|
->format(fn ($value, $row) => Number::currency($value ?? 0, $row->market_data?->currency)),
|
||||||
|
Column::make(__('Dividends Earned'), 'dividends_earned')
|
||||||
|
->sortable()
|
||||||
|
->format(fn ($value, $row) => Number::currency($value ?? 0, $row->market_data?->currency)),
|
||||||
|
Column::make(__('52 week low'), 'market_data.fifty_two_week_low')
|
||||||
|
->sortable()
|
||||||
|
->format(fn ($value, $row) => Number::currency($value ?? 0, $row->market_data?->currency)),
|
||||||
|
Column::make(__('52 week high'), 'market_data.fifty_two_week_high')
|
||||||
|
->sortable()
|
||||||
|
->format(fn ($value, $row) => Number::currency($value ?? 0, $row->market_data?->currency)),
|
||||||
|
Column::make(__('Number of Transactions'))
|
||||||
|
->sortable(fn (Builder $query, string $direction) => $query->orderBy('num_transactions', $direction))
|
||||||
|
->label(fn ($row) => $row->num_transactions),
|
||||||
|
Column::make(__('Last Refreshed'), 'market_data.updated_at')
|
||||||
|
->sortable()
|
||||||
|
->format(fn ($value) => \Carbon\Carbon::parse($value)->diffForHumans()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Livewire\Datatables;
|
||||||
|
|
||||||
|
use App\Models\Transaction;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Number;
|
||||||
|
use Rappasoft\LaravelLivewireTables\DataTableComponent;
|
||||||
|
use Rappasoft\LaravelLivewireTables\Views\Column;
|
||||||
|
|
||||||
|
class TransactionsTable extends DataTableComponent
|
||||||
|
{
|
||||||
|
public array $hiddenColumns = [];
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public function builder(): Builder
|
||||||
|
{
|
||||||
|
return Transaction::query()
|
||||||
|
->with(['portfolio', 'market_data'])
|
||||||
|
->myTransactions()
|
||||||
|
->addSelect(['portfolio_id', 'transaction_type', 'split', 'cost_basis'])
|
||||||
|
->selectRaw('
|
||||||
|
(CASE
|
||||||
|
WHEN transaction_type = \'SELL\'
|
||||||
|
THEN COALESCE(transactions.sale_price, 0)
|
||||||
|
ELSE COALESCE(market_data.market_value, 0)
|
||||||
|
END) - COALESCE(transactions.cost_basis, 0) AS gain_dollars');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configure(): void
|
||||||
|
{
|
||||||
|
$this->hiddenColumns = ['name', 'cost_basis', 'gain_dollars'];
|
||||||
|
|
||||||
|
$this->setTableWrapperAttributes([
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => false,
|
||||||
|
'default-colors' => false,
|
||||||
|
'class' => 'overflow-scroll',
|
||||||
|
]);
|
||||||
|
$this->setTableAttributes([
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => false,
|
||||||
|
'default-colors' => false,
|
||||||
|
'class' => 'table',
|
||||||
|
]);
|
||||||
|
$this->setTheadAttributes([
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => true,
|
||||||
|
'default-colors' => false,
|
||||||
|
]);
|
||||||
|
$this->setThAttributes(function (Column $column) {
|
||||||
|
|
||||||
|
$attributes = [
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => false,
|
||||||
|
'default-colors' => false,
|
||||||
|
'class' => 'text-xs font-medium whitespace-nowrap uppercase tracking-wider text-nowrap',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (in_array($column->getField(), $this->hiddenColumns)) {
|
||||||
|
$attributes['class'] = $attributes['class'].' hidden md:table-cell';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $attributes;
|
||||||
|
});
|
||||||
|
$this->setThSortButtonAttributes(fn () => [
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => true,
|
||||||
|
'default-colors' => false,
|
||||||
|
'class' => 'cursor-pointer',
|
||||||
|
]);
|
||||||
|
$this->setTbodyAttributes([
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => true,
|
||||||
|
'default-colors' => false,
|
||||||
|
]);
|
||||||
|
$this->setTrAttributes(fn () => [
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => true,
|
||||||
|
'default-colors' => false,
|
||||||
|
'class' => 'cursor-pointer hover:bg-neutral/25',
|
||||||
|
]);
|
||||||
|
$this->setTdAttributes(function (Column $column) {
|
||||||
|
|
||||||
|
$attributes = [
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => false,
|
||||||
|
'default-colors' => false,
|
||||||
|
'class' => 'text-nowrap',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (in_array($column->getField(), $this->hiddenColumns)) {
|
||||||
|
$attributes['class'] = $attributes['class'].' hidden md:table-cell';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $attributes;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->setDefaultSort('date', 'desc');
|
||||||
|
|
||||||
|
$this->setPerPageAccepted([10, 15, 20]);
|
||||||
|
$this->setPerPage(15);
|
||||||
|
$this->setSearchDisabled();
|
||||||
|
$this->setColumnSelectDisabled();
|
||||||
|
$this->setPerPageVisibilityDisabled();
|
||||||
|
$this->setFooterDisabled();
|
||||||
|
|
||||||
|
$this->setPrimaryKey('id');
|
||||||
|
|
||||||
|
$this->setTableRowUrl(function ($row) {
|
||||||
|
return route('holding.show', ['portfolio' => $row->portfolio_id, 'symbol' => $row->symbol]);
|
||||||
|
|
||||||
|
})->setTableRowUrlTarget(function ($row) {
|
||||||
|
|
||||||
|
return 'navigate';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function columns(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
|
||||||
|
Column::make(__('Date'), 'date')
|
||||||
|
->sortable()
|
||||||
|
->format(fn ($value) => \Carbon\Carbon::parse($value)->format('M d, Y')),
|
||||||
|
Column::make(__('Portfolio'), 'portfolio.title')
|
||||||
|
->sortable(),
|
||||||
|
Column::make(__('Symbol'), 'symbol')
|
||||||
|
->sortable(),
|
||||||
|
Column::make(__('Name'), 'market_data.name')
|
||||||
|
->sortable(),
|
||||||
|
Column::make(__('Type'), 'transaction_type')
|
||||||
|
->label(fn ($row) => view('components.ui.badge', [
|
||||||
|
'value' => $row->split ? 'SPLIT'
|
||||||
|
: ($row->reinvested_dividend
|
||||||
|
? 'REINVEST'
|
||||||
|
: $row->transaction_type),
|
||||||
|
'class' => ($row->transaction_type == 'BUY'
|
||||||
|
? 'badge-success'
|
||||||
|
: 'badge-error').' badge-sm mr-3',
|
||||||
|
]))
|
||||||
|
->sortable(fn (Builder $query, string $direction) => $query->orderBy('transaction_type', $direction)),
|
||||||
|
Column::make(__('Quantity'), 'quantity')
|
||||||
|
->sortable(),
|
||||||
|
Column::make(__('Cost Basis'), 'cost_basis')
|
||||||
|
->sortable(fn (Builder $query, string $direction) => $query->orderBy('cost_basis', $direction))
|
||||||
|
->label(fn ($row) => Number::currency($row->cost_basis ?? 0, $row->market_data->currency)),
|
||||||
|
Column::make(__('Gain/Loss'), 'gain_dollars')
|
||||||
|
->sortable(fn (Builder $query, string $direction) => $query->orderBy('gain_dollars', $direction))
|
||||||
|
->label(fn ($row) => Number::currency($row->gain_dollars ?? 0, $row->market_data->currency)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class AiChat extends Model
|
class AiChat extends Model
|
||||||
{
|
{
|
||||||
@@ -11,7 +13,7 @@ class AiChat extends Model
|
|||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'role',
|
'role',
|
||||||
'content'
|
'content',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = [];
|
protected $hidden = [];
|
||||||
@@ -26,7 +28,8 @@ class AiChat extends Model
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function user() {
|
public function user()
|
||||||
|
{
|
||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Maatwebsite\Excel\Facades\Excel;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use App\Imports\BackupImport as BackupImportExcel;
|
|
||||||
use App\Jobs\BackupImportJob;
|
use App\Jobs\BackupImportJob;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class BackupImport extends Model
|
class BackupImport extends Model
|
||||||
{
|
{
|
||||||
@@ -20,7 +20,7 @@ class BackupImport extends Model
|
|||||||
'status', // pending, in_progress, success, failed
|
'status', // pending, in_progress, success, failed
|
||||||
'message', // Import starting, Import is in progress, Importing portfolios, Importing transactions, Importing daily changes, Import completed successfully
|
'message', // Import starting, Import is in progress, Importing portfolios, Importing transactions, Importing daily changes, Import completed successfully
|
||||||
'has_errors',
|
'has_errors',
|
||||||
'completed_at'
|
'completed_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static function boot()
|
protected static function boot()
|
||||||
@@ -32,9 +32,9 @@ class BackupImport extends Model
|
|||||||
$import->status = 'pending';
|
$import->status = 'pending';
|
||||||
$import->message = __('Import starting...');
|
$import->message = __('Import starting...');
|
||||||
});
|
});
|
||||||
|
|
||||||
static::created(function ($import) {
|
static::created(function ($import) {
|
||||||
|
|
||||||
BackupImportJob::dispatch($import);
|
BackupImportJob::dispatch($import);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -47,7 +47,12 @@ class BackupImport extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'has_errors' => 'boolean',
|
'has_errors' => 'boolean',
|
||||||
'completed_at' => 'datetime'
|
'completed_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasTimestamps;
|
use Illuminate\Database\Eloquent\Concerns\HasTimestamps;
|
||||||
@@ -29,7 +31,7 @@ class ConnectedAccount extends Model
|
|||||||
];
|
];
|
||||||
|
|
||||||
protected $with = [
|
protected $with = [
|
||||||
'user'
|
'user',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,4 +54,4 @@ class ConnectedAccount extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Number;
|
||||||
|
|
||||||
|
class Currency extends Model
|
||||||
|
{
|
||||||
|
protected $hidden = [];
|
||||||
|
|
||||||
|
protected $primaryKey = 'currency';
|
||||||
|
|
||||||
|
protected $keyType = 'string';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'currency',
|
||||||
|
'label',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the attributes that should be cast.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
'updated_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function forHumans(int|float $number, ?string $currency = null, ?string $locale = null): string
|
||||||
|
{
|
||||||
|
$symbol = Number::currencySymbol($currency, $locale);
|
||||||
|
|
||||||
|
return $symbol.Number::forHumans($number);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a list of supported currencies
|
||||||
|
*
|
||||||
|
* @param bool|null $withAliases Whether to include aliases in list of currencies
|
||||||
|
*/
|
||||||
|
public static function list(?bool $withAliases = true): Collection
|
||||||
|
{
|
||||||
|
$aliases = $withAliases ? collect(config('investbrain.currency_aliases'))->map(function ($value, $currency) {
|
||||||
|
return [
|
||||||
|
'currency' => $currency,
|
||||||
|
'label' => $value['label'],
|
||||||
|
];
|
||||||
|
})->values() : collect();
|
||||||
|
|
||||||
|
return $aliases->merge(self::get()->map->only(['currency', 'label']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts between supported currencies
|
||||||
|
*
|
||||||
|
* @param string|null $to (defaults to base currency)
|
||||||
|
*/
|
||||||
|
public static function convert(?float $value, string $from, ?string $to = null, mixed $date = null): float
|
||||||
|
{
|
||||||
|
if (empty($value)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assume converting to base
|
||||||
|
if (empty($to)) {
|
||||||
|
$to = config('investbrain.base_currency');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get rate
|
||||||
|
[$from, $to] = [
|
||||||
|
cache()->remember($from.'_rate_'.$date, 10, function () use ($from, $date) {
|
||||||
|
return CurrencyRate::historic($from, $date);
|
||||||
|
}),
|
||||||
|
cache()->remember($to.'_rate_'.$date, 10, function () use ($to, $date) {
|
||||||
|
return CurrencyRate::historic($to, $date);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
// get from rate
|
||||||
|
$rate_to_base = 1 / $from;
|
||||||
|
|
||||||
|
// get value in base currency
|
||||||
|
$base_currency_value = $value * $rate_to_base;
|
||||||
|
|
||||||
|
return (float) $base_currency_value * $to;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Jobs\QueuedCurrencyRateInsertJob;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Carbon\CarbonPeriod;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Investbrain\Frankfurter\Frankfurter;
|
||||||
|
|
||||||
|
class CurrencyRate extends Model
|
||||||
|
{
|
||||||
|
protected $hidden = [];
|
||||||
|
|
||||||
|
protected $primaryKey = 'currency';
|
||||||
|
|
||||||
|
protected $keyType = 'string';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'date',
|
||||||
|
'currency',
|
||||||
|
'rate',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the attributes that should be cast.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'rate' => 'float',
|
||||||
|
'date' => 'date',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
'updated_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function current(string $currency): float
|
||||||
|
{
|
||||||
|
return (float) self::historic($currency);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get historic rate for symbol
|
||||||
|
*/
|
||||||
|
public static function historic(string $currency, mixed $date = null): float
|
||||||
|
{
|
||||||
|
// No need to convert
|
||||||
|
if ($currency === config('investbrain.base_currency')) {
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we don't need historic, let's use current rate
|
||||||
|
if (empty($date)) {
|
||||||
|
|
||||||
|
$date = now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure we have a Carbon date
|
||||||
|
$date = Carbon::parse($date);
|
||||||
|
|
||||||
|
// Handle aliases
|
||||||
|
[$currency, $adjustment] = self::getCurrencyAliasAdjustments($currency);
|
||||||
|
|
||||||
|
// Get or create historic rate
|
||||||
|
$rate = self::select('rate')
|
||||||
|
->whereDate('date', $date->toDateString())
|
||||||
|
->where(['currency' => $currency])
|
||||||
|
->firstOr(function () use ($date, $currency) {
|
||||||
|
|
||||||
|
$currencies = Currency::all()->pluck('currency')->toArray();
|
||||||
|
|
||||||
|
$rates = Frankfurter::setSymbols($currencies)->historical($date);
|
||||||
|
|
||||||
|
$date = Arr::get($rates, 'date');
|
||||||
|
|
||||||
|
$updates = Arr::map(Arr::get($rates, 'rates', []), function ($rate, $curr) use ($date) {
|
||||||
|
|
||||||
|
return [
|
||||||
|
'currency' => $curr,
|
||||||
|
'date' => $date,
|
||||||
|
'rate' => $rate,
|
||||||
|
'updated_at' => now()->toDateTimeString(),
|
||||||
|
'created_at' => now()->toDateTimeString(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// persist
|
||||||
|
self::chunkInsert($updates);
|
||||||
|
|
||||||
|
return new CurrencyRate(Arr::first($updates, fn ($update) => $update['currency'] == $currency) ?? ['rate' => 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (float) $rate->rate * $adjustment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rates for range of dates
|
||||||
|
*
|
||||||
|
* @return array<string, float>
|
||||||
|
*/
|
||||||
|
public static function timeSeriesRates(string|array|null $currency = null, mixed $start = null, mixed $end = null): array
|
||||||
|
{
|
||||||
|
if (empty($start)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$end = $end ?? now();
|
||||||
|
|
||||||
|
$period = CarbonPeriod::create($start, $end);
|
||||||
|
|
||||||
|
// No need to send network request - just generate 1s
|
||||||
|
if ($currency === config('investbrain.base_currency')) {
|
||||||
|
|
||||||
|
$dateRange = [];
|
||||||
|
foreach ($period as $date) {
|
||||||
|
$dateRange[$date->toDateString()] = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dateRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($currency)) {
|
||||||
|
|
||||||
|
$i = 1;
|
||||||
|
foreach ($currency as $curr) {
|
||||||
|
|
||||||
|
dispatch(fn () => self::timeSeriesRates($curr, $start, $end))->delay(now()->addSeconds(30 * $i));
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle currency alias
|
||||||
|
if (! empty($currency)) {
|
||||||
|
|
||||||
|
[$currency, $adjustment] = self::getCurrencyAliasAdjustments($currency);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
$currency = Currency::all()->pluck('currency')->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// get rates
|
||||||
|
$rates = Frankfurter::setSymbols($currency)->timeSeries($period->first(), $period->last());
|
||||||
|
|
||||||
|
$rates = collect(Arr::get($rates, 'rates', []))->sortKeys()->toArray();
|
||||||
|
|
||||||
|
$datesOnly = array_keys($rates);
|
||||||
|
|
||||||
|
// loop through each date
|
||||||
|
$updates = [];
|
||||||
|
foreach ($period as $date) {
|
||||||
|
|
||||||
|
$lookupDate = self::getNearestPastDate($date, $datesOnly, $rates);
|
||||||
|
|
||||||
|
if (is_null($lookupDate)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// loop through each rate
|
||||||
|
foreach ($rates[$lookupDate->toDateString()] as $curr => $rate) {
|
||||||
|
|
||||||
|
// add to updates
|
||||||
|
$updates[] = [
|
||||||
|
'currency' => $curr,
|
||||||
|
'date' => $date->toDateString(),
|
||||||
|
'rate' => $rate,
|
||||||
|
'updated_at' => now()->toDateTimeString(),
|
||||||
|
'created_at' => now()->toDateTimeString(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// persist
|
||||||
|
self::chunkInsert($updates);
|
||||||
|
|
||||||
|
if (is_string($currency)) {
|
||||||
|
|
||||||
|
return collect($updates)
|
||||||
|
->whereBetween('date', [$start, $end ?? now()])
|
||||||
|
->where('currency', $currency)
|
||||||
|
->mapWithKeys(fn ($rate) => [
|
||||||
|
$rate['date'] => $rate['rate'] * ($adjustment ?? 1),
|
||||||
|
])
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function getNearestPastDate(CarbonInterface $date, array $datesOnly, array $rates): ?CarbonInterface
|
||||||
|
{
|
||||||
|
|
||||||
|
// if no dates, nothing to do...
|
||||||
|
if (empty($datesOnly)) {
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mutableDate = $date->copy();
|
||||||
|
$weekAgo = $date->copy()->subWeek();
|
||||||
|
$firstDate = Carbon::parse($datesOnly[0]);
|
||||||
|
|
||||||
|
// get rates or find closest valid rate (handles missing weekend rates)
|
||||||
|
while (! isset($rates[$mutableDate->toDateString()])) {
|
||||||
|
|
||||||
|
// prevent runaway infinite loops
|
||||||
|
if ($mutableDate->lessThan($weekAgo)) {
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// is this the start of a range that falls on a weekend?
|
||||||
|
if ($mutableDate->lessThan($firstDate)) {
|
||||||
|
|
||||||
|
return $firstDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// try the day before then
|
||||||
|
$mutableDate = $mutableDate->subDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $mutableDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function refreshCurrencyData($force = false): void
|
||||||
|
{
|
||||||
|
$currencies = Currency::all()->pluck('currency')->toArray();
|
||||||
|
|
||||||
|
$rates = Frankfurter::setBaseCurrency(config('investbrain.base_currency'))
|
||||||
|
->setSymbols($currencies)
|
||||||
|
->latest();
|
||||||
|
|
||||||
|
$updates = [];
|
||||||
|
foreach (Arr::get($rates, 'rates', []) as $currency => $rate) {
|
||||||
|
|
||||||
|
// update currency
|
||||||
|
$updates[] = [
|
||||||
|
'date' => now()->toDateString(),
|
||||||
|
'currency' => $currency,
|
||||||
|
'rate' => $rate,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// nothing to update
|
||||||
|
if (empty($updates)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($force) {
|
||||||
|
|
||||||
|
// force overwrite existing rates
|
||||||
|
CurrencyRate::upsert($updates, ['currency', 'date'], ['rate']);
|
||||||
|
} else {
|
||||||
|
|
||||||
|
// only insert new rates
|
||||||
|
CurrencyRate::insertOrIgnore($updates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function chunkInsert(array $updates): void
|
||||||
|
{
|
||||||
|
|
||||||
|
foreach (array_chunk($updates, 500) as $chunk) {
|
||||||
|
|
||||||
|
QueuedCurrencyRateInsertJob::dispatch($chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function getCurrencyAliasAdjustments(string $currency)
|
||||||
|
{
|
||||||
|
$adjustment = 1;
|
||||||
|
|
||||||
|
if (array_key_exists($currency, config('investbrain.currency_aliases', []))) {
|
||||||
|
|
||||||
|
$config = config('investbrain.currency_aliases.'.$currency);
|
||||||
|
|
||||||
|
$adjustment = $config['adjustment'];
|
||||||
|
$currency = $config['alias_of'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$currency, $adjustment];
|
||||||
|
}
|
||||||
|
}
|
||||||
+120
-12
@@ -1,14 +1,17 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Traits\HasCompositePrimaryKey;
|
use App\Traits\HasCompositePrimaryKey;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class DailyChange extends Model
|
class DailyChange extends Model
|
||||||
{
|
{
|
||||||
use HasFactory, HasCompositePrimaryKey;
|
use HasCompositePrimaryKey, HasFactory;
|
||||||
|
|
||||||
public $timestamps = false;
|
public $timestamps = false;
|
||||||
|
|
||||||
@@ -20,10 +23,6 @@ class DailyChange extends Model
|
|||||||
'portfolio_id',
|
'portfolio_id',
|
||||||
'date',
|
'date',
|
||||||
'total_market_value',
|
'total_market_value',
|
||||||
'total_cost_basis',
|
|
||||||
'total_gain',
|
|
||||||
'total_dividends_earned',
|
|
||||||
'realized_gains',
|
|
||||||
'notes',
|
'notes',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -31,28 +30,137 @@ class DailyChange extends Model
|
|||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'date' => 'datetime',
|
'date' => 'datetime',
|
||||||
|
'total_market_value' => 'float',
|
||||||
|
'total_cost_basis' => 'float',
|
||||||
|
'total_market_gain' => 'float',
|
||||||
|
'realized_gain_dollars' => 'float',
|
||||||
|
'total_dividends_earned' => 'float',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function scopePortfolio($query, $portfolio)
|
public function scopePortfolio($query, $portfolio)
|
||||||
{
|
{
|
||||||
return $query->where('portfolio_id', $portfolio);
|
return $query->where('daily_change.portfolio_id', $portfolio);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeMyDailyChanges()
|
public function scopeMyDailyChanges($query)
|
||||||
{
|
{
|
||||||
return $this->whereHas('portfolio', function ($query) {
|
return $query->whereHas('portfolio', function ($query) {
|
||||||
$query->whereHas('users', function ($query) {
|
$query->whereHas('users', function ($query) {
|
||||||
return $query->where('id', auth()->id());
|
return $query->where('id', auth()->id());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeWithoutWishlists($query) {
|
public function scopeWithoutWishlists($query)
|
||||||
|
{
|
||||||
return $query->whereHas('portfolio', function ($query) {
|
return $query->whereHas('portfolio', function ($query) {
|
||||||
$query->where('portfolios.wishlist', 0);
|
$query->where('portfolios.wishlist', 0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function scopeWithDailyPerformance($query)
|
||||||
|
{
|
||||||
|
$currency = auth()->user()?->getCurrency() ?? config('investbrain.base_currency');
|
||||||
|
|
||||||
|
$dividendSub = DB::table('holdings')
|
||||||
|
->join('dividends', 'dividends.symbol', '=', 'holdings.symbol')
|
||||||
|
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||||
|
$join->on('cr.date', '=', 'dividends.date')
|
||||||
|
->where('cr.currency', '=', $currency);
|
||||||
|
})
|
||||||
|
->join('transactions as tx', function ($join) {
|
||||||
|
$join->on('tx.symbol', '=', 'holdings.symbol')
|
||||||
|
->on('tx.portfolio_id', '=', 'holdings.portfolio_id')
|
||||||
|
->whereColumn('tx.date', '<=', 'dividends.date');
|
||||||
|
})
|
||||||
|
->select(['holdings.portfolio_id', 'dividends.date'])
|
||||||
|
->selectRaw("
|
||||||
|
((CASE WHEN tx.transaction_type = 'BUY'
|
||||||
|
THEN tx.quantity ELSE 0 END)
|
||||||
|
- (CASE WHEN tx.transaction_type = 'SELL'
|
||||||
|
THEN tx.quantity ELSE 0 END))
|
||||||
|
* SUM(
|
||||||
|
dividends.dividend_amount_base
|
||||||
|
* COALESCE(cr.rate, 1)
|
||||||
|
)
|
||||||
|
AS total_dividends_earned")
|
||||||
|
->groupBy(['holdings.portfolio_id', 'dividends.date', 'tx.transaction_type', 'tx.quantity']);
|
||||||
|
|
||||||
|
$transactionTotals = DB::table('transactions')
|
||||||
|
->select(['transactions.portfolio_id', 'transactions.date'])
|
||||||
|
->selectRaw("
|
||||||
|
SUM(
|
||||||
|
(CASE WHEN transactions.transaction_type = 'BUY' THEN 1 ELSE -1 END)
|
||||||
|
* transactions.quantity
|
||||||
|
* transactions.cost_basis_base
|
||||||
|
* COALESCE(cr.rate, 1)
|
||||||
|
) AS daily_cost_basis
|
||||||
|
")
|
||||||
|
->selectRaw("
|
||||||
|
SUM(
|
||||||
|
(CASE
|
||||||
|
WHEN transactions.transaction_type = 'SELL'
|
||||||
|
THEN ( transactions.sale_price_base - transactions.cost_basis_base )
|
||||||
|
* transactions.quantity
|
||||||
|
* COALESCE(cr.rate, 1)
|
||||||
|
END)
|
||||||
|
) AS daily_realized_gains
|
||||||
|
")
|
||||||
|
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||||
|
$join
|
||||||
|
->on(DB::raw('DATE(cr.date)'), '=', DB::raw('DATE(transactions.date)'))
|
||||||
|
->where('cr.currency', $currency);
|
||||||
|
})
|
||||||
|
->groupBy('transactions.portfolio_id', 'transactions.date');
|
||||||
|
|
||||||
|
$cumulativeCostBasis = DB::table(DB::raw("({$transactionTotals->toSql()}) AS transaction_totals"))
|
||||||
|
->mergeBindings($transactionTotals)
|
||||||
|
->select(['portfolio_id', 'date'])
|
||||||
|
->selectRaw('SUM(daily_cost_basis) AS cumulative_cost_basis')
|
||||||
|
->selectRaw('SUM(daily_realized_gains) AS cumulative_realized_gains')
|
||||||
|
->groupBy('portfolio_id', 'date');
|
||||||
|
|
||||||
|
return $query
|
||||||
|
->select(['daily_change.date', 'daily_change.portfolio_id'])
|
||||||
|
->selectRaw('daily_change.total_market_value * COALESCE(cr.rate, 1) AS total_market_value')
|
||||||
|
->selectRaw('SUM(COALESCE(ccb.cumulative_cost_basis, 0)) AS total_cost_basis')
|
||||||
|
->selectRaw('daily_change.total_market_value * COALESCE(cr.rate, 1)
|
||||||
|
- SUM(COALESCE(ccb.cumulative_cost_basis, 0))
|
||||||
|
AS total_market_gain')
|
||||||
|
->selectRaw('SUM(COALESCE(ccb.cumulative_realized_gains, 0)) AS realized_gain_dollars')
|
||||||
|
->selectSub(function ($query) use ($dividendSub) {
|
||||||
|
$query->fromSub($dividendSub, 'd')
|
||||||
|
->selectRaw('SUM(d.total_dividends_earned)')
|
||||||
|
->whereColumn('d.date', '<=', 'daily_change.date')
|
||||||
|
->whereColumn('d.portfolio_id', '=', 'daily_change.portfolio_id');
|
||||||
|
}, 'total_dividends_earned')
|
||||||
|
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||||
|
$join
|
||||||
|
->on(DB::raw('DATE(cr.date)'), '=', DB::raw('DATE(daily_change.date)'))
|
||||||
|
->where('cr.currency', $currency);
|
||||||
|
})
|
||||||
|
->leftJoinSub($cumulativeCostBasis, 'ccb', function ($join) {
|
||||||
|
$join
|
||||||
|
->on('ccb.portfolio_id', '=', 'daily_change.portfolio_id')
|
||||||
|
->whereRaw('ccb.date <= daily_change.date');
|
||||||
|
})
|
||||||
|
->groupBy(['daily_change.date', 'daily_change.portfolio_id', 'cr.rate'])
|
||||||
|
->orderBy('daily_change.date');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeWithMultipleDailyPerformance($query)
|
||||||
|
{
|
||||||
|
return DB::table(DB::raw("({$query->toSql()}) AS daily_query"))
|
||||||
|
->addBinding($query->getQuery()->getBindings(), 'join')
|
||||||
|
->select('date')
|
||||||
|
->selectRaw('SUM(total_market_value) AS total_market_value')
|
||||||
|
->selectRaw('SUM(total_cost_basis) AS total_cost_basis')
|
||||||
|
->selectRaw('SUM(total_market_gain) AS total_market_gain')
|
||||||
|
->selectRaw('SUM(realized_gain_dollars) AS realized_gain_dollars')
|
||||||
|
->selectRaw('SUM(total_dividends_earned) AS total_dividends_earned')
|
||||||
|
->groupBy('date');
|
||||||
|
}
|
||||||
|
|
||||||
public function portfolio()
|
public function portfolio()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Portfolio::class);
|
return $this->belongsTo(Portfolio::class);
|
||||||
|
|||||||
+104
-65
@@ -1,20 +1,27 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Models\Holding;
|
use App\Actions\CopyToBaseCurrency;
|
||||||
use App\Models\MarketData;
|
use App\Casts\BaseCurrency;
|
||||||
use App\Models\Transaction;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use App\Interfaces\MarketData\MarketDataInterface;
|
use App\Interfaces\MarketData\MarketDataInterface;
|
||||||
|
use App\Traits\HasMarketData;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Pipeline;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class Dividend extends Model
|
class Dividend extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
use HasMarketData;
|
||||||
use HasUuids;
|
use HasUuids;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
@@ -26,19 +33,33 @@ class Dividend extends Model
|
|||||||
protected $hidden = [];
|
protected $hidden = [];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'date' => 'datetime',
|
'date' => 'date',
|
||||||
'last_dividend_update' => 'datetime',
|
'last_dividend_update' => 'date',
|
||||||
|
'dividend_amount' => 'float',
|
||||||
|
'dividend_amount_base' => BaseCurrency::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
public function marketData() {
|
protected static function boot()
|
||||||
return $this->belongsTo(MarketData::class, 'symbol', 'symbol');
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
static::saving(function ($dividend) {
|
||||||
|
|
||||||
|
$dividend = Pipeline::send($dividend)
|
||||||
|
->through([
|
||||||
|
CopyToBaseCurrency::class,
|
||||||
|
])
|
||||||
|
->then(fn (Dividend $dividend) => $dividend);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function holdings() {
|
public function holdings(): HasMany
|
||||||
|
{
|
||||||
return $this->hasMany(Holding::class, 'symbol', 'symbol');
|
return $this->hasMany(Holding::class, 'symbol', 'symbol');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function transactions() {
|
public function transactions(): HasMany
|
||||||
|
{
|
||||||
return $this->hasMany(Transaction::class, 'symbol', 'symbol');
|
return $this->hasMany(Transaction::class, 'symbol', 'symbol');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +70,6 @@ class Dividend extends Model
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Grab new dividend data
|
* Grab new dividend data
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
public static function refreshDividendData(string $symbol): void
|
public static function refreshDividendData(string $symbol): void
|
||||||
{
|
{
|
||||||
@@ -64,11 +84,11 @@ class Dividend extends Model
|
|||||||
$end_date = now();
|
$end_date = now();
|
||||||
|
|
||||||
// nope, refresh forward looking only
|
// nope, refresh forward looking only
|
||||||
if ( $dividends_meta->total_dividends ) {
|
if ($dividends_meta->total_dividends) {
|
||||||
|
|
||||||
$start_date = $dividends_meta->last_dividend_update->addHours(24);
|
$start_date = $dividends_meta->last_dividend_update;
|
||||||
}
|
}
|
||||||
|
|
||||||
// skip refresh if there's already recent data
|
// skip refresh if there's already recent data
|
||||||
if ($start_date->greaterThan($end_date)) {
|
if ($start_date->greaterThan($end_date)) {
|
||||||
|
|
||||||
@@ -82,20 +102,32 @@ class Dividend extends Model
|
|||||||
|
|
||||||
// ah, we found some dividends...
|
// ah, we found some dividends...
|
||||||
if ($dividend_data->isNotEmpty()) {
|
if ($dividend_data->isNotEmpty()) {
|
||||||
// create mass insert
|
|
||||||
foreach ($dividend_data as $index => $dividend){
|
|
||||||
$dividend_data[$index] = [...$dividend, ...['id' => Str::uuid()->toString(), 'updated_at' => now(), 'created_at' => now()]];
|
|
||||||
}
|
|
||||||
|
|
||||||
// insert records
|
$market_data = MarketData::getMarketData($symbol);
|
||||||
(new self)->insert($dividend_data->toArray());
|
|
||||||
|
$dividend_data
|
||||||
|
->chunk(10)
|
||||||
|
->each(function ($chunk) use ($market_data) {
|
||||||
|
|
||||||
|
// get historic conversion rates
|
||||||
|
$rate_to_base = CurrencyRate::timeSeriesRates($market_data->currency, $chunk->min('date'), $chunk->max('date'));
|
||||||
|
|
||||||
|
// create mass insert
|
||||||
|
foreach ($chunk as $index => $dividend) {
|
||||||
|
$rate_to_base_date = 1 / Arr::get($rate_to_base, Carbon::parse(Arr::get($dividend, 'date'))->toDateString(), 1);
|
||||||
|
|
||||||
|
$dividend['dividend_amount_base'] = $dividend['dividend_amount'] * $rate_to_base_date;
|
||||||
|
|
||||||
|
$chunk[$index] = [...$dividend, ...['id' => Str::uuid()->toString(), 'updated_at' => now(), 'created_at' => now()]];
|
||||||
|
}
|
||||||
|
|
||||||
|
// insert records
|
||||||
|
(new self)->insertOrIgnore($chunk->toArray());
|
||||||
|
});
|
||||||
|
|
||||||
// sync to holdings
|
// sync to holdings
|
||||||
self::syncHoldings($symbol);
|
self::syncHoldings($symbol);
|
||||||
|
|
||||||
// get market data
|
|
||||||
$market_data = MarketData::firstOrNew(['symbol' => $symbol]);
|
|
||||||
|
|
||||||
// re-invest dividends
|
// re-invest dividends
|
||||||
self::reinvestDividends($dividend_data, $market_data);
|
self::reinvestDividends($dividend_data, $market_data);
|
||||||
|
|
||||||
@@ -108,33 +140,39 @@ class Dividend extends Model
|
|||||||
public static function syncHoldings(string $symbol): void
|
public static function syncHoldings(string $symbol): void
|
||||||
{
|
{
|
||||||
// group by holdings
|
// group by holdings
|
||||||
$dividends = self::select(['holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount'])
|
$subQuery = self::select([
|
||||||
->selectRaw('
|
'holdings.portfolio_id',
|
||||||
(COALESCE(CASE WHEN transactions.transaction_type = "BUY"
|
'dividends.date',
|
||||||
AND date(transactions.date) <= date(dividends.date)
|
'dividends.symbol',
|
||||||
THEN transactions.quantity ELSE 0 END, 0)
|
'dividends.dividend_amount',
|
||||||
- COALESCE(CASE WHEN transactions.transaction_type = "SELL"
|
])->selectRaw("
|
||||||
AND date(transactions.date) <= date(dividends.date)
|
(COALESCE(SUM(CASE WHEN transactions.transaction_type = 'BUY'
|
||||||
THEN transactions.quantity ELSE 0 END, 0))
|
AND date(transactions.date) <= date(dividends.date)
|
||||||
* dividends.dividend_amount
|
THEN transactions.quantity ELSE 0 END), 0)
|
||||||
AS total_received
|
- COALESCE(SUM(CASE WHEN transactions.transaction_type = 'SELL'
|
||||||
')
|
AND date(transactions.date) <= date(dividends.date)
|
||||||
->join('transactions', 'transactions.symbol', '=', 'dividends.symbol')
|
THEN transactions.quantity ELSE 0 END), 0))
|
||||||
->join('holdings', 'transactions.portfolio_id', '=', 'holdings.portfolio_id')
|
* dividends.dividend_amount
|
||||||
->where('dividends.symbol', $symbol)
|
AS total_received
|
||||||
->groupBy('holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount', 'total_received')
|
")->join('transactions', 'transactions.symbol', '=', 'dividends.symbol')
|
||||||
->havingRaw('total_received > 0')
|
->join('holdings', 'transactions.portfolio_id', '=', 'holdings.portfolio_id')
|
||||||
->get();
|
->where('dividends.symbol', $symbol)
|
||||||
|
->groupBy('holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount', 'dividends.dividend_amount_base');
|
||||||
|
|
||||||
// iterate through holdings and update
|
$dividends = DB::table(DB::raw("({$subQuery->toSql()}) as sub"))
|
||||||
|
->mergeBindings($subQuery->getQuery())
|
||||||
|
->where('total_received', '>', 0)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// iterate through holdings and update
|
||||||
Holding::where(['symbol' => $symbol])
|
Holding::where(['symbol' => $symbol])
|
||||||
->get()
|
->get()
|
||||||
->each(function ($holding) use ($dividends) {
|
->each(function ($holding) use ($dividends) {
|
||||||
$holding->update([
|
$holding->update([
|
||||||
'dividends_earned' => $dividends->where('portfolio_id', $holding->portfolio_id)
|
'dividends_earned' => $dividends->where('portfolio_id', $holding->portfolio_id)
|
||||||
->sum('total_received')
|
->sum('total_received'),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function reinvestDividends(iterable $dividend_data, MarketData $market_data): void
|
public static function reinvestDividends(iterable $dividend_data, MarketData $market_data): void
|
||||||
@@ -144,21 +182,22 @@ class Dividend extends Model
|
|||||||
'symbol' => $market_data->symbol,
|
'symbol' => $market_data->symbol,
|
||||||
'reinvest_dividends' => true,
|
'reinvest_dividends' => true,
|
||||||
])
|
])
|
||||||
->get()
|
->get()
|
||||||
->each(function($holding) use ($dividend_data, $market_data) {
|
->each(function ($holding) use ($dividend_data, $market_data) {
|
||||||
|
|
||||||
foreach($dividend_data as $dividend) {
|
foreach ($dividend_data as $dividend) {
|
||||||
|
|
||||||
Transaction::create([
|
Transaction::create([
|
||||||
'date' => $dividend['date'],
|
'date' => $dividend['date'],
|
||||||
'portfolio_id' => $holding->portfolio_id,
|
'portfolio_id' => $holding->portfolio_id,
|
||||||
'symbol' => $holding->symbol,
|
'symbol' => $holding->symbol,
|
||||||
'transaction_type' => "BUY",
|
'currency' => $holding->market_data->currency,
|
||||||
'reinvested_dividend' => true,
|
'transaction_type' => 'BUY',
|
||||||
'cost_basis' => 0,
|
'reinvested_dividend' => true,
|
||||||
'quantity' => ($dividend['dividend_amount'] * $holding->qtyOwned(Carbon::parse($dividend['date']))) / $market_data->market_value,
|
'cost_basis' => 0,
|
||||||
]);
|
'quantity' => ($dividend['dividend_amount'] * $holding->qtyOwned(Carbon::parse($dividend['date']))) / $market_data->market_value,
|
||||||
}
|
]);
|
||||||
});
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+418
-151
@@ -1,21 +1,21 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Models\Split;
|
use App\Traits\HasMarketData;
|
||||||
use App\Models\AiChat;
|
|
||||||
use App\Models\Dividend;
|
|
||||||
use App\Models\Portfolio;
|
|
||||||
use App\Models\MarketData;
|
|
||||||
use App\Models\Transaction;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class Holding extends Model
|
class Holding extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
use HasMarketData;
|
||||||
use HasUuids;
|
use HasUuids;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
@@ -27,36 +27,34 @@ class Holding extends Model
|
|||||||
'realized_gain_dollars',
|
'realized_gain_dollars',
|
||||||
'dividends_earned',
|
'dividends_earned',
|
||||||
'splits_synced_at',
|
'splits_synced_at',
|
||||||
'reinvest_dividends'
|
'reinvest_dividends',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
'reinvest_dividends' => 'boolean',
|
||||||
'splits_synced_at' => 'datetime',
|
'splits_synced_at' => 'datetime',
|
||||||
'first_transaction_date' => 'datetime',
|
'first_transaction_date' => 'datetime',
|
||||||
'reinvest_dividends' => 'boolean'
|
'quantity' => 'float',
|
||||||
|
'average_cost_basis' => 'float',
|
||||||
|
'total_cost_basis' => 'float',
|
||||||
|
'realized_gain_dollars' => 'float',
|
||||||
|
'dividends_earned' => 'float',
|
||||||
|
'total_market_gain_dollars' => 'float',
|
||||||
|
'market_gain_dollars' => 'float',
|
||||||
|
'total_market_value' => 'float',
|
||||||
|
'total_dividends_earned' => 'float',
|
||||||
|
'market_data_market_value' => 'float',
|
||||||
|
'market_data_fifty_two_week_low' => 'float',
|
||||||
|
'market_data_fifty_two_week_high' => 'float',
|
||||||
|
'market_gain_percent' => 'float',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $attributes = [
|
|
||||||
'realized_gain_dollars' => 0,
|
|
||||||
'dividends_earned' => 0,
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Market data for holding
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function market_data()
|
|
||||||
{
|
|
||||||
return $this->hasOne(MarketData::class, 'symbol', 'symbol');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Related transactions for holding
|
* Related transactions for holding
|
||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function transactions()
|
public function transactions()
|
||||||
{
|
{
|
||||||
return $this->hasManyThrough(Transaction::class, Portfolio::class, 'id', 'portfolio_id', 'portfolio_id', 'id')->orderBy('date', 'DESC');
|
return $this->hasManyThrough(Transaction::class, Portfolio::class, 'id', 'portfolio_id', 'portfolio_id', 'id')->orderBy('date', 'DESC');
|
||||||
}
|
}
|
||||||
@@ -66,49 +64,80 @@ class Holding extends Model
|
|||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function dividends()
|
public function dividends()
|
||||||
{
|
{
|
||||||
return $this->hasMany(Dividend::class, 'symbol', 'symbol')
|
return $this->hasMany(Dividend::class, 'symbol', 'symbol')
|
||||||
->select(['dividends.symbol','dividends.date','dividends.dividend_amount'])
|
->select(['dividends.symbol', 'dividends.date', 'dividends.dividend_amount', 'dividends.dividend_amount_base'])
|
||||||
->selectRaw("SUM(
|
->selectRaw("SUM(
|
||||||
CASE WHEN transaction_type = 'BUY'
|
CASE WHEN transaction_type = 'BUY'
|
||||||
AND transactions.symbol = dividends.symbol
|
AND transactions.symbol = dividends.symbol
|
||||||
AND transactions.portfolio_id = '$this->portfolio_id'
|
AND transactions.portfolio_id = '$this->portfolio_id'
|
||||||
AND date(dividends.date) >= date(transactions.date)
|
AND date(dividends.date) >= date(transactions.date)
|
||||||
THEN transactions.quantity
|
THEN transactions.quantity
|
||||||
ELSE 0 END
|
ELSE 0 END
|
||||||
) AS purchased")
|
) AS purchased")
|
||||||
->selectRaw("SUM(
|
->selectRaw("SUM(
|
||||||
CASE WHEN transaction_type = 'SELL'
|
CASE WHEN transaction_type = 'SELL'
|
||||||
AND transactions.symbol = dividends.symbol
|
AND transactions.symbol = dividends.symbol
|
||||||
AND transactions.portfolio_id = '$this->portfolio_id'
|
AND transactions.portfolio_id = '$this->portfolio_id'
|
||||||
AND date(dividends.date) >= date(transactions.date)
|
AND date(dividends.date) >= date(transactions.date)
|
||||||
THEN transactions.quantity
|
THEN transactions.quantity
|
||||||
ELSE 0 END
|
ELSE 0 END
|
||||||
) AS sold")
|
) AS sold")
|
||||||
->selectRaw("SUM(
|
->selectRaw("SUM(
|
||||||
(CASE WHEN transaction_type = 'BUY'
|
(CASE WHEN transaction_type = 'BUY'
|
||||||
AND transactions.symbol = dividends.symbol
|
AND transactions.symbol = dividends.symbol
|
||||||
AND transactions.portfolio_id = '$this->portfolio_id'
|
AND transactions.portfolio_id = '$this->portfolio_id'
|
||||||
AND date(transactions.date) <= date(dividends.date)
|
AND date(transactions.date) <= date(dividends.date)
|
||||||
THEN transactions.quantity ELSE 0 END
|
THEN transactions.quantity ELSE 0 END
|
||||||
- CASE WHEN transaction_type = 'SELL'
|
- CASE WHEN transaction_type = 'SELL'
|
||||||
AND transactions.symbol = dividends.symbol
|
AND transactions.symbol = dividends.symbol
|
||||||
AND transactions.portfolio_id = '$this->portfolio_id'
|
AND transactions.portfolio_id = '$this->portfolio_id'
|
||||||
AND date(transactions.date) <= date(dividends.date)
|
AND date(transactions.date) <= date(dividends.date)
|
||||||
THEN transactions.quantity ELSE 0 END)
|
THEN transactions.quantity ELSE 0 END)
|
||||||
* dividends.dividend_amount
|
* dividends.dividend_amount
|
||||||
) AS total_received")
|
) AS total_received")
|
||||||
->join('transactions', 'transactions.symbol', 'dividends.symbol')
|
->selectRaw("SUM(
|
||||||
->groupBy(['dividends.symbol','dividends.date','dividends.dividend_amount'])
|
(CASE WHEN transaction_type = 'BUY'
|
||||||
->orderBy('dividends.date', 'DESC')
|
AND transactions.symbol = dividends.symbol
|
||||||
->where('dividends.date', '>=', function ($query) {
|
AND transactions.portfolio_id = '$this->portfolio_id'
|
||||||
$query->selectRaw('min(transactions.date)')
|
AND date(transactions.date) <= date(dividends.date)
|
||||||
->from('transactions')
|
THEN transactions.quantity ELSE 0 END
|
||||||
->whereRaw("transactions.portfolio_id = '$this->portfolio_id'")
|
- CASE WHEN transaction_type = 'SELL'
|
||||||
->whereRaw("transactions.symbol = '$this->symbol'");
|
AND transactions.symbol = dividends.symbol
|
||||||
})
|
AND transactions.portfolio_id = '$this->portfolio_id'
|
||||||
->having('total_received', '>', 0);
|
AND date(transactions.date) <= date(dividends.date)
|
||||||
|
THEN transactions.quantity ELSE 0 END)
|
||||||
|
* dividends.dividend_amount_base
|
||||||
|
) AS total_received_base")
|
||||||
|
->join('transactions', 'transactions.symbol', 'dividends.symbol')
|
||||||
|
->groupBy(['dividends.symbol', 'dividends.date', 'dividends.dividend_amount', 'dividends.dividend_amount_base'])
|
||||||
|
->orderBy('dividends.date', 'DESC')
|
||||||
|
->where('dividends.date', '>=', function ($query) {
|
||||||
|
$query->selectRaw('min(transactions.date)')
|
||||||
|
->from('transactions')
|
||||||
|
->whereRaw("transactions.portfolio_id = '$this->portfolio_id'")
|
||||||
|
->whereRaw("transactions.symbol = '$this->symbol'");
|
||||||
|
})
|
||||||
|
->havingRaw("SUM(
|
||||||
|
(CASE
|
||||||
|
WHEN transaction_type = 'BUY'
|
||||||
|
AND transactions.symbol = dividends.symbol
|
||||||
|
AND transactions.portfolio_id = '$this->portfolio_id'
|
||||||
|
AND transactions.date <= dividends.date
|
||||||
|
THEN transactions.quantity
|
||||||
|
ELSE 0
|
||||||
|
END)
|
||||||
|
-
|
||||||
|
(CASE
|
||||||
|
WHEN transaction_type = 'SELL'
|
||||||
|
AND transactions.symbol = dividends.symbol
|
||||||
|
AND transactions.portfolio_id = '$this->portfolio_id'
|
||||||
|
AND transactions.date <= dividends.date
|
||||||
|
THEN transactions.quantity
|
||||||
|
ELSE 0
|
||||||
|
END)
|
||||||
|
) * dividends.dividend_amount_base > 0");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,7 +145,7 @@ class Holding extends Model
|
|||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function portfolio()
|
public function portfolio()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Portfolio::class);
|
return $this->belongsTo(Portfolio::class);
|
||||||
}
|
}
|
||||||
@@ -126,7 +155,7 @@ class Holding extends Model
|
|||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function splits()
|
public function splits()
|
||||||
{
|
{
|
||||||
return $this->hasMany(Split::class, 'symbol', 'symbol')
|
return $this->hasMany(Split::class, 'symbol', 'symbol')
|
||||||
->orderBy('date', 'DESC');
|
->orderBy('date', 'DESC');
|
||||||
@@ -145,18 +174,22 @@ class Holding extends Model
|
|||||||
public function scopeWithMarketData($query)
|
public function scopeWithMarketData($query)
|
||||||
{
|
{
|
||||||
return $query->withAggregate('market_data', 'name')
|
return $query->withAggregate('market_data', 'name')
|
||||||
->withAggregate('market_data', 'market_value')
|
->withAggregate('market_data', 'market_value')
|
||||||
->withAggregate('market_data', 'fifty_two_week_low')
|
->withAggregate('market_data', 'market_value_base')
|
||||||
->withAggregate('market_data', 'fifty_two_week_high')
|
->withAggregate('market_data', 'fifty_two_week_low')
|
||||||
->withAggregate('market_data', 'updated_at')
|
->withAggregate('market_data', 'fifty_two_week_high')
|
||||||
->join('market_data', 'holdings.symbol', 'market_data.symbol');
|
->withAggregate('market_data', 'updated_at')
|
||||||
|
->join('market_data', 'holdings.symbol', 'market_data.symbol');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate performance for holding in its local currency
|
||||||
|
*/
|
||||||
public function scopeWithPerformance($query)
|
public function scopeWithPerformance($query)
|
||||||
{
|
{
|
||||||
return $query->selectRaw('COALESCE(market_data.market_value * holdings.quantity, 0) AS total_market_value')
|
return $query->selectRaw('COALESCE(market_data.market_value * holdings.quantity, 0) AS total_market_value')
|
||||||
->selectRaw('COALESCE((market_data.market_value - holdings.average_cost_basis) * holdings.quantity, 0) AS market_gain_dollars')
|
->selectRaw('COALESCE((market_data.market_value - holdings.average_cost_basis) * holdings.quantity, 0) AS market_gain_dollars')
|
||||||
->selectRaw('COALESCE(((market_data.market_value - holdings.average_cost_basis) / holdings.average_cost_basis) * 100, 0) AS market_gain_percent');
|
->selectRaw('COALESCE(((market_data.market_value - holdings.average_cost_basis) / NULLIF(holdings.average_cost_basis, 0)) * 100, 0) AS market_gain_percent');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopePortfolio($query, $portfolio)
|
public function scopePortfolio($query, $portfolio)
|
||||||
@@ -169,68 +202,258 @@ class Holding extends Model
|
|||||||
return $query->where('holdings.symbol', $symbol);
|
return $query->where('holdings.symbol', $symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeWithoutWishlists($query) {
|
public function scopeWithoutWishlists($query)
|
||||||
|
{
|
||||||
return $query->whereHas('portfolio', function ($query) {
|
return $query->whereHas('portfolio', function ($query) {
|
||||||
$query->where('portfolios.wishlist', 0);
|
$query->where('portfolios.wishlist', 0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeMyHoldings($query, $userId = null)
|
public function scopeMyHoldings($query, $userId = null)
|
||||||
{
|
{
|
||||||
return $query->whereHas('portfolio', function($query) use ($userId) {
|
return $query->whereHas('portfolio', function ($query) use ($userId) {
|
||||||
$query->whereRelation('users', 'id', $userId ?? auth()->user()->id);
|
$query->whereRelation('users', 'id', $userId ?? auth()->user()->id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeWithPortfolioMetrics($query)
|
/**
|
||||||
|
* Scope which returns collection of performance metrics for holdings
|
||||||
|
*
|
||||||
|
* @param string $currency Allows casting to specified currency
|
||||||
|
*/
|
||||||
|
public function scopeGetPortfolioMetrics($query, $currency = null): Collection
|
||||||
{
|
{
|
||||||
return $query->selectRaw('COALESCE(SUM(holdings.dividends_earned), 0) AS total_dividends_earned')
|
$result = $query->withPortfolioMetrics($currency)->get();
|
||||||
->selectRaw('COALESCE(SUM(holdings.realized_gain_dollars), 0) AS realized_gain_dollars')
|
|
||||||
->selectRaw('COALESCE(SUM(holdings.quantity * market_data.market_value), 0) AS total_market_value')
|
return collect([
|
||||||
->selectRaw('COALESCE(SUM(holdings.total_cost_basis), 0) AS total_cost_basis')
|
'total_cost_basis' => $result->sum('total_cost_basis'),
|
||||||
->selectRaw('COALESCE(SUM(holdings.quantity * market_data.market_value), 0) - COALESCE(SUM(holdings.total_cost_basis), 0) AS total_gain_dollars')
|
'total_market_value' => $result->sum('total_market_value'),
|
||||||
// ->selectRaw('COALESCE((@total_gain_dollars / @sum_total_cost_basis) * 100,0) AS total_gain_percent')
|
'total_market_gain_dollars' => $result->sum('total_market_gain_dollars'),
|
||||||
->join('market_data', 'market_data.symbol', '=', 'holdings.symbol');
|
'realized_gain_dollars' => $result->sum('realized_gain_dollars'),
|
||||||
|
'total_dividends_earned' => $result->sum('total_dividends_earned'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope to collect performance metrics for holdings
|
||||||
|
*
|
||||||
|
* @param string $currency Allows casting to specified currency
|
||||||
|
*/
|
||||||
|
public function scopeWithPortfolioMetrics($query, $currency = null): mixed
|
||||||
|
{
|
||||||
|
$currency = $currency ?? auth()->user()->getCurrency();
|
||||||
|
|
||||||
|
$cost_basis_sub = DB::table('transactions')
|
||||||
|
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||||
|
$join
|
||||||
|
->on('cr.date', '=', 'transactions.date')
|
||||||
|
->where('cr.currency', '=', $currency);
|
||||||
|
})
|
||||||
|
->select([
|
||||||
|
'transactions.id',
|
||||||
|
'transactions.symbol',
|
||||||
|
'transactions.portfolio_id',
|
||||||
|
])
|
||||||
|
->leftJoinSub(
|
||||||
|
DB::table('transactions')
|
||||||
|
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||||
|
$join
|
||||||
|
->on('cr.date', '=', 'transactions.date')
|
||||||
|
->where('cr.currency', '=', $currency);
|
||||||
|
})
|
||||||
|
->select([
|
||||||
|
'transactions.symbol',
|
||||||
|
'transactions.portfolio_id',
|
||||||
|
'transactions.quantity',
|
||||||
|
'transactions.cost_basis_base',
|
||||||
|
'transactions.date',
|
||||||
|
])
|
||||||
|
->selectRaw("
|
||||||
|
(CASE
|
||||||
|
WHEN
|
||||||
|
transactions.transaction_type = 'BUY'
|
||||||
|
OR SUM(transactions.cost_basis_base) = 0
|
||||||
|
THEN
|
||||||
|
COALESCE(cr.rate, 1)
|
||||||
|
ELSE (
|
||||||
|
SELECT
|
||||||
|
SUM(COALESCE(cr2.rate, 1) * buy.cost_basis_base)
|
||||||
|
/ SUM(buy.cost_basis_base)
|
||||||
|
FROM transactions as buy
|
||||||
|
LEFT JOIN currency_rates as cr2
|
||||||
|
ON cr2.date = buy.date
|
||||||
|
AND cr2.currency = '{$currency}'
|
||||||
|
WHERE buy.symbol = transactions.symbol
|
||||||
|
AND buy.portfolio_id = transactions.portfolio_id
|
||||||
|
AND buy.transaction_type = 'BUY'
|
||||||
|
AND buy.date <= transactions.date
|
||||||
|
) END)
|
||||||
|
AS rate")
|
||||||
|
->groupBy([
|
||||||
|
'transactions.id',
|
||||||
|
'transactions.symbol',
|
||||||
|
'transactions.date',
|
||||||
|
'transactions.portfolio_id',
|
||||||
|
'transactions.transaction_type',
|
||||||
|
'transactions.cost_basis_base',
|
||||||
|
'transactions.quantity',
|
||||||
|
'cr.rate',
|
||||||
|
]),
|
||||||
|
'cost_basis_display',
|
||||||
|
function ($join) {
|
||||||
|
$join
|
||||||
|
->on('transactions.symbol', '=', 'cost_basis_display.symbol')
|
||||||
|
->on(
|
||||||
|
'transactions.portfolio_id',
|
||||||
|
'=',
|
||||||
|
'cost_basis_display.portfolio_id'
|
||||||
|
)
|
||||||
|
->on('transactions.date', '=', 'cost_basis_display.date');
|
||||||
|
}
|
||||||
|
)
|
||||||
|
->selectRaw(
|
||||||
|
"CASE WHEN transactions.transaction_type = 'SELL' THEN (transactions.sale_price_base - transactions.cost_basis_base) * transactions.quantity * COALESCE(cr.rate, 1) END AS realized_gain_dollars"
|
||||||
|
)
|
||||||
|
->selectRaw(
|
||||||
|
"CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.cost_basis_base * transactions.quantity * cost_basis_display.rate END AS total_cost_basis"
|
||||||
|
)
|
||||||
|
->selectRaw(
|
||||||
|
"CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity END AS total_purchases"
|
||||||
|
)
|
||||||
|
->groupBy([
|
||||||
|
'transactions.id',
|
||||||
|
'transactions.symbol',
|
||||||
|
'transactions.portfolio_id',
|
||||||
|
'transactions.cost_basis_base',
|
||||||
|
'transactions.quantity',
|
||||||
|
'cost_basis_display.rate',
|
||||||
|
'cr.rate',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dividends_sub = DB::table('dividends')
|
||||||
|
->join('transactions as tx', function ($join) {
|
||||||
|
$join
|
||||||
|
->on('tx.symbol', '=', 'dividends.symbol')
|
||||||
|
->on('tx.date', '<=', 'dividends.date');
|
||||||
|
})
|
||||||
|
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||||
|
$join
|
||||||
|
->on('cr.date', '=', 'dividends.date')
|
||||||
|
->where('cr.currency', '=', $currency);
|
||||||
|
})
|
||||||
|
->select(['dividends.symbol', 'tx.portfolio_id'])
|
||||||
|
->selectRaw(
|
||||||
|
"SUM(((CASE WHEN transaction_type = 'BUY' THEN tx.quantity ELSE 0 END) - (CASE WHEN transaction_type = 'SELL' THEN tx.quantity ELSE 0 END)) * dividends.dividend_amount_base * COALESCE(cr.rate, 1)) AS total_dividends_earned"
|
||||||
|
)
|
||||||
|
->groupBy(['dividends.symbol', 'tx.portfolio_id']);
|
||||||
|
|
||||||
|
return $query->select([
|
||||||
|
'holdings.symbol',
|
||||||
|
'holdings.portfolio_id',
|
||||||
|
'dividends_display.total_dividends_earned',
|
||||||
|
])
|
||||||
|
->groupBy([
|
||||||
|
'holdings.symbol',
|
||||||
|
'holdings.quantity',
|
||||||
|
'holdings.portfolio_id',
|
||||||
|
'cr.rate',
|
||||||
|
'dividends_display.total_dividends_earned',
|
||||||
|
'market_data.market_value_base',
|
||||||
|
])
|
||||||
|
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||||
|
$join->where('cr.currency', '=', $currency);
|
||||||
|
|
||||||
|
if (config('database.default') === 'sqlite') {
|
||||||
|
$join->whereRaw("strftime('%Y-%m-%d', cr.date) = ?", [
|
||||||
|
now()->toDateString(),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$join->on('cr.date', '=', DB::raw("'".now()->toDateString()."'"));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->leftJoin('market_data', function ($join) {
|
||||||
|
$join->on('market_data.symbol', '=', 'holdings.symbol');
|
||||||
|
})
|
||||||
|
->selectRaw('
|
||||||
|
holdings.quantity * market_data.market_value_base * COALESCE(cr.rate, 1)
|
||||||
|
AS total_market_value
|
||||||
|
')
|
||||||
|
->selectRaw('
|
||||||
|
SUM(transactions_display.realized_gain_dollars)
|
||||||
|
AS realized_gain_dollars
|
||||||
|
')
|
||||||
|
->selectRaw('
|
||||||
|
(SUM(transactions_display.total_cost_basis) / SUM(transactions_display.total_purchases))
|
||||||
|
* holdings.quantity
|
||||||
|
AS total_cost_basis
|
||||||
|
')
|
||||||
|
->selectRaw('
|
||||||
|
(holdings.quantity * market_data.market_value_base * COALESCE(cr.rate, 1))
|
||||||
|
- (SUM(transactions_display.total_cost_basis) / SUM(transactions_display.total_purchases))
|
||||||
|
* holdings.quantity
|
||||||
|
AS total_market_gain_dollars
|
||||||
|
')
|
||||||
|
->leftJoinSub($cost_basis_sub, 'transactions_display',
|
||||||
|
function ($join) {
|
||||||
|
$join
|
||||||
|
->on('holdings.symbol', '=', 'transactions_display.symbol')
|
||||||
|
->on('holdings.portfolio_id', '=', 'transactions_display.portfolio_id');
|
||||||
|
}
|
||||||
|
)
|
||||||
|
->leftJoinSub($dividends_sub, 'dividends_display',
|
||||||
|
function ($join) {
|
||||||
|
$join->on('holdings.symbol', '=', 'dividends_display.symbol') // todo: this isnt limiting to port ids
|
||||||
|
->on('holdings.portfolio_id', '=', 'dividends_display.portfolio_id');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function syncTransactionsAndDividends()
|
public function syncTransactionsAndDividends()
|
||||||
{
|
{
|
||||||
// pull existing transaction data
|
// pull existing transaction data
|
||||||
$query = Transaction::where([
|
$query = Transaction::where([
|
||||||
'portfolio_id' => $this->portfolio_id,
|
'portfolio_id' => $this->portfolio_id,
|
||||||
'symbol' => $this->symbol,
|
'transactions.symbol' => $this->symbol,
|
||||||
])->selectRaw('SUM(CASE WHEN transaction_type = "BUY" THEN quantity ELSE 0 END) AS `qty_purchases`')
|
])->selectRaw("SUM(CASE WHEN transaction_type = 'BUY' THEN quantity ELSE 0 END) AS qty_purchases")
|
||||||
->selectRaw('SUM(CASE WHEN transaction_type = "SELL" THEN quantity ELSE 0 END) AS `qty_sales`')
|
->selectRaw("SUM(CASE WHEN transaction_type = 'SELL' THEN quantity ELSE 0 END) AS qty_sales")
|
||||||
->selectRaw('SUM(CASE WHEN transaction_type = "BUY" THEN (quantity * cost_basis) ELSE 0 END) AS `total_cost_basis`')
|
->selectRaw("SUM(CASE WHEN transaction_type = 'SELL' THEN (sale_price - cost_basis) * quantity ELSE 0 END) AS realized_gain_dollars")
|
||||||
->selectRaw('SUM(CASE WHEN transaction_type = "SELL" THEN (quantity * sale_price) ELSE 0 END) AS `total_sale_price`')
|
->selectRaw("SUM(CASE WHEN transaction_type = 'BUY' THEN (quantity * cost_basis) ELSE 0 END) AS total_cost_basis")
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
$total_quantity = round($query->qty_purchases - $query->qty_sales, 3);
|
// delete holding if no transactions
|
||||||
|
if (empty($query->qty_purchases + $query->qty_sales)) {
|
||||||
|
|
||||||
|
$this->delete();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$total_quantity = round($query->qty_purchases - $query->qty_sales, 4);
|
||||||
|
|
||||||
$average_cost_basis = (
|
$average_cost_basis = (
|
||||||
$query->qty_purchases > 0
|
$query->qty_purchases > 0
|
||||||
&& $total_quantity > 0
|
&& $total_quantity > 0
|
||||||
)
|
) ? $query->total_cost_basis / $query->qty_purchases
|
||||||
? $query->total_cost_basis / $query->qty_purchases
|
: 0;
|
||||||
: 0;
|
|
||||||
|
|
||||||
// update holding
|
// update holding
|
||||||
$this->fill([
|
$this->fill([
|
||||||
'quantity' => $total_quantity,
|
'quantity' => $total_quantity,
|
||||||
'average_cost_basis' => $average_cost_basis,
|
'average_cost_basis' => $average_cost_basis,
|
||||||
'total_cost_basis' => $total_quantity * $average_cost_basis,
|
'total_cost_basis' => $total_quantity * $average_cost_basis,
|
||||||
'realized_gain_dollars' => $query->qty_purchases > 0 && $query->total_sale_price > 0
|
'realized_gain_dollars' => $query->realized_gain_dollars ?? 0,
|
||||||
? $query->total_sale_price - ($query->qty_sales * ($query->total_cost_basis / $query->qty_purchases))
|
'dividends_earned' => $this->dividends->sum('total_received'),
|
||||||
: 0,
|
|
||||||
'dividends_earned' => $this->dividends->sum('total_received')
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->save();
|
$this->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function qtyOwned(\Illuminate\Support\Carbon $date = null)
|
public function qtyOwned(?\Illuminate\Support\Carbon $date = null)
|
||||||
{
|
{
|
||||||
if ($date == null) $date = now();
|
if ($date == null) {
|
||||||
|
$date = now();
|
||||||
|
}
|
||||||
|
|
||||||
$transactions = $this->transactions->where('date', '<=', $date);
|
$transactions = $this->transactions->where('date', '<=', $date);
|
||||||
|
|
||||||
@@ -241,78 +464,122 @@ class Holding extends Model
|
|||||||
return $purchases - $sales;
|
return $purchases - $sales;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method that enables calculating daily performance for a given holding
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
public function dailyPerformance(
|
public function dailyPerformance(
|
||||||
\Illuminate\Support\Carbon $start_date = null,
|
?\Illuminate\Support\Carbon $start_date = null,
|
||||||
\Illuminate\Support\Carbon $end_date = null,
|
?\Illuminate\Support\Carbon $end_date = null,
|
||||||
) {
|
) {
|
||||||
if ($start_date == null) $start_date = now();
|
if ($start_date == null) {
|
||||||
if ($end_date == null) $end_date = now();
|
$start_date = now();
|
||||||
|
}
|
||||||
$date_interval = "DATE_ADD(date, INTERVAL 1 DAY)";
|
if ($end_date == null) {
|
||||||
|
$end_date = now();
|
||||||
if (config('database.default') === 'sqlite') {
|
|
||||||
|
|
||||||
$date_interval = "date(date, '+1 day')";
|
|
||||||
} else {
|
|
||||||
|
|
||||||
DB::statement('SET cte_max_recursion_depth=1000000;');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return DB::table(DB::raw("(
|
// MySQL default interval
|
||||||
WITH RECURSIVE date_series AS (
|
$date_interval = 'DATE_ADD(date, INTERVAL 1 DAY)';
|
||||||
SELECT '{$start_date->format('Y-m-d')}' AS date
|
$castNumberType = 'decimal';
|
||||||
UNION ALL
|
|
||||||
SELECT $date_interval
|
// Use SQLite interval grammar
|
||||||
FROM date_series
|
if (config('database.default') === 'sqlite') {
|
||||||
WHERE date < '{$end_date->format('Y-m-d')}'
|
|
||||||
)
|
$date_interval = "date(date, '+1 day')";
|
||||||
SELECT date_series.date
|
}
|
||||||
|
|
||||||
|
// Default CTE time series query (for MySQL and SQLite)
|
||||||
|
$timeSeriesQuery = DB::table(DB::raw("(
|
||||||
|
WITH RECURSIVE date_series AS (
|
||||||
|
SELECT '{$start_date->toDateString()}' AS date
|
||||||
|
UNION ALL
|
||||||
|
SELECT $date_interval
|
||||||
FROM date_series
|
FROM date_series
|
||||||
) as date_series")
|
WHERE date < '{$end_date->toDateString()}'
|
||||||
)
|
)
|
||||||
->select([
|
SELECT date_series.date
|
||||||
'date_series.date',
|
FROM date_series
|
||||||
DB::raw("
|
) as date_series"));
|
||||||
ROUND(
|
|
||||||
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity ELSE 0 END), 0) -
|
// PGSql time series query
|
||||||
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN transactions.quantity ELSE 0 END), 0), 3) AS `owned`
|
if (config('database.default') === 'pgsql') {
|
||||||
"),
|
|
||||||
DB::raw("
|
$timeSeriesQuery = DB::table(DB::raw("
|
||||||
COALESCE(CASE
|
generate_series(
|
||||||
WHEN (
|
date '{$start_date->toDateString()}',
|
||||||
ROUND(
|
date '{$end_date->toDateString()}',
|
||||||
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity ELSE 0 END), 0) -
|
interval '1 day'
|
||||||
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN transactions.quantity ELSE 0 END), 0), 3)
|
) as date_series"));
|
||||||
) = 0 THEN 0
|
|
||||||
ELSE SUM(CASE
|
$castNumberType = 'numeric';
|
||||||
WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity * transactions.cost_basis
|
}
|
||||||
ELSE 0
|
|
||||||
END)
|
// Set MySQL-like query CTE max iterations
|
||||||
END, 0) AS cost_basis
|
if (config('database.default') === 'mysql') {
|
||||||
"),
|
|
||||||
DB::raw("COALESCE(SUM(CASE WHEN transaction_type = 'SELL' THEN ((sale_price - cost_basis) * quantity) ELSE 0 END), 0) AS `realized_gains`")
|
// MySQL default
|
||||||
])
|
$max_recursion_var_name = 'cte_max_recursion_depth';
|
||||||
->leftJoin('transactions', function ($join) {
|
|
||||||
$join->on(DB::raw('DATE(transactions.date)'), '<=', 'date_series.date')
|
// Determine if running MySQL or MariaDB
|
||||||
->where('transactions.symbol', '=', $this->symbol)
|
$versionString = Arr::get(
|
||||||
->where('transactions.portfolio_id', '=', $this->portfolio_id);
|
DB::select('SELECT VERSION() as version;'),
|
||||||
})
|
'0', new \stdClass
|
||||||
->groupBy('date_series.date')
|
)->version;
|
||||||
->orderBy('date_series.date')
|
if (stripos($versionString, 'MariaDB') !== false) {
|
||||||
->get()
|
$max_recursion_var_name = 'max_recursive_iterations'; // Must be MariaDB
|
||||||
->keyBy('date');
|
}
|
||||||
|
|
||||||
|
DB::statement("SET $max_recursion_var_name=1000000;");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extracted query for counting QTY owned
|
||||||
|
$quantityQuery = "ROUND(CAST(COALESCE(
|
||||||
|
SUM(CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity ELSE 0 END)
|
||||||
|
- SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN transactions.quantity ELSE 0 END),
|
||||||
|
0
|
||||||
|
) AS {$castNumberType}), 3)";
|
||||||
|
|
||||||
|
return $timeSeriesQuery
|
||||||
|
->select([
|
||||||
|
'date_series.date',
|
||||||
|
DB::raw("
|
||||||
|
{$quantityQuery} AS owned
|
||||||
|
"),
|
||||||
|
DB::raw("
|
||||||
|
CASE
|
||||||
|
WHEN ({$quantityQuery}) = 0 THEN 0
|
||||||
|
ELSE SUM(CASE
|
||||||
|
WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity * transactions.cost_basis_base
|
||||||
|
ELSE 0
|
||||||
|
END)
|
||||||
|
END AS cost_basis
|
||||||
|
"),
|
||||||
|
DB::raw("COALESCE(SUM(CASE WHEN transaction_type = 'SELL' THEN ((sale_price_base - cost_basis_base) * quantity) ELSE 0 END), 0) AS realized_gains"),
|
||||||
|
])
|
||||||
|
->leftJoin('transactions', function ($join) {
|
||||||
|
$join->on(DB::raw('DATE(transactions.date)'), '<=', 'date_series.date')
|
||||||
|
->where('transactions.symbol', '=', $this->symbol)
|
||||||
|
->where('transactions.portfolio_id', '=', $this->portfolio_id);
|
||||||
|
})
|
||||||
|
->groupBy('date_series.date')
|
||||||
|
->orderBy('date_series.date')
|
||||||
|
->get()
|
||||||
|
->keyBy('date');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getFormattedTransactions()
|
public function getFormattedTransactions()
|
||||||
{
|
{
|
||||||
$formattedTransactions = '';
|
$formattedTransactions = '';
|
||||||
foreach($this->transactions->sortByDesc('date') as $transaction) {
|
foreach ($this->transactions->sortByDesc('date') as $transaction) {
|
||||||
$formattedTransactions .= " * ".$transaction->date->format('Y-m-d')
|
$formattedTransactions .= ' * '.$transaction->date->toDateString()
|
||||||
." ". $transaction->transaction_type
|
.' '.$transaction->transaction_type
|
||||||
." ". $transaction->quantity
|
.' '.$transaction->quantity
|
||||||
." @ ". $transaction->cost_basis
|
.' @ '.$transaction->cost_basis
|
||||||
." each \n\n";
|
." each \n\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
return $formattedTransactions;
|
return $formattedTransactions;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-11
@@ -1,23 +1,32 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use App\Actions\CopyToBaseCurrency;
|
||||||
|
use App\Casts\BaseCurrency;
|
||||||
use App\Interfaces\MarketData\MarketDataInterface;
|
use App\Interfaces\MarketData\MarketDataInterface;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Pipeline;
|
||||||
|
|
||||||
class MarketData extends Model
|
class MarketData extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $primaryKey = 'symbol';
|
protected $primaryKey = 'symbol';
|
||||||
|
|
||||||
protected $keyType = 'string';
|
protected $keyType = 'string';
|
||||||
|
|
||||||
public $incrementing = false;
|
public $incrementing = false;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'symbol',
|
'symbol',
|
||||||
'name',
|
'name',
|
||||||
|
'currency',
|
||||||
'market_value',
|
'market_value',
|
||||||
|
'market_value_base',
|
||||||
'fifty_two_week_high',
|
'fifty_two_week_high',
|
||||||
'fifty_two_week_low',
|
'fifty_two_week_low',
|
||||||
'forward_pe',
|
'forward_pe',
|
||||||
@@ -25,22 +34,41 @@ class MarketData extends Model
|
|||||||
'market_cap',
|
'market_cap',
|
||||||
'book_value',
|
'book_value',
|
||||||
'last_dividend_date',
|
'last_dividend_date',
|
||||||
'dividend_yield'
|
'last_dividend_amount',
|
||||||
|
'dividend_yield',
|
||||||
|
'meta_data',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'last_dividend_date' => 'datetime',
|
|
||||||
'market_value' => 'float',
|
'market_value' => 'float',
|
||||||
|
'market_value_base' => BaseCurrency::class,
|
||||||
'fifty_two_week_high' => 'float',
|
'fifty_two_week_high' => 'float',
|
||||||
'fifty_two_week_low' => 'float',
|
'fifty_two_week_low' => 'float',
|
||||||
'forward_pe' => 'float',
|
'forward_pe' => 'float',
|
||||||
'trailing_pe' => 'float',
|
'trailing_pe' => 'float',
|
||||||
'market_cap' => 'float',
|
'market_cap' => 'integer',
|
||||||
'book_value' => 'float',
|
'book_value' => 'float',
|
||||||
'dividend_yield' => 'float'
|
'last_dividend_date' => 'datetime',
|
||||||
|
'last_dividend_amount' => 'float',
|
||||||
|
'dividend_yield' => 'float',
|
||||||
|
'meta_data' => 'json',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function holdings()
|
protected static function boot()
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
static::saving(function ($market_data) {
|
||||||
|
|
||||||
|
$market_data = Pipeline::send($market_data)
|
||||||
|
->through([
|
||||||
|
CopyToBaseCurrency::class,
|
||||||
|
])
|
||||||
|
->then(fn (MarketData $market_data) => $market_data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function holdings()
|
||||||
{
|
{
|
||||||
return $this->hasMany(Holding::class, 'symbol', 'symbol');
|
return $this->hasMany(Holding::class, 'symbol', 'symbol');
|
||||||
}
|
}
|
||||||
@@ -50,20 +78,20 @@ class MarketData extends Model
|
|||||||
return $query->where('symbol', $symbol);
|
return $query->where('symbol', $symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getMarketData($symbol, $force = false)
|
public static function getMarketData($symbol, $force = false): self
|
||||||
{
|
{
|
||||||
$market_data = self::firstOrNew([
|
$market_data = self::firstOrNew([
|
||||||
'symbol' => $symbol
|
'symbol' => $symbol,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// check if new or stale
|
// check if new or stale
|
||||||
if (
|
if (
|
||||||
$force
|
$force
|
||||||
|| !$market_data->exists
|
|| ! $market_data->exists
|
||||||
|| is_null($market_data->updated_at)
|
|| is_null($market_data->updated_at)
|
||||||
|| $market_data->updated_at->diffInMinutes(now()) >= config('investbrain.refresh')
|
|| $market_data->updated_at->diffInMinutes(now()) >= config('investbrain.refresh')
|
||||||
) {
|
) {
|
||||||
|
|
||||||
// get quote
|
// get quote
|
||||||
$quote = app(MarketDataInterface::class)->quote($symbol);
|
$quote = app(MarketDataInterface::class)->quote($symbol);
|
||||||
|
|
||||||
@@ -76,4 +104,4 @@ class MarketData extends Model
|
|||||||
|
|
||||||
return $market_data;
|
return $market_data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-71
@@ -1,16 +1,19 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Models\AiChat;
|
use App\Interfaces\MarketData\MarketDataInterface;
|
||||||
|
use App\Notifications\InvitedOnboardingNotification;
|
||||||
use Carbon\CarbonPeriod;
|
use Carbon\CarbonPeriod;
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Support\Str;
|
||||||
use App\Interfaces\MarketData\MarketDataInterface;
|
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
|
|
||||||
class Portfolio extends Model
|
class Portfolio extends Model
|
||||||
{
|
{
|
||||||
@@ -28,7 +31,7 @@ class Portfolio extends Model
|
|||||||
protected static function boot()
|
protected static function boot()
|
||||||
{
|
{
|
||||||
parent::boot();
|
parent::boot();
|
||||||
|
|
||||||
static::saved(function ($portfolio) {
|
static::saved(function ($portfolio) {
|
||||||
|
|
||||||
self::ensurePortfolioHasOwner($portfolio);
|
self::ensurePortfolioHasOwner($portfolio);
|
||||||
@@ -38,7 +41,7 @@ class Portfolio extends Model
|
|||||||
protected $hidden = [];
|
protected $hidden = [];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'wishlist' => 'boolean'
|
'wishlist' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $with = ['users', 'transactions'];
|
protected $with = ['users', 'transactions'];
|
||||||
@@ -51,8 +54,8 @@ class Portfolio extends Model
|
|||||||
public function holdings()
|
public function holdings()
|
||||||
{
|
{
|
||||||
return $this->hasMany(Holding::class, 'portfolio_id')
|
return $this->hasMany(Holding::class, 'portfolio_id')
|
||||||
->withMarketData()
|
->withMarketData()
|
||||||
->withPerformance();
|
->withPerformance();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function transactions()
|
public function transactions()
|
||||||
@@ -75,25 +78,25 @@ class Portfolio extends Model
|
|||||||
return $this->morphMany(AiChat::class, 'chatable')->where('user_id', auth()->user()->id);
|
return $this->morphMany(AiChat::class, 'chatable')->where('user_id', auth()->user()->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeMyPortfolios()
|
public function scopeMyPortfolios()
|
||||||
{
|
{
|
||||||
return $this->whereHas('users', function ($query) {
|
return $this->whereHas('users', function ($query) {
|
||||||
$query->where('user_id', auth()->user()->id);
|
$query->where('user_id', auth()->user()->id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeFullAccess($query, $user_id = null)
|
public function scopeFullAccess($query, $user_id = null)
|
||||||
{
|
{
|
||||||
return $query->whereHas('users', function ($query) use ($user_id) {
|
return $query->whereHas('users', function ($query) use ($user_id) {
|
||||||
$query->where('user_id', $user_id ?? auth()->user()->id)
|
$query->where('user_id', $user_id ?? auth()->user()->id)
|
||||||
->where(function ($query) {
|
->where(function ($query) {
|
||||||
$query->where('full_access', true)
|
$query->where('full_access', true)
|
||||||
->orWhere('owner', true);
|
->orWhere('owner', true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeWithoutWishlists()
|
public function scopeWithoutWishlists()
|
||||||
{
|
{
|
||||||
return $this->where(['wishlist' => false]);
|
return $this->where(['wishlist' => false]);
|
||||||
}
|
}
|
||||||
@@ -101,7 +104,7 @@ class Portfolio extends Model
|
|||||||
public function setOwnerIdAttribute($value)
|
public function setOwnerIdAttribute($value)
|
||||||
{
|
{
|
||||||
// enable queued jobs to create portfolios with owners
|
// enable queued jobs to create portfolios with owners
|
||||||
if (!auth()->user()?->id && !$this->owner_id) {
|
if (! auth()->user()?->id && ! $this->owner_id) {
|
||||||
static::$owner_id = $value;
|
static::$owner_id = $value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,124 +116,122 @@ class Portfolio extends Model
|
|||||||
|
|
||||||
public function getOwnerAttribute()
|
public function getOwnerAttribute()
|
||||||
{
|
{
|
||||||
if (!$this->relationLoaded('user')) {
|
if (! $this->relationLoaded('user')) {
|
||||||
|
|
||||||
$this->load('users');
|
$this->load('users');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->users->where('pivot.owner', true)->first();
|
return $this->users->where('pivot.owner', true)->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function ensurePortfolioHasOwner(self $portfolio)
|
public static function ensurePortfolioHasOwner(self $portfolio)
|
||||||
{
|
{
|
||||||
// make sure we don't remove owner access
|
// make sure we don't remove owner access
|
||||||
if (!$portfolio->owner_id) {
|
if (! $portfolio->owner_id) {
|
||||||
$owner[static::$owner_id ?? auth()->user()->id] = ['owner' => true];
|
$owner[static::$owner_id ?? auth()->user()->id] = ['owner' => true];
|
||||||
|
|
||||||
// save
|
// save
|
||||||
$portfolio->users()->sync($owner);
|
$portfolio->users()->sync($owner);
|
||||||
|
static::$owner_id = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes daily change history for a portfolio to the database
|
||||||
|
*/
|
||||||
public function syncDailyChanges(): void
|
public function syncDailyChanges(): void
|
||||||
{
|
{
|
||||||
$holdings = $this->holdings()
|
$holdings = $this->holdings()
|
||||||
->join('transactions', function($join) {
|
->join('transactions', function ($join) {
|
||||||
$join->on('transactions.symbol', '=', 'holdings.symbol')
|
$join->on('transactions.symbol', '=', 'holdings.symbol')
|
||||||
->where('transactions.portfolio_id', '=', $this->id);
|
->where('transactions.portfolio_id', '=', $this->id);
|
||||||
})
|
})
|
||||||
->select('holdings.symbol', 'holdings.portfolio_id', DB::raw('min(transactions.date) as first_transaction_date')) // get first transaction date
|
->select('holdings.symbol', 'holdings.portfolio_id', DB::raw('min(transactions.date) as first_transaction_date')) // get first transaction date
|
||||||
->groupBy(['holdings.symbol', 'holdings.portfolio_id'])
|
->groupBy(['holdings.symbol', 'holdings.portfolio_id'])
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$dividends = Dividend::whereIn('symbol', $holdings->pluck('symbol'))->get();
|
|
||||||
|
|
||||||
$total_performance = [];
|
$total_performance = [];
|
||||||
|
|
||||||
$holdings->each(function($holding) use (&$total_performance, $dividends) {
|
// get unique currencies for holdings
|
||||||
|
$currency_rates = [];
|
||||||
|
foreach ($holdings->groupBy('market_data.currency')->keys() as $currency) {
|
||||||
|
$currency_rates[$currency] = CurrencyRate::timeSeriesRates($currency, $holdings->min('first_transaction_date'), now());
|
||||||
|
}
|
||||||
|
|
||||||
|
$holdings->each(function ($holding) use (&$total_performance, $currency_rates) {
|
||||||
|
|
||||||
$period = CarbonPeriod::create(
|
$period = CarbonPeriod::create(
|
||||||
$holding->first_transaction_date,
|
$holding->first_transaction_date,
|
||||||
now()->isBefore(Carbon::parse(config('investbrain.daily_change_time_of_day')))
|
now()->isBefore(Carbon::parse(config('investbrain.daily_change_time_of_day')))
|
||||||
? now()->subDay()
|
? now()->subDay()
|
||||||
: now()
|
: now()
|
||||||
);
|
);
|
||||||
|
|
||||||
$holding->setRelation('dividends', $dividends->where('symbol', $holding->symbol));
|
|
||||||
|
|
||||||
$daily_performance = $holding->dailyPerformance($holding->first_transaction_date, now());
|
$daily_performance = $holding->dailyPerformance($holding->first_transaction_date, now());
|
||||||
$dividends = $holding->dividends->keyBy(function ($dividend, $key) {
|
|
||||||
return $dividend['date']->format('Y-m-d');
|
|
||||||
});
|
|
||||||
$all_history = app(MarketDataInterface::class)->history($holding->symbol, $holding->first_transaction_date, now());
|
$all_history = app(MarketDataInterface::class)->history($holding->symbol, $holding->first_transaction_date, now());
|
||||||
|
|
||||||
$dividends_earned = 0;
|
|
||||||
$holding_performance = [];
|
$holding_performance = [];
|
||||||
|
|
||||||
foreach($period as $date) {
|
foreach ($period as $date) {
|
||||||
$date = $date->format('Y-m-d');
|
$date = $date->toDateString();
|
||||||
|
|
||||||
$close = $this->getMostRecentCloseData($all_history, $date);
|
$close = $this->getMostRecentCloseData($all_history, $date);
|
||||||
|
|
||||||
$total_market_value = $daily_performance->get($date)->owned * $close;
|
$total_market_value = $daily_performance->get($date)->owned * $close;
|
||||||
$dividends_earned += $daily_performance->get($date)->owned * ($dividends->get($date)?->dividend_amount ?? 0);
|
|
||||||
|
|
||||||
if (Carbon::parse($date)->isWeekday()) {
|
if (Carbon::parse($date)->isWeekday()) {
|
||||||
|
|
||||||
$holding_performance[$date] = [
|
$holding_performance[$date] = [
|
||||||
'date' => $date,
|
'date' => $date,
|
||||||
'portfolio_id' => $this->id,
|
'portfolio_id' => $this->id,
|
||||||
'total_market_value' => $total_market_value,
|
'total_market_value' => $total_market_value * (1 / Arr::get($currency_rates[$holding->market_data->currency], $date, 1)),
|
||||||
'total_cost_basis' => $daily_performance->get($date)->cost_basis,
|
|
||||||
'total_gain' => $total_market_value - $daily_performance->get($date)->cost_basis,
|
|
||||||
'realized_gains' => $daily_performance->get($date)->realized_gains,
|
|
||||||
'total_dividends_earned' => $dividends_earned
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($holding_performance as $date => $performance) {
|
foreach ($holding_performance as $date => $performance) {
|
||||||
if (Arr::get($total_performance, $date) == null) {
|
if (Arr::get($total_performance, $date) == null) {
|
||||||
|
|
||||||
$total_performance[$date] = $performance;
|
$total_performance[$date] = $performance;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
$total_performance[$date]['total_market_value'] += $performance['total_market_value'];
|
$total_performance[$date]['total_market_value'] += $performance['total_market_value'];
|
||||||
$total_performance[$date]['total_cost_basis'] += $performance['total_cost_basis'];
|
|
||||||
$total_performance[$date]['total_gain'] += $performance['total_gain'];
|
|
||||||
$total_performance[$date]['realized_gains'] += $performance['realized_gains'];
|
|
||||||
$total_performance[$date]['total_dividends_earned'] += $performance['total_dividends_earned'];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!empty($total_performance)) {
|
if (! empty($total_performance)) {
|
||||||
DB::transaction(function () use ($total_performance) {
|
DB::transaction(function () use ($total_performance) {
|
||||||
|
|
||||||
|
// delete old history
|
||||||
|
$firstDate = array_keys($total_performance)[0];
|
||||||
|
$this->daily_change()->where('date', '<', $firstDate)->delete();
|
||||||
|
|
||||||
|
// upsert new history
|
||||||
$this->daily_change()->upsert(
|
$this->daily_change()->upsert(
|
||||||
$total_performance,
|
$total_performance,
|
||||||
['date', 'portfolio_id'],
|
['date', 'portfolio_id'],
|
||||||
[
|
[
|
||||||
'total_market_value',
|
'total_market_value',
|
||||||
'total_cost_basis',
|
|
||||||
'total_gain',
|
|
||||||
'realized_gains',
|
|
||||||
'total_dividends_earned'
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cache()->forget('graph-YTD-'.$this->id);
|
||||||
|
cache()->forget('graph-YTD-'.request()->user()?->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getMostRecentCloseData($history, $date, $i = 0, $max_attempts = 5)
|
protected function getMostRecentCloseData($history, $date, $i = 0, $max_attempts = 5)
|
||||||
{
|
{
|
||||||
$close = Arr::get($history, "$date.close", 0);
|
$close = Arr::get($history, "$date.close", 0);
|
||||||
|
|
||||||
if (!$close && $i < $max_attempts) {
|
if (! $close && $i < $max_attempts) {
|
||||||
|
|
||||||
$i++;
|
$i++;
|
||||||
|
|
||||||
$date = Carbon::parse($date)->subDay()->format('Y-m-d');
|
$date = Carbon::parse($date)->subDay()->toDateString();
|
||||||
|
|
||||||
return $this->getMostRecentCloseData($history, $date, $i);
|
return $this->getMostRecentCloseData($history, $date, $i);
|
||||||
}
|
}
|
||||||
@@ -241,16 +242,50 @@ class Portfolio extends Model
|
|||||||
public function getFormattedHoldings()
|
public function getFormattedHoldings()
|
||||||
{
|
{
|
||||||
$formattedHoldings = '';
|
$formattedHoldings = '';
|
||||||
foreach($this->holdings as $holding) {
|
foreach ($this->holdings as $holding) {
|
||||||
$formattedHoldings .= " * Holding of ".$holding->market_data->name." (".$holding->symbol.")"
|
$formattedHoldings .= ' * Holding of '.$holding->market_data->name.' ('.$holding->symbol.')'
|
||||||
."; with ". ($holding->quantity > 0 ? $holding->quantity : 'ZERO') . " shares"
|
.'; with '.($holding->quantity > 0 ? $holding->quantity : 'ZERO').' shares'
|
||||||
."; avg cost basis ". $holding->average_cost_basis
|
.'; avg cost basis '.$holding->average_cost_basis
|
||||||
."; curr market value ". $holding->market_data->market_value
|
.'; curr market value '.$holding->market_data->market_value
|
||||||
."; unrealized gains ". $holding->market_gain_dollars
|
.'; unrealized gains '.$holding->market_gain_dollars
|
||||||
."; realized gains ". $holding->realized_gain_dollars
|
.'; realized gains '.$holding->realized_gain_dollars
|
||||||
."; dividends earned ". $holding->dividends_earned
|
.'; dividends earned '.$holding->dividends_earned
|
||||||
."\n\n";
|
."\n\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
return $formattedHoldings;
|
return $formattedHoldings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share a portfolio with a user
|
||||||
|
*/
|
||||||
|
public function share(string $email, bool $fullAccess = false): void
|
||||||
|
{
|
||||||
|
$user = User::firstOrCreate([
|
||||||
|
'email' => $email,
|
||||||
|
], [
|
||||||
|
'name' => Str::title(Str::before($email, '@')),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$permissions[$user->id] = [
|
||||||
|
'full_access' => $fullAccess,
|
||||||
|
];
|
||||||
|
|
||||||
|
$sync = $this->users()->syncWithoutDetaching($permissions);
|
||||||
|
|
||||||
|
if (! empty($sync['attached'])) {
|
||||||
|
|
||||||
|
foreach ($sync['attached'] as $newUserId) {
|
||||||
|
User::find($newUserId)->notify(new InvitedOnboardingNotification($this, auth()->user()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un-share a portfolio
|
||||||
|
*/
|
||||||
|
public function unShare(string $userId): void
|
||||||
|
{
|
||||||
|
$this->users()->detach($userId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-39
@@ -1,18 +1,22 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Models\Transaction;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use App\Interfaces\MarketData\MarketDataInterface;
|
use App\Interfaces\MarketData\MarketDataInterface;
|
||||||
|
use App\Traits\HasMarketData;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class Split extends Model
|
class Split extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
use HasMarketData;
|
||||||
use HasUuids;
|
use HasUuids;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
@@ -28,22 +32,23 @@ class Split extends Model
|
|||||||
'last_date' => 'datetime',
|
'last_date' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function holdings() {
|
public function holdings(): HasMany
|
||||||
|
{
|
||||||
return $this->hasMany(Holding::class, 'symbol', 'symbol');
|
return $this->hasMany(Holding::class, 'symbol', 'symbol');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function transactions() {
|
public function transactions(): HasMany
|
||||||
|
{
|
||||||
return $this->hasMany(Transaction::class, 'symbol', 'symbol');
|
return $this->hasMany(Transaction::class, 'symbol', 'symbol');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Grab new split data
|
* Grab new split data
|
||||||
*
|
*
|
||||||
* @param string $symbol
|
* @param \DateTimeInterface|null $start_date
|
||||||
* @param \DateTimeInterface|null $start_date
|
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function refreshSplitData(string $symbol)
|
public static function refreshSplitData(string $symbol)
|
||||||
{
|
{
|
||||||
// dates for split data
|
// dates for split data
|
||||||
$splits_meta = self::where(['symbol' => $symbol])
|
$splits_meta = self::where(['symbol' => $symbol])
|
||||||
@@ -58,9 +63,9 @@ class Split extends Model
|
|||||||
|
|
||||||
// nope, need to populate newer split data
|
// nope, need to populate newer split data
|
||||||
if ($splits_meta->total_splits) {
|
if ($splits_meta->total_splits) {
|
||||||
|
|
||||||
$start_date = $splits_meta->last_date->addHours(48);
|
$start_date = $splits_meta->last_date->addHours(48);
|
||||||
$end_date = now();
|
$end_date = now();
|
||||||
}
|
}
|
||||||
|
|
||||||
// get some data
|
// get some data
|
||||||
@@ -71,10 +76,10 @@ class Split extends Model
|
|||||||
if ($split_data->isNotEmpty()) {
|
if ($split_data->isNotEmpty()) {
|
||||||
|
|
||||||
// insert records
|
// insert records
|
||||||
(new self)->insert($split_data->map(function($split) {
|
(new self)->insertOrIgnore($split_data->map(function ($split) {
|
||||||
|
|
||||||
return [...$split, ...['id' => Str::uuid()->toString()]];
|
return [...$split, ...['id' => Str::uuid()->toString()]];
|
||||||
})->toArray());
|
})->toArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
// sync to transactions
|
// sync to transactions
|
||||||
@@ -84,39 +89,39 @@ class Split extends Model
|
|||||||
/**
|
/**
|
||||||
* Syncs all transactions of symbol with split data
|
* Syncs all transactions of symbol with split data
|
||||||
*
|
*
|
||||||
* @param string $symbol
|
* @param string $symbol
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function syncToTransactions($symbol)
|
public static function syncToTransactions($symbol)
|
||||||
{
|
{
|
||||||
// get splits joined with matching holdings
|
// get splits joined with matching holdings
|
||||||
$splits = self::select([
|
$splits = self::select([
|
||||||
'splits.date',
|
'splits.date',
|
||||||
'splits.symbol',
|
'splits.symbol',
|
||||||
'splits.split_amount',
|
'splits.split_amount',
|
||||||
'holdings.portfolio_id'
|
'holdings.portfolio_id',
|
||||||
])
|
])
|
||||||
->where([
|
->where([
|
||||||
'splits.symbol' => $symbol,
|
'splits.symbol' => $symbol,
|
||||||
])
|
])
|
||||||
->whereDate('splits.date', '>', DB::raw('IFNULL(holdings.splits_synced_at, "0000-00-00")'))
|
->whereDate('splits.date', '>', DB::raw("COALESCE(holdings.splits_synced_at, '1901-01-01')"))
|
||||||
->where('holdings.quantity', '>', 0)
|
->where('holdings.quantity', '>', 0)
|
||||||
->join('holdings', 'splits.symbol', 'holdings.symbol')
|
->join('holdings', 'splits.symbol', 'holdings.symbol')
|
||||||
->orderBy('splits.date', 'ASC')
|
->orderBy('splits.date', 'ASC')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
foreach($splits as $split) {
|
foreach ($splits as $split) {
|
||||||
|
|
||||||
// get qty owned when split was issued
|
// get qty owned when split was issued
|
||||||
$qty_owned = Transaction::where([
|
$qty_owned = Transaction::where([
|
||||||
'symbol' => $split->symbol,
|
'symbol' => $split->symbol,
|
||||||
'portfolio_id' => $split->portfolio_id
|
'portfolio_id' => $split->portfolio_id,
|
||||||
])
|
])
|
||||||
->whereDate('transactions.date', '<', $split->date->format('Y-m-d'))
|
->whereDate('transactions.date', '<', $split->date->toDateString())
|
||||||
->selectRaw('SUM(CASE WHEN transaction_type = "BUY" THEN quantity ELSE 0 END) -
|
->selectRaw("SUM(CASE WHEN transaction_type = 'BUY' THEN quantity ELSE 0 END) -
|
||||||
SUM(CASE WHEN transaction_type = "SELL" THEN quantity ELSE 0 END) AS qty_owned')
|
SUM(CASE WHEN transaction_type = 'SELL' THEN quantity ELSE 0 END) AS qty_owned")
|
||||||
->value('qty_owned');
|
->value('qty_owned');
|
||||||
|
|
||||||
if ($qty_owned > 0) {
|
if ($qty_owned > 0) {
|
||||||
|
|
||||||
Transaction::create([
|
Transaction::create([
|
||||||
@@ -128,14 +133,14 @@ class Split extends Model
|
|||||||
'cost_basis' => 0,
|
'cost_basis' => 0,
|
||||||
'split' => true,
|
'split' => true,
|
||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
'updated_at' => now()
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Holding::where([
|
Holding::where([
|
||||||
'symbol' => $split->symbol,
|
'symbol' => $split->symbol,
|
||||||
'portfolio_id' => $split->portfolio_id
|
'portfolio_id' => $split->portfolio_id,
|
||||||
])->update([
|
])->update([
|
||||||
'splits_synced_at' => now()
|
'splits_synced_at' => now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-67
@@ -1,17 +1,28 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Models\MarketData;
|
use App\Actions\ConvertToMarketDataCurrency;
|
||||||
use Illuminate\Support\Arr;
|
use App\Actions\CopyToBaseCurrency;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use App\Actions\EnsureCostBasisAddedToSale;
|
||||||
|
use App\Actions\EnsureDailyChangeIsSynced;
|
||||||
|
use App\Casts\BaseCurrency;
|
||||||
|
use App\Traits\HasMarketData;
|
||||||
|
use Illuminate\Contracts\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\Pipeline;
|
||||||
|
|
||||||
class Transaction extends Model
|
class Transaction extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
use HasMarketData;
|
||||||
use HasUuids;
|
use HasUuids;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
@@ -19,11 +30,12 @@ class Transaction extends Model
|
|||||||
'date',
|
'date',
|
||||||
'portfolio_id',
|
'portfolio_id',
|
||||||
'transaction_type',
|
'transaction_type',
|
||||||
|
'currency',
|
||||||
'quantity',
|
'quantity',
|
||||||
'cost_basis',
|
'cost_basis',
|
||||||
'sale_price',
|
'sale_price',
|
||||||
'split',
|
'split',
|
||||||
'reinvested_dividend'
|
'reinvested_dividend',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = [];
|
protected $hidden = [];
|
||||||
@@ -31,7 +43,12 @@ class Transaction extends Model
|
|||||||
protected $casts = [
|
protected $casts = [
|
||||||
'date' => 'datetime',
|
'date' => 'datetime',
|
||||||
'split' => 'boolean',
|
'split' => 'boolean',
|
||||||
'reinvested_dividend' => 'boolean'
|
'reinvested_dividend' => 'boolean',
|
||||||
|
'quantity' => 'float',
|
||||||
|
'cost_basis' => 'float',
|
||||||
|
'sale_price' => 'float',
|
||||||
|
'cost_basis_base' => BaseCurrency::class,
|
||||||
|
'sale_price_base' => BaseCurrency::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static function boot()
|
protected static function boot()
|
||||||
@@ -40,26 +57,33 @@ class Transaction extends Model
|
|||||||
|
|
||||||
static::saving(function ($transaction) {
|
static::saving(function ($transaction) {
|
||||||
|
|
||||||
if ($transaction->transaction_type == 'SELL') {
|
$transaction = Pipeline::send($transaction)
|
||||||
|
->through([
|
||||||
$transaction->ensureCostBasisIsAddedToSale();
|
ConvertToMarketDataCurrency::class,
|
||||||
}
|
EnsureCostBasisAddedToSale::class,
|
||||||
|
CopyToBaseCurrency::class,
|
||||||
|
])
|
||||||
|
->then(fn (Transaction $transaction) => $transaction);
|
||||||
});
|
});
|
||||||
|
|
||||||
static::saved(function ($transaction) {
|
static::saved(function ($transaction) {
|
||||||
|
|
||||||
$transaction->syncToHolding();
|
$transaction->syncToHolding();
|
||||||
|
|
||||||
$transaction->refreshMarketData();
|
$transaction = Pipeline::send($transaction)
|
||||||
|
->through([
|
||||||
|
EnsureDailyChangeIsSynced::class,
|
||||||
|
])
|
||||||
|
->then(fn (Transaction $transaction) => $transaction);
|
||||||
|
|
||||||
cache()->forget('portfolio-metrics-' . $transaction->portfolio_id);
|
cache()->forget('portfolio-metrics-'.$transaction->portfolio_id);
|
||||||
});
|
});
|
||||||
|
|
||||||
static::deleted(function ($transaction) {
|
static::deleted(function ($transaction) {
|
||||||
|
|
||||||
$transaction->syncToHolding();
|
$transaction->syncToHolding();
|
||||||
|
|
||||||
cache()->forget('portfolio-metrics-' . $transaction->portfolio_id);
|
cache()->forget('portfolio-metrics-'.$transaction->portfolio_id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,62 +97,53 @@ class Transaction extends Model
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Related market data for transaction
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function market_data()
|
|
||||||
{
|
|
||||||
return $this->hasOne(MarketData::class, 'symbol', 'symbol');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Related portfolio
|
* Related portfolio
|
||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function portfolio()
|
public function portfolio(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Portfolio::class);
|
return $this->belongsTo(Portfolio::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeWithMarketData($query)
|
public function scopeWithMarketData($query): Builder
|
||||||
{
|
{
|
||||||
return $query->withAggregate('market_data', 'name')
|
return $query->withAggregate('market_data', 'name')
|
||||||
->withAggregate('market_data', 'market_value')
|
->withAggregate('market_data', 'market_value')
|
||||||
->withAggregate('market_data', 'fifty_two_week_low')
|
->withAggregate('market_data', 'currency')
|
||||||
->withAggregate('market_data', 'fifty_two_week_high')
|
->withAggregate('market_data', 'fifty_two_week_low')
|
||||||
->withAggregate('market_data', 'updated_at')
|
->withAggregate('market_data', 'fifty_two_week_high')
|
||||||
->join('market_data', 'transactions.symbol', 'market_data.symbol');
|
->withAggregate('market_data', 'updated_at')
|
||||||
|
->join('market_data', 'transactions.symbol', 'market_data.symbol');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopePortfolio($query, $portfolio)
|
public function scopePortfolio($query, $portfolio): Builder
|
||||||
{
|
{
|
||||||
return $query->where('portfolio_id', $portfolio);
|
return $query->where('portfolio_id', $portfolio);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeSymbol($query, $symbol)
|
public function scopeSymbol($query, $symbol): Builder
|
||||||
{
|
{
|
||||||
return $query->where('symbol', $symbol);
|
return $query->where('symbol', $symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeBuy($query)
|
public function scopeBuy($query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('transaction_type', 'BUY');
|
return $query->where('transaction_type', 'BUY');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeSell($query)
|
public function scopeSell($query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('transaction_type', 'SELL');
|
return $query->where('transaction_type', 'SELL');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeBeforeDate($query, $date)
|
public function scopeBeforeDate($query, $date): Builder
|
||||||
{
|
{
|
||||||
return $query->whereDate('date', '<=', $date);
|
return $query->whereDate('date', '<=', $date);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeMyTransactions()
|
public function scopeMyTransactions(): Builder
|
||||||
{
|
{
|
||||||
return $this->whereHas('portfolio', function ($query) {
|
return $this->whereHas('portfolio', function ($query) {
|
||||||
$query->whereHas('users', function ($query) {
|
$query->whereHas('users', function ($query) {
|
||||||
@@ -137,36 +152,11 @@ class Transaction extends Model
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function refreshMarketData()
|
|
||||||
{
|
|
||||||
return MarketData::getMarketData($this->attributes['symbol']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Writes average cost basis to a sale transaction
|
|
||||||
*
|
|
||||||
* @return Transaction
|
|
||||||
*/
|
|
||||||
public function ensureCostBasisIsAddedToSale()
|
|
||||||
{
|
|
||||||
$average_cost_basis = Transaction::where([
|
|
||||||
'portfolio_id' => $this->portfolio_id,
|
|
||||||
'symbol' => $this->symbol,
|
|
||||||
'transaction_type' => 'BUY',
|
|
||||||
])->whereDate('date', '<=', $this->date)
|
|
||||||
->average('cost_basis');
|
|
||||||
|
|
||||||
$this->cost_basis = $average_cost_basis ?? 0;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Syncs the holding related to this transaction
|
* Syncs the holding related to this transaction
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function syncToHolding() {
|
public function syncToHolding(): void
|
||||||
|
{
|
||||||
|
|
||||||
// if symbol name changed, sync previous symbol too
|
// if symbol name changed, sync previous symbol too
|
||||||
if (Arr::has($this->changes, 'symbol')) {
|
if (Arr::has($this->changes, 'symbol')) {
|
||||||
@@ -181,14 +171,14 @@ class Transaction extends Model
|
|||||||
// get the holding for a symbol and portfolio (or create one)
|
// get the holding for a symbol and portfolio (or create one)
|
||||||
Holding::firstOrNew([
|
Holding::firstOrNew([
|
||||||
'portfolio_id' => $this->portfolio_id,
|
'portfolio_id' => $this->portfolio_id,
|
||||||
'symbol' => $this->symbol
|
'symbol' => $this->symbol,
|
||||||
], [
|
], [
|
||||||
'portfolio_id' => $this->portfolio_id,
|
'portfolio_id' => $this->portfolio_id,
|
||||||
'symbol' => $this->symbol,
|
'symbol' => $this->symbol,
|
||||||
'quantity' => $this->quantity,
|
'quantity' => $this->quantity,
|
||||||
'average_cost_basis' => $this->cost_basis,
|
'average_cost_basis' => $this->cost_basis_base,
|
||||||
'total_cost_basis' => $this->quantity * $this->cost_basis,
|
'total_cost_basis' => $this->quantity * $this->cost_basis_base,
|
||||||
'splits_synced_at' => now(),
|
'splits_synced_at' => now(),
|
||||||
])->syncTransactionsAndDividends();
|
])->syncTransactionsAndDividends();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-12
@@ -1,34 +1,38 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Traits\HasConnectedAccounts;
|
use App\Traits\HasConnectedAccounts;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use App\Traits\HasProfilePhoto;
|
||||||
use Laravel\Jetstream\HasProfilePhoto;
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use Illuminate\Notifications\Notifiable;
|
|
||||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
|
||||||
use Staudenmeir\EloquentHasManyDeep\HasManyDeep;
|
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Staudenmeir\EloquentHasManyDeep\HasRelationships;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
use Staudenmeir\EloquentHasManyDeep\HasManyDeep;
|
||||||
|
use Staudenmeir\EloquentHasManyDeep\HasRelationships;
|
||||||
|
|
||||||
class User extends Authenticatable implements MustVerifyEmail
|
class User extends Authenticatable implements MustVerifyEmail
|
||||||
{
|
{
|
||||||
use HasApiTokens;
|
use HasApiTokens;
|
||||||
|
use HasConnectedAccounts;
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
use HasProfilePhoto;
|
use HasProfilePhoto;
|
||||||
|
use HasRelationships;
|
||||||
|
use HasUuids;
|
||||||
use Notifiable;
|
use Notifiable;
|
||||||
use TwoFactorAuthenticatable;
|
use TwoFactorAuthenticatable;
|
||||||
use HasUuids;
|
|
||||||
use HasRelationships;
|
|
||||||
use HasConnectedAccounts;
|
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
|
'options',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
@@ -48,6 +52,8 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
return [
|
return [
|
||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
|
'admin' => 'boolean',
|
||||||
|
'options' => 'json',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +71,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
{
|
{
|
||||||
return $this->hasManyDeep(Holding::class, ['portfolio_user', Portfolio::class])
|
return $this->hasManyDeep(Holding::class, ['portfolio_user', Portfolio::class])
|
||||||
->withMarketData()
|
->withMarketData()
|
||||||
->withPerformance();
|
->withPerformance();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function transactions(): HasManyDeep
|
public function transactions(): HasManyDeep
|
||||||
@@ -78,6 +84,28 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
WHEN transaction_type = \'SELL\'
|
WHEN transaction_type = \'SELL\'
|
||||||
THEN COALESCE(transactions.sale_price - transactions.cost_basis, 0)
|
THEN COALESCE(transactions.sale_price - transactions.cost_basis, 0)
|
||||||
ELSE COALESCE(market_data.market_value - transactions.cost_basis, 0)
|
ELSE COALESCE(market_data.market_value - transactions.cost_basis, 0)
|
||||||
END AS gain_dollars');
|
END AS gain_dollars');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCurrency(): string
|
||||||
|
{
|
||||||
|
return Arr::get($this->options, 'display_currency') ?? config('investbrain.base_currency');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLocale(): string
|
||||||
|
{
|
||||||
|
$available_locales = Arr::pluck(config('app.available_locales'), 'locale');
|
||||||
|
|
||||||
|
return Arr::get($this->options, 'locale') ?? request()->getPreferredLanguage($available_locales) ?? config('app.locale');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setOption(mixed $key, ?string $value = null): self
|
||||||
|
{
|
||||||
|
|
||||||
|
$options = is_array($key) ? $key : [$key => $value];
|
||||||
|
|
||||||
|
$this->options = array_merge($this->options ?? [], $options);
|
||||||
|
|
||||||
|
return $this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Notifications;
|
namespace App\Notifications;
|
||||||
|
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Notifications\Notification;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Notifications\Messages\MailMessage;
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
class ImportFailedNotification extends Notification implements ShouldQueue
|
class ImportFailedNotification extends Notification implements ShouldQueue
|
||||||
{
|
{
|
||||||
@@ -16,7 +18,7 @@ class ImportFailedNotification extends Notification implements ShouldQueue
|
|||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $errorMessage
|
public string $errorMessage
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the notification's delivery channels.
|
* Get the notification's delivery channels.
|
||||||
@@ -34,12 +36,12 @@ class ImportFailedNotification extends Notification implements ShouldQueue
|
|||||||
public function toMail(object $notifiable): MailMessage
|
public function toMail(object $notifiable): MailMessage
|
||||||
{
|
{
|
||||||
return (new MailMessage)
|
return (new MailMessage)
|
||||||
->greeting('Oh no!')
|
->greeting('Oh no!')
|
||||||
->subject("Your Investbrain import failed!")
|
->subject('Your Investbrain import failed!')
|
||||||
->line("Heads up, your Investbrain import was unable to successfully complete. There were errors which caused the import to fail.")
|
->line('Heads up, your Investbrain import was unable to successfully complete. There were errors which caused the import to fail.')
|
||||||
->action("Try again?", route('import-export'))
|
->action('Try again?', route('import-export'))
|
||||||
->line("**Technical details:**")
|
->line('**Technical details:**')
|
||||||
->line($this->errorMessage);
|
->line($this->errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Notifications;
|
namespace App\Notifications;
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Portfolio;
|
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Notifications\Notification;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Notifications\Messages\MailMessage;
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
class ImportSucceededNotification extends Notification implements ShouldQueue
|
class ImportSucceededNotification extends Notification implements ShouldQueue
|
||||||
{
|
{
|
||||||
@@ -16,7 +16,7 @@ class ImportSucceededNotification extends Notification implements ShouldQueue
|
|||||||
/**
|
/**
|
||||||
* Create a new notification instance.
|
* Create a new notification instance.
|
||||||
*/
|
*/
|
||||||
public function __construct() { }
|
public function __construct() {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the notification's delivery channels.
|
* Get the notification's delivery channels.
|
||||||
@@ -34,10 +34,10 @@ class ImportSucceededNotification extends Notification implements ShouldQueue
|
|||||||
public function toMail(object $notifiable): MailMessage
|
public function toMail(object $notifiable): MailMessage
|
||||||
{
|
{
|
||||||
return (new MailMessage)
|
return (new MailMessage)
|
||||||
->greeting('Woot! 🎉')
|
->greeting('Woot! 🎉')
|
||||||
->subject("Your Investbrain import was successful!")
|
->subject('Your Investbrain import was successful!')
|
||||||
->line("Just a heads up that your Investbrain import succeeded! Your portfolios, transactions, and daily changes are now available in your account.")
|
->line('Just a heads up that your Investbrain import succeeded! Your portfolios, transactions, and daily changes are now available in your account.')
|
||||||
->action("Get Started", route('dashboard'));
|
->action('Get Started', route('dashboard'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Notifications;
|
namespace App\Notifications;
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Portfolio;
|
use App\Models\Portfolio;
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Notifications\Notification;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Notifications\Messages\MailMessage;
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
class InvitedOnboardingNotification extends Notification implements ShouldQueue
|
class InvitedOnboardingNotification extends Notification implements ShouldQueue
|
||||||
{
|
{
|
||||||
@@ -19,7 +21,7 @@ class InvitedOnboardingNotification extends Notification implements ShouldQueue
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
public Portfolio $portfolio,
|
public Portfolio $portfolio,
|
||||||
public User $sender,
|
public User $sender,
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the notification's delivery channels.
|
* Get the notification's delivery channels.
|
||||||
@@ -40,14 +42,14 @@ class InvitedOnboardingNotification extends Notification implements ShouldQueue
|
|||||||
$url = url()->signedRoute('invited_onboarding', ['portfolio' => $this->portfolio->id, 'user' => $notifiable->id], now()->addDays(90));
|
$url = url()->signedRoute('invited_onboarding', ['portfolio' => $this->portfolio->id, 'user' => $notifiable->id], now()->addDays(90));
|
||||||
|
|
||||||
return (new MailMessage)
|
return (new MailMessage)
|
||||||
->replyTo($this->sender->email, $this->sender->name)
|
->replyTo($this->sender->email, $this->sender->name)
|
||||||
->greeting('Hey there! 👋')
|
->greeting('Hey there! 👋')
|
||||||
->subject("You've been invited to {$this->portfolio->title} on Investbrain!")
|
->subject("You've been invited to {$this->portfolio->title} on Investbrain!")
|
||||||
->line("{$this->sender->name} has invited you to **{$this->portfolio->title}** on Investbrain, a smart open-source investment tracker that consolidates and monitors market performance across your different brokerages.")
|
->line("{$this->sender->name} has invited you to **{$this->portfolio->title}** on Investbrain, a smart open-source investment tracker that consolidates and monitors market performance across your different brokerages.")
|
||||||
->line("Once you're in, you'll be able to see all the holdings, dividends, market performance and more for {$this->portfolio->title}!")
|
->line("Once you're in, you'll be able to see all the holdings, dividends, market performance and more for {$this->portfolio->title}!")
|
||||||
->action("Get Started", $url)
|
->action('Get Started', $url)
|
||||||
->line("If you have any questions, you can reply to this email.")
|
->line('If you have any questions, you can reply to this email.')
|
||||||
->salutation("See you there,\n". e($this->sender->name));
|
->salutation("See you there,\n".e($this->sender->name));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Notifications;
|
namespace App\Notifications;
|
||||||
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use App\Models\ConnectedAccount;
|
use App\Models\ConnectedAccount;
|
||||||
use Illuminate\Notifications\Notification;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Notifications\Messages\MailMessage;
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
class VerifyConnectedAccountNotification extends Notification implements ShouldQueue
|
class VerifyConnectedAccountNotification extends Notification implements ShouldQueue
|
||||||
{
|
{
|
||||||
@@ -17,7 +19,7 @@ class VerifyConnectedAccountNotification extends Notification implements ShouldQ
|
|||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $connected_account_id
|
public string $connected_account_id
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the notification's delivery channels.
|
* Get the notification's delivery channels.
|
||||||
@@ -40,11 +42,11 @@ class VerifyConnectedAccountNotification extends Notification implements ShouldQ
|
|||||||
$url = url()->signedRoute('oauth.verify_connected_account', ['connected_account' => $this->connected_account_id], now()->days($days = 7));
|
$url = url()->signedRoute('oauth.verify_connected_account', ['connected_account' => $this->connected_account_id], now()->days($days = 7));
|
||||||
|
|
||||||
return (new MailMessage)
|
return (new MailMessage)
|
||||||
->greeting('Welcome back!')
|
->greeting('Welcome back!')
|
||||||
->subject("Connect your $provider account with Investbrain")
|
->subject("Connect your $provider account with Investbrain")
|
||||||
->line("You recently attempted to log into an existing Investbrain account using $provider. To safeguard your Investbrain account, please confirm this was you by pressing the 'Connect $provider' button below:")
|
->line("You recently attempted to log into an existing Investbrain account using $provider. To safeguard your Investbrain account, please confirm this was you by pressing the 'Connect $provider' button below:")
|
||||||
->action("Connect $provider", $url)
|
->action("Connect $provider", $url)
|
||||||
->line("If you do not recognize this activity, we recommend [changing your password](".route('profile.show').") as soon as possible. Otherwise, you can disregard this message. This link will expire in {$days} days.");
|
->line('If you do not recognize this activity, we recommend [changing your password]('.route('profile.show').") as soon as possible. Otherwise, you can disregard this message. This link will expire in {$days} days.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,26 +1,21 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Policies;
|
namespace App\Policies;
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Portfolio;
|
use App\Models\Portfolio;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
class PortfolioPolicy
|
class PortfolioPolicy
|
||||||
{
|
{
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
public function readOnly(User $user, Portfolio $portfolio)
|
public function readOnly(User $user, Portfolio $portfolio)
|
||||||
{
|
{
|
||||||
$pivot = $portfolio->users()->where('user_id', $user->id)->first();
|
$pivot = $portfolio->users()->where('user_id', $user->id)->first();
|
||||||
|
|
||||||
return !!$pivot;
|
return (bool) $pivot;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
public function fullAccess(User $user, Portfolio $portfolio)
|
public function fullAccess(User $user, Portfolio $portfolio)
|
||||||
{
|
{
|
||||||
$pivot = $portfolio->users()->where('user_id', $user->id)->first();
|
$pivot = $portfolio->users()->where('user_id', $user->id)->first();
|
||||||
@@ -28,9 +23,6 @@ class PortfolioPolicy
|
|||||||
return $pivot && ($pivot->pivot->full_access || $pivot->pivot->owner);
|
return $pivot && ($pivot->pivot->full_access || $pivot->pivot->owner);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
public function owner(User $user, Portfolio $portfolio)
|
public function owner(User $user, Portfolio $portfolio)
|
||||||
{
|
{
|
||||||
$pivot = $portfolio->users()->where('user_id', $user->id)->first();
|
$pivot = $portfolio->users()->where('user_id', $user->id)->first();
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Number;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use NumberFormatter;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -22,6 +28,29 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
//
|
JsonResource::withoutWrapping();
|
||||||
|
|
||||||
|
Arr::macro('skipEmptyValues', function (array $array) {
|
||||||
|
|
||||||
|
return Arr::mapWithKeys($array, function (mixed $value, mixed $key) {
|
||||||
|
$result = [];
|
||||||
|
if (! empty($value)) {
|
||||||
|
$result[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Number::macro('currencySymbol', function (?string $currency = null, ?string $locale = null) {
|
||||||
|
|
||||||
|
$currency = $currency ?? Number::defaultCurrency();
|
||||||
|
|
||||||
|
$locale = $locale ?? Number::defaultLocale();
|
||||||
|
|
||||||
|
$formatter = new NumberFormatter($locale."@currency=$currency", NumberFormatter::CURRENCY);
|
||||||
|
|
||||||
|
return $formatter->getSymbol(NumberFormatter::CURRENCY_SYMBOL);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user