Compare commits
156 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ea4b5c12a | |||
| d3337c7c01 | |||
| 23a9b84659 | |||
| 6bc174a87b | |||
| 401b0eef91 | |||
| 98f47baa73 | |||
| 66889abc72 | |||
| fc6b7a8c52 | |||
| 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 |
+1
-1
@@ -13,4 +13,4 @@ storage/framework/cache/*
|
|||||||
storage/framework/sessions/*
|
storage/framework/sessions/*
|
||||||
storage/framework/testing/*
|
storage/framework/testing/*
|
||||||
storage/framework/views/*
|
storage/framework/views/*
|
||||||
storage/framework/logs/*
|
storage/logs/*
|
||||||
+8
-24
@@ -16,14 +16,16 @@ REGISTRATION_ENABLED=true
|
|||||||
# Enable or disable AI chat feature
|
# Enable or disable AI chat feature
|
||||||
AI_CHAT_ENABLED=false
|
AI_CHAT_ENABLED=false
|
||||||
|
|
||||||
# API key for OpenAI (for Llama support, see docs)
|
# API key for OpenAI (for other labs or Ollama support, see docs)
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
OPENAI_ORGANIZATION=
|
|
||||||
|
|
||||||
# Market data provider to use (comma separated list)
|
# Market data provider to use (comma separated list)
|
||||||
MARKET_DATA_PROVIDER=yahoo
|
MARKET_DATA_PROVIDER=yahoo
|
||||||
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)
|
# Cadence to refresh market data (in minutes)
|
||||||
MARKET_DATA_REFRESH=30
|
MARKET_DATA_REFRESH=30
|
||||||
@@ -40,13 +42,10 @@ LINKEDIN_CLIENT_SECRET=
|
|||||||
FACEBOOK_CLIENT_ID=
|
FACEBOOK_CLIENT_ID=
|
||||||
FACEBOOK_CLIENT_SECRET=
|
FACEBOOK_CLIENT_SECRET=
|
||||||
|
|
||||||
APP_NAME=Investbrain
|
FILESYSTEM_DISK=local
|
||||||
APP_TIMEZONE=UTC
|
SESSION_DRIVER=redis
|
||||||
APP_ENV=production
|
QUEUE_CONNECTION=redis
|
||||||
APP_DEBUG=true
|
CACHE_STORE=redis
|
||||||
APP_LOCALE=en
|
|
||||||
APP_FALLBACK_LOCALE=en
|
|
||||||
SELF_HOSTED=true
|
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=mysql
|
||||||
DB_HOST=investbrain-mysql
|
DB_HOST=investbrain-mysql
|
||||||
@@ -55,20 +54,7 @@ DB_DATABASE=investbrain
|
|||||||
DB_USERNAME=investbrain
|
DB_USERNAME=investbrain
|
||||||
DB_PASSWORD=investbrain
|
DB_PASSWORD=investbrain
|
||||||
|
|
||||||
SESSION_DRIVER=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=investbrain-redis
|
REDIS_HOST=investbrain-redis
|
||||||
REDIS_PATH=/tmp/database_server.sock
|
|
||||||
REDIS_PASSWORD=null
|
REDIS_PASSWORD=null
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|
||||||
@@ -86,5 +72,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}"
|
|
||||||
|
|||||||
@@ -8,11 +8,8 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-22.04 #ubuntu-latest
|
runs-on: self-hosted
|
||||||
steps:
|
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
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
@@ -43,7 +40,16 @@ jobs:
|
|||||||
- name: Extract version from tag
|
- name: Extract version from tag
|
||||||
id: extract-version
|
id: extract-version
|
||||||
run: |
|
run: |
|
||||||
echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
|
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
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -51,8 +57,7 @@ jobs:
|
|||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
file: ./docker/Dockerfile
|
file: ./docker/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: ${{ steps.extract-version.outputs.tags }}
|
||||||
investbrainapp/investbrain:latest
|
build-args: |
|
||||||
investbrainapp/investbrain:${{ env.version }}
|
VERSION=${{ github.ref_name }}
|
||||||
ghcr.io/investbrainapp/investbrain:latest
|
|
||||||
ghcr.io/investbrainapp/investbrain:${{ env.version }}
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"laravel-boost": {
|
||||||
|
"command": "php",
|
||||||
|
"args": [
|
||||||
|
"artisan",
|
||||||
|
"boost:mcp"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
<laravel-boost-guidelines>
|
||||||
|
=== foundation rules ===
|
||||||
|
|
||||||
|
# Laravel Boost Guidelines
|
||||||
|
|
||||||
|
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
|
||||||
|
|
||||||
|
## Foundational Context
|
||||||
|
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||||
|
|
||||||
|
- php - 8.4.18
|
||||||
|
- laravel/fortify (FORTIFY) - v1
|
||||||
|
- laravel/framework (LARAVEL) - v12
|
||||||
|
- laravel/prompts (PROMPTS) - v0
|
||||||
|
- laravel/sanctum (SANCTUM) - v4
|
||||||
|
- laravel/socialite (SOCIALITE) - v5
|
||||||
|
- livewire/livewire (LIVEWIRE) - v4
|
||||||
|
- livewire/volt (VOLT) - v1
|
||||||
|
- laravel/mcp (MCP) - v0
|
||||||
|
- laravel/pint (PINT) - v1
|
||||||
|
- phpunit/phpunit (PHPUNIT) - v11
|
||||||
|
- alpinejs (ALPINEJS) - v3
|
||||||
|
- tailwindcss (TAILWINDCSS) - v4
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||||
|
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||||
|
- Check for existing components to reuse before writing a new one.
|
||||||
|
|
||||||
|
## Verification Scripts
|
||||||
|
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
|
||||||
|
|
||||||
|
## Application Structure & Architecture
|
||||||
|
- Stick to existing directory structure; don't create new base folders without approval.
|
||||||
|
- Do not change the application's dependencies without approval.
|
||||||
|
|
||||||
|
## Frontend Bundling
|
||||||
|
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||||
|
|
||||||
|
## Replies
|
||||||
|
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||||
|
|
||||||
|
## Documentation Files
|
||||||
|
- You must only create documentation files if explicitly requested by the user.
|
||||||
|
|
||||||
|
=== boost rules ===
|
||||||
|
|
||||||
|
## Laravel Boost
|
||||||
|
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||||
|
|
||||||
|
## Artisan
|
||||||
|
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
|
||||||
|
|
||||||
|
## URLs
|
||||||
|
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
|
||||||
|
|
||||||
|
## Tinker / Debugging
|
||||||
|
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||||
|
- Use the `database-query` tool when you only need to read from the database.
|
||||||
|
|
||||||
|
## Reading Browser Logs With the `browser-logs` Tool
|
||||||
|
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||||
|
- Only recent browser logs will be useful - ignore old logs.
|
||||||
|
|
||||||
|
## Searching Documentation (Critically Important)
|
||||||
|
- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||||
|
- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
||||||
|
- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
|
||||||
|
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||||
|
- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
|
||||||
|
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||||
|
|
||||||
|
### Available Search Syntax
|
||||||
|
- You can and should pass multiple queries at once. The most relevant results will be returned first.
|
||||||
|
|
||||||
|
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
|
||||||
|
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
||||||
|
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
|
||||||
|
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
|
||||||
|
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
|
||||||
|
|
||||||
|
=== php rules ===
|
||||||
|
|
||||||
|
## PHP
|
||||||
|
|
||||||
|
- Always use curly braces for control structures, even if it has one line.
|
||||||
|
|
||||||
|
### Constructors
|
||||||
|
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||||
|
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||||
|
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
||||||
|
|
||||||
|
### Type Declarations
|
||||||
|
- Always use explicit return type declarations for methods and functions.
|
||||||
|
- Use appropriate PHP type hints for method parameters.
|
||||||
|
|
||||||
|
<code-snippet name="Explicit Return Types and Method Params" lang="php">
|
||||||
|
protected function isAccessible(User $user, ?string $path = null): bool
|
||||||
|
{
|
||||||
|
...
|
||||||
|
}
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
|
||||||
|
|
||||||
|
## PHPDoc Blocks
|
||||||
|
- Add useful array shape type definitions for arrays when appropriate.
|
||||||
|
|
||||||
|
## Enums
|
||||||
|
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||||
|
|
||||||
|
=== herd rules ===
|
||||||
|
|
||||||
|
## Laravel Herd
|
||||||
|
|
||||||
|
- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate URLs for the user to ensure valid URLs.
|
||||||
|
- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
|
||||||
|
|
||||||
|
=== tests rules ===
|
||||||
|
|
||||||
|
## Test Enforcement
|
||||||
|
|
||||||
|
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||||
|
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
|
||||||
|
|
||||||
|
=== laravel/core rules ===
|
||||||
|
|
||||||
|
## Do Things the Laravel Way
|
||||||
|
|
||||||
|
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||||
|
- If you're creating a generic PHP class, use `php artisan make:class`.
|
||||||
|
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||||
|
|
||||||
|
### Database
|
||||||
|
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||||
|
- Use Eloquent models and relationships before suggesting raw database queries.
|
||||||
|
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||||
|
- Generate code that prevents N+1 query problems by using eager loading.
|
||||||
|
- Use Laravel's query builder for very complex database operations.
|
||||||
|
|
||||||
|
### Model Creation
|
||||||
|
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
|
||||||
|
|
||||||
|
### APIs & Eloquent Resources
|
||||||
|
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||||
|
|
||||||
|
### Controllers & Validation
|
||||||
|
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||||
|
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||||
|
|
||||||
|
### Queues
|
||||||
|
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||||
|
|
||||||
|
### Authentication & Authorization
|
||||||
|
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||||
|
|
||||||
|
### URL Generation
|
||||||
|
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||||
|
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||||
|
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||||
|
|
||||||
|
### Vite Error
|
||||||
|
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||||
|
|
||||||
|
=== laravel/v12 rules ===
|
||||||
|
|
||||||
|
## Laravel 12
|
||||||
|
|
||||||
|
- Use the `search-docs` tool to get version-specific documentation.
|
||||||
|
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||||
|
|
||||||
|
### Laravel 12 Structure
|
||||||
|
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
|
||||||
|
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
|
||||||
|
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||||
|
- `bootstrap/providers.php` contains application specific service providers.
|
||||||
|
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||||
|
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||||
|
|
||||||
|
### Database
|
||||||
|
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||||
|
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||||
|
|
||||||
|
### Models
|
||||||
|
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||||
|
|
||||||
|
=== livewire/core rules ===
|
||||||
|
|
||||||
|
## Livewire
|
||||||
|
|
||||||
|
- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
|
||||||
|
- Use the `php artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
|
||||||
|
- State should live on the server, with the UI reflecting it.
|
||||||
|
- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
|
||||||
|
|
||||||
|
## Livewire Best Practices
|
||||||
|
- Livewire components require a single root element.
|
||||||
|
- Use `wire:loading` and `wire:dirty` for delightful loading states.
|
||||||
|
- Add `wire:key` in loops:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
@foreach ($items as $item)
|
||||||
|
<div wire:key="item-{{ $item->id }}">
|
||||||
|
{{ $item->name }}
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
```
|
||||||
|
|
||||||
|
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
|
||||||
|
|
||||||
|
<code-snippet name="Lifecycle Hook Examples" lang="php">
|
||||||
|
public function mount(User $user) { $this->user = $user; }
|
||||||
|
public function updatedSearch() { $this->resetPage(); }
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
## Testing Livewire
|
||||||
|
|
||||||
|
<code-snippet name="Example Livewire Component Test" lang="php">
|
||||||
|
Livewire::test(Counter::class)
|
||||||
|
->assertSet('count', 0)
|
||||||
|
->call('increment')
|
||||||
|
->assertSet('count', 1)
|
||||||
|
->assertSee(1)
|
||||||
|
->assertStatus(200);
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
|
||||||
|
$this->get('/posts/create')
|
||||||
|
->assertSeeLivewire(CreatePost::class);
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
=== volt/core rules ===
|
||||||
|
|
||||||
|
## Livewire Volt
|
||||||
|
|
||||||
|
- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt.
|
||||||
|
- Make new Volt components using `php artisan make:volt [name] [--test] [--pest]`.
|
||||||
|
- Volt is a class-based and functional API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to coexist in the same file.
|
||||||
|
- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@volt` directive.
|
||||||
|
- You must check existing Volt components to determine if they're functional or class-based. If you can't detect that, ask the user which they prefer before writing a Volt component.
|
||||||
|
|
||||||
|
### Volt Functional Component Example
|
||||||
|
|
||||||
|
<code-snippet name="Volt Functional Component Example" lang="php">
|
||||||
|
@volt
|
||||||
|
<?php
|
||||||
|
use function Livewire\Volt\{state, computed};
|
||||||
|
|
||||||
|
state(['count' => 0]);
|
||||||
|
|
||||||
|
$increment = fn () => $this->count++;
|
||||||
|
$decrement = fn () => $this->count--;
|
||||||
|
|
||||||
|
$double = computed(fn () => $this->count * 2);
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1>Count: {{ $count }}</h1>
|
||||||
|
<h2>Double: {{ $this->double }}</h2>
|
||||||
|
<button wire:click="increment">+</button>
|
||||||
|
<button wire:click="decrement">-</button>
|
||||||
|
</div>
|
||||||
|
@endvolt
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
### Volt Class Based Component Example
|
||||||
|
To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax:
|
||||||
|
|
||||||
|
<code-snippet name="Volt Class-based Volt Component Example" lang="php">
|
||||||
|
use Livewire\Volt\Component;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
public $count = 0;
|
||||||
|
|
||||||
|
public function increment()
|
||||||
|
{
|
||||||
|
$this->count++;
|
||||||
|
}
|
||||||
|
} ?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1>{{ $count }}</h1>
|
||||||
|
<button wire:click="increment">+</button>
|
||||||
|
</div>
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
### Testing Volt & Volt Components
|
||||||
|
- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`.
|
||||||
|
|
||||||
|
<code-snippet name="Livewire Test Example" lang="php">
|
||||||
|
use Livewire\Volt\Volt;
|
||||||
|
|
||||||
|
test('counter increments', function () {
|
||||||
|
Volt::test('counter')
|
||||||
|
->assertSee('Count: 0')
|
||||||
|
->call('increment')
|
||||||
|
->assertSee('Count: 1');
|
||||||
|
});
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
<code-snippet name="Volt Component Test Using Pest" lang="php">
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\{User, Product};
|
||||||
|
use Livewire\Volt\Volt;
|
||||||
|
|
||||||
|
test('product form creates product', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
Volt::test('pages.products.create')
|
||||||
|
->actingAs($user)
|
||||||
|
->set('form.name', 'Test Product')
|
||||||
|
->set('form.description', 'Test Description')
|
||||||
|
->set('form.price', 99.99)
|
||||||
|
->call('create')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
expect(Product::where('name', 'Test Product')->exists())->toBeTrue();
|
||||||
|
});
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
### Common Patterns
|
||||||
|
|
||||||
|
<code-snippet name="CRUD With Volt" lang="php">
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Product;
|
||||||
|
use function Livewire\Volt\{state, computed};
|
||||||
|
|
||||||
|
state(['editing' => null, 'search' => '']);
|
||||||
|
|
||||||
|
$products = computed(fn() => Product::when($this->search,
|
||||||
|
fn($q) => $q->where('name', 'like', "%{$this->search}%")
|
||||||
|
)->get());
|
||||||
|
|
||||||
|
$edit = fn(Product $product) => $this->editing = $product->id;
|
||||||
|
$delete = fn(Product $product) => $product->delete();
|
||||||
|
|
||||||
|
?>
|
||||||
|
|
||||||
|
<!-- HTML / UI Here -->
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
<code-snippet name="Real-Time Search With Volt" lang="php">
|
||||||
|
<flux:input
|
||||||
|
wire:model.live.debounce.300ms="search"
|
||||||
|
placeholder="Search..."
|
||||||
|
/>
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
<code-snippet name="Loading States With Volt" lang="php">
|
||||||
|
<flux:button wire:click="save" wire:loading.attr="disabled">
|
||||||
|
<span wire:loading.remove>Save</span>
|
||||||
|
<span wire:loading>Saving...</span>
|
||||||
|
</flux:button>
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
=== pint/core rules ===
|
||||||
|
|
||||||
|
## Laravel Pint Code Formatter
|
||||||
|
|
||||||
|
- You must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||||
|
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
|
||||||
|
|
||||||
|
=== phpunit/core rules ===
|
||||||
|
|
||||||
|
## PHPUnit
|
||||||
|
|
||||||
|
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
|
||||||
|
- If you see a test using "Pest", convert it to PHPUnit.
|
||||||
|
- Every time a test has been updated, run that singular test.
|
||||||
|
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
|
||||||
|
- Tests should test all of the happy paths, failure paths, and weird paths.
|
||||||
|
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
- Run the minimal number of tests, using an appropriate filter, before finalizing.
|
||||||
|
- To run all tests: `php artisan test --compact`.
|
||||||
|
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||||
|
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
||||||
|
|
||||||
|
=== tailwindcss/core rules ===
|
||||||
|
|
||||||
|
## Tailwind CSS
|
||||||
|
|
||||||
|
- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
|
||||||
|
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
|
||||||
|
- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
|
||||||
|
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
|
||||||
|
|
||||||
|
### Spacing
|
||||||
|
- When listing items, use gap utilities for spacing; don't use margins.
|
||||||
|
|
||||||
|
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
|
||||||
|
<div class="flex gap-8">
|
||||||
|
<div>Superior</div>
|
||||||
|
<div>Michigan</div>
|
||||||
|
<div>Erie</div>
|
||||||
|
</div>
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
### Dark Mode
|
||||||
|
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
|
||||||
|
|
||||||
|
=== tailwindcss/v4 rules ===
|
||||||
|
|
||||||
|
## Tailwind CSS 4
|
||||||
|
|
||||||
|
- Always use Tailwind CSS v4; do not use the deprecated utilities.
|
||||||
|
- `corePlugins` is not supported in Tailwind v4.
|
||||||
|
- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
|
||||||
|
|
||||||
|
<code-snippet name="Extending Theme in CSS" lang="css">
|
||||||
|
@theme {
|
||||||
|
--color-brand: oklch(0.72 0.11 178);
|
||||||
|
}
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
|
||||||
|
|
||||||
|
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
|
||||||
|
- @tailwind base;
|
||||||
|
- @tailwind components;
|
||||||
|
- @tailwind utilities;
|
||||||
|
+ @import "tailwindcss";
|
||||||
|
</code-snippet>
|
||||||
|
|
||||||
|
### Replaced Utilities
|
||||||
|
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
|
||||||
|
- Opacity values are still numeric.
|
||||||
|
|
||||||
|
| Deprecated | Replacement |
|
||||||
|
|------------+--------------|
|
||||||
|
| bg-opacity-* | bg-black/* |
|
||||||
|
| text-opacity-* | text-black/* |
|
||||||
|
| border-opacity-* | border-black/* |
|
||||||
|
| divide-opacity-* | divide-black/* |
|
||||||
|
| ring-opacity-* | ring-black/* |
|
||||||
|
| placeholder-opacity-* | placeholder-black/* |
|
||||||
|
| flex-shrink-* | shrink-* |
|
||||||
|
| flex-grow-* | grow-* |
|
||||||
|
| overflow-ellipsis | text-ellipsis |
|
||||||
|
| decoration-slice | box-decoration-slice |
|
||||||
|
| decoration-clone | box-decoration-clone |
|
||||||
|
</laravel-boost-guidelines>
|
||||||
@@ -28,7 +28,7 @@ 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 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.
|
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
|
||||||
|
|
||||||
@@ -50,8 +50,6 @@ curl -O https://raw.githubusercontent.com/investbrainapp/investbrain/main/docker
|
|||||||
|
|
||||||
Adjust the `environment` properties in the compose file to your preferences.
|
Adjust the `environment` properties in the compose file to your preferences.
|
||||||
|
|
||||||
**Importantly**, you need to set the `APP_KEY` value. If you're unsure, Investbrain will generate an `APP_KEY` for you on first run, but it will not persist. You must _manually_ update your environment configuration with this generated value!
|
|
||||||
|
|
||||||
**3. Run `docker compose up`**
|
**3. Run `docker compose up`**
|
||||||
|
|
||||||
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:
|
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:
|
||||||
@@ -66,17 +64,19 @@ Congrats! You've just installed Investbrain!
|
|||||||
|
|
||||||
Investbrain offers an AI powered chat assistant that is grounded on *your* investments. This enables you to use AI as a thought partner when making investment decisions.
|
Investbrain offers an AI powered chat assistant that is grounded on *your* investments. This enables you to use AI as a thought partner when making investment decisions.
|
||||||
|
|
||||||
When self-hosting, you can enable the chat assistant by configuring your OpenAI Secret Key and Organization ID in your [.env](https://github.com/investbrainapp/investbrain/blob/main/.env.example) file. Navigate to OpenAI to [create your keys](https://platform.openai.com/api-keys).
|
Most of the major labs are currently supported (OpenAI, Anthropic, Gemini, xAI, etc). You'll need to obtain API keys from your selected provider and configure that in your [.env](https://github.com/investbrainapp/investbrain/blob/main/.env.example) file using the appropriate keys.
|
||||||
|
|
||||||
If you are self-hosting your own large language models ("LLMs") that expose an OpenAI compatible API (e.g. [Ollama](https://ollama.com/blog/openai-compatibility)), you can update the `OPENAI_BASE_URI` configuration to your self-hosted instance. Ensure you also update the `OPENAI_MODEL` to an available model.
|
Investbrain is also compatible with Ollama (and other OpenAI compatible APIs). If you are self-hosting your own large language models ("LLMs") that exposes an OpenAI compatible API (e.g. [Ollama](https://ollama.com/blog/openai-compatibility)), you'll need to configure your local endpoint in your [.env](https://github.com/investbrainapp/investbrain/blob/main/.env.example) file.
|
||||||
|
|
||||||
|
See available options [below](#configuration).
|
||||||
|
|
||||||
Always keep in mind the limitations of LLMs. When in doubt, consult a licensed investment advisor.
|
Always keep in mind the limitations of LLMs. When in doubt, consult a licensed investment advisor.
|
||||||
|
|
||||||
## 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
|
### Market Data Configuration
|
||||||
|
|
||||||
You can specify the market data provider you want to use in your environment variables:
|
You can specify the market data provider you want to use in your environment variables:
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ Your selected providers should be listed in your environment variables. Each sho
|
|||||||
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
|
||||||
|
|
||||||
@@ -137,17 +137,22 @@ There are several optional configurations available when installing using the re
|
|||||||
| ------------- | ------------- | ------------- |
|
| ------------- | ------------- | ------------- |
|
||||||
| 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 |
|
||||||
| APP_KEY | Must be set during install - encryption key for various security-related functions | `null` |
|
| APP_KEY | Encryption key for various security-related functions | Set automatically during install |
|
||||||
| MARKET_DATA_PROVIDER | The market data provider to use (either `yahoo`, `alphavantage`, or `finnhub`) | yahoo |
|
| 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` |
|
||||||
| OPENAI_API_KEY | OpenAI secret key (required for AI chat) | `null` |
|
| CHAT_PROVIDER | Which chat provider to use (one of `openai`, `anthropic`, `gemini`, `azure`, `groq`, `xai`, `deepseek`, `mistral`, `ollama`) | `openai` |
|
||||||
| OPENAI_ORGANIZATION | OpenAI org id (required for AI chat) | `null` |
|
| CHAT_MODEL | The selected LLM used for AI chat | defaults to current smartest model from lab |
|
||||||
| OPENAI_MODEL | The selected LLM used for AI chat | gpt-4o |
|
| ANTHROPIC_API_KEY | If using Anthropic for chat | `null` |
|
||||||
| OPENAI_BASE_URI | The URI for your self-hosted LLM | api.openai.com/v1 |
|
| OPENAI_API_KEY | If using OpenAI for chat | `null` |
|
||||||
|
| OLLAMA_BASE_URL | If using Ollama for chat | `http://localhost:11434` |
|
||||||
|
| OLLAMA_API_KEY | May be required if using Ollama for chat | `null` |
|
||||||
| DAILY_CHANGE_TIME | The time of day to capture daily change | 23:00 |
|
| DAILY_CHANGE_TIME | The time of day to capture daily change | 23:00 |
|
||||||
| REGISTRATION_ENABLED | Whether to enable registration of new users | `true` |
|
| REGISTRATION_ENABLED | Whether to enable registration of new users | `true` |
|
||||||
|
|
||||||
@@ -178,7 +183,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:
|
||||||
|
|
||||||
@@ -186,16 +191,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
|
||||||
|
|
||||||
@@ -214,6 +226,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@ use App\Traits\WithTrimStrings;
|
|||||||
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;
|
||||||
use Laravel\Jetstream\Jetstream;
|
|
||||||
|
|
||||||
class CreateNewUser implements CreatesNewUsers
|
class CreateNewUser implements CreatesNewUsers
|
||||||
{
|
{
|
||||||
@@ -32,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,21 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
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,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Ai\Agents;
|
||||||
|
|
||||||
|
use App\Models\Holding;
|
||||||
|
use Laravel\Ai\Concerns\RemembersConversations;
|
||||||
|
use Laravel\Ai\Contracts\Agent;
|
||||||
|
use Laravel\Ai\Contracts\Conversational;
|
||||||
|
use Laravel\Ai\Contracts\HasTools;
|
||||||
|
use Laravel\Ai\Contracts\Tool;
|
||||||
|
use Laravel\Ai\Promptable;
|
||||||
|
use Stringable;
|
||||||
|
|
||||||
|
class ChatWithHoldingAgent implements Agent, Conversational, HasTools
|
||||||
|
{
|
||||||
|
use Promptable;
|
||||||
|
use RemembersConversations;
|
||||||
|
|
||||||
|
public function __construct(public readonly Holding $holding) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the instructions that the agent should follow.
|
||||||
|
*/
|
||||||
|
public function instructions(): Stringable|string
|
||||||
|
{
|
||||||
|
$holding = $this->holding;
|
||||||
|
$quantity = $holding->quantity > 0
|
||||||
|
? 'a total of '.$holding->quantity
|
||||||
|
: 'ZERO';
|
||||||
|
|
||||||
|
return 'Most recent training data: '.now()->toDateString().'.
|
||||||
|
|
||||||
|
You are an investment portfolio assistant providing advice to an investor. Use the following information to provide relevant recommendations. Use the words \'likely\' or \'may\' instead of concrete statements (except for obvious statements of fact or common sense). Do not apologize. Be polite, but minimize gratuitous niceties. If something is unclear, ask for clarification. When referencing numbers with precision, always round to the nearest 100th decimal place. If no precision, display numbers in integers.
|
||||||
|
|
||||||
|
The investor owns '.$quantity.' shares of '.$holding->market_data->name.' (ticker: '.$holding->symbol.') with an average cost basis of '.$holding->average_cost_basis.'. Here are the relevant transactions - sales and purchases of '.$holding->symbol.':
|
||||||
|
|
||||||
|
'.$holding->getFormattedTransactions().'
|
||||||
|
|
||||||
|
This investor has earned $ '.$holding->dividends_earned.' in dividends so far and earned '.$holding->realized_gains_dollars.' in realized gains (sales) from '.$holding->symbol.' in this portfolio.
|
||||||
|
|
||||||
|
The current market price for '.$holding->symbol.' is '.$holding->market_data->market_value.'. Additionally, here\'s other critical fundamentals for '.$holding->market_data->name.' that might help:
|
||||||
|
* Market cap: '.$holding->market_data->market_cap.'
|
||||||
|
* Forward PE: '.$holding->market_data->forward_pe.'
|
||||||
|
* Trailing PE: '.$holding->market_data->trailing_pe.'
|
||||||
|
* Book value: '.$holding->market_data->book_value.'
|
||||||
|
* 52 week low: '.$holding->market_data->fifty_two_week_low.'
|
||||||
|
* 52 week high: '.$holding->market_data->fifty_two_week_high.'
|
||||||
|
* Dividend yield: '.$holding->market_data->dividend_yield.'
|
||||||
|
|
||||||
|
Based on this current market data, quantity owned, and average cost basis, you should determine if the '.$holding->symbol.' holding is making or losing money.
|
||||||
|
|
||||||
|
Below is the question from the investor. Considering these facts, provide a concise response to the following question (give a direct response). Limit your response to no more than 75 words and consider using a common decision framework. Use github style markdown for any formatting:';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the tools available to the agent.
|
||||||
|
*
|
||||||
|
* @return Tool[]
|
||||||
|
*/
|
||||||
|
public function tools(): iterable
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Ai\Agents;
|
||||||
|
|
||||||
|
use App\Models\Portfolio;
|
||||||
|
use Laravel\Ai\Concerns\RemembersConversations;
|
||||||
|
use Laravel\Ai\Contracts\Agent;
|
||||||
|
use Laravel\Ai\Contracts\Conversational;
|
||||||
|
use Laravel\Ai\Contracts\HasTools;
|
||||||
|
use Laravel\Ai\Contracts\Tool;
|
||||||
|
use Laravel\Ai\Promptable;
|
||||||
|
use Stringable;
|
||||||
|
|
||||||
|
class ChatWithPortfolioAgent implements Agent, Conversational, HasTools
|
||||||
|
{
|
||||||
|
use Promptable;
|
||||||
|
use RemembersConversations;
|
||||||
|
|
||||||
|
public function __construct(public readonly Portfolio $portfolio) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the instructions that the agent should follow.
|
||||||
|
*/
|
||||||
|
public function instructions(): Stringable|string
|
||||||
|
{
|
||||||
|
return 'Most recent training data: '.now()->toDateString().'.
|
||||||
|
|
||||||
|
You are an investment portfolio assistant providing advice to an investor. Use the following information to provide relevant recommendations. Use the words \'likely\' or \'may\' in lieu of concrete statements (except for obvious statements of fact or common sense). Do not apologize. Be polite, but minimize gratuitous niceties. When referencing numbers with precision, always round to the nearest 100th decimal place. If no precision, display numbers in integers.
|
||||||
|
|
||||||
|
The investor has the following holdings in this portfolio:
|
||||||
|
|
||||||
|
'.$this->portfolio->getFormattedHoldings().'
|
||||||
|
|
||||||
|
Based on the current market data, quantity owned, and average cost basis, you can determine the performance of any holding.
|
||||||
|
|
||||||
|
Below is the question from the investor. Considering these facts, provide a concise response to the following question (give a direct response). Limit your response to no more than 75 words and consider using a common decision framework. Use github style markdown for any formatting:';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the tools available to the agent.
|
||||||
|
*
|
||||||
|
* @return Tool[]
|
||||||
|
*/
|
||||||
|
public function tools(): iterable
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Ai\Agents;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
|
use Laravel\Ai\Contracts\Agent;
|
||||||
|
use Laravel\Ai\Contracts\HasStructuredOutput;
|
||||||
|
use Laravel\Ai\Contracts\HasTools;
|
||||||
|
use Laravel\Ai\Contracts\Messages;
|
||||||
|
use Laravel\Ai\Contracts\Tool;
|
||||||
|
use Laravel\Ai\Promptable;
|
||||||
|
use Stringable;
|
||||||
|
|
||||||
|
class ChatWithSuggestedPromptsAgent implements Agent, HasStructuredOutput, HasTools
|
||||||
|
{
|
||||||
|
use Promptable;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public array $messages
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the instructions that the agent should follow.
|
||||||
|
*/
|
||||||
|
public function instructions(): Stringable|string
|
||||||
|
{
|
||||||
|
return 'Your role is a savvy helper that assists curious investors in asking thoughtful questions to investment advisors.
|
||||||
|
|
||||||
|
You should recommend between 1 and 5 (no more than 5) questions. You should ensure the questions you recommend are based on the provided context. Be sure to keep the questions short!
|
||||||
|
|
||||||
|
The questions you recommend might be based on natural follow up from the given context, requests to further refine a previous response, clarify undefined terms, common decision frameworks, possible risks or benefits, or commonly understood investing concepts that may require additional explanation.
|
||||||
|
|
||||||
|
Generate between 1 and 5 (no more than 5) follow up questions a curious investor might ask their advisor based on the provided conversation.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the tools available to the agent.
|
||||||
|
*
|
||||||
|
* @return Tool[]
|
||||||
|
*/
|
||||||
|
public function tools(): iterable
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the messages available to the agent.
|
||||||
|
*
|
||||||
|
* @return Messages[]
|
||||||
|
*/
|
||||||
|
public function messages(): iterable
|
||||||
|
{
|
||||||
|
return $this->messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the agent's structured output schema definition.
|
||||||
|
*/
|
||||||
|
public function schema(JsonSchema $schema): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'suggested_prompts' => $schema->array()->items(
|
||||||
|
$schema->object([
|
||||||
|
'text' => $schema->string()
|
||||||
|
->description('Short description of suggested prompt (no more than 5 words)')
|
||||||
|
->required(),
|
||||||
|
'value' => $schema->string()
|
||||||
|
->description('The detailed version of the prompt (think good prompt engineering!)')
|
||||||
|
->required(),
|
||||||
|
])->withoutAdditionalProperties()
|
||||||
|
)->required(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ 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;
|
||||||
|
|
||||||
@@ -44,23 +45,13 @@ class CaptureDailyChange extends Command
|
|||||||
|
|
||||||
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ namespace App\Console\Commands;
|
|||||||
use App\Models\Holding;
|
use App\Models\Holding;
|
||||||
use App\Models\MarketData;
|
use App\Models\MarketData;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class RefreshMarketData extends Command
|
class RefreshMarketData extends Command
|
||||||
{
|
{
|
||||||
@@ -61,7 +60,7 @@ class RefreshMarketData extends Command
|
|||||||
try {
|
try {
|
||||||
MarketData::getMarketData($holding->symbol, $force);
|
MarketData::getMarketData($holding->symbol, $force);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::error('Could not refresh '.$holding->symbol.' ('.$e->getMessage().')');
|
$this->line('Could not refresh '.$holding->symbol.' ('.$e->getMessage().')');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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;
|
||||||
@@ -24,6 +25,7 @@ class BackupExport implements WithMultipleSheets
|
|||||||
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,9 +22,8 @@ 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',
|
||||||
|
'Total Dividends Earned',
|
||||||
'Annotation',
|
'Annotation',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -34,7 +33,24 @@ class DailyChangesSheet implements FromCollection, WithHeadings, WithTitle
|
|||||||
*/
|
*/
|
||||||
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,
|
||||||
|
];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function title(): string
|
public function title(): string
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ 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',
|
||||||
@@ -38,7 +39,30 @@ class TransactionsSheet implements FromCollection, WithHeadings, WithTitle
|
|||||||
*/
|
*/
|
||||||
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,
|
||||||
|
];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function title(): string
|
public function title(): string
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class TransactionController extends ApiController
|
|||||||
|
|
||||||
$filters->setQuery(Transaction::query());
|
$filters->setQuery(Transaction::query());
|
||||||
$filters->setScopes(['myTransactions']);
|
$filters->setScopes(['myTransactions']);
|
||||||
|
$filters->setEagerRelations(['market_data']);
|
||||||
$filters->setSearchableColumns(['symbol']);
|
$filters->setSearchableColumns(['symbol']);
|
||||||
|
|
||||||
return TransactionResource::collection($filters->paginated());
|
return TransactionResource::collection($filters->paginated());
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -124,7 +124,7 @@ 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',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -17,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();
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -29,14 +29,13 @@ class PortfolioController extends Controller
|
|||||||
$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();
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -30,18 +30,20 @@ class TransactionRequest extends FormRequest
|
|||||||
'portfolio_id' => ['required', 'exists:portfolios,id'],
|
'portfolio_id' => ['required', 'exists:portfolios,id'],
|
||||||
'symbol' => ['required', 'string', new SymbolValidationRule],
|
'symbol' => ['required', 'string', new SymbolValidationRule],
|
||||||
'transaction_type' => ['required', 'string', 'in:BUY,SELL'],
|
'transaction_type' => ['required', 'string', 'in:BUY,SELL'],
|
||||||
'date' => ['required', 'date_format:Y-m-d', 'before_or_equal:'.now()->format('Y-m-d')],
|
'date' => ['required', 'date_format:Y-m-d', 'before_or_equal:'.now()->toDateString()],
|
||||||
'quantity' => [
|
'quantity' => [
|
||||||
'required',
|
'required',
|
||||||
'numeric',
|
'numeric',
|
||||||
'min:0',
|
'gt:0',
|
||||||
new QuantityValidationRule(
|
new QuantityValidationRule(
|
||||||
$this->input('portfolio'),
|
$this->input('portfolio'),
|
||||||
$this->requestOrModelValue('symbol', 'transaction'),
|
$this->requestOrModelValue('symbol', 'transaction'),
|
||||||
$this->requestOrModelValue('transaction_type', 'transaction'),
|
$this->requestOrModelValue('transaction_type', 'transaction'),
|
||||||
$this->requestOrModelValue('date', 'transaction')
|
$this->requestOrModelValue('date', 'transaction'),
|
||||||
|
$this->transaction
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
'currency' => ['required', 'exists:currencies,currency'],
|
||||||
'cost_basis' => ['exclude_if:transaction_type,SELL', 'min:0', 'numeric'],
|
'cost_basis' => ['exclude_if:transaction_type,SELL', 'min:0', 'numeric'],
|
||||||
'sale_price' => ['exclude_if:transaction_type,BUY', 'min:0', 'numeric'],
|
'sale_price' => ['exclude_if:transaction_type,BUY', 'min:0', 'numeric'],
|
||||||
];
|
];
|
||||||
@@ -50,6 +52,7 @@ class TransactionRequest extends FormRequest
|
|||||||
$rules['portfolio_id'][0] = 'sometimes';
|
$rules['portfolio_id'][0] = 'sometimes';
|
||||||
$rules['symbol'][0] = 'sometimes';
|
$rules['symbol'][0] = 'sometimes';
|
||||||
$rules['transaction_type'][0] = 'sometimes';
|
$rules['transaction_type'][0] = 'sometimes';
|
||||||
|
$rules['currency'][0] = 'sometimes';
|
||||||
$rules['date'][0] = 'sometimes';
|
$rules['date'][0] = 'sometimes';
|
||||||
$rules['quantity'][0] = 'sometimes';
|
$rules['quantity'][0] = 'sometimes';
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class HoldingResource extends JsonResource
|
|||||||
'portfolio_id' => $this->portfolio_id,
|
'portfolio_id' => $this->portfolio_id,
|
||||||
'symbol' => $this->symbol,
|
'symbol' => $this->symbol,
|
||||||
'quantity' => $this->quantity,
|
'quantity' => $this->quantity,
|
||||||
|
'currency' => $this->market_data->currency,
|
||||||
'reinvest_dividends' => $this->reinvest_dividends,
|
'reinvest_dividends' => $this->reinvest_dividends,
|
||||||
'average_cost_basis' => $this->average_cost_basis,
|
'average_cost_basis' => $this->average_cost_basis,
|
||||||
'total_cost_basis' => $this->total_cost_basis,
|
'total_cost_basis' => $this->total_cost_basis,
|
||||||
|
|||||||
@@ -22,11 +22,12 @@ class TransactionResource extends JsonResource
|
|||||||
'portfolio_id' => $this->portfolio_id,
|
'portfolio_id' => $this->portfolio_id,
|
||||||
'transaction_type' => $this->transaction_type,
|
'transaction_type' => $this->transaction_type,
|
||||||
'quantity' => $this->quantity,
|
'quantity' => $this->quantity,
|
||||||
|
'currency' => $this->market_data->currency,
|
||||||
'cost_basis' => $this->cost_basis,
|
'cost_basis' => $this->cost_basis,
|
||||||
'sale_price' => $this->sale_price,
|
'sale_price' => $this->sale_price,
|
||||||
'split' => $this->split,
|
'split' => $this->split,
|
||||||
'reinvested_dividend' => $this->reinvested_dividend,
|
'reinvested_dividend' => $this->reinvested_dividend,
|
||||||
'date' => $this->date,
|
'date' => date_format($this->date, 'Y-m-d'),
|
||||||
'created_at' => $this->created_at,
|
'created_at' => $this->created_at,
|
||||||
'updated_at' => $this->updated_at,
|
'updated_at' => $this->updated_at,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ class UserResource extends JsonResource
|
|||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
'email' => $this->email,
|
'email' => $this->email,
|
||||||
'profile_photo_url' => $this->profile_photo_url,
|
'profile_photo_url' => $this->profile_photo_url,
|
||||||
|
'options' => [
|
||||||
|
'display_currency' => $this->getCurrency(),
|
||||||
|
'locale' => $this->getLocale(),
|
||||||
|
],
|
||||||
'created_at' => $this->created_at,
|
'created_at' => $this->created_at,
|
||||||
'updated_at' => $this->updated_at,
|
'updated_at' => $this->updated_at,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Console\Commands\RefreshDividendData;
|
|||||||
use App\Console\Commands\RefreshMarketData;
|
use App\Console\Commands\RefreshMarketData;
|
||||||
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\PortfoliosSheet;
|
||||||
use App\Imports\Sheets\TransactionsSheet;
|
use App\Imports\Sheets\TransactionsSheet;
|
||||||
@@ -69,6 +70,7 @@ class BackupImport implements WithEvents, WithMultipleSheets
|
|||||||
'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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
|||||||
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();
|
||||||
},
|
},
|
||||||
@@ -40,22 +40,23 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
|||||||
|
|
||||||
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(),
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,11 +64,6 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
|||||||
$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',
|
||||||
@@ -86,11 +82,6 @@ class DailyChangesSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
|||||||
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'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ namespace App\Imports\Sheets;
|
|||||||
|
|
||||||
use App\Imports\ValidatesPortfolioAccess;
|
use App\Imports\ValidatesPortfolioAccess;
|
||||||
use App\Models\BackupImport;
|
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\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -33,7 +35,7 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
|||||||
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();
|
||||||
},
|
},
|
||||||
@@ -43,13 +45,37 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
|||||||
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']),
|
||||||
@@ -58,9 +84,11 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
|||||||
'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,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,7 +109,7 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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) {
|
||||||
|
|
||||||
@@ -112,6 +140,7 @@ class TransactionsSheet implements SkipsEmptyRows, ToCollection, WithEvents, Wit
|
|||||||
'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'],
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,23 +23,44 @@ class AlphaVantageMarketData implements MarketDataInterface
|
|||||||
|
|
||||||
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', []);
|
||||||
|
|
||||||
$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' => $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,8 +69,20 @@ 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',
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +101,7 @@ class AlphaVantageMarketData implements MarketDataInterface
|
|||||||
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'),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -88,7 +121,7 @@ class AlphaVantageMarketData implements MarketDataInterface
|
|||||||
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'),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -107,12 +140,12 @@ class AlphaVantageMarketData implements MarketDataInterface
|
|||||||
})
|
})
|
||||||
->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'),
|
||||||
])];
|
])];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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\Quote;
|
||||||
use App\Interfaces\MarketData\Types\Split;
|
use App\Interfaces\MarketData\Types\Split;
|
||||||
|
use Carbon\CarbonPeriod;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ class FakeMarketData implements MarketDataInterface
|
|||||||
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,
|
||||||
@@ -34,6 +36,7 @@ class FakeMarketData implements MarketDataInterface
|
|||||||
'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' => [],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +68,7 @@ class FakeMarketData implements MarketDataInterface
|
|||||||
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,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
@@ -73,16 +76,27 @@ class FakeMarketData implements MarketDataInterface
|
|||||||
|
|
||||||
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),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ class FallbackInterface
|
|||||||
foreach ($providers as $provider) {
|
foreach ($providers as $provider) {
|
||||||
|
|
||||||
$provider = trim($provider);
|
$provider = trim($provider);
|
||||||
|
$symbol = $arguments[0];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Log::warning("Calling method {$method} ({$provider})");
|
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', [])))) {
|
||||||
|
|
||||||
@@ -35,17 +36,17 @@ class FallbackInterface
|
|||||||
|
|
||||||
$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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// don't need to throw error if calling exists
|
// don't need to throw error if calling exists method...
|
||||||
if ($method == 'exists') {
|
if ($method == 'exists') {
|
||||||
|
|
||||||
// symbol prob just doesn't exist
|
// symbol prob just doesn't exist
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new \Exception("Could not get market data: {$this->latest_error}");
|
throw new \Exception("Could not get market data calling method {$method}: {$this->latest_error}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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\Quote;
|
||||||
use App\Interfaces\MarketData\Types\Split;
|
use App\Interfaces\MarketData\Types\Split;
|
||||||
|
use Finnhub\ObjectSerializer;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@@ -35,32 +36,46 @@ class FinnhubMarketData implements MarketDataInterface
|
|||||||
{
|
{
|
||||||
$quote = $this->client->quote($symbol);
|
$quote = $this->client->quote($symbol);
|
||||||
|
|
||||||
|
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,
|
||||||
|
'currency' => Arr::get($fundamental, 'currency'),
|
||||||
'market_value' => Arr::get($quote, 'c'),
|
'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) {
|
||||||
|
|
||||||
@@ -75,7 +90,7 @@ 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) {
|
||||||
|
|
||||||
@@ -96,7 +111,7 @@ class FinnhubMarketData implements MarketDataInterface
|
|||||||
$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,
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
])];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ 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;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Interfaces\MarketData\Types;
|
namespace App\Interfaces\MarketData\Types;
|
||||||
|
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
@@ -12,24 +13,79 @@ class MarketDataType extends Collection
|
|||||||
public function __construct($items = [])
|
public function __construct($items = [])
|
||||||
{
|
{
|
||||||
|
|
||||||
foreach ($this->getArrayableItems($items) as $key => $value) {
|
$items = $this->getArrayableItems($items);
|
||||||
|
|
||||||
|
foreach ($items as $key => $value) {
|
||||||
|
|
||||||
|
$this->validateRequiredTypes($key, $value);
|
||||||
|
|
||||||
|
if (! is_null($value)) {
|
||||||
$this->{$key} = $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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ 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;
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ 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;
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ 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;
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ 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;
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ 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;
|
||||||
|
|
||||||
class Quote extends MarketDataType
|
class Quote extends MarketDataType
|
||||||
{
|
{
|
||||||
public function setName(string $name): self
|
public function setName($name): self
|
||||||
{
|
{
|
||||||
|
if (! empty($name)) {
|
||||||
$this->items['name'] = (string) $name;
|
$this->items['name'] = (string) $name;
|
||||||
|
}
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
@@ -33,7 +36,27 @@ 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;
|
||||||
|
|
||||||
@@ -95,6 +118,7 @@ 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;
|
||||||
@@ -117,6 +141,18 @@ 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');
|
||||||
@@ -140,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'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ 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;
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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\Quote;
|
||||||
use App\Interfaces\MarketData\Types\Split;
|
use App\Interfaces\MarketData\Types\Split;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Scheb\YahooFinanceApi\ApiClient;
|
use Scheb\YahooFinanceApi\ApiClient;
|
||||||
use Scheb\YahooFinanceApi\ApiClientFactory as YahooFinance;
|
use Scheb\YahooFinanceApi\ApiClientFactory as YahooFinance;
|
||||||
@@ -20,7 +21,10 @@ class YahooMarketData implements MarketDataInterface
|
|||||||
{
|
{
|
||||||
|
|
||||||
// create yahoo finance client factory
|
// create yahoo finance client factory
|
||||||
$this->client = YahooFinance::createApiClient();
|
$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 exists(string $symbol): bool
|
public function exists(string $symbol): bool
|
||||||
@@ -34,9 +38,14 @@ class YahooMarketData implements MarketDataInterface
|
|||||||
|
|
||||||
$quote = $this->client->getQuote($symbol);
|
$quote = $this->client->getQuote($symbol);
|
||||||
|
|
||||||
|
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' => $symbol,
|
'symbol' => $symbol,
|
||||||
|
'currency' => $quote?->getCurrency(),
|
||||||
'market_value' => $quote?->getRegularMarketPrice(),
|
'market_value' => $quote?->getRegularMarketPrice(),
|
||||||
'fifty_two_week_high' => $quote?->getFiftyTwoWeekHigh(),
|
'fifty_two_week_high' => $quote?->getFiftyTwoWeekHigh(),
|
||||||
'fifty_two_week_low' => $quote?->getFiftyTwoWeekLow(),
|
'fifty_two_week_low' => $quote?->getFiftyTwoWeekLow(),
|
||||||
@@ -46,6 +55,11 @@ class YahooMarketData implements MarketDataInterface
|
|||||||
'book_value' => $quote?->getBookValue(),
|
'book_value' => $quote?->getBookValue(),
|
||||||
'last_dividend_date' => $quote?->getDividendDate(),
|
'last_dividend_date' => $quote?->getDividendDate(),
|
||||||
'dividend_yield' => $quote?->getTrailingAnnualDividendYield() * 100,
|
'dividend_yield' => $quote?->getTrailingAnnualDividendYield() * 100,
|
||||||
|
'meta_data' => [
|
||||||
|
'exchange' => $quote?->getExchange(),
|
||||||
|
'asset_type' => $quote?->getQuoteType(),
|
||||||
|
'source' => 'yahoo',
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +98,7 @@ class YahooMarketData implements MarketDataInterface
|
|||||||
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,
|
||||||
|
|||||||
@@ -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,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Livewire\Tables;
|
||||||
|
|
||||||
|
use App\Models\Holding;
|
||||||
|
use Filament\Actions\Concerns\InteractsWithActions;
|
||||||
|
use Filament\Actions\Contracts\HasActions;
|
||||||
|
use Filament\Schemas\Concerns\InteractsWithSchemas;
|
||||||
|
use Filament\Schemas\Contracts\HasSchemas;
|
||||||
|
use Filament\Support\Contracts\TranslatableContentDriver;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Support\Number;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class HoldingsTable extends Component implements HasActions, HasSchemas, HasTable
|
||||||
|
{
|
||||||
|
use InteractsWithActions;
|
||||||
|
use InteractsWithSchemas;
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
public $portfolio;
|
||||||
|
|
||||||
|
public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
|
||||||
|
return $table
|
||||||
|
->query(
|
||||||
|
Holding::query()
|
||||||
|
->portfolio($this->portfolio->id)
|
||||||
|
->withMarketData()
|
||||||
|
->withCount(['transactions as num_transactions' => function ($query) {
|
||||||
|
return $query->whereRaw('transactions.symbol = holdings.symbol');
|
||||||
|
}])
|
||||||
|
->withPerformance()
|
||||||
|
)
|
||||||
|
->defaultSort('symbol', 'asc')
|
||||||
|
->paginated(false)
|
||||||
|
->recordUrl(fn ($record) => route('holding.show', ['portfolio' => $record->portfolio_id, 'symbol' => $record->symbol]))
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('symbol')
|
||||||
|
->label(__('Symbol'))
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('market_data.name')
|
||||||
|
->label(__('Name'))
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('quantity')
|
||||||
|
->label(__('Quantity'))
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('average_cost_basis')
|
||||||
|
->label(__('Average Cost Basis'))
|
||||||
|
->sortable()
|
||||||
|
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data?->currency)),
|
||||||
|
TextColumn::make('total_cost_basis')
|
||||||
|
->label(__('Total Cost Basis'))
|
||||||
|
->sortable()
|
||||||
|
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data?->currency)),
|
||||||
|
TextColumn::make('market_data.market_value')
|
||||||
|
->label(__('Market Value'))
|
||||||
|
->sortable()
|
||||||
|
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data?->currency)),
|
||||||
|
TextColumn::make('total_market_value')
|
||||||
|
->label(__('Total Market Value'))
|
||||||
|
->sortable()
|
||||||
|
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data?->currency)),
|
||||||
|
TextColumn::make('market_gain_dollars')
|
||||||
|
->label(__('Market Gain/Loss'))
|
||||||
|
->sortable()
|
||||||
|
->html()
|
||||||
|
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data?->currency).view('components.ui.gain-loss-arrow-badge', [
|
||||||
|
'costBasis' => $record->average_cost_basis,
|
||||||
|
'marketValue' => $record->market_data?->market_value,
|
||||||
|
'small' => true,
|
||||||
|
])->render()),
|
||||||
|
TextColumn::make('realized_gain_dollars')
|
||||||
|
->label(__('Realized Gain/Loss'))
|
||||||
|
->sortable()
|
||||||
|
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data?->currency)),
|
||||||
|
TextColumn::make('dividends_earned')
|
||||||
|
->label(__('Dividends Earned'))
|
||||||
|
->sortable()
|
||||||
|
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data?->currency)),
|
||||||
|
TextColumn::make('market_data.fifty_two_week_low')
|
||||||
|
->label(__('52 week low'))
|
||||||
|
->sortable()
|
||||||
|
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data?->currency)),
|
||||||
|
TextColumn::make('market_data.fifty_two_week_high')
|
||||||
|
->label(__('52 week high'))
|
||||||
|
->sortable()
|
||||||
|
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data?->currency)),
|
||||||
|
TextColumn::make('num_transactions')
|
||||||
|
->label(__('Number of Transactions'))
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('market_data.updated_at')
|
||||||
|
->label(__('Last Refreshed'))
|
||||||
|
->sortable()
|
||||||
|
->since(),
|
||||||
|
])
|
||||||
|
->stackedOnMobile();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): string
|
||||||
|
{
|
||||||
|
return <<<'HTML'
|
||||||
|
<div>
|
||||||
|
{{ $this->table }}
|
||||||
|
</div>
|
||||||
|
HTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Livewire\Tables;
|
||||||
|
|
||||||
|
use App\Models\Transaction;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Filament\Actions\Concerns\InteractsWithActions;
|
||||||
|
use Filament\Actions\Contracts\HasActions;
|
||||||
|
use Filament\Schemas\Concerns\InteractsWithSchemas;
|
||||||
|
use Filament\Schemas\Contracts\HasSchemas;
|
||||||
|
use Filament\Support\Contracts\TranslatableContentDriver;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Support\Number;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class TransactionsTable extends Component implements HasActions, HasSchemas, HasTable
|
||||||
|
{
|
||||||
|
use InteractsWithActions;
|
||||||
|
use InteractsWithSchemas;
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('transaction_type')
|
||||||
|
->options([
|
||||||
|
'BUY' => 'BUY',
|
||||||
|
'SELL' => 'SELL',
|
||||||
|
]),
|
||||||
|
SelectFilter::make('portfolio')
|
||||||
|
->relationship('portfolio', 'title'),
|
||||||
|
])
|
||||||
|
->deferFilters(false)
|
||||||
|
->query(
|
||||||
|
Transaction::query()
|
||||||
|
->with(['portfolio', 'market_data'])
|
||||||
|
->myTransactions()
|
||||||
|
->addSelect(['transactions.*'])
|
||||||
|
->selectRaw('
|
||||||
|
(CASE
|
||||||
|
WHEN transaction_type = \'SELL\'
|
||||||
|
THEN COALESCE(transactions.sale_price, 0)
|
||||||
|
ELSE COALESCE((SELECT market_value FROM market_data WHERE market_data.symbol = transactions.symbol LIMIT 1), 0)
|
||||||
|
END) - COALESCE(transactions.cost_basis, 0) AS gain_dollars')
|
||||||
|
)
|
||||||
|
->defaultSort('date', 'desc')
|
||||||
|
->extremePaginationLinks()
|
||||||
|
->paginated([10])
|
||||||
|
->defaultPaginationPageOption(10)
|
||||||
|
->recordUrl(fn ($record) => route('holding.show', ['portfolio' => $record->portfolio_id, 'symbol' => $record->symbol]))
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('date')
|
||||||
|
->label(__('Date'))
|
||||||
|
->sortable()
|
||||||
|
->formatStateUsing(fn ($state) => Carbon::parse($state)->format('M d, Y')),
|
||||||
|
TextColumn::make('portfolio.title')
|
||||||
|
->label(__('Portfolio'))
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('symbol')
|
||||||
|
->label(__('Symbol'))
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('market_data.name')
|
||||||
|
->label(__('Name'))
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('transaction_type')
|
||||||
|
->label(__('Type'))
|
||||||
|
->sortable()
|
||||||
|
->html()
|
||||||
|
->formatStateUsing(fn ($state, $record) => view('components.ui.badge', [
|
||||||
|
'value' => $record->split ? 'SPLIT' : ($record->reinvested_dividend ? 'REINVEST' : $record->transaction_type),
|
||||||
|
'class' => ($record->transaction_type == 'BUY' ? 'badge-success' : 'badge-error').' badge-sm mr-3',
|
||||||
|
])->render()),
|
||||||
|
TextColumn::make('quantity')
|
||||||
|
->label(__('Quantity'))
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('cost_basis')
|
||||||
|
->label(__('Cost Basis'))
|
||||||
|
->sortable()
|
||||||
|
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data->currency)),
|
||||||
|
TextColumn::make('gain_dollars')
|
||||||
|
->label(__('Gain/Loss'))
|
||||||
|
->sortable()
|
||||||
|
->formatStateUsing(fn ($state, $record) => Number::currency($state ?? 0, $record->market_data->currency)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): string
|
||||||
|
{
|
||||||
|
return <<<'HTML'
|
||||||
|
<div>
|
||||||
|
{{ $this->table }}
|
||||||
|
</div>
|
||||||
|
HTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class AgentConversationMessage extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'agent_conversation_messages';
|
||||||
|
|
||||||
|
public $incrementing = false;
|
||||||
|
|
||||||
|
protected $keyType = 'string';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'id',
|
||||||
|
'conversation_id',
|
||||||
|
'user_id',
|
||||||
|
'agent',
|
||||||
|
'role',
|
||||||
|
'content',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function conversation(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ChatWithConversation::class, 'conversation_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
class AiChat extends Model
|
|
||||||
{
|
|
||||||
use HasUuids;
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'role',
|
|
||||||
'content',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $hidden = [];
|
|
||||||
|
|
||||||
protected static function boot()
|
|
||||||
{
|
|
||||||
parent::boot();
|
|
||||||
|
|
||||||
static::creating(function ($chat) {
|
|
||||||
|
|
||||||
$chat->user_id = auth()->user()->id;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function user()
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function chatable()
|
|
||||||
{
|
|
||||||
return $this->morphTo();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -50,4 +50,9 @@ class BackupImport extends Model
|
|||||||
'completed_at' => 'datetime',
|
'completed_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class ChatWithConversation extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'agent_conversations';
|
||||||
|
|
||||||
|
public $incrementing = false;
|
||||||
|
|
||||||
|
protected $keyType = 'string';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'id',
|
||||||
|
'user_id',
|
||||||
|
'title',
|
||||||
|
'chatable_type',
|
||||||
|
'chatable_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected static function boot(): void
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
static::creating(function (ChatWithConversation $model) {
|
||||||
|
if (empty($model->id)) {
|
||||||
|
$model->id = (string) Str::uuid7();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function chatable(): MorphTo
|
||||||
|
{
|
||||||
|
return $this->morphTo();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(AgentConversationMessage::class, 'conversation_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
+112
-7
@@ -7,6 +7,7 @@ namespace App\Models;
|
|||||||
use App\Traits\HasCompositePrimaryKey;
|
use App\Traits\HasCompositePrimaryKey;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class DailyChange extends Model
|
class DailyChange extends Model
|
||||||
{
|
{
|
||||||
@@ -22,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',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -33,16 +30,21 @@ 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());
|
||||||
});
|
});
|
||||||
@@ -56,6 +58,109 @@ class DailyChange extends Model
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|||||||
+65
-24
@@ -4,16 +4,24 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Actions\CopyToBaseCurrency;
|
||||||
|
use App\Casts\BaseCurrency;
|
||||||
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\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Pipeline;
|
||||||
use Illuminate\Support\Str;
|
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 = [
|
||||||
@@ -25,21 +33,32 @@ 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');
|
||||||
}
|
}
|
||||||
@@ -67,7 +86,7 @@ class Dividend extends Model
|
|||||||
// 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
|
||||||
@@ -83,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()) {
|
||||||
|
|
||||||
|
$market_data = MarketData::getMarketData($symbol);
|
||||||
|
|
||||||
|
$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
|
// create mass insert
|
||||||
foreach ($dividend_data as $index => $dividend) {
|
foreach ($chunk as $index => $dividend) {
|
||||||
$dividend_data[$index] = [...$dividend, ...['id' => Str::uuid()->toString(), 'updated_at' => now(), 'created_at' => now()]];
|
$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
|
// insert records
|
||||||
(new self)->insert($dividend_data->toArray());
|
(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);
|
||||||
|
|
||||||
@@ -109,22 +140,31 @@ 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',
|
||||||
|
'dividends.symbol',
|
||||||
|
'dividends.dividend_amount',
|
||||||
|
])->selectRaw("
|
||||||
|
(COALESCE(SUM(CASE WHEN transactions.transaction_type = 'BUY'
|
||||||
AND date(transactions.date) <= date(dividends.date)
|
AND date(transactions.date) <= date(dividends.date)
|
||||||
THEN transactions.quantity ELSE 0 END, 0)
|
THEN transactions.quantity ELSE 0 END), 0)
|
||||||
- COALESCE(CASE WHEN transactions.transaction_type = "SELL"
|
- COALESCE(SUM(CASE WHEN transactions.transaction_type = 'SELL'
|
||||||
AND date(transactions.date) <= date(dividends.date)
|
AND date(transactions.date) <= date(dividends.date)
|
||||||
THEN transactions.quantity ELSE 0 END, 0))
|
THEN transactions.quantity ELSE 0 END), 0))
|
||||||
* dividends.dividend_amount
|
* dividends.dividend_amount
|
||||||
AS total_received
|
AS total_received
|
||||||
')
|
")->join('transactions', 'transactions.symbol', '=', 'dividends.symbol')
|
||||||
->join('transactions', 'transactions.symbol', '=', 'dividends.symbol')
|
->join('holdings', function ($join) {
|
||||||
->join('holdings', 'transactions.portfolio_id', '=', 'holdings.portfolio_id')
|
$join->on('transactions.portfolio_id', '=', 'holdings.portfolio_id')
|
||||||
|
->on('holdings.symbol', '=', 'dividends.symbol');
|
||||||
|
})
|
||||||
->where('dividends.symbol', $symbol)
|
->where('dividends.symbol', $symbol)
|
||||||
->groupBy('holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount', 'total_received')
|
->groupBy('holdings.portfolio_id', 'dividends.date', 'dividends.symbol', 'dividends.dividend_amount', 'dividends.dividend_amount_base');
|
||||||
->havingRaw('total_received > 0')
|
|
||||||
|
$dividends = DB::table(DB::raw("({$subQuery->toSql()}) as sub"))
|
||||||
|
->mergeBindings($subQuery->getQuery())
|
||||||
|
->where('total_received', '>', 0)
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
// iterate through holdings and update
|
// iterate through holdings and update
|
||||||
@@ -154,6 +194,7 @@ class Dividend extends Model
|
|||||||
'date' => $dividend['date'],
|
'date' => $dividend['date'],
|
||||||
'portfolio_id' => $holding->portfolio_id,
|
'portfolio_id' => $holding->portfolio_id,
|
||||||
'symbol' => $holding->symbol,
|
'symbol' => $holding->symbol,
|
||||||
|
'currency' => $holding->market_data->currency,
|
||||||
'transaction_type' => 'BUY',
|
'transaction_type' => 'BUY',
|
||||||
'reinvested_dividend' => true,
|
'reinvested_dividend' => true,
|
||||||
'cost_basis' => 0,
|
'cost_basis' => 0,
|
||||||
|
|||||||
+331
-65
@@ -4,14 +4,20 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
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\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphOne;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
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,21 +33,24 @@ class Holding extends Model
|
|||||||
];
|
];
|
||||||
|
|
||||||
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',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
||||||
*
|
*
|
||||||
@@ -60,7 +69,7 @@ class Holding extends Model
|
|||||||
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
|
||||||
@@ -90,8 +99,21 @@ class Holding extends Model
|
|||||||
THEN transactions.quantity ELSE 0 END)
|
THEN transactions.quantity ELSE 0 END)
|
||||||
* dividends.dividend_amount
|
* dividends.dividend_amount
|
||||||
) AS total_received")
|
) AS total_received")
|
||||||
|
->selectRaw("SUM(
|
||||||
|
(CASE WHEN transaction_type = 'BUY'
|
||||||
|
AND transactions.symbol = dividends.symbol
|
||||||
|
AND transactions.portfolio_id = '$this->portfolio_id'
|
||||||
|
AND date(transactions.date) <= 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 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')
|
->join('transactions', 'transactions.symbol', 'dividends.symbol')
|
||||||
->groupBy(['dividends.symbol', 'dividends.date', 'dividends.dividend_amount'])
|
->groupBy(['dividends.symbol', 'dividends.date', 'dividends.dividend_amount', 'dividends.dividend_amount_base'])
|
||||||
->orderBy('dividends.date', 'DESC')
|
->orderBy('dividends.date', 'DESC')
|
||||||
->where('dividends.date', '>=', function ($query) {
|
->where('dividends.date', '>=', function ($query) {
|
||||||
$query->selectRaw('min(transactions.date)')
|
$query->selectRaw('min(transactions.date)')
|
||||||
@@ -99,7 +121,25 @@ class Holding extends Model
|
|||||||
->whereRaw("transactions.portfolio_id = '$this->portfolio_id'")
|
->whereRaw("transactions.portfolio_id = '$this->portfolio_id'")
|
||||||
->whereRaw("transactions.symbol = '$this->symbol'");
|
->whereRaw("transactions.symbol = '$this->symbol'");
|
||||||
})
|
})
|
||||||
->having('total_received', '>', 0);
|
->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");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,31 +163,31 @@ class Holding extends Model
|
|||||||
->orderBy('date', 'DESC');
|
->orderBy('date', 'DESC');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function chatWithConversation(): MorphOne
|
||||||
* Related chats for holding
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function chats()
|
|
||||||
{
|
{
|
||||||
return $this->morphMany(AiChat::class, 'chatable')->where('user_id', auth()->user()->id);
|
return $this->morphOne(ChatWithConversation::class, 'chatable')
|
||||||
|
->where('user_id', auth()->id());
|
||||||
}
|
}
|
||||||
|
|
||||||
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', 'market_value_base')
|
||||||
->withAggregate('market_data', 'fifty_two_week_low')
|
->withAggregate('market_data', 'fifty_two_week_low')
|
||||||
->withAggregate('market_data', 'fifty_two_week_high')
|
->withAggregate('market_data', 'fifty_two_week_high')
|
||||||
->withAggregate('market_data', 'updated_at')
|
->withAggregate('market_data', 'updated_at')
|
||||||
->join('market_data', 'holdings.symbol', 'market_data.symbol');
|
->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)
|
||||||
@@ -174,15 +214,197 @@ class Holding extends Model
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
||||||
@@ -190,20 +412,27 @@ class Holding extends Model
|
|||||||
// 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
|
||||||
@@ -211,16 +440,14 @@ class Holding extends Model
|
|||||||
'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))
|
|
||||||
: 0,
|
|
||||||
'dividends_earned' => $this->dividends->sum('total_received'),
|
'dividends_earned' => $this->dividends->sum('total_received'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->save();
|
$this->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function qtyOwned(?\Illuminate\Support\Carbon $date = null)
|
public function qtyOwned(?Carbon $date = null)
|
||||||
{
|
{
|
||||||
if ($date == null) {
|
if ($date == null) {
|
||||||
$date = now();
|
$date = now();
|
||||||
@@ -235,9 +462,14 @@ 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,
|
?Carbon $start_date = null,
|
||||||
?\Illuminate\Support\Carbon $end_date = null,
|
?Carbon $end_date = null,
|
||||||
) {
|
) {
|
||||||
if ($start_date == null) {
|
if ($start_date == null) {
|
||||||
$start_date = now();
|
$start_date = now();
|
||||||
@@ -246,49 +478,83 @@ class Holding extends Model
|
|||||||
$end_date = now();
|
$end_date = now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MySQL default interval
|
||||||
$date_interval = 'DATE_ADD(date, INTERVAL 1 DAY)';
|
$date_interval = 'DATE_ADD(date, INTERVAL 1 DAY)';
|
||||||
|
$castNumberType = 'decimal';
|
||||||
|
|
||||||
|
// Use SQLite interval grammar
|
||||||
if (config('database.default') === 'sqlite') {
|
if (config('database.default') === 'sqlite') {
|
||||||
|
|
||||||
$date_interval = "date(date, '+1 day')";
|
$date_interval = "date(date, '+1 day')";
|
||||||
} else {
|
|
||||||
|
|
||||||
DB::statement('SET cte_max_recursion_depth=1000000;');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return DB::table(DB::raw("(
|
// Default CTE time series query (for MySQL and SQLite)
|
||||||
|
$timeSeriesQuery = DB::table(DB::raw("(
|
||||||
WITH RECURSIVE date_series AS (
|
WITH RECURSIVE date_series AS (
|
||||||
SELECT '{$start_date->format('Y-m-d')}' AS date
|
SELECT '{$start_date->toDateString()}' AS date
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT $date_interval
|
SELECT $date_interval
|
||||||
FROM date_series
|
FROM date_series
|
||||||
WHERE date < '{$end_date->format('Y-m-d')}'
|
WHERE date < '{$end_date->toDateString()}'
|
||||||
)
|
)
|
||||||
SELECT date_series.date
|
SELECT date_series.date
|
||||||
FROM date_series
|
FROM date_series
|
||||||
) as date_series")
|
) as date_series"));
|
||||||
)
|
|
||||||
|
// PGSql time series query
|
||||||
|
if (config('database.default') === 'pgsql') {
|
||||||
|
|
||||||
|
$timeSeriesQuery = DB::table(DB::raw("
|
||||||
|
generate_series(
|
||||||
|
date '{$start_date->toDateString()}',
|
||||||
|
date '{$end_date->toDateString()}',
|
||||||
|
interval '1 day'
|
||||||
|
) as date_series"));
|
||||||
|
|
||||||
|
$castNumberType = 'numeric';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set MySQL-like query CTE max iterations
|
||||||
|
if (config('database.default') === 'mysql') {
|
||||||
|
|
||||||
|
// MySQL default
|
||||||
|
$max_recursion_var_name = 'cte_max_recursion_depth';
|
||||||
|
|
||||||
|
// Determine if running MySQL or MariaDB
|
||||||
|
$versionString = Arr::get(
|
||||||
|
DB::select('SELECT VERSION() as version;'),
|
||||||
|
'0', new \stdClass
|
||||||
|
)->version;
|
||||||
|
if (stripos($versionString, 'MariaDB') !== false) {
|
||||||
|
$max_recursion_var_name = 'max_recursive_iterations'; // Must be MariaDB
|
||||||
|
}
|
||||||
|
|
||||||
|
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([
|
->select([
|
||||||
'date_series.date',
|
'date_series.date',
|
||||||
DB::raw("
|
DB::raw("
|
||||||
ROUND(
|
{$quantityQuery} AS owned
|
||||||
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity ELSE 0 END), 0) -
|
|
||||||
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN transactions.quantity ELSE 0 END), 0), 3) AS `owned`
|
|
||||||
"),
|
"),
|
||||||
DB::raw("
|
DB::raw("
|
||||||
COALESCE(CASE
|
CASE
|
||||||
WHEN (
|
WHEN ({$quantityQuery}) = 0 THEN 0
|
||||||
ROUND(
|
|
||||||
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity ELSE 0 END), 0) -
|
|
||||||
COALESCE(SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN transactions.quantity ELSE 0 END), 0), 3)
|
|
||||||
) = 0 THEN 0
|
|
||||||
ELSE SUM(CASE
|
ELSE SUM(CASE
|
||||||
WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity * transactions.cost_basis
|
WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity * transactions.cost_basis_base
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END)
|
END)
|
||||||
END, 0) AS cost_basis
|
END AS cost_basis
|
||||||
"),
|
"),
|
||||||
DB::raw("COALESCE(SUM(CASE WHEN transaction_type = 'SELL' THEN ((sale_price - cost_basis) * quantity) ELSE 0 END), 0) AS `realized_gains`"),
|
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) {
|
->leftJoin('transactions', function ($join) {
|
||||||
$join->on(DB::raw('DATE(transactions.date)'), '<=', 'date_series.date')
|
$join->on(DB::raw('DATE(transactions.date)'), '<=', 'date_series.date')
|
||||||
@@ -305,7 +571,7 @@ class Holding extends Model
|
|||||||
{
|
{
|
||||||
$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
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
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\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Pipeline;
|
||||||
|
|
||||||
class MarketData extends Model
|
class MarketData extends Model
|
||||||
{
|
{
|
||||||
@@ -21,7 +24,9 @@ class MarketData extends Model
|
|||||||
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',
|
||||||
@@ -29,21 +34,40 @@ class MarketData extends Model
|
|||||||
'market_cap',
|
'market_cap',
|
||||||
'book_value',
|
'book_value',
|
||||||
'last_dividend_date',
|
'last_dividend_date',
|
||||||
|
'last_dividend_amount',
|
||||||
'dividend_yield',
|
'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',
|
||||||
|
'last_dividend_date' => 'datetime',
|
||||||
|
'last_dividend_amount' => 'float',
|
||||||
'dividend_yield' => 'float',
|
'dividend_yield' => 'float',
|
||||||
|
'meta_data' => 'json',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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()
|
public function holdings()
|
||||||
{
|
{
|
||||||
return $this->hasMany(Holding::class, 'symbol', 'symbol');
|
return $this->hasMany(Holding::class, 'symbol', 'symbol');
|
||||||
@@ -54,7 +78,7 @@ 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,
|
||||||
|
|||||||
+26
-32
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Interfaces\MarketData\MarketDataInterface;
|
use App\Interfaces\MarketData\MarketDataInterface;
|
||||||
|
use App\Models\ChatWithConversation;
|
||||||
use App\Notifications\InvitedOnboardingNotification;
|
use App\Notifications\InvitedOnboardingNotification;
|
||||||
use Carbon\CarbonPeriod;
|
use Carbon\CarbonPeriod;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
@@ -68,14 +69,10 @@ class Portfolio extends Model
|
|||||||
return $this->hasMany(DailyChange::class);
|
return $this->hasMany(DailyChange::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function chatWithConversation(): \Illuminate\Database\Eloquent\Relations\MorphOne
|
||||||
* Related chats for portfolio
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function chats()
|
|
||||||
{
|
{
|
||||||
return $this->morphMany(AiChat::class, 'chatable')->where('user_id', auth()->user()->id);
|
return $this->morphOne(ChatWithConversation::class, 'chatable')
|
||||||
|
->where('user_id', auth()->id());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeMyPortfolios()
|
public function scopeMyPortfolios()
|
||||||
@@ -136,6 +133,9 @@ class Portfolio extends Model
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes daily change history for a portfolio to the database
|
||||||
|
*/
|
||||||
public function syncDailyChanges(): void
|
public function syncDailyChanges(): void
|
||||||
{
|
{
|
||||||
$holdings = $this->holdings()
|
$holdings = $this->holdings()
|
||||||
@@ -147,11 +147,15 @@ class Portfolio extends Model
|
|||||||
->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,
|
||||||
@@ -160,34 +164,24 @@ class Portfolio extends Model
|
|||||||
: 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,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,10 +194,6 @@ class Portfolio extends Model
|
|||||||
} 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'];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -211,19 +201,23 @@ class Portfolio extends Model
|
|||||||
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)
|
||||||
@@ -234,7 +228,7 @@ class Portfolio extends Model
|
|||||||
|
|
||||||
$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);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-7
@@ -5,15 +5,18 @@ declare(strict_types=1);
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
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\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Str;
|
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 = [
|
||||||
@@ -29,12 +32,12 @@ 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');
|
||||||
}
|
}
|
||||||
@@ -73,7 +76,7 @@ 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());
|
||||||
@@ -101,7 +104,7 @@ class Split extends Model
|
|||||||
->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')
|
||||||
@@ -114,9 +117,9 @@ class Split extends Model
|
|||||||
'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) {
|
||||||
|
|||||||
+29
-40
@@ -4,18 +4,25 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Actions\ConvertToMarketDataCurrency;
|
||||||
|
use App\Actions\CopyToBaseCurrency;
|
||||||
|
use App\Actions\EnsureCostBasisAddedToSale;
|
||||||
|
use App\Actions\EnsureDailyChangeIsSynced;
|
||||||
|
use App\Casts\BaseCurrency;
|
||||||
|
use App\Traits\HasMarketData;
|
||||||
use Illuminate\Contracts\Database\Eloquent\Builder;
|
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\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
|
||||||
use Illuminate\Support\Arr;
|
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 = [
|
||||||
@@ -23,6 +30,7 @@ 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',
|
||||||
@@ -36,6 +44,11 @@ class Transaction extends Model
|
|||||||
'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()
|
||||||
@@ -44,17 +57,24 @@ 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);
|
||||||
});
|
});
|
||||||
@@ -77,16 +97,6 @@ class Transaction extends Model
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Related market data for transaction
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function market_data(): HasOne
|
|
||||||
{
|
|
||||||
return $this->hasOne(MarketData::class, 'symbol', 'symbol');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Related portfolio
|
* Related portfolio
|
||||||
*
|
*
|
||||||
@@ -101,6 +111,7 @@ class Transaction extends Model
|
|||||||
{
|
{
|
||||||
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', 'currency')
|
||||||
->withAggregate('market_data', 'fifty_two_week_low')
|
->withAggregate('market_data', 'fifty_two_week_low')
|
||||||
->withAggregate('market_data', 'fifty_two_week_high')
|
->withAggregate('market_data', 'fifty_two_week_high')
|
||||||
->withAggregate('market_data', 'updated_at')
|
->withAggregate('market_data', 'updated_at')
|
||||||
@@ -141,28 +152,6 @@ class Transaction extends Model
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function refreshMarketData(): void
|
|
||||||
{
|
|
||||||
MarketData::getMarketData($this->attributes['symbol']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Writes average cost basis to a sale transaction
|
|
||||||
*/
|
|
||||||
public function ensureCostBasisIsAddedToSale(): Transaction
|
|
||||||
{
|
|
||||||
$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
|
||||||
*/
|
*/
|
||||||
@@ -187,8 +176,8 @@ class Transaction extends Model
|
|||||||
'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();
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-1
@@ -5,13 +5,14 @@ declare(strict_types=1);
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Traits\HasConnectedAccounts;
|
use App\Traits\HasConnectedAccounts;
|
||||||
|
use App\Traits\HasProfilePhoto;
|
||||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
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\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
use Laravel\Jetstream\HasProfilePhoto;
|
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
use Staudenmeir\EloquentHasManyDeep\HasManyDeep;
|
use Staudenmeir\EloquentHasManyDeep\HasManyDeep;
|
||||||
use Staudenmeir\EloquentHasManyDeep\HasRelationships;
|
use Staudenmeir\EloquentHasManyDeep\HasRelationships;
|
||||||
@@ -31,6 +32,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
'name',
|
'name',
|
||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
|
'options',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
@@ -50,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',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,4 +86,26 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,15 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Interfaces\MarketData\FallbackInterface;
|
||||||
|
use App\Interfaces\MarketData\MarketDataInterface;
|
||||||
|
use Filament\Support\Colors\Color;
|
||||||
|
use Filament\Support\Facades\FilamentColor;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
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
|
||||||
{
|
{
|
||||||
@@ -15,8 +22,8 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
$this->app->bind(
|
$this->app->bind(
|
||||||
\App\Interfaces\MarketData\MarketDataInterface::class,
|
MarketDataInterface::class,
|
||||||
\App\Interfaces\MarketData\FallbackInterface::class
|
FallbackInterface::class
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +32,38 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
FilamentColor::register([
|
||||||
|
'primary' => Color::Stone,
|
||||||
|
'gray' => Color::Zinc,
|
||||||
|
'info' => Color::Blue,
|
||||||
|
'success' => Color::Emerald,
|
||||||
|
'warning' => Color::Amber,
|
||||||
|
'danger' => Color::Red,
|
||||||
|
]);
|
||||||
|
|
||||||
JsonResource::withoutWrapping();
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class FortifyServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
Fortify::viewPrefix('auth.');
|
||||||
|
|
||||||
Fortify::createUsersUsing(CreateNewUser::class);
|
Fortify::createUsersUsing(CreateNewUser::class);
|
||||||
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
|
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Providers;
|
|
||||||
|
|
||||||
use App\Actions\Jetstream\DeleteUser;
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
|
||||||
use Laravel\Jetstream\Jetstream;
|
|
||||||
|
|
||||||
class JetstreamServiceProvider extends ServiceProvider
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Register any application services.
|
|
||||||
*/
|
|
||||||
public function register(): void
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bootstrap any application services.
|
|
||||||
*/
|
|
||||||
public function boot(): void
|
|
||||||
{
|
|
||||||
|
|
||||||
$this->configurePermissions();
|
|
||||||
|
|
||||||
Jetstream::deleteUsersUsing(DeleteUser::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure the permissions that are available within the application.
|
|
||||||
*/
|
|
||||||
protected function configurePermissions(): void
|
|
||||||
{
|
|
||||||
Jetstream::defaultApiTokenPermissions([
|
|
||||||
// 'portfolio:read',
|
|
||||||
// 'portfolio:write',
|
|
||||||
// 'holding:read',
|
|
||||||
// 'holding:write',
|
|
||||||
// 'transaction:read',
|
|
||||||
// 'transaction:write',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Jetstream::permissions([
|
|
||||||
// 'Read Portfolios' => 'portfolio:read',
|
|
||||||
// 'Create Portfolios' => 'portfolio:write',
|
|
||||||
// 'Read Holdings' => 'holding:read',
|
|
||||||
// 'Update Holdings' => 'holding:write',
|
|
||||||
// 'Read Transactions' => 'transaction:read',
|
|
||||||
// 'Create Transactions' => 'transaction:write',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
|
||||||
use Livewire\Volt\Volt;
|
use Livewire\Volt\Volt;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class VoltServiceProvider extends ServiceProvider
|
class VoltServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -23,8 +23,15 @@ class VoltServiceProvider extends ServiceProvider
|
|||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
Volt::mount([
|
Volt::mount([
|
||||||
config('livewire.view_path', resource_path('views/livewire')),
|
// config('livewire.view_path', resource_path('views/livewire')),
|
||||||
resource_path('views/pages'),
|
resource_path('views/components'),
|
||||||
|
resource_path('views/profile'),
|
||||||
|
resource_path('views/api'),
|
||||||
|
resource_path('views/holding'),
|
||||||
|
resource_path('views/transaction'),
|
||||||
|
resource_path('views/portfolio'),
|
||||||
|
resource_path('views/import-export'),
|
||||||
|
resource_path('views/auth'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Rules;
|
namespace App\Rules;
|
||||||
|
|
||||||
use App\Models\Portfolio;
|
use App\Models\Portfolio;
|
||||||
|
use App\Models\Transaction;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
@@ -19,13 +20,9 @@ class QuantityValidationRule implements ValidationRule
|
|||||||
protected ?Portfolio $portfolio,
|
protected ?Portfolio $portfolio,
|
||||||
protected ?string $symbol,
|
protected ?string $symbol,
|
||||||
protected ?string $transactionType,
|
protected ?string $transactionType,
|
||||||
protected string|Carbon|null $date
|
protected string|Carbon|null $date,
|
||||||
) {
|
protected ?Transaction $transaction
|
||||||
$this->portfolio = $portfolio;
|
) {}
|
||||||
$this->symbol = $symbol;
|
|
||||||
$this->transactionType = $transactionType;
|
|
||||||
$this->date = $date;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate the attribute.
|
* Validate the attribute.
|
||||||
@@ -39,21 +36,22 @@ class QuantityValidationRule implements ValidationRule
|
|||||||
|
|
||||||
if ($this->transactionType == 'SELL') {
|
if ($this->transactionType == 'SELL') {
|
||||||
|
|
||||||
$purchase_qty = $this->portfolio->transactions()
|
$purchase_qty = (float) $this->portfolio->transactions()
|
||||||
->symbol($this->symbol)
|
->symbol($this->symbol)
|
||||||
->buy()
|
->buy()
|
||||||
->beforeDate($this->date)
|
->whereDate('date', '<', $this->date)
|
||||||
->sum('quantity');
|
->sum('quantity');
|
||||||
|
|
||||||
$sales_qty = $this->portfolio->transactions()
|
$sales_qty = (float) $this->portfolio->transactions()
|
||||||
|
->where('id', '!=', $this->transaction?->id)
|
||||||
->symbol($this->symbol)
|
->symbol($this->symbol)
|
||||||
->sell()
|
->sell()
|
||||||
->beforeDate($this->date)
|
->whereDate('date', '<', $this->date)
|
||||||
->sum('quantity');
|
->sum('quantity');
|
||||||
|
|
||||||
$maxQuantity = $purchase_qty - $sales_qty;
|
$maxQuantity = $purchase_qty - $sales_qty;
|
||||||
|
|
||||||
if (round($value, 3) > round($maxQuantity, 3)) {
|
if (round($value, 4) > round($maxQuantity, 4)) {
|
||||||
$fail(__('The quantity must not be greater than the available quantity.'));
|
$fail(__('The quantity must not be greater than the available quantity.'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// if (!function_exists('formatMoney')) {
|
|
||||||
// /**
|
|
||||||
// * Returns a formatted string for currency
|
|
||||||
// *
|
|
||||||
// * @param int|float $amount
|
|
||||||
// *
|
|
||||||
// * */
|
|
||||||
// function formatMoney(int|float $amount) {
|
|
||||||
// $formatter = new NumberFormatter('en_US', NumberFormatter::CURRENCY);
|
|
||||||
|
|
||||||
// return $formatter->formatCurrency((float) $amount, 'USD');
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
@@ -19,7 +19,7 @@ class Spotlight
|
|||||||
}
|
}
|
||||||
|
|
||||||
$portfolios = $request->user()->portfolios()
|
$portfolios = $request->user()->portfolios()
|
||||||
->where('title', 'LIKE', '%'.$request->input('search').'%')
|
->whereFullText('title', $request->input('search'))
|
||||||
->limit(5)
|
->limit(5)
|
||||||
->get();
|
->get();
|
||||||
$portfolios->each(function ($portfolio) use ($results) {
|
$portfolios->each(function ($portfolio) use ($results) {
|
||||||
@@ -35,8 +35,8 @@ class Spotlight
|
|||||||
$holdings = $request->user()->holdings()
|
$holdings = $request->user()->holdings()
|
||||||
->where('holdings.quantity', '>', 0)
|
->where('holdings.quantity', '>', 0)
|
||||||
->where(function ($query) use ($request) {
|
->where(function ($query) use ($request) {
|
||||||
return $query->where('holdings.symbol', 'LIKE', '%'.$request->input('search').'%')
|
return $query->whereFullText('holdings.symbol', $request->input('search'))
|
||||||
->orWhere('market_data.name', 'LIKE', '%'.$request->input('search').'%');
|
->orWhereFullText('market_data.name', $request->input('search'));
|
||||||
})
|
})
|
||||||
->limit(5)
|
->limit(5)
|
||||||
->get();
|
->get();
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Traits;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Auth\StatefulGuard;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Laravel\Fortify\Actions\ConfirmPassword;
|
||||||
|
|
||||||
|
trait ConfirmsPasswords
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Indicates if the user's password is being confirmed.
|
||||||
|
*
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
public $confirmingPassword = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ID of the operation being confirmed.
|
||||||
|
*
|
||||||
|
* @var string|null
|
||||||
|
*/
|
||||||
|
public $confirmableId = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's password.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
public $confirmablePassword = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start confirming the user's password.
|
||||||
|
*
|
||||||
|
* @param string $confirmableId
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function startConfirmingPassword(string $confirmableId)
|
||||||
|
{
|
||||||
|
$this->resetErrorBag();
|
||||||
|
|
||||||
|
if ($this->passwordIsConfirmed()) {
|
||||||
|
return $this->dispatch('password-confirmed',
|
||||||
|
id: $confirmableId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->confirmingPassword = true;
|
||||||
|
$this->confirmableId = $confirmableId;
|
||||||
|
$this->confirmablePassword = '';
|
||||||
|
|
||||||
|
$this->dispatch('confirming-password');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop confirming the user's password.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function stopConfirmingPassword()
|
||||||
|
{
|
||||||
|
$this->confirmingPassword = false;
|
||||||
|
$this->confirmableId = null;
|
||||||
|
$this->confirmablePassword = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm the user's password.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function confirmPassword()
|
||||||
|
{
|
||||||
|
if (! app(ConfirmPassword::class)(app(StatefulGuard::class), Auth::user(), $this->confirmablePassword)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'confirmable_password' => [__('This password does not match our records.')],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
session(['auth.password_confirmed_at' => time()]);
|
||||||
|
|
||||||
|
$this->dispatch('password-confirmed',
|
||||||
|
id: $this->confirmableId,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->stopConfirmingPassword();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure that the user's password has been recently confirmed.
|
||||||
|
*
|
||||||
|
* @param int|null $maximumSecondsSinceConfirmation
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function ensurePasswordIsConfirmed($maximumSecondsSinceConfirmation = null)
|
||||||
|
{
|
||||||
|
$maximumSecondsSinceConfirmation = $maximumSecondsSinceConfirmation ?: config('auth.password_timeout', 900);
|
||||||
|
|
||||||
|
$this->passwordIsConfirmed($maximumSecondsSinceConfirmation) ? null : abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the user's password has been recently confirmed.
|
||||||
|
*
|
||||||
|
* @param int|null $maximumSecondsSinceConfirmation
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
protected function passwordIsConfirmed($maximumSecondsSinceConfirmation = null)
|
||||||
|
{
|
||||||
|
$maximumSecondsSinceConfirmation = $maximumSecondsSinceConfirmation ?: config('auth.password_timeout', 900);
|
||||||
|
|
||||||
|
return (time() - session('auth.password_confirmed_at', 0)) < $maximumSecondsSinceConfirmation;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Traits;
|
||||||
|
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
trait HasLocalizedMarkdown
|
||||||
|
{
|
||||||
|
public function localizedMarkdownPath($name)
|
||||||
|
{
|
||||||
|
$localName = preg_replace('#(\.md)$#i', '.'.app()->getLocale().'$1', $name);
|
||||||
|
|
||||||
|
return Arr::first([
|
||||||
|
resource_path('markdown/'.$localName),
|
||||||
|
resource_path('markdown/'.$name),
|
||||||
|
], function ($path) {
|
||||||
|
return file_exists($path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Traits;
|
||||||
|
|
||||||
|
use App\Models\MarketData;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
|
||||||
|
trait HasMarketData
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Related market data for model
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function market_data(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(MarketData::class, 'symbol', 'symbol');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gracefully loads related market data as relationship (creates if doesn't exist)
|
||||||
|
*/
|
||||||
|
public function loadMarketData(): void
|
||||||
|
{
|
||||||
|
if (is_null($this->market_data)) {
|
||||||
|
|
||||||
|
$this->setRelation('market_data', MarketData::getMarketData($this->attributes['symbol']));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeNotBaseCurrency($query): void
|
||||||
|
{
|
||||||
|
$query->with('market_data')
|
||||||
|
->whereRelation(
|
||||||
|
'market_data',
|
||||||
|
'currency',
|
||||||
|
'!=',
|
||||||
|
config('investbrain.base_currency')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Traits;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
trait HasProfilePhoto
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Update the user's profile photo.
|
||||||
|
*
|
||||||
|
* @param string $storagePath
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function updateProfilePhoto(UploadedFile $photo, $storagePath = 'profile-photos')
|
||||||
|
{
|
||||||
|
tap($this->profile_photo_path, function ($previous) use ($photo, $storagePath) {
|
||||||
|
$this->forceFill([
|
||||||
|
'profile_photo_path' => $photo->storePublicly(
|
||||||
|
$storagePath, ['disk' => $this->profilePhotoDisk()]
|
||||||
|
),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
if ($previous) {
|
||||||
|
Storage::disk($this->profilePhotoDisk())->delete($previous);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the user's profile photo.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function deleteProfilePhoto()
|
||||||
|
{
|
||||||
|
if (is_null($this->profile_photo_path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Storage::disk($this->profilePhotoDisk())->delete($this->profile_photo_path);
|
||||||
|
|
||||||
|
$this->forceFill([
|
||||||
|
'profile_photo_path' => null,
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the URL to the user's profile photo.
|
||||||
|
*/
|
||||||
|
protected function profilePhotoUrl(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::get(function (): string {
|
||||||
|
return $this->profile_photo_path
|
||||||
|
? Storage::disk($this->profilePhotoDisk())->url($this->profile_photo_path)
|
||||||
|
: $this->defaultProfilePhotoUrl();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default profile photo URL if no profile photo has been uploaded.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function defaultProfilePhotoUrl()
|
||||||
|
{
|
||||||
|
$name = trim(collect(explode(' ', $this->name))->map(function ($segment) {
|
||||||
|
return mb_substr($segment, 0, 1);
|
||||||
|
})->join(' '));
|
||||||
|
|
||||||
|
return 'https://ui-avatars.com/api/?name='.urlencode($name).'&color=7F9CF5&background=EBF4FF';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the disk that profile photos should be stored on.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function profilePhotoDisk()
|
||||||
|
{
|
||||||
|
return config('filesystems.default', 'local');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Traits;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
|
||||||
|
trait Toast
|
||||||
|
{
|
||||||
|
public function toast(
|
||||||
|
string $type,
|
||||||
|
string $title,
|
||||||
|
?string $description = null,
|
||||||
|
?string $position = null,
|
||||||
|
string $icon = 'o-information-circle',
|
||||||
|
string $css = 'alert-info',
|
||||||
|
int $timeout = 3000,
|
||||||
|
?string $redirectTo = null
|
||||||
|
) {
|
||||||
|
$toast = [
|
||||||
|
'type' => $type,
|
||||||
|
'title' => $title,
|
||||||
|
'description' => $description,
|
||||||
|
'position' => $position,
|
||||||
|
'icon' => Blade::render("<x-ui.icon class='w-7 h-7' name='".$icon."' />"),
|
||||||
|
'css' => $css,
|
||||||
|
'timeout' => $timeout,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->js('toast('.json_encode(['toast' => $toast]).')');
|
||||||
|
|
||||||
|
if ($redirectTo) {
|
||||||
|
return $this->redirect($redirectTo, navigate: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function success(
|
||||||
|
string $title,
|
||||||
|
?string $description = null,
|
||||||
|
?string $position = null,
|
||||||
|
string $icon = 'o-check-circle',
|
||||||
|
string $css = 'alert-success',
|
||||||
|
int $timeout = 3000,
|
||||||
|
?string $redirectTo = null
|
||||||
|
) {
|
||||||
|
return $this->toast('success', $title, $description, $position, $icon, $css, $timeout, $redirectTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function warning(
|
||||||
|
string $title,
|
||||||
|
?string $description = null,
|
||||||
|
?string $position = null,
|
||||||
|
string $icon = 'o-exclamation-triangle',
|
||||||
|
string $css = 'alert-warning',
|
||||||
|
int $timeout = 3000,
|
||||||
|
?string $redirectTo = null
|
||||||
|
) {
|
||||||
|
return $this->toast('warning', $title, $description, $position, $icon, $css, $timeout, $redirectTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function error(
|
||||||
|
string $title,
|
||||||
|
?string $description = null,
|
||||||
|
?string $position = null,
|
||||||
|
string $icon = 'o-x-circle',
|
||||||
|
string $css = 'alert-error',
|
||||||
|
int $timeout = 3000,
|
||||||
|
?string $redirectTo = null
|
||||||
|
) {
|
||||||
|
return $this->toast('error', $title, $description, $position, $icon, $css, $timeout, $redirectTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function info(
|
||||||
|
string $title,
|
||||||
|
?string $description = null,
|
||||||
|
?string $position = null,
|
||||||
|
string $icon = 'o-information-circle',
|
||||||
|
string $css = 'alert-info',
|
||||||
|
int $timeout = 3000,
|
||||||
|
?string $redirectTo = null
|
||||||
|
) {
|
||||||
|
return $this->toast('info', $title, $description, $position, $icon, $css, $timeout, $redirectTo);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\View\Components;
|
|
||||||
|
|
||||||
use Illuminate\View\Component;
|
|
||||||
|
|
||||||
class AppLayout extends Component
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Get the view / contents that represents the component.
|
|
||||||
*/
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return <<<'HTML'
|
|
||||||
<x-main-layout>
|
|
||||||
<x-slot:body class="min-h-screen font-sans antialiased bg-base-200/50 dark:bg-base-200" x-data>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
|
|
||||||
<x-partials.nav-bar />
|
|
||||||
|
|
||||||
<x-main with-nav full-width>
|
|
||||||
|
|
||||||
<x-slot:sidebar drawer="main-drawer" class="bg-base-100 lg:bg-inherit">
|
|
||||||
|
|
||||||
<x-partials.side-bar />
|
|
||||||
|
|
||||||
</x-slot:sidebar>
|
|
||||||
|
|
||||||
<x-slot:content>
|
|
||||||
|
|
||||||
{{ $slot }}
|
|
||||||
</x-slot:content>
|
|
||||||
|
|
||||||
</x-main>
|
|
||||||
|
|
||||||
@if(session('toast'))
|
|
||||||
<script lang="text/javascript">
|
|
||||||
window.addEventListener('DOMContentLoaded', function () {
|
|
||||||
window.toast(JSON.parse(@json(session('toast'))))
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@endif
|
|
||||||
<x-toast />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</x-slot:body>
|
|
||||||
</x-main-layout>
|
|
||||||
HTML;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\View\Components;
|
|
||||||
|
|
||||||
use Illuminate\View\Component;
|
|
||||||
|
|
||||||
class GuestLayout extends Component
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Get the view / contents that represents the component.
|
|
||||||
*/
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return <<<'HTML'
|
|
||||||
<x-main-layout>
|
|
||||||
<x-slot:body class="font-sans text-gray-900 dark:text-gray-100 antialiased">
|
|
||||||
|
|
||||||
{{ $slot }}
|
|
||||||
|
|
||||||
<x-theme-toggle class="hidden" darkTheme="business" lightTheme="corporate"/>
|
|
||||||
|
|
||||||
</x-slot:body>
|
|
||||||
</x-main-layout>
|
|
||||||
HTML;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\View\Components;
|
|
||||||
|
|
||||||
use Illuminate\View\Component;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class MainLayout extends Component
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
|
|
||||||
// Slots
|
|
||||||
public mixed $body = null,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the view / contents that represents the component.
|
|
||||||
*/
|
|
||||||
public function render(): View
|
|
||||||
{
|
|
||||||
return view('layouts.main-layout');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"agents": [
|
||||||
|
"claude_code"
|
||||||
|
],
|
||||||
|
"editors": [
|
||||||
|
"claude_code",
|
||||||
|
"vscode"
|
||||||
|
],
|
||||||
|
"guidelines": [],
|
||||||
|
"herd_mcp": true
|
||||||
|
}
|
||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Http\Middleware\SetLocale;
|
use App\Http\Middleware\LocalizationMiddleware;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
@@ -15,7 +15,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware) {
|
->withMiddleware(function (Middleware $middleware) {
|
||||||
$middleware->append(SetLocale::class);
|
$middleware->appendToGroup('web', LocalizationMiddleware::class);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions) {
|
->withExceptions(function (Exceptions $exceptions) {
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -5,6 +5,5 @@ declare(strict_types=1);
|
|||||||
return [
|
return [
|
||||||
App\Providers\AppServiceProvider::class,
|
App\Providers\AppServiceProvider::class,
|
||||||
App\Providers\FortifyServiceProvider::class,
|
App\Providers\FortifyServiceProvider::class,
|
||||||
App\Providers\JetstreamServiceProvider::class,
|
|
||||||
App\Providers\VoltServiceProvider::class,
|
App\Providers\VoltServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|||||||
+28
-14
@@ -2,38 +2,48 @@
|
|||||||
"name": "investbrainapp/investbrain",
|
"name": "investbrainapp/investbrain",
|
||||||
"type": "project",
|
"type": "project",
|
||||||
"description": "A smart open-source tool that consolidates and tracks portfolios from your different brokerages",
|
"description": "A smart open-source tool that consolidates and tracks portfolios from your different brokerages",
|
||||||
"keywords": ["stocks", "dividends", "investments", "tracking"],
|
"keywords": [
|
||||||
|
"stocks",
|
||||||
|
"dividends",
|
||||||
|
"investments",
|
||||||
|
"tracking"
|
||||||
|
],
|
||||||
"license": "CC-BY-NC 4.0",
|
"license": "CC-BY-NC 4.0",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.3",
|
"php": "^8.3",
|
||||||
"ext-gd": "*",
|
"ext-gd": "*",
|
||||||
|
"ext-json": "*",
|
||||||
"ext-mbstring": "*",
|
"ext-mbstring": "*",
|
||||||
"ext-zip": "*",
|
"ext-zip": "*",
|
||||||
|
"blade-ui-kit/blade-heroicons": "^2.6",
|
||||||
|
"filament/tables": "^5.0",
|
||||||
"finnhub/client": "master@dev",
|
"finnhub/client": "master@dev",
|
||||||
"hackeresq/filter-models": "dev-main",
|
"hackeresq/filter-models": "dev-main",
|
||||||
"laravel/framework": "^11.35",
|
"investbrainapp/frankfurter-client": "dev-main",
|
||||||
"laravel/jetstream": "^5.1",
|
"laravel/ai": "^0.2.5",
|
||||||
|
"laravel/fortify": "^1.30.0",
|
||||||
|
"laravel/framework": "^12.0",
|
||||||
"laravel/sanctum": "^4.0",
|
"laravel/sanctum": "^4.0",
|
||||||
"laravel/socialite": "^5.16",
|
"laravel/socialite": "^5.16",
|
||||||
"laravel/tinker": "^2.9",
|
"laravel/tinker": "^2.9",
|
||||||
"league/flysystem-aws-s3-v3": "^3.0",
|
"league/flysystem-aws-s3-v3": "^3.0",
|
||||||
"livewire/livewire": "^3.5",
|
"livewire/livewire": "^4.0",
|
||||||
"livewire/volt": "^1.6",
|
"livewire/volt": "^1.6",
|
||||||
"maatwebsite/excel": "^3.1",
|
"maatwebsite/excel": "^3.1",
|
||||||
"openai-php/client": "^0.10.3",
|
"openai-php/client": "^0.10.3",
|
||||||
"predis/predis": "^2.2",
|
"predis/predis": "^2.2",
|
||||||
"robsontenorio/mary": "^1.35",
|
"scheb/yahoo-finance-api": "^5.0",
|
||||||
"scheb/yahoo-finance-api": "^4.11",
|
|
||||||
"staudenmeir/eloquent-has-many-deep": "^1.20",
|
"staudenmeir/eloquent-has-many-deep": "^1.20",
|
||||||
|
"symfony/cache": "^7.3",
|
||||||
"tschucki/alphavantage-laravel": "^0.0"
|
"tschucki/alphavantage-laravel": "^0.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
"laravel/pint": "^1.13",
|
"laravel/boost": "^1.8",
|
||||||
"laravel/sail": "^1.26",
|
"laravel/pint": "^1.25",
|
||||||
"mockery/mockery": "^1.6",
|
"mockery/mockery": "^1.6",
|
||||||
"nunomaduro/collision": "^8.0",
|
"nunomaduro/collision": "^8.0",
|
||||||
"phpunit/phpunit": "^11.0.1"
|
"phpunit/phpunit": "^11.0"
|
||||||
},
|
},
|
||||||
"repositories": [
|
"repositories": [
|
||||||
{
|
{
|
||||||
@@ -41,6 +51,11 @@
|
|||||||
"no-api": true,
|
"no-api": true,
|
||||||
"url": "https://github.com/hackeresq/filter-models"
|
"url": "https://github.com/hackeresq/filter-models"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "vcs",
|
||||||
|
"no-api": true,
|
||||||
|
"url": "https://github.com/investbrainapp/frankfurter-client"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "vcs",
|
"type": "vcs",
|
||||||
"no-api": true,
|
"no-api": true,
|
||||||
@@ -48,9 +63,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"files": [
|
|
||||||
"app/Support/Helpers.php"
|
|
||||||
],
|
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"App\\": "app/",
|
"App\\": "app/",
|
||||||
"Database\\Factories\\": "database/factories/",
|
"Database\\Factories\\": "database/factories/",
|
||||||
@@ -65,10 +77,12 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"post-autoload-dump": [
|
"post-autoload-dump": [
|
||||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||||
"@php artisan package:discover --ansi"
|
"@php artisan package:discover --ansi",
|
||||||
|
"@php artisan filament:upgrade"
|
||||||
],
|
],
|
||||||
"post-update-cmd": [
|
"post-update-cmd": [
|
||||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
"@php artisan vendor:publish --tag=laravel-assets --ansi --force",
|
||||||
|
"@php artisan boost:update --ansi; exit 0"
|
||||||
],
|
],
|
||||||
"post-root-package-install": [
|
"post-root-package-install": [
|
||||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||||
|
|||||||
Generated
+3387
-1512
File diff suppressed because it is too large
Load Diff
+137
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default AI Provider Names
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify which of the AI providers below should be the
|
||||||
|
| default for AI operations when no explicit provider is provided
|
||||||
|
| for the operation. This should be any provider defined below.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('CHAT_PROVIDER', 'openai'),
|
||||||
|
'default_for_images' => 'gemini',
|
||||||
|
'default_for_audio' => 'openai',
|
||||||
|
'default_for_transcription' => 'openai',
|
||||||
|
'default_for_embeddings' => 'openai',
|
||||||
|
'default_for_reranking' => 'cohere',
|
||||||
|
|
||||||
|
'models' => [
|
||||||
|
'text' => [
|
||||||
|
'default' => env('CHAT_MODEL', null),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Caching
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Below you may configure caching strategies for AI related operations
|
||||||
|
| such as embedding generation. You are free to adjust these values
|
||||||
|
| based on your application's available caching stores and needs.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'caching' => [
|
||||||
|
'embeddings' => [
|
||||||
|
'cache' => false,
|
||||||
|
'store' => env('CACHE_STORE', 'database'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| AI Providers
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Below are each of your AI providers defined for this application. Each
|
||||||
|
| represents an AI provider and API key combination which can be used
|
||||||
|
| to perform tasks like text, image, and audio creation via agents.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'providers' => [
|
||||||
|
'anthropic' => [
|
||||||
|
'driver' => 'anthropic',
|
||||||
|
'key' => env('ANTHROPIC_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'azure' => [
|
||||||
|
'driver' => 'azure',
|
||||||
|
'key' => env('AZURE_OPENAI_API_KEY'),
|
||||||
|
'url' => env('AZURE_OPENAI_URL'),
|
||||||
|
'api_version' => env('AZURE_OPENAI_API_VERSION', '2024-10-21'),
|
||||||
|
'deployment' => env('AZURE_OPENAI_DEPLOYMENT', 'gpt-4o'),
|
||||||
|
'embedding_deployment' => env('AZURE_OPENAI_EMBEDDING_DEPLOYMENT', 'text-embedding-3-small'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'cohere' => [
|
||||||
|
'driver' => 'cohere',
|
||||||
|
'key' => env('COHERE_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'deepseek' => [
|
||||||
|
'driver' => 'deepseek',
|
||||||
|
'key' => env('DEEPSEEK_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'eleven' => [
|
||||||
|
'driver' => 'eleven',
|
||||||
|
'key' => env('ELEVENLABS_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'gemini' => [
|
||||||
|
'driver' => 'gemini',
|
||||||
|
'key' => env('GEMINI_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'groq' => [
|
||||||
|
'driver' => 'groq',
|
||||||
|
'key' => env('GROQ_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'jina' => [
|
||||||
|
'driver' => 'jina',
|
||||||
|
'key' => env('JINA_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'mistral' => [
|
||||||
|
'driver' => 'mistral',
|
||||||
|
'key' => env('MISTRAL_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'ollama' => [
|
||||||
|
'driver' => 'ollama',
|
||||||
|
'key' => env('OLLAMA_API_KEY', ''),
|
||||||
|
'url' => env('OLLAMA_BASE_URL', 'http://localhost:11434'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'openai' => [
|
||||||
|
'driver' => 'openai',
|
||||||
|
'key' => env('OPENAI_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'openrouter' => [
|
||||||
|
'driver' => 'openrouter',
|
||||||
|
'key' => env('OPENROUTER_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'voyageai' => [
|
||||||
|
'driver' => 'voyageai',
|
||||||
|
'key' => env('VOYAGEAI_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'xai' => [
|
||||||
|
'driver' => 'xai',
|
||||||
|
'key' => env('XAI_API_KEY'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'key' => env('ALPACA_API_KEY'),
|
||||||
|
'secret' => env('ALPACA_API_SECRET'),
|
||||||
|
];
|
||||||
+85
-3
@@ -79,14 +79,95 @@ return [
|
|||||||
| set to any locale for which you plan to have translation strings.
|
| set to any locale for which you plan to have translation strings.
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
'available_locales' => ['en', 'es'],
|
|
||||||
|
|
||||||
'locale' => env('APP_LOCALE', 'en'),
|
'locale' => env('APP_LOCALE', 'en'),
|
||||||
|
|
||||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||||
|
|
||||||
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||||
|
|
||||||
|
'available_locales' => [
|
||||||
|
[
|
||||||
|
'locale' => 'en_AU',
|
||||||
|
'label' => 'English (Australia)',
|
||||||
|
'flag' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'locale' => 'en_BE',
|
||||||
|
'label' => 'English (Belgium)',
|
||||||
|
'flag' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'locale' => 'en_CA',
|
||||||
|
'label' => 'English (Canada)',
|
||||||
|
'flag' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'locale' => 'en_HK',
|
||||||
|
'label' => 'English (Hong Kong SAR China)',
|
||||||
|
'flag' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'locale' => 'en_IN',
|
||||||
|
'label' => 'English (India)',
|
||||||
|
'flag' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'locale' => 'en_IE',
|
||||||
|
'label' => 'English (Ireland)',
|
||||||
|
'flag' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'locale' => 'en_MT',
|
||||||
|
'label' => 'English (Malta)',
|
||||||
|
'flag' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'locale' => 'en_NZ',
|
||||||
|
'label' => 'English (New Zealand)',
|
||||||
|
'flag' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'locale' => 'en_PH',
|
||||||
|
'label' => 'English (Philippines)',
|
||||||
|
'flag' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'locale' => 'en_SG',
|
||||||
|
'label' => 'English (Singapore)',
|
||||||
|
'flag' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'locale' => 'en_ZA',
|
||||||
|
'label' => 'English (South Africa)',
|
||||||
|
'flag' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'locale' => 'en_GB',
|
||||||
|
'label' => 'English (United Kingdom)',
|
||||||
|
'flag' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'locale' => 'en_US',
|
||||||
|
'label' => 'English (United States)',
|
||||||
|
'flag' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'locale' => 'es_419',
|
||||||
|
'label' => 'Spanish (Latin America)',
|
||||||
|
'flag' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'locale' => 'es_ES',
|
||||||
|
'label' => 'Spanish (Spain)',
|
||||||
|
'flag' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'locale' => 'es_US',
|
||||||
|
'label' => 'Spanish (United States)',
|
||||||
|
'flag' => '',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Encryption Key
|
| Encryption Key
|
||||||
@@ -100,7 +181,8 @@ return [
|
|||||||
|
|
||||||
'cipher' => 'AES-256-CBC',
|
'cipher' => 'AES-256-CBC',
|
||||||
|
|
||||||
'key' => env('APP_KEY'),
|
'key' => env('APP_KEY')
|
||||||
|
?: when(file_exists(storage_path('app/.key')), fn () => trim(file_get_contents(storage_path('app/.key')))),
|
||||||
|
|
||||||
'previous_keys' => [
|
'previous_keys' => [
|
||||||
...array_filter(
|
...array_filter(
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'default' => env('CACHE_STORE', 'database'),
|
'default' => env('CACHE_STORE', 'redis'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -11,11 +11,21 @@ return [
|
|||||||
'interfaces' => [
|
'interfaces' => [
|
||||||
'yahoo' => App\Interfaces\MarketData\YahooMarketData::class,
|
'yahoo' => App\Interfaces\MarketData\YahooMarketData::class,
|
||||||
'alphavantage' => App\Interfaces\MarketData\AlphaVantageMarketData::class,
|
'alphavantage' => App\Interfaces\MarketData\AlphaVantageMarketData::class,
|
||||||
|
'alpaca' => App\Interfaces\MarketData\AlpacaMarketData::class,
|
||||||
'finnhub' => App\Interfaces\MarketData\FinnhubMarketData::class,
|
'finnhub' => App\Interfaces\MarketData\FinnhubMarketData::class,
|
||||||
|
'twelvedata' => App\Interfaces\MarketData\TwelveDataMarketData::class,
|
||||||
'fake' => App\Interfaces\MarketData\FakeMarketData::class,
|
'fake' => App\Interfaces\MarketData\FakeMarketData::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
'self_hosted' => env('SELF_HOSTED', true),
|
'self_hosted' => env('SELF_HOSTED', true),
|
||||||
|
|
||||||
'daily_change_time_of_day' => env('DAILY_CHANGE_TIME', '23:00'),
|
'daily_change_time_of_day' => env('DAILY_CHANGE_TIME', '23:00'),
|
||||||
|
|
||||||
|
'base_currency' => env('BASE_CURRENCY', 'USD'),
|
||||||
|
|
||||||
|
'currency_aliases' => [
|
||||||
|
'RMB' => ['alias_of' => 'CNY', 'label' => 'Chinese Yuan (Renminbi)', 'adjustment' => 1],
|
||||||
|
'GBX' => ['alias_of' => 'GBP', 'label' => 'British Sterling Pence', 'adjustment' => 100],
|
||||||
|
'ZAC' => ['alias_of' => 'ZAR', 'label' => 'South Africa Rand Cent', 'adjustment' => 100],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Laravel\Jetstream\Features;
|
|
||||||
use Laravel\Jetstream\Http\Middleware\AuthenticateSession;
|
|
||||||
|
|
||||||
return [
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Jetstream Stack
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| This configuration value informs Jetstream which "stack" you will be
|
|
||||||
| using for your application. In general, this value is set for you
|
|
||||||
| during installation and will not need to be changed after that.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'stack' => 'livewire',
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Jetstream Route Middleware
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Here you may specify which middleware Jetstream will assign to the routes
|
|
||||||
| that it registers with the application. When necessary, you may modify
|
|
||||||
| these middleware; however, this default value is usually sufficient.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'middleware' => ['web'],
|
|
||||||
|
|
||||||
'auth_session' => AuthenticateSession::class,
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Jetstream Guard
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Here you may specify the authentication guard Jetstream will use while
|
|
||||||
| authenticating users. This value should correspond with one of your
|
|
||||||
| guards that is already present in your "auth" configuration file.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'guard' => 'sanctum',
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Features
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Some of Jetstream's features are optional. You may disable the features
|
|
||||||
| by removing them from this array. You're free to only remove some of
|
|
||||||
| these features or you can even remove all of these if you need to.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'features' => [
|
|
||||||
! env('SELF_HOSTED', true) ? Features::termsAndPrivacyPolicy() : null,
|
|
||||||
Features::profilePhotos(),
|
|
||||||
Features::api(),
|
|
||||||
Features::accountDeletion(),
|
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Profile Photo Disk
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| This configuration value determines the default disk that will be used
|
|
||||||
| when storing profile photos for your application's users. Typically
|
|
||||||
| this will be the "public" disk but you may adjust this if needed.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'profile_photo_disk' => 'public',
|
|
||||||
|
|
||||||
];
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user