├── config ├── packages │ ├── prod │ │ ├── webpack_encore.php │ │ ├── routing.php │ │ └── doctrine.php │ ├── test │ │ ├── webpack_encore.php │ │ ├── doctrine.php │ │ ├── validator.php │ │ └── framework.php │ ├── cache.php │ ├── dev │ │ ├── mailer.php │ │ ├── routing.php │ │ └── monolog.php │ ├── mailer.php │ ├── routing.php │ ├── validator.php │ ├── assets.php │ ├── webpack_encore.php │ ├── sonata_intl.php │ ├── twig.php │ ├── web_profiler.php │ ├── doctrine.php │ ├── framework.php │ └── monolog.php ├── preload.php ├── routes │ └── dev │ │ ├── framework.php │ │ └── web_profiler.php ├── routes.php ├── bundles.php └── services.php ├── doc ├── charts.png └── tables.png ├── assets ├── favicons │ ├── favicon.ico │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── site.webmanifest │ └── favicon.svg ├── images │ ├── neutral.svg │ ├── up.svg │ ├── down.svg │ ├── triple-down.svg │ ├── triple-up.svg │ ├── double-up.svg │ └── double-down.svg ├── utils.ts ├── styles │ ├── charts.scss │ ├── bootstrap.scss │ ├── app.scss │ └── table.scss ├── request.ts ├── charts.ts └── app.ts ├── .dockerignore ├── src ├── Provider │ ├── StockUpdateEvent.php │ ├── StockEarningsProvider.php │ ├── YahooFinanceApi.php │ └── StockPriceProvider.php ├── Kernel.php ├── Controller │ ├── EarningsController.php │ ├── AdminController.php │ └── PanelController.php ├── Command │ ├── UpdateStocksCommand.php │ ├── FetchStockHistoryCommand.php │ ├── SetLastDayPriceCommand.php │ ├── UpdateEarningsTimesCommand.php │ └── EarningsNotificationCommand.php ├── Repository │ ├── StockRepository.php │ └── StockHistoryRepository.php ├── Entity │ ├── StockHistory.php │ ├── RecentPrice.php │ ├── PortfolioPerformance.php │ ├── Exchange.php │ └── Stock.php ├── Notifications │ ├── StockAlertNotifier.php │ └── EarningsAlertNotifier.php └── Form │ └── Type │ └── StockType.php ├── .env.test ├── public └── index.php ├── tsconfig.json ├── .editorconfig ├── templates ├── Notifications │ ├── earningsAlert.txt.twig │ └── stockAlert.txt.twig ├── Panel │ ├── table.html.twig │ ├── changeHistory.svg.twig │ ├── tableContent.html.twig │ ├── charts.html.twig │ └── tableRows.html.twig ├── Admin │ ├── edit.html.twig │ ├── add.html.twig │ └── form.html.twig ├── Earnings │ └── earnings.html.twig └── base.html.twig ├── tests ├── bootstrap.php └── ApplicationTest.php ├── .github ├── dependabot.yml └── workflows │ ├── dependabot-auto-merge.yaml │ └── ci.yaml ├── .php-cs-fixer.dist.php ├── .gitignore ├── bin ├── console └── phpunit ├── nginx.conf ├── docker-compose.yaml ├── phpunit.xml.dist ├── package.json ├── LICENSE ├── .env.dist ├── composer.json ├── Dockerfile ├── README.md ├── webpack.config.js └── symfony.lock /config/packages/prod/webpack_encore.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/packages/test/webpack_encore.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /doc/charts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scheb/stock-panel/HEAD/doc/charts.png -------------------------------------------------------------------------------- /doc/tables.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scheb/stock-panel/HEAD/doc/tables.png -------------------------------------------------------------------------------- /assets/favicons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scheb/stock-panel/HEAD/assets/favicons/favicon.ico -------------------------------------------------------------------------------- /assets/favicons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scheb/stock-panel/HEAD/assets/favicons/apple-touch-icon.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .editorconfig 2 | .git 3 | .github 4 | .gitignore 5 | .idea 6 | **/.env 7 | node_modules 8 | var 9 | vendor 10 | -------------------------------------------------------------------------------- /assets/favicons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scheb/stock-panel/HEAD/assets/favicons/android-chrome-192x192.png -------------------------------------------------------------------------------- /assets/favicons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scheb/stock-panel/HEAD/assets/favicons/android-chrome-512x512.png -------------------------------------------------------------------------------- /config/preload.php: -------------------------------------------------------------------------------- 1 | 7 | {% include "Panel/tableContent.html.twig" %} 8 | 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /config/packages/cache.php: -------------------------------------------------------------------------------- 1 | extension('framework', [ 9 | 'cache' => null, 10 | ]); 11 | }; 12 | -------------------------------------------------------------------------------- /config/routes/dev/framework.php: -------------------------------------------------------------------------------- 1 | import('@FrameworkBundle/Resources/config/routing/errors.php') 9 | ->prefix('/_error'); 10 | }; 11 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | bootEnv(dirname(__DIR__).'/.env'); 11 | } 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "composer" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | assignees: 8 | - "scheb" 9 | - package-ecosystem: "npm" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | assignees: 14 | - "scheb" 15 | -------------------------------------------------------------------------------- /config/packages/dev/mailer.php: -------------------------------------------------------------------------------- 1 | extension('framework', [ 9 | 'mailer' => [ 10 | 'dsn' => 'null://null', 11 | ], 12 | ]); 13 | }; 14 | -------------------------------------------------------------------------------- /config/packages/mailer.php: -------------------------------------------------------------------------------- 1 | extension('framework', [ 9 | 'mailer' => [ 10 | 'dsn' => '%env(MAILER_DSN)%', 11 | ], 12 | ]); 13 | }; 14 | -------------------------------------------------------------------------------- /config/packages/routing.php: -------------------------------------------------------------------------------- 1 | extension('framework', [ 9 | 'router' => [ 10 | 'strict_requirements' => null, 11 | ], 12 | ]); 13 | }; 14 | -------------------------------------------------------------------------------- /config/packages/test/doctrine.php: -------------------------------------------------------------------------------- 1 | extension('doctrine', [ 9 | 'dbal' => [ 10 | 'url' => 'sqlite:///:memory:', 11 | ], 12 | ]); 13 | }; 14 | -------------------------------------------------------------------------------- /config/packages/dev/routing.php: -------------------------------------------------------------------------------- 1 | extension('framework', [ 9 | 'router' => [ 10 | 'strict_requirements' => true, 11 | ], 12 | ]); 13 | }; 14 | -------------------------------------------------------------------------------- /config/packages/prod/routing.php: -------------------------------------------------------------------------------- 1 | extension('framework', [ 9 | 'router' => [ 10 | 'strict_requirements' => null, 11 | ], 12 | ]); 13 | }; 14 | -------------------------------------------------------------------------------- /config/packages/validator.php: -------------------------------------------------------------------------------- 1 | extension('framework', [ 9 | 'validation' => [ 10 | 'email_validation_mode' => 'html5', 11 | ], 12 | ]); 13 | }; 14 | -------------------------------------------------------------------------------- /config/packages/test/validator.php: -------------------------------------------------------------------------------- 1 | extension('framework', [ 9 | 'validation' => [ 10 | 'not_compromised_password' => false, 11 | ], 12 | ]); 13 | }; 14 | -------------------------------------------------------------------------------- /config/packages/assets.php: -------------------------------------------------------------------------------- 1 | extension('framework', [ 9 | 'assets' => [ 10 | 'json_manifest_path' => '%kernel.project_dir%/public/build/manifest.json', 11 | ], 12 | ]); 13 | }; 14 | -------------------------------------------------------------------------------- /config/routes.php: -------------------------------------------------------------------------------- 1 | import([ 10 | 'path' => '../src/Controller/', 11 | 'namespace' => 'App\Controller', 12 | ], 'attribute'); 13 | 14 | $routingConfigurator->import(Kernel::class, 'attribute'); 15 | }; 16 | -------------------------------------------------------------------------------- /assets/images/neutral.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /assets/images/up.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /config/packages/test/framework.php: -------------------------------------------------------------------------------- 1 | extension('framework', [ 9 | 'test' => true, 10 | 'session' => [ 11 | 'storage_factory_id' => 'session.storage.factory.mock_file', 12 | ], 13 | ]); 14 | }; 15 | -------------------------------------------------------------------------------- /assets/images/down.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /config/packages/webpack_encore.php: -------------------------------------------------------------------------------- 1 | extension('webpack_encore', [ 9 | 'output_path' => '%kernel.project_dir%/public/build', 10 | 'script_attributes' => [ 11 | 'defer' => true, 12 | ], 13 | ]); 14 | }; 15 | -------------------------------------------------------------------------------- /config/routes/dev/web_profiler.php: -------------------------------------------------------------------------------- 1 | import('@WebProfilerBundle/Resources/config/routing/wdt.php') 9 | ->prefix('/_wdt'); 10 | $routingConfigurator->import('@WebProfilerBundle/Resources/config/routing/profiler.php') 11 | ->prefix('/_profiler'); 12 | }; 13 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | true, 5 | ]; 6 | 7 | $finder = PhpCsFixer\Finder::create() 8 | ->in([ 9 | __DIR__.'/bin', 10 | __DIR__.'/config', 11 | __DIR__.'/public', 12 | __DIR__.'/src', 13 | __DIR__.'/tests', 14 | ]); 15 | 16 | $config = new PhpCsFixer\Config(); 17 | return $config 18 | ->setFinder($finder) 19 | ->setRiskyAllowed(true) 20 | ->setRules($rules) 21 | ->setUsingCache(true) 22 | ; 23 | -------------------------------------------------------------------------------- /assets/images/triple-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /assets/images/triple-up.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /templates/Admin/edit.html.twig: -------------------------------------------------------------------------------- 1 | {% extends "base.html.twig" %} 2 | 3 | 4 | {% block title %}{{ parent() }} – Aktie bearbeiten{% endblock %} 5 | 6 | 7 | {% block body %} 8 |
9 |

Aktie bearbeiten

10 | {{ form_start(form) }} 11 | {% include "Admin/form.html.twig" %} 12 |

13 | {{ form_end(form) }} 14 |
15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /templates/Admin/add.html.twig: -------------------------------------------------------------------------------- 1 | {% extends "base.html.twig" %} 2 | 3 | 4 | {% block title %}{{ parent() }} – Aktie hinzufügen{% endblock %} 5 | 6 | 7 | {% block body %} 8 |
9 |

Aktie hinzufügen

10 | {{ form_start(form) }} 11 | {% include "Admin/form.html.twig" %} 12 |

13 | {{ form_end(form) }} 14 |
15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.env 2 | /.php-cs-fixer.cache 3 | 4 | ###> symfony/framework-bundle ### 5 | /.env.local 6 | /.env.local.php 7 | /.env.*.local 8 | /config/secrets/prod/prod.decrypt.private.php 9 | /public/bundles/ 10 | /var/ 11 | /vendor/ 12 | ###< symfony/framework-bundle ### 13 | 14 | ###> symfony/webpack-encore-bundle ### 15 | /node_modules/ 16 | /public/build/ 17 | npm-debug.log 18 | yarn-error.log 19 | ###< symfony/webpack-encore-bundle ### 20 | 21 | ###> phpunit/phpunit ### 22 | /phpunit.xml 23 | .phpunit.result.cache 24 | ###< phpunit/phpunit ### 25 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /config/bundles.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 5 | Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], 6 | Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], 7 | Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], 8 | Sonata\IntlBundle\SonataIntlBundle::class => ['all' => true], 9 | Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true], 10 | Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], 11 | ]; 12 | -------------------------------------------------------------------------------- /assets/images/double-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | index index.php index.html; 3 | server_name localhost; 4 | error_log /var/log/nginx/error.log; 5 | access_log /var/log/nginx/access.log; 6 | root /var/www/html; 7 | 8 | location /build/ { 9 | proxy_pass http://assets/build/; 10 | proxy_set_header Host $host; 11 | } 12 | 13 | location / { 14 | fastcgi_split_path_info ^(.+\.php)(/.*)$; 15 | fastcgi_pass php-fpm:9000; 16 | fastcgi_index index.php; 17 | include fastcgi_params; 18 | fastcgi_param SCRIPT_FILENAME /application/public/index.php; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /config/packages/sonata_intl.php: -------------------------------------------------------------------------------- 1 | extension('sonata_intl', [ 9 | 'timezone' => [ 10 | 'locales' => [ 11 | 'de' => 'Europe/Berlin', 12 | ], 13 | 'default' => 'Europe/Berlin', 14 | 'detectors' => [ 15 | 'sonata.intl.timezone_detector.locale_aware', 16 | ], 17 | ], 18 | ]); 19 | }; 20 | -------------------------------------------------------------------------------- /config/packages/twig.php: -------------------------------------------------------------------------------- 1 | extension('twig', [ 9 | 'default_path' => '%kernel.project_dir%/templates', 10 | 'form_themes' => [ 11 | 'bootstrap_5_layout.html.twig', 12 | ], 13 | ]); 14 | if ($containerConfigurator->env() === 'test') { 15 | $containerConfigurator->extension('twig', [ 16 | 'strict_variables' => true, 17 | ]); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /assets/utils.ts: -------------------------------------------------------------------------------- 1 | export function setCookie(name: string, val: string) { 2 | const date = new Date(); 3 | const value = val; 4 | 5 | // Set it expire in 365 days 6 | date.setTime(date.getTime() + (365 * 24 * 60 * 60 * 1000)); 7 | 8 | // Set it 9 | document.cookie = name+"="+value+"; expires="+date.toUTCString()+"; path=/"; 10 | } 11 | 12 | export function deleteCookie(name: string) { 13 | const date = new Date(); 14 | 15 | // Set it expire in -1000 days 16 | date.setTime(date.getTime() + (-1000 * 24 * 60 * 60 * 1000)); 17 | 18 | // Set it 19 | document.cookie = name+"=; expires="+date.toUTCString()+"; path=/"; 20 | } 21 | -------------------------------------------------------------------------------- /templates/Notifications/stockAlert.txt.twig: -------------------------------------------------------------------------------- 1 | Stock Alert: {{ stock.name }} is {{ stock.alertComparator }} {{ stock.alertThreshold }} {{ stock.currency }} 2 | ------------------------------------------------------------ 3 | 4 | Alert condition: {{ stock.alertComparator }} {{ stock.alertThreshold }} {{ stock.currency }} 5 | {% if stock.alertMessage %} 6 | 7 | Message: {{ stock.alertMessage }} 8 | {% endif %} 9 | 10 | Current price: {{ stock.currentPrice }} ({{ stock.changeSinceLastDayPercent|sonata_number_format_percent({'fraction_digits': 2}, {'positive_prefix': '+'}) }} today) 11 | 12 | https://finance.yahoo.com/quote/{{ stock.currentPriceSymbol }}/ 13 | 14 | {{ url('stock_edit', {"id": stock.id}) }} 15 | -------------------------------------------------------------------------------- /bin/phpunit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | 2 | 3 | 5 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /assets/styles/charts.scss: -------------------------------------------------------------------------------- 1 | #stockCharts { 2 | .charts { 3 | .box-chart { 4 | .box-inner { 5 | border: 1px solid $gray-500; 6 | padding: 0.5rem; 7 | 8 | h2 { 9 | margin-top: 0; 10 | } 11 | 12 | .profit { 13 | margin-left: 0.75rem; 14 | font-size: 0.8rem; 15 | } 16 | 17 | .chart { 18 | width: 100%; 19 | height: 12rem; 20 | background: $gray-100; 21 | } 22 | 23 | .up { 24 | color: $green; 25 | } 26 | 27 | .down { 28 | color: $red; 29 | } 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /config/packages/web_profiler.php: -------------------------------------------------------------------------------- 1 | env() === 'dev') { 9 | $containerConfigurator->extension('web_profiler', [ 10 | 'toolbar' => true, 11 | ]); 12 | $containerConfigurator->extension('framework', [ 13 | 'profiler' => [ 14 | 'collect_serializer_data' => true, 15 | ], 16 | ]); 17 | } 18 | if ($containerConfigurator->env() === 'test') { 19 | $containerConfigurator->extension('framework', [ 20 | 'profiler' => [ 21 | 'collect' => false, 22 | ], 23 | ]); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yaml: -------------------------------------------------------------------------------- 1 | name: "Dependabot Auto-Merge" 2 | 3 | on: 4 | workflow_run: 5 | types: 6 | - "completed" 7 | workflows: 8 | - "CI" 9 | 10 | permissions: 11 | pull-requests: "write" 12 | contents: "write" 13 | 14 | jobs: 15 | dependabot: 16 | runs-on: "ubuntu-latest" 17 | # Checking the actor will prevent the action from running on non-Dependabot PRs 18 | if: ${{ (github.actor == 'dependabot[bot]') && (github.event.workflow_run.conclusion == 'success') }} 19 | steps: 20 | - name: "Auto-merge Dependabot PR" 21 | uses: ridedott/merge-me-action@v2 22 | with: 23 | GITHUB_TOKEN: ${{ secrets.PRIVATE_TOKEN }} 24 | PRESET: DEPENDABOT_MINOR 25 | MERGE_METHOD: REBASE 26 | -------------------------------------------------------------------------------- /src/Controller/EarningsController.php: -------------------------------------------------------------------------------- 1 | stockEarningsProvider->getStocksWithEarnings(); 23 | 24 | return $this->render('Earnings/earnings.html.twig', [ 25 | 'stocks' => $stocksWithEarnings 26 | ]); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /config/packages/doctrine.php: -------------------------------------------------------------------------------- 1 | extension('doctrine', [ 9 | 'dbal' => [ 10 | 'url' => '%env(resolve:DATABASE_URL)%', 11 | ], 12 | 'orm' => [ 13 | 'naming_strategy' => 'doctrine.orm.naming_strategy.underscore_number_aware', 14 | 'auto_mapping' => true, 15 | 'mappings' => [ 16 | 'App' => [ 17 | 'is_bundle' => false, 18 | 'type' => 'attribute', 19 | 'dir' => '%kernel.project_dir%/src/Entity', 20 | 'prefix' => 'App\Entity', 21 | 'alias' => 'App', 22 | ], 23 | ], 24 | ], 25 | ]); 26 | }; 27 | -------------------------------------------------------------------------------- /config/packages/dev/monolog.php: -------------------------------------------------------------------------------- 1 | extension('monolog', [ 9 | 'handlers' => [ 10 | 'main' => [ 11 | 'type' => 'stream', 12 | 'path' => '%kernel.logs_dir%/%kernel.environment%.log', 13 | 'level' => 'debug', 14 | 'channels' => [ 15 | '!event', 16 | ], 17 | ], 18 | 'console' => [ 19 | 'type' => 'console', 20 | 'process_psr_3_messages' => false, 21 | 'channels' => [ 22 | '!event', 23 | '!doctrine', 24 | '!console', 25 | ], 26 | ], 27 | ], 28 | ]); 29 | }; 30 | -------------------------------------------------------------------------------- /templates/Panel/changeHistory.svg.twig: -------------------------------------------------------------------------------- 1 | {% set barWidth = 2 %}{% set barHeight = 40 %}{% set barSpacing = 1 %}{% set maxChange = 0.1 %} 2 | 3 | {% for day, changePercent in changeHistory %} 4 | 5 | {% set height = 500 * changePercent %} 6 | {% set color = changePercent > 0 ? 'green' : 'red' %} 7 | {% set absChange = changePercent|abs %} 8 | {% if absChange > maxChange %}{% set change = maxChange %}{% endif %} 9 | {% set displayHeight = absChange / maxChange * 50 %} 10 | {% if changePercent > 0 %}{% set yPos = 50 - displayHeight %}{% else %}{% set yPos = 50 %}{% endif %} 11 | 12 | {% endfor %} 13 | 14 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | tests 20 | 21 | 22 | 23 | 24 | 25 | src 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/Command/UpdateStocksCommand.php: -------------------------------------------------------------------------------- 1 | setName('stock:update') 24 | ->setDescription('Update stocks') 25 | ; 26 | } 27 | 28 | protected function execute(InputInterface $input, OutputInterface $output): int 29 | { 30 | $this->stockPriceProvider->updateStocks(); 31 | $output->writeln('Stocks updated successfully!'); 32 | return Command::SUCCESS; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@babel/core": "^7.28.5", 4 | "@babel/plugin-proposal-class-properties": "^7.18.6", 5 | "@babel/preset-env": "^7.28.5", 6 | "@symfony/webpack-encore": "^5", 7 | "@types/bootstrap": "^5.2.10", 8 | "bootstrap": "^5.3.8", 9 | "bootstrap-icons": "^1.13.1", 10 | "core-js": "^3.47.0", 11 | "file-loader": "^6.0.0", 12 | "highcharts": "^12", 13 | "regenerator-runtime": "^0.14.1", 14 | "sass": "^1.96.0", 15 | "sass-loader": "^16.0.6", 16 | "ts-loader": "^9.5.4", 17 | "typescript": "^5", 18 | "webpack": "^5.103.0", 19 | "webpack-cli": "^6.0.1", 20 | "webpack-notifier": "^1.6.0" 21 | }, 22 | "private": true, 23 | "scripts": { 24 | "dev-server": "encore dev-server", 25 | "dev": "encore dev", 26 | "watch": "encore dev --watch", 27 | "build": "encore production --progress" 28 | }, 29 | "dependencies": {} 30 | } 31 | -------------------------------------------------------------------------------- /src/Command/FetchStockHistoryCommand.php: -------------------------------------------------------------------------------- 1 | setName('stock:fetch-history') 24 | ->setDescription('Fetch stock history') 25 | ; 26 | } 27 | 28 | protected function execute(InputInterface $input, OutputInterface $output): int 29 | { 30 | $this->stockPriceProvider->updateStocksHistory(); 31 | $output->writeln('Stocks history updated successfully!'); 32 | return Command::SUCCESS; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Command/SetLastDayPriceCommand.php: -------------------------------------------------------------------------------- 1 | setName('stock:last-day-price') 24 | ->setDescription('Set last day price based on historic data') 25 | ; 26 | } 27 | 28 | protected function execute(InputInterface $input, OutputInterface $output): int 29 | { 30 | $this->stockPriceProvider->setLastDayPrices(); 31 | $output->writeln('Stocks updated successfully!'); 32 | return Command::SUCCESS; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Command/UpdateEarningsTimesCommand.php: -------------------------------------------------------------------------------- 1 | setName('stock:update-earnings') 24 | ->setDescription('Update earnings times'); 25 | } 26 | 27 | protected function execute(InputInterface $input, OutputInterface $output): int 28 | { 29 | $this->stockEarningsProvider->updateStockEarningTimes(); 30 | $output->writeln('Stocks earnings times successfully!'); 31 | return Command::SUCCESS; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Command/EarningsNotificationCommand.php: -------------------------------------------------------------------------------- 1 | setName('stock:notify-earnings') 24 | ->setDescription('Notify about upcoming earnings'); 25 | } 26 | 27 | protected function execute(InputInterface $input, OutputInterface $output): int 28 | { 29 | $this->earningsAlertNotifier->notifyAboutUpcomingEarnings(); 30 | $output->writeln('Notified about upcoming earnings!'); 31 | return Command::SUCCESS; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /config/packages/prod/doctrine.php: -------------------------------------------------------------------------------- 1 | extension('doctrine', [ 9 | 'orm' => [ 10 | 'query_cache_driver' => [ 11 | 'type' => 'pool', 12 | 'pool' => 'doctrine.system_cache_pool', 13 | ], 14 | 'result_cache_driver' => [ 15 | 'type' => 'pool', 16 | 'pool' => 'doctrine.result_cache_pool', 17 | ], 18 | ], 19 | ]); 20 | 21 | $containerConfigurator->extension('framework', [ 22 | 'cache' => [ 23 | 'pools' => [ 24 | 'doctrine.result_cache_pool' => [ 25 | 'adapter' => 'cache.app', 26 | ], 27 | 'doctrine.system_cache_pool' => [ 28 | 'adapter' => 'cache.system', 29 | ], 30 | ], 31 | ], 32 | ]); 33 | }; 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Christian Scheb 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /config/packages/framework.php: -------------------------------------------------------------------------------- 1 | extension('framework', [ 9 | 'secret' => '%env(APP_SECRET)%', 10 | 'http_method_override' => false, 11 | 'session' => [ 12 | 'handler_id' => null, 13 | 'cookie_secure' => 'auto', 14 | 'cookie_samesite' => 'lax', 15 | 'storage_factory_id' => 'session.storage.factory.native', 16 | ], 17 | 'default_locale' => 'de', 18 | 'php_errors' => [ 19 | 'log' => true, 20 | ], 21 | 'router' => [ 22 | 'default_uri' => '%env(DEFAULT_URL)%', 23 | ], 24 | ]); 25 | if ($containerConfigurator->env() === 'test') { 26 | $containerConfigurator->extension('framework', [ 27 | 'test' => true, 28 | 'session' => [ 29 | 'storage_factory_id' => 'session.storage.factory.mock_file', 30 | ], 31 | ]); 32 | } 33 | if ($containerConfigurator->env() === 'prod') { 34 | $containerConfigurator->extension('framework', [ 35 | 'http_cache' => true, 36 | ]); 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /tests/ApplicationTest.php: -------------------------------------------------------------------------------- 1 | client = self::createClient(); 19 | 20 | if ('test' !== self::$kernel->getEnvironment()) { 21 | throw new \LogicException('Execution only in Test environment possible!'); 22 | } 23 | 24 | $this->initDatabase(); 25 | } 26 | 27 | private function initDatabase(): void 28 | { 29 | $entityManager = self::getContainer()->get('doctrine.orm.entity_manager'); 30 | $metaData = $entityManager->getMetadataFactory()->getAllMetadata(); 31 | $schemaTool = new SchemaTool($entityManager); 32 | $schemaTool->updateSchema($metaData); 33 | } 34 | 35 | public function testApplicationLoads(): void 36 | { 37 | $this->client->request('GET', '/'); 38 | 39 | $this->assertEquals( 40 | 200, 41 | $this->client->getResponse()->getStatusCode(), 42 | 'The client must respond with HTTP status 200', 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /.env.dist: -------------------------------------------------------------------------------- 1 | # In all environments, the following files are loaded if they exist, 2 | # the latter taking precedence over the former: 3 | # 4 | # * .env contains default values for the environment variables needed by the app 5 | # * .env.local uncommitted file with local overrides 6 | # * .env.$APP_ENV committed environment-specific defaults 7 | # * .env.$APP_ENV.local uncommitted environment-specific overrides 8 | # 9 | # Real environment variables win over .env files. 10 | # 11 | # DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES. 12 | # 13 | # Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2). 14 | # https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration 15 | 16 | ###> symfony/framework-bundle ### 17 | APP_ENV=dev 18 | APP_SECRET=xxx 19 | ###< symfony/framework-bundle ### 20 | 21 | ###> doctrine/doctrine-bundle ### 22 | # Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url 23 | # IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml 24 | DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" 25 | ###< doctrine/doctrine-bundle ### 26 | 27 | ###> symfony/mailer ### 28 | MAILER_DSN=null://null 29 | ###< symfony/mailer ### 30 | 31 | NOTIFICATION_SENDER=no-reply@example.com 32 | NOTIFICATION_RECIPIENT=mail@example.com 33 | DEFAULT_URL=http://localhost:8000/ 34 | -------------------------------------------------------------------------------- /src/Provider/StockEarningsProvider.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | public function getStocksWithEarnings(): iterable 24 | { 25 | return $this->stockRepository->getStocksWithEarnings(); 26 | } 27 | 28 | public function updateStockEarningTimes(): void 29 | { 30 | $stocks = $this->stockRepository->getAll(); 31 | foreach ($stocks as $stock) { 32 | foreach ($stock->getSymbols() as $symbol) { 33 | try { 34 | $earnings = $this->yahooFinanceApi->getNextEarningsDate($symbol); 35 | if (isset($earnings[0]['raw'])) { 36 | $earningsTimestamp = $earnings[0]['raw']; 37 | $stock->setNextEarningsTime(new \DateTime('@' . $earningsTimestamp)); 38 | $this->em->persist($stock); 39 | break; 40 | } 41 | } catch (\Exception) { 42 | } 43 | } 44 | } 45 | $this->em->flush(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /config/services.php: -------------------------------------------------------------------------------- 1 | parameters(); 13 | 14 | $parameters->set('notification_sender', '%env(resolve:NOTIFICATION_SENDER)%'); 15 | 16 | $parameters->set('notification_recipient', '%env(resolve:NOTIFICATION_RECIPIENT)%'); 17 | 18 | $services = $containerConfigurator->services(); 19 | 20 | $services->defaults() 21 | ->autowire() 22 | ->autoconfigure(); 23 | 24 | $services->load('App\\', __DIR__ . '/../src/') 25 | ->exclude([ 26 | __DIR__ . '/../src/DependencyInjection/', 27 | __DIR__ . '/../src/Entity/', 28 | __DIR__ . '/../src/Kernel.php', 29 | __DIR__ . '/../src/Tests/', 30 | ]); 31 | 32 | $services->set(ApiClient::class, ApiClient::class) 33 | ->factory([ 34 | ApiClientFactory::class, 35 | 'createApiClient', 36 | ]) 37 | ->arg('$retries', 2) 38 | ->arg('$cache', service('cache.app')); 39 | 40 | $services->set(ContextManagerInterface::class, ApiClient::class) 41 | ->factory([ 42 | ApiClientFactory::class, 43 | 'createContextManager', 44 | ]) 45 | ->arg('$retries', 2) 46 | ->arg('$cache', service('cache.app')); 47 | 48 | $services->set('sonata.intl.timezone_detector.user', 'stdClass'); 49 | }; 50 | -------------------------------------------------------------------------------- /templates/Earnings/earnings.html.twig: -------------------------------------------------------------------------------- 1 | {% extends "base.html.twig" %} 2 | 3 | 4 | {% block title %}{{ parent() }} – Earnings{% endblock %} 5 | 6 | 7 | {% block body %} 8 |
9 |

Earnings

10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {% for stock in stocks %} 26 | 27 | 31 | 32 | 33 | 34 | {% endfor %} 35 | 36 |
WertpapierNächster TerminCountdown
28 | {{ stock.name }} 29 | {% if stock.daysUntilEarnings > -1 and stock.daysUntilEarnings < 3 %}💰{% endif %} 30 | {{ stock.nextEarningsTime|sonata_format_datetime }}{% if stock.daysUntilEarnings > 0 %}{{ stock.daysUntilEarnings }} Tage{% else %}{{ stock.hoursUntilEarnings }} Std.{% endif %}
37 |
38 |
39 | {% endblock %} 40 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: "CI" 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | include: 12 | - image: scheb42/stock-panel-assets 13 | target: frontend-deployment 14 | - image: scheb42/stock-panel-php 15 | target: backend-deployment 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Login to Docker Hub 21 | uses: docker/login-action@v3 22 | if: ${{ github.ref_name == github.event.repository.default_branch }} 23 | with: 24 | username: ${{ secrets.DOCKERHUB_USERNAME }} 25 | password: ${{ secrets.DOCKERHUB_TOKEN }} 26 | 27 | - id: meta 28 | uses: docker/metadata-action@v5 29 | with: 30 | images: ${{ matrix.image }} 31 | tags: | 32 | type=sha 33 | type=ref,event=branch 34 | type=ref,event=pr 35 | # set latest tag for default branch 36 | type=raw,value=latest,enable={{ is_default_branch }} 37 | 38 | - name: Build and push 39 | uses: docker/build-push-action@v5 40 | with: 41 | context: . 42 | file: ./Dockerfile 43 | target: ${{ matrix.target }} 44 | tags: ${{ steps.meta.outputs.tags }} 45 | labels: ${{ steps.meta.outputs.labels }} 46 | push: ${{ github.ref_name == github.event.repository.default_branch }} 47 | -------------------------------------------------------------------------------- /assets/styles/bootstrap.scss: -------------------------------------------------------------------------------- 1 | @import "~bootstrap/scss/mixins/banner"; 2 | @include bsBanner(""); 3 | 4 | 5 | // scss-docs-start import-stack 6 | // Configuration 7 | @import "~bootstrap/scss/functions"; 8 | @import "~bootstrap/scss/variables"; 9 | @import "~bootstrap/scss/variables-dark"; 10 | @import "~bootstrap/scss/maps"; 11 | @import "~bootstrap/scss/mixins"; 12 | @import "~bootstrap/scss/utilities"; 13 | 14 | // Layout & components 15 | @import "~bootstrap/scss/root"; 16 | @import "~bootstrap/scss/reboot"; 17 | @import "~bootstrap/scss/type"; 18 | //@import "~bootstrap/scss/images"; 19 | @import "~bootstrap/scss/containers"; 20 | @import "~bootstrap/scss/grid"; 21 | @import "~bootstrap/scss/tables"; 22 | @import "~bootstrap/scss/forms"; 23 | @import "~bootstrap/scss/buttons"; 24 | @import "~bootstrap/scss/transitions"; 25 | //@import "~bootstrap/scss/dropdown"; 26 | @import "~bootstrap/scss/button-group"; 27 | @import "~bootstrap/scss/nav"; 28 | @import "~bootstrap/scss/navbar"; 29 | @import "~bootstrap/scss/card"; 30 | //@import "~bootstrap/scss/accordion"; 31 | //@import "~bootstrap/scss/breadcrumb"; 32 | //@import "~bootstrap/scss/pagination"; 33 | //@import "~bootstrap/scss/badge"; 34 | @import "~bootstrap/scss/alert"; 35 | //@import "~bootstrap/scss/progress"; 36 | //@import "~bootstrap/scss/list-group"; 37 | //@import "~bootstrap/scss/close"; 38 | //@import "~bootstrap/scss/toasts"; 39 | //@import "~bootstrap/scss/modal"; 40 | //@import "~bootstrap/scss/tooltip"; 41 | //@import "~bootstrap/scss/popover"; 42 | //@import "~bootstrap/scss/carousel"; 43 | @import "~bootstrap/scss/spinners"; 44 | //@import "~bootstrap/scss/offcanvas"; 45 | //@import "~bootstrap/scss/placeholders"; 46 | 47 | // Helpers 48 | @import "~bootstrap/scss/helpers"; 49 | 50 | // Utilities 51 | @import "~bootstrap/scss/utilities/api"; 52 | // scss-docs-end import-stack 53 | -------------------------------------------------------------------------------- /src/Repository/StockRepository.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | public function getAll(): iterable 20 | { 21 | $qb = $this->createQueryBuilder("s"); 22 | $qb->orderBy("s.favourite", "DESC")->addOrderBy("s.name", "ASC"); 23 | return $qb->getQuery()->execute(); 24 | } 25 | 26 | public function getLastUpdate(): \DateTime 27 | { 28 | $qb = $this->createQueryBuilder("s"); 29 | $qb->select("MAX(s.updatedAt)"); 30 | $lastUpdate = $qb->getQuery()->getSingleScalarResult(); 31 | return $lastUpdate ? new \DateTime($lastUpdate) : new \DateTime("-1 day"); 32 | } 33 | 34 | public function getAllCategories(): array 35 | { 36 | $categories = $this->createQueryBuilder("s") 37 | ->select("DISTINCT s.category") 38 | ->orderBy("s.category", "ASC") 39 | ->where("s.category IS NOT NULL") 40 | ->getQuery() 41 | ->getResult(); 42 | 43 | return array_map(fn (array $category) => $category['category'], $categories); 44 | } 45 | 46 | public function getStocksWithEarnings() 47 | { 48 | return $this->createQueryBuilder("s") 49 | ->orderBy("s.nextEarningsTime", "ASC") 50 | ->where("s.nextEarningsTime > :today") 51 | ->setParameter('today', new \DateTime("today")) 52 | ->getQuery() 53 | ->getResult(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /assets/styles/app.scss: -------------------------------------------------------------------------------- 1 | @import "bootstrap.scss"; 2 | @import "~bootstrap-icons/font/bootstrap-icons.scss"; 3 | @import "table.scss"; 4 | @import "charts.scss"; 5 | 6 | body { 7 | padding-top: 3.5rem; 8 | } 9 | 10 | .privacyIsActive { 11 | .isPrivate { 12 | display: none !important; 13 | } 14 | } 15 | 16 | span.indicator { 17 | display: inline-block; 18 | width: 12px; 19 | height: 12px; 20 | margin: 0 0.4rem 0.1rem 0; 21 | vertical-align: middle; 22 | background-position: center; 23 | } 24 | 25 | .up { 26 | span.indicator { 27 | background: url('../images/up.svg') no-repeat; 28 | } 29 | 30 | &.strong { 31 | span.indicator { 32 | background: url('../images/double-up.svg') no-repeat; 33 | } 34 | } 35 | 36 | &.very-strong { 37 | span.indicator { 38 | background: url('../images/triple-up.svg') no-repeat; 39 | } 40 | } 41 | } 42 | 43 | .down { 44 | span.indicator { 45 | background: url('../images/down.svg') no-repeat; 46 | } 47 | 48 | &.strong { 49 | span.indicator { 50 | background: url('../images/double-down.svg') no-repeat; 51 | } 52 | } 53 | 54 | &.very-strong { 55 | span.indicator { 56 | background: url('../images/triple-down.svg') no-repeat; 57 | } 58 | } 59 | } 60 | 61 | .neutral { 62 | span.indicator { 63 | background: url('../images/neutral.svg') no-repeat; 64 | } 65 | } 66 | 67 | .help { 68 | cursor: help; 69 | } 70 | 71 | .privacyButtonIcon { 72 | @extend .bi-eye; 73 | } 74 | 75 | .btn.loading { 76 | .bi { 77 | display: none; 78 | } 79 | 80 | .spinner-border { 81 | display: inline-block; 82 | } 83 | } 84 | 85 | .privacyIsActive { 86 | .privacyButtonIcon { 87 | @extend .bi-eye-slash; 88 | } 89 | } 90 | 91 | .nav-link { 92 | cursor: pointer; 93 | } 94 | 95 | .form-label { 96 | color: $text-muted; 97 | margin-bottom: 0.25rem; 98 | } 99 | -------------------------------------------------------------------------------- /src/Entity/StockHistory.php: -------------------------------------------------------------------------------- 1 | id; 34 | } 35 | 36 | public function getStock(): Stock 37 | { 38 | return $this->stock; 39 | } 40 | 41 | public function setStock(Stock $stock): self 42 | { 43 | $this->stock = $stock; 44 | return $this; 45 | } 46 | 47 | public function getSymbol(): string 48 | { 49 | return $this->symbol; 50 | } 51 | 52 | public function setSymbol(string $symbol): self 53 | { 54 | $this->symbol = $symbol; 55 | return $this; 56 | } 57 | 58 | public function getDate(): \DateTimeInterface 59 | { 60 | return $this->date; 61 | } 62 | 63 | public function setDate(\DateTimeInterface $date): self 64 | { 65 | $this->date = $date; 66 | return $this; 67 | } 68 | 69 | public function getPrice(): float 70 | { 71 | return $this->price; 72 | } 73 | 74 | public function setPrice(float $price): self 75 | { 76 | $this->price = $price; 77 | return $this; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /templates/Panel/tableContent.html.twig: -------------------------------------------------------------------------------- 1 |

{{ lastUpdateDate|sonata_format_datetime("E d.M.Y HH:mm:ss") }}

2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {% for categoryName, stocks in categories %} 22 | 23 | 24 | 25 | 26 | {% include "Panel/tableRows.html.twig" %} 27 | 28 | {% endfor %} 29 | 30 | 31 | 32 | 33 | 34 | 35 | 39 | 43 | 44 | 45 |
WertpapierAnzahlEinstandAktuellProfitHeute
{{ categoryName }}
{{ performance.currentValue|sonata_number_format_currency(performance.currency) }} 36 |
{{ performance.profitPercent|sonata_number_format_percent({'fraction_digits': 2}, {'positive_prefix': '+'}) }}
37 |
{{ performance.profit|sonata_number_format_currency(performance.currency, {}, {'positive_prefix': '+'}) }}
38 |
40 |
{{ performance.profitSinceLastDayPercent|sonata_number_format_percent({'fraction_digits': 2}, {'positive_prefix': '+'}) }}
41 |
{{ performance.profitSinceLastDay|sonata_number_format_currency(performance.currency, {}, {'positive_prefix': '+'}) }}
42 |
46 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "project", 3 | "license": "proprietary", 4 | "minimum-stability": "stable", 5 | "prefer-stable": true, 6 | "require": { 7 | "php": "^8.4", 8 | "ext-ctype": "*", 9 | "ext-iconv": "*", 10 | "doctrine/doctrine-bundle": "^3.0", 11 | "doctrine/orm": "^3.0", 12 | "eluceo/ical": "^2.14", 13 | "guzzlehttp/guzzle": "^7.4", 14 | "scheb/yahoo-finance-api": "^5", 15 | "sonata-project/intl-bundle": "^3", 16 | "symfony/console": "^8.0", 17 | "symfony/dotenv": "^8.0", 18 | "symfony/flex": "^2.0", 19 | "symfony/form": "^8.0", 20 | "symfony/framework-bundle": "^8.0", 21 | "symfony/mailer": "^8.0", 22 | "symfony/monolog-bundle": "^4.0", 23 | "symfony/runtime": "^8.0", 24 | "symfony/twig-bundle": "^8.0", 25 | "symfony/validator": "^8.0", 26 | "symfony/webpack-encore-bundle": "^2.1", 27 | "symfony/yaml": "^8.0" 28 | }, 29 | "require-dev": { 30 | "phpunit/phpunit": "^12", 31 | "symfony/browser-kit": "^8.0", 32 | "symfony/css-selector": "^8.0", 33 | "symfony/phpunit-bridge": "^8.0", 34 | "symfony/stopwatch": "^8.0", 35 | "symfony/web-profiler-bundle": "^8.0", 36 | "symplify/config-transformer": "^12.4" 37 | }, 38 | "config": { 39 | "optimize-autoloader": true, 40 | "preferred-install": { 41 | "*": "dist" 42 | }, 43 | "sort-packages": true, 44 | "allow-plugins": { 45 | "composer/package-versions-deprecated": true, 46 | "symfony/flex": true, 47 | "symfony/runtime": true 48 | } 49 | }, 50 | "autoload": { 51 | "psr-4": { 52 | "App\\": "src/" 53 | } 54 | }, 55 | "autoload-dev": { 56 | "psr-4": { 57 | "App\\Tests\\": "tests/" 58 | } 59 | }, 60 | "replace": { 61 | "symfony/polyfill-ctype": "*", 62 | "symfony/polyfill-iconv": "*", 63 | "symfony/polyfill-php72": "*" 64 | }, 65 | "conflict": { 66 | "symfony/symfony": "*" 67 | }, 68 | "extra": { 69 | "symfony": { 70 | "allow-contrib": true 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ########## 2 | # Assets # 3 | ########## 4 | 5 | # Frontend code builder 6 | FROM node:24-alpine AS frontend-build 7 | WORKDIR /application 8 | 9 | COPY package.json yarn.lock ./ 10 | COPY webpack* ./ 11 | COPY tsconfig.json ./ 12 | COPY assets ./assets 13 | COPY public ./public 14 | 15 | RUN yarn install; \ 16 | yarn build 17 | 18 | # Actual deployable image 19 | FROM nginx:stable AS frontend-deployment 20 | COPY --from=frontend-build /application/public/build /usr/share/nginx/html/build 21 | 22 | ############### 23 | # PHP Backend # 24 | ############### 25 | 26 | FROM phpdockerio/php:8.4-fpm AS backend-deployment 27 | 28 | # Install selected extensions and other stuff 29 | RUN apt-get update \ 30 | && apt-get -y --no-install-recommends install \ 31 | php8.4-sqlite \ 32 | php8.4-mysql \ 33 | php8.4-intl \ 34 | wget \ 35 | tar \ 36 | patchelf \ 37 | && apt-get clean \ 38 | && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /var/log/* /var/cache/* /usr/share/doc/* 39 | 40 | WORKDIR /application/libcurl-impersonate 41 | 42 | RUN wget -O libcurl-impersonate.tar.gz https://github.com/lexiforest/curl-impersonate/releases/download/v1.0.3/libcurl-impersonate-v1.0.3.x86_64-linux-gnu.tar.gz \ 43 | && echo "e4d2066f7f1c544a2a0ddadfe1d164cb3daffdefcb3143363b0afd6d2f2125e7 libcurl-impersonate.tar.gz" > checksum.sha256 \ 44 | && sha256sum --check checksum.sha256 || exit 1 \ 45 | && tar -xf libcurl-impersonate.tar.gz \ 46 | && patchelf --set-soname libcurl.so.4 /application/libcurl-impersonate/libcurl-impersonate.so 47 | 48 | ENV LD_PRELOAD=/application/libcurl-impersonate/libcurl-impersonate.so 49 | ENV CURL_IMPERSONATE=chrome136 50 | ENV APP_ENV=prod 51 | ENV APP_SECRET="" 52 | 53 | WORKDIR /application 54 | COPY bin/console ./bin/ 55 | COPY composer.* ./ 56 | COPY .env.dist ./.env 57 | COPY config ./config 58 | COPY public/index.php ./public/ 59 | COPY src ./src 60 | COPY templates ./templates 61 | COPY --from=frontend-build /application/public/build ./public/build 62 | 63 | RUN composer install --no-dev --no-scripts; \ 64 | composer clear-cache; \ 65 | composer dump-autoload --optimize --classmap-authoritative; \ 66 | touch ./.env; \ 67 | bin/console cache:warmup; \ 68 | chown -R www-data:www-data ./var/ 69 | -------------------------------------------------------------------------------- /src/Repository/StockHistoryRepository.php: -------------------------------------------------------------------------------- 1 | getHistory($stock) as $day) { 22 | $thisDayPrice = $day['maxPrice']; 23 | if ($previousDayPrice !== null) { 24 | $change = ($thisDayPrice - $previousDayPrice) / $previousDayPrice; 25 | $date = $day['date']->format('Y-m-d'); 26 | $dailyChange[$date] = $change; 27 | } 28 | 29 | $previousDayPrice = $thisDayPrice; 30 | } 31 | 32 | return $dailyChange; 33 | } 34 | 35 | private function getHistory(Stock $stock) 36 | { 37 | $minDate = new \DateTime('-28 days'); 38 | return $this->createQueryBuilder('sh') 39 | ->select('sh.date', 'MAX(sh.price) as maxPrice') 40 | ->where('sh.stock = :stock') 41 | ->setParameter('stock', $stock) 42 | ->andWhere('sh.date >= :minDate') 43 | ->setParameter('minDate', $minDate) 44 | ->groupBy('sh.date') 45 | ->orderBy('sh.date', 'ASC') 46 | ->getQuery() 47 | ->getResult(); 48 | } 49 | 50 | public function insertOrUpdate(StockHistory $stockHistory): void 51 | { 52 | $em = $this->getEntityManager(); 53 | $existingEntity = $this->findOneBy([ 54 | 'stock' => $stockHistory->getStock(), 55 | 'symbol' => $stockHistory->getSymbol(), 56 | 'date' => $stockHistory->getDate() 57 | ]); 58 | 59 | if ($existingEntity) { 60 | $existingEntity->setPrice($stockHistory->getPrice()); 61 | $em->persist($existingEntity); 62 | } else { 63 | $em->persist($stockHistory); 64 | } 65 | 66 | $em->flush(); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | scheb/stock-panel 2 | ================= 3 | 4 | I've written this Symfony application for myself to keep track of my stock portfolio. 5 | 6 | It uses my [Yahoo Finance API library](https://github.com/scheb/yahoo-finance-api) to fetch current quotes and calculates profit / loss from it. 7 | 8 | Features 9 | -------- 10 | 11 | - Add any stock available on Yahoo Finance 12 | - Show daily wins/losses 13 | - Show overall wins/losses 14 | - Add stocks to watch 15 | - Auto and forced refresh on the tabular view 16 | - Privacy feature to hide sensitive information 17 | 18 | **Tabular view** 19 | 20 | ![Tabular view](doc/tables.png) 21 | 22 | **Charts view** 23 | 24 | ![Charts view](doc/charts.png) 25 | 26 | Requirements 27 | ------------ 28 | 29 | - PHP8.4 30 | - [Composer package manager](https://getcomposer.org/) 31 | - [Yarn package manager](https://yarnpkg.com/) 32 | 33 | Installation 34 | ------------ 35 | 36 | 1) Configure Symfony environment variables, e.g. as an `.env.local` file (example can be found in `.env.dist`) 37 | 2) Install Composer dependencies: `composer install` 38 | 3) Initialize the database: `bin/console doctrine:schema:create` 39 | 4) Install Yarn dependencies: `yarn install` 40 | 5) Build production assets: `yarn build` 41 | 42 | License 43 | ------- 44 | 45 | This software is available under the [MIT license](LICENSE). 46 | 47 | Contributing 48 | ------------ 49 | 50 | Thanks for your interest in contributing to this project! Glad you like it 😊 51 | 52 | I typically do not accept contributions to this project, as I've built this for myself and it just works fine for me the 53 | way it is. The project isn't intended to work for anyone but myself. I've put it onto GitHub in case someone finds this 54 | useful. So if you need changes, feel free to fork the repository and modify it for your own needs. 55 | 56 | If you have an idea that you believe is worth integrating, please reach out first. I don't want you to work on things 57 | that I wouldn't merge. 58 | 59 | Support Me 60 | ---------- 61 | 62 | I love to hear from people using my work, it's giving me the motivation to keep working on it. 63 | 64 | If you want to let me know you're finding it useful, please consider giving it a star ⭐ on GitHub. 65 | 66 | If you love my work and want to say thank you, you can help me out for a beer 🍻️ 67 | [via PayPal](https://paypal.me/ChristianScheb). 68 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const Encore = require('@symfony/webpack-encore'); 2 | 3 | // Manually configure the runtime environment if not already configured yet by the "encore" command. 4 | // It's useful when you use tools that rely on webpack.config.js file. 5 | if (!Encore.isRuntimeEnvironmentConfigured()) { 6 | Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev'); 7 | } 8 | 9 | Encore 10 | // directory where compiled assets will be stored 11 | .setOutputPath('public/build/') 12 | // public path used by the web server to access the output path 13 | .setPublicPath('/build') 14 | // only needed for CDN's or sub-directory deploy 15 | //.setManifestKeyPrefix('build/') 16 | 17 | /* 18 | * ENTRY CONFIG 19 | * 20 | * Each entry will result in one JavaScript file (e.g. app.js) 21 | * and one CSS file (e.g. app.css) if your JavaScript imports CSS. 22 | */ 23 | .addEntry('app', './assets/app.ts') 24 | 25 | // When enabled, Webpack "splits" your files into smaller pieces for greater optimization. 26 | .splitEntryChunks() 27 | 28 | // will require an extra script tag for runtime.js 29 | // but, you probably want this, unless you're building a single-page app 30 | .enableSingleRuntimeChunk() 31 | 32 | /* 33 | * FEATURE CONFIG 34 | * 35 | * Enable & configure other features below. For a full 36 | * list of features, see: 37 | * https://symfony.com/doc/current/frontend.html#adding-more-features 38 | */ 39 | .cleanupOutputBeforeBuild() 40 | .enableBuildNotifications() 41 | .enableSourceMaps(!Encore.isProduction()) 42 | .enableVersioning(Encore.isProduction()) // enables hashed filenames (e.g. app.abc123.css) 43 | 44 | .configureBabel((config) => { 45 | config.plugins.push('@babel/plugin-proposal-class-properties'); 46 | }) 47 | 48 | // enables @babel/preset-env polyfills 49 | .configureBabelPresetEnv((config) => { 50 | config.useBuiltIns = 'usage'; 51 | config.corejs = 3; 52 | }) 53 | 54 | // enables Sass/SCSS support 55 | .enableSassLoader() 56 | 57 | // uncomment if you use TypeScript 58 | .enableTypeScriptLoader() 59 | 60 | .copyFiles({ 61 | from: './assets/favicons', 62 | to: 'favicons/[path][name].[ext]', 63 | }) 64 | ; 65 | 66 | module.exports = Encore.getWebpackConfig(); 67 | -------------------------------------------------------------------------------- /assets/styles/table.scss: -------------------------------------------------------------------------------- 1 | #stockTable { 2 | 3 | &.loading { 4 | opacity: 0.5; 5 | } 6 | 7 | table.stocks { 8 | @include media-breakpoint-down(sm) { 9 | .hideColumn { 10 | display: none; 11 | } 12 | } 13 | 14 | td, th { 15 | padding: 0.25rem 0.5rem; 16 | } 17 | 18 | thead { 19 | border-top-width: 0; 20 | border-bottom-color: $gray-500; 21 | border-bottom-width: 1px; 22 | 23 | th { 24 | text-align: right; 25 | font-weight: normal; 26 | 27 | &:nth-child(1) { 28 | text-align: left; 29 | } 30 | } 31 | } 32 | 33 | tbody { 34 | border-top-width: 0; 35 | border-bottom-width: 0; 36 | } 37 | 38 | tbody, tfoot { 39 | 40 | th { 41 | vertical-align: middle; 42 | min-width: 8rem; 43 | background-position: right center; 44 | background-repeat: no-repeat; 45 | 46 | div { 47 | position: relative; 48 | display: block; 49 | 50 | span.actions { 51 | position: absolute; 52 | display: none; 53 | } 54 | 55 | &:hover { 56 | span.actions { 57 | display: inline; 58 | margin-left: 0.5rem; 59 | } 60 | } 61 | } 62 | } 63 | 64 | td { 65 | text-align: right; 66 | font-size: 0.8rem; 67 | vertical-align: middle; 68 | white-space: nowrap; 69 | 70 | &.up { 71 | color: $green; 72 | } 73 | 74 | &.down { 75 | color: $red; 76 | } 77 | } 78 | } 79 | 80 | tfoot { 81 | border-top: 3px double $gray-500; 82 | 83 | td { 84 | border-bottom: 0; 85 | } 86 | } 87 | } 88 | } 89 | 90 | .stock-name { 91 | font-weight: 600; 92 | } 93 | 94 | .secondaryValue { 95 | color: tint-color($secondary, 33%); 96 | } 97 | 98 | .privacyIsActive, .watchlist { 99 | .secondaryValue { 100 | color: var(--bs-table-color-state, var(--bs-table-color-type, var(--bs-table-color))); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Entity/RecentPrice.php: -------------------------------------------------------------------------------- 1 | getPreMarketPrice() && $quote->getPreMarketTime() > $quote->getRegularMarketTime()) { 14 | return new self( 15 | Stock::PRICE_TYPE_PRE_MARKET, 16 | $quote->getExchange(), 17 | $quote->getSymbol(), 18 | $quote->getPreMarketPrice(), 19 | min(array_filter([$quote->getPreMarketPrice(), $quote->getRegularMarketDayLow()])), 20 | max(array_filter([$quote->getPreMarketPrice(), $quote->getRegularMarketDayHigh()])), 21 | $quote->getCurrency(), 22 | $quote->getPreMarketChange(), 23 | $quote->getPreMarketTime() 24 | ); 25 | } 26 | 27 | if ($quote->getPostMarketPrice() && $quote->getPostMarketTime() > $quote->getRegularMarketTime()) { 28 | return new self( 29 | Stock::PRICE_TYPE_POST_MARKET, 30 | $quote->getExchange(), 31 | $quote->getSymbol(), 32 | $quote->getPostMarketPrice(), 33 | min(array_filter([$quote->getPreMarketPrice(), $quote->getPostMarketPrice(), $quote->getRegularMarketDayLow()])), 34 | max(array_filter([$quote->getPreMarketPrice(), $quote->getPostMarketPrice(), $quote->getRegularMarketDayHigh()])), 35 | $quote->getCurrency(), 36 | $quote->getPostMarketChange(), 37 | $quote->getPostMarketTime() 38 | ); 39 | } 40 | 41 | return new self( 42 | Stock::PRICE_TYPE_REGULAR_MARKET, 43 | $quote->getExchange(), 44 | $quote->getSymbol(), 45 | $quote->getRegularMarketPrice(), 46 | min(array_filter([$quote->getPreMarketPrice(), $quote->getRegularMarketPrice(), $quote->getRegularMarketDayLow()])), 47 | max(array_filter([$quote->getPreMarketPrice(), $quote->getRegularMarketPrice(), $quote->getRegularMarketDayHigh()])), 48 | $quote->getCurrency(), 49 | $quote->getRegularMarketChange(), 50 | $quote->getRegularMarketTime() 51 | ); 52 | } 53 | 54 | public function __construct( 55 | public string $market, 56 | public string $exchange, 57 | public string $symbol, 58 | public float $price, 59 | public float $priceLow, 60 | public float $priceHigh, 61 | public string $currency, 62 | public float $change, 63 | public \DateTimeInterface $time, 64 | ) { 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Notifications/StockAlertNotifier.php: -------------------------------------------------------------------------------- 1 | stockRepository->findAll(); 36 | foreach ($stocks as $stock) { 37 | /** @var Stock $stock */ 38 | if (!($stock->getAlertComparator() && $stock->getAlertThreshold())) { 39 | continue; 40 | } 41 | 42 | // Minimum time between alerts 43 | if ($stock->getAlertLastTime() > new \DateTime("-".self::ALERT_PERIOD_HOURS."hours")) { 44 | continue; 45 | } 46 | 47 | if ( 48 | ($stock->getAlertComparator() === Stock::COMPARATOR_ABOVE && $stock->getCurrentPrice() > $stock->getAlertThreshold()) 49 | || ($stock->getAlertComparator() === Stock::COMPARATOR_BELOW && $stock->getCurrentPrice() < $stock->getAlertThreshold()) 50 | ) { 51 | $this->notify($stock); 52 | } 53 | } 54 | } 55 | 56 | private function notify(Stock $stock): void 57 | { 58 | $text = $this->twig->render('Notifications/stockAlert.txt.twig', ['stock' => $stock]); 59 | $subject = substr($text, 0, strpos($text, "\n")); 60 | 61 | $email = new Email() 62 | ->from($this->sender) 63 | ->to($this->recipient) 64 | ->subject($subject) 65 | ->text($text); 66 | $this->mailer->send($email); 67 | 68 | $stock->setAlertLastTime(new \DateTime()); 69 | $this->em->persist($stock); 70 | $this->em->flush(); 71 | } 72 | 73 | public static function getSubscribedEvents(): array 74 | { 75 | return [ 76 | StockUpdateEvent::class => 'onStockUpdate', 77 | ]; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /templates/base.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block title %}Stock Panel{% endblock %} 5 | 6 | 7 | {% block stylesheets %} 8 | {{ encore_entry_link_tags('app') }} 9 | {% endblock %} 10 | {% block javascripts %} 11 | {{ encore_entry_script_tags('app') }} 12 | {% endblock %} 13 | 14 | 15 | 16 | 17 | 18 | 19 | 39 | 40 | {% if app.session.flashbag.peekAll()|length > 0 %} 41 |
42 | {% for label, messages in app.flashes %} 43 | {% if label == 'error' %}{% set label = 'danger' %}{% endif %} 44 | {% for message in messages %} 45 |
46 | {{ message }} 47 |
48 | {% endfor %} 49 | {% endfor %} 50 |
51 | {% endif %} 52 | 53 | {% block body %}{% endblock %} 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /src/Entity/PortfolioPerformance.php: -------------------------------------------------------------------------------- 1 | investment = 0; 22 | $this->currentValue = 0; 23 | $this->profitSinceLastDay = 0; 24 | foreach ($this->stocks as $stock) { 25 | $originalValue = $stock->getInvestment(); 26 | $currentValue = $stock->getCurrentValue(); 27 | if (null !== $currentValue && null !== $originalValue) { 28 | if ($stock->getCurrency() !== $this->currency) { 29 | $originalValue = $this->stockPriceProvider->convertPrice($originalValue, $stock->getCurrency(), $this->currency); 30 | $currentValue = $this->stockPriceProvider->convertPrice($currentValue, $stock->getCurrency(), $this->currency); 31 | } 32 | $this->investment += $originalValue; 33 | $this->currentValue += $currentValue; 34 | } 35 | 36 | $profitSinceLastDay = $stock->getProfitSinceLastDay(); 37 | if (null !== $profitSinceLastDay) { 38 | $this->profitSinceLastDay += $profitSinceLastDay; 39 | } 40 | } 41 | } 42 | 43 | public function getCurrency(): string 44 | { 45 | return $this->currency; 46 | } 47 | 48 | public function getInvestment(): float 49 | { 50 | return $this->investment; 51 | } 52 | 53 | public function getCurrentValue(): float 54 | { 55 | return $this->currentValue; 56 | } 57 | 58 | public function getProfitIndicator(): string 59 | { 60 | return $this->getProfit() < 0 ? Stock::INDICATOR_DOWN : Stock::INDICATOR_UP; 61 | } 62 | 63 | public function getProfit(): float 64 | { 65 | return $this->currentValue - $this->investment; 66 | } 67 | 68 | public function getProfitPercent(): float 69 | { 70 | return $this->getProfit() / $this->investment; 71 | } 72 | 73 | public function getProfitSinceLastDayPercent(): float 74 | { 75 | return $this->profitSinceLastDay / ($this->currentValue - $this->profitSinceLastDay); 76 | } 77 | 78 | public function getProfitSinceLastDay(): float 79 | { 80 | return $this->profitSinceLastDay; 81 | } 82 | 83 | public function getProfitSinceLastDayIndicator(): string 84 | { 85 | if (0.0 === $this->profitSinceLastDay) { 86 | return Stock::INDICATOR_NEUTRAL; 87 | } 88 | 89 | return $this->profitSinceLastDay < 0 ? Stock::INDICATOR_DOWN : Stock::INDICATOR_UP; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Form/Type/StockType.php: -------------------------------------------------------------------------------- 1 | "EUR", "USD" => "USD"]; 18 | $comparators = ['Kurs fällt unter' => Stock::COMPARATOR_BELOW, 'Kurs steigt über' => Stock::COMPARATOR_ABOVE]; 19 | $builder 20 | ->add("name", null, ['label' => 'Name']) 21 | ->add('symbols', TextType::class, ['label' => 'Symbols', 'required' => true]) 22 | ->add("category", null, ['label' => 'Kategorie (optional)', 'required' => false]) 23 | ->add("quantity", NumberType::class, ['label' => 'Anzahl', 'required' => false]) 24 | ->add("initialPrice", NumberType::class, ['label' => 'Erster Kurs', 'required' => false]) 25 | ->add("currency", ChoiceType::class, ['choices' => $currencies, 'label' => 'Währung']) 26 | ->add("displayChart", null, ['required' => false, 'label' => 'Chart anzeigen?']) 27 | ->add("favourite", null, ['required' => false, 'label' => 'Favoriten?']) 28 | ->add("alertThreshold", NumberType::class, ['required' => false, 'label' => 'Preisgrenze']) 29 | ->add("alertDynamicThresholdPercent", NumberType::class, ['required' => false, 'label' => 'Dyn. Preisgrenze (Prozent)']) 30 | ->add("alertComparator", ChoiceType::class, ['choices' => $comparators, 'required' => false, 'label' => 'Vergleich']) 31 | ->add("alertMessage", null, ['required' => false, 'label' => 'Nachricht']) 32 | ; 33 | 34 | $builder->get('symbols') 35 | ->addModelTransformer( 36 | new CallbackTransformer( 37 | function ($tagsAsArray): string { 38 | if (null === $tagsAsArray) { 39 | return ''; 40 | } 41 | // transform the array to a string 42 | return implode(', ', $tagsAsArray); 43 | }, 44 | function ($tagsAsString): array { 45 | // transform the string back to an array 46 | $values = explode(', ', $tagsAsString); 47 | $values = array_map('trim', $values); 48 | $values = array_filter($values, fn ($value): bool => strlen($value) > 0); 49 | return array_values($values); 50 | }, 51 | ) 52 | ); 53 | } 54 | 55 | public function getName(): string 56 | { 57 | return "stock"; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /templates/Admin/form.html.twig: -------------------------------------------------------------------------------- 1 | {% set two_columns_css = "col-sm-6" %} 2 | {% set three_columns_css = "col-sm-6 col-md-4 col-lg-3 col-xxl-2" %} 3 | 4 | {{ form_errors(form) }} 5 | 6 |
7 | Wertpapier 8 |
9 |
10 |
11 |
12 | {{ form_row(form.name) }} 13 |
14 |
15 | {{ form_row(form.symbols) }} 16 |
17 |
18 |
19 |
20 | {{ form_row(form.category) }} 21 |

{% for category in categories %} {% endfor %}

22 |
23 |
24 |
25 |
26 |
27 | 28 |
29 | Position 30 |
31 |
32 |
33 |
34 | {{ form_row(form.quantity) }} 35 |
36 |
37 | {{ form_row(form.initialPrice) }} 38 |
39 |
40 | {{ form_row(form.currency) }} 41 |
42 |
43 |
44 |
45 |
46 | 47 |
48 | Alerts 49 |
50 |
51 |
52 |
53 | {{ form_row(form.alertComparator) }} 54 |
55 |
56 | {{ form_row(form.alertThreshold) }} 57 |
58 |
59 | {{ form_row(form.alertDynamicThresholdPercent) }} 60 |
61 |
62 | {{ form_row(form.alertMessage) }} 63 |
64 |
65 |
66 |
67 |
68 | 69 |
70 | Optionen 71 |
72 |
73 |
74 |
75 | {{ form_row(form.displayChart) }} 76 |
77 |
78 | {{ form_row(form.favourite) }} 79 |
80 |
81 |
82 |
83 |
84 | -------------------------------------------------------------------------------- /templates/Panel/charts.html.twig: -------------------------------------------------------------------------------- 1 | {% extends "base.html.twig" %} 2 | 3 | {% block title %}{{ parent() }} – Charts{% endblock %} 4 | 5 | {% block body %} 6 |
7 | 17 | 18 |
19 | {% for stock in stocks %}{% if stock.displayChart %} 20 |
21 |
22 |
23 |

{{ stock.name }}

24 | {% if stock.profit is not null %} 25 |
26 |
{{ stock.profitPercent|sonata_number_format_percent({'fraction_digits': 2}, {'positive_prefix': '+'}) }}
27 |
{{ stock.profit|sonata_number_format_currency(stock.currency, {}, {'positive_prefix': '+'}) }}
28 |
29 | {% endif %} 30 | {% if stock.currentChange is not null %} 31 |
32 |
{{ stock.changeSinceLastDayPercent|sonata_number_format_percent({'fraction_digits': 2}, {'positive_prefix': '+'}) }}
33 |
{{ stock.changeSinceLastDayPercent|sonata_number_format_currency(stock.currency, {}, {'positive_prefix': '+'}) }}
34 |
35 | {% endif %} 36 |
37 |
38 |
39 |
40 | {% endif %}{% endfor %} 41 |
42 | 43 |
44 | {% endblock %} 45 | -------------------------------------------------------------------------------- /src/Provider/YahooFinanceApi.php: -------------------------------------------------------------------------------- 1 | '2m', 16 | '5d' => '15m', 17 | '1mo' => '1h', 18 | '6mo' => '1d', 19 | 'ytd' => '1wk', 20 | '1y' => '1wk', 21 | '5y' => '1mo', 22 | 'max' => '1mo', 23 | ]; 24 | 25 | public function __construct( 26 | private readonly ApiClient $apiClient, 27 | private readonly ContextManagerInterface $sessionManager, 28 | ) { 29 | } 30 | 31 | public function getChartsData(string $symbol, string $range): array 32 | { 33 | if (!array_key_exists($range, self::RANGE_INTERVAL_MAP)) { 34 | throw new \InvalidArgumentException('Invalid range'); 35 | } 36 | 37 | $interval = self::RANGE_INTERVAL_MAP[$range]; 38 | $url = "https://query1.finance.yahoo.com/v8/finance/chart/" . $symbol . "?range=" . $range . "&includePrePost=false&interval=" . $interval; 39 | try { 40 | $response = $this->sessionManager->request('GET', $url); 41 | } catch (GuzzleException $e) { 42 | throw new ApiException($e->getMessage(), $e->getCode(), $e); 43 | } 44 | 45 | $json = json_decode((string) $response->getBody(), true); 46 | if (!isset($json['chart']['result'][0]['timestamp'])) { 47 | throw new ApiException('Timestamps not found'); 48 | } 49 | if (!isset($json['chart']['result'][0]['indicators']['quote'][0]['close'])) { 50 | throw new ApiException('Closing pricees not found'); 51 | } 52 | 53 | $dataPrice = []; 54 | $dataVolume = []; 55 | $timestamps = $json['chart']['result'][0]['timestamp']; 56 | $openPrices = $json['chart']['result'][0]['indicators']['quote'][0]['open']; 57 | $highPrices = $json['chart']['result'][0]['indicators']['quote'][0]['high']; 58 | $lowPrices = $json['chart']['result'][0]['indicators']['quote'][0]['low']; 59 | $closingPrices = $json['chart']['result'][0]['indicators']['quote'][0]['close']; 60 | $volumes = $json['chart']['result'][0]['indicators']['quote'][0]['volume']; 61 | foreach ($timestamps as $index => $timestamp) { 62 | $timestampMs = $timestamp * 1000; 63 | $openPrice = $openPrices[$index] ?? null; 64 | $highPrice = $highPrices[$index] ?? null; 65 | $lowPrice = $lowPrices[$index] ?? null; 66 | $closingPrice = $closingPrices[$index] ?? null; 67 | $volume = $volumes[$index] ?? null; 68 | $dataPrice[] = [$timestampMs, $openPrice, $highPrice, $lowPrice, $closingPrice]; 69 | $dataVolume[] = [$timestampMs, $volume]; 70 | } 71 | 72 | return [ 73 | 'price' => $dataPrice, 74 | 'volume' => $dataVolume, 75 | ]; 76 | } 77 | 78 | public function getNextEarningsDate(string $symbol): ?array 79 | { 80 | $earnings = $this->apiClient->getStockSummary($symbol, ['earnings']); 81 | 82 | return $earnings[0]['earnings']['earningsChart']['earningsDate'] ?? null; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Entity/Exchange.php: -------------------------------------------------------------------------------- 1 | 'Frankfurt', 50 | self::GER => 'Xetra', 51 | self::BER => 'Berlin', 52 | self::DUS => 'Düsseldorf', 53 | self::HAM => 'Hamburg', 54 | self::MUN => 'München', 55 | self::STU => 'Stuttgart', 56 | 57 | self::NMS => 'Nasdaq', 58 | self::NYQ => 'NYSE', 59 | self::PKN => 'OTC Markets OTCPK', 60 | self::NCM => 'NasdaqCM', 61 | self::NGM => 'NasdaqGM', 62 | 63 | self::LSE => 'London', 64 | self::VIE => 'Wien', 65 | self::BRU => 'Brüssel', 66 | self::PRA => 'Prag', 67 | self::CPH => 'Copenhagen', 68 | self::HEL => 'Helsinki', 69 | self::PAR => 'Paris', 70 | self::ATH => 'Athen', 71 | self::BUD => 'Budapest', 72 | self::AMS => 'Amsterdam', 73 | self::OSL => 'Oslo', 74 | self::WSE => 'Warschau', 75 | self::LIS => 'Lisabon', 76 | self::STO => 'Stockholm', 77 | 78 | self::HKG => 'Hong Kong', 79 | }; 80 | } 81 | 82 | public function flag(): string 83 | { 84 | return match($this) { 85 | self::FRA => '🇩🇪', 86 | self::GER => '🇩🇪', 87 | self::BER => '🇩🇪', 88 | self::DUS => '🇩🇪', 89 | self::HAM => '🇩🇪', 90 | self::MUN => '🇩🇪', 91 | self::STU => '🇩🇪', 92 | 93 | self::NMS => '🇺🇸', 94 | self::NYQ => '🇺🇸', 95 | self::PKN => '🇺🇸', 96 | self::NCM => '🇺🇸', 97 | self::NGM => '🇺🇸', 98 | 99 | self::LSE => '🇬🇧', 100 | self::VIE => '🇦🇹', 101 | self::BRU => '🇧🇪', 102 | self::PRA => '🇨🇿', 103 | self::CPH => '🇩🇰', 104 | self::HEL => '🇫🇮', 105 | self::PAR => '🇫🇷', 106 | self::ATH => '🇬🇷', 107 | self::BUD => '🇭🇺', 108 | self::AMS => '🇳🇱', 109 | self::OSL => '🇳🇴', 110 | self::WSE => '🇵🇱', 111 | self::LIS => '🇵🇹', 112 | self::STO => '🇸🇪', 113 | 114 | self::HKG => '🇭🇰', 115 | }; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /config/packages/monolog.php: -------------------------------------------------------------------------------- 1 | extension('monolog', [ 9 | 'channels' => [ 10 | 'deprecation', 11 | ], 12 | ]); 13 | if ($containerConfigurator->env() === 'dev') { 14 | $containerConfigurator->extension('monolog', [ 15 | 'handlers' => [ 16 | 'main' => [ 17 | 'type' => 'stream', 18 | 'path' => '%kernel.logs_dir%/%kernel.environment%.log', 19 | 'level' => 'debug', 20 | 'channels' => [ 21 | '!event', 22 | ], 23 | ], 24 | 'console' => [ 25 | 'type' => 'console', 26 | 'process_psr_3_messages' => false, 27 | 'channels' => [ 28 | '!event', 29 | '!doctrine', 30 | '!console', 31 | ], 32 | ], 33 | ], 34 | ]); 35 | } 36 | if ($containerConfigurator->env() === 'test') { 37 | $containerConfigurator->extension('monolog', [ 38 | 'handlers' => [ 39 | 'main' => [ 40 | 'type' => 'fingers_crossed', 41 | 'action_level' => 'error', 42 | 'handler' => 'nested', 43 | 'excluded_http_codes' => [ 44 | 404, 45 | 405, 46 | ], 47 | 'channels' => [ 48 | '!event', 49 | ], 50 | ], 51 | 'nested' => [ 52 | 'type' => 'stream', 53 | 'path' => '%kernel.logs_dir%/%kernel.environment%.log', 54 | 'level' => 'debug', 55 | ], 56 | ], 57 | ]); 58 | } 59 | if ($containerConfigurator->env() === 'prod') { 60 | $containerConfigurator->extension('monolog', [ 61 | 'handlers' => [ 62 | 'main' => [ 63 | 'type' => 'fingers_crossed', 64 | 'action_level' => 'error', 65 | 'handler' => 'nested', 66 | 'excluded_http_codes' => [ 67 | 404, 68 | 405, 69 | ], 70 | 'buffer_size' => 50, 71 | ], 72 | 'nested' => [ 73 | 'type' => 'stream', 74 | 'path' => 'php://stderr', 75 | 'level' => 'debug', 76 | 'formatter' => 'monolog.formatter.json', 77 | ], 78 | 'console' => [ 79 | 'type' => 'console', 80 | 'process_psr_3_messages' => false, 81 | 'channels' => [ 82 | '!event', 83 | '!doctrine', 84 | ], 85 | ], 86 | 'deprecation' => [ 87 | 'type' => 'stream', 88 | 'channels' => [ 89 | 'deprecation', 90 | ], 91 | 'path' => 'php://stderr', 92 | ], 93 | ], 94 | ]); 95 | } 96 | }; 97 | -------------------------------------------------------------------------------- /templates/Panel/tableRows.html.twig: -------------------------------------------------------------------------------- 1 | {% for stock in stocks %} 2 | 3 | 4 |
5 | {{ stock.name }} 6 | {% if stock.daysUntilEarnings > -1 and stock.daysUntilEarnings < 3 %}💰{% endif %} 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | {% if stock.quantity %}{{ stock.quantity|sonata_number_format_decimal }}{% else %}–{% endif %} 16 | 17 | 18 | {% if stock.initialPrice is not null %} 19 |
{{ stock.initialPrice|sonata_number_format_currency(stock.currency) }}
20 | {% if stock.investment %}
{{ stock.investment|sonata_number_format_currency(stock.currency) }}
{% endif %} 21 | {% else %} 22 | – 23 | {% endif %} 24 | 25 | 26 | 27 | {% if stock.currentPrice is not null %} 28 |
{{ stock.currentPrice|sonata_number_format_currency(stock.currency) }}
29 | {% if stock.currentValue %}
{{ stock.currentValue|sonata_number_format_currency(stock.currency) }}
{% endif %} 30 | {% else %} 31 | – 32 | {% endif %} 33 | 34 | 35 | {% if stock.profit is not null %} 36 | 37 |
{{ stock.profitPercent|sonata_number_format_percent({'fraction_digits': 2}, {'positive_prefix': '+'}) }}
38 |
{{ stock.profit|sonata_number_format_currency(stock.currency, {}, {'positive_prefix': '+'}) }}
39 | 40 | {% else %} 41 | – 42 | {% endif %} 43 | 44 | {% if stock.changeSinceLastDayPercent is not null %} 45 | 46 |
47 | {{ stock.changeSinceLastDayPercent|sonata_number_format_percent({'fraction_digits': 2}, {'positive_prefix': '+'}) }} 48 |
49 | {% if stock.profitSinceLastDay is not null %}
{{ stock.profitSinceLastDay|sonata_number_format_currency(stock.currency, {}, {'positive_prefix': '+'}) }}
{% endif %} 50 | 51 | {% else %} 52 | – 53 | {% endif %} 54 | 55 | {% endfor %} 56 | -------------------------------------------------------------------------------- /assets/request.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | export interface RequestOptions { 7 | ignoreCache?: boolean 8 | headers?: { [key: string]: string } 9 | // 0 (or negative) to wait forever 10 | timeout?: number 11 | } 12 | 13 | export const DEFAULT_REQUEST_OPTIONS = { 14 | ignoreCache: false, 15 | headers: { 16 | Accept: 'application/json, text/javascript, text/plain', 17 | }, 18 | // default max duration for a request 19 | timeout: 30000, 20 | } 21 | 22 | export interface RequestResult { 23 | ok: boolean 24 | status: number 25 | statusText: string 26 | data: string 27 | json: () => T 28 | headers: string 29 | } 30 | 31 | export function queryParams(params: any = {}) { 32 | return Object.keys(params) 33 | .map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k])) 34 | .join('&') 35 | } 36 | 37 | function withQuery(url: string, params: any = {}) { 38 | const queryString = queryParams(params) 39 | return queryString ? url + (url.indexOf('?') === -1 ? '?' : '&') + queryString : url 40 | } 41 | 42 | function parseXHRResult(xhr: XMLHttpRequest): RequestResult { 43 | return { 44 | ok: xhr.status >= 200 && xhr.status < 300, 45 | status: xhr.status, 46 | statusText: xhr.statusText, 47 | headers: xhr.getAllResponseHeaders(), 48 | data: xhr.responseText, 49 | json: () => JSON.parse(xhr.responseText) as T, 50 | } 51 | } 52 | 53 | function errorResponse(xhr: XMLHttpRequest, message: string | null = null): RequestResult { 54 | return { 55 | ok: false, 56 | status: xhr.status, 57 | statusText: xhr.statusText, 58 | headers: xhr.getAllResponseHeaders(), 59 | data: message || xhr.statusText, 60 | json: () => JSON.parse(message || xhr.statusText) as T, 61 | } 62 | } 63 | 64 | export function request( 65 | method: 'get' | 'post', 66 | url: string, 67 | queryParams: any = {}, 68 | body: any = null, 69 | onResponse: CallableFunction = function() {}, 70 | options: RequestOptions = DEFAULT_REQUEST_OPTIONS 71 | ): XMLHttpRequest { 72 | const ignoreCache = options.ignoreCache || DEFAULT_REQUEST_OPTIONS.ignoreCache 73 | const headers = options.headers || DEFAULT_REQUEST_OPTIONS.headers 74 | const timeout = options.timeout || DEFAULT_REQUEST_OPTIONS.timeout 75 | 76 | const xhr = new XMLHttpRequest() 77 | xhr.open(method, withQuery(url, queryParams)) 78 | 79 | if (headers) { 80 | Object.keys(headers).forEach(key => xhr.setRequestHeader(key, headers[key as keyof typeof headers])) 81 | } 82 | 83 | if (ignoreCache) { 84 | xhr.setRequestHeader('Cache-Control', 'no-cache') 85 | } 86 | 87 | xhr.timeout = timeout 88 | 89 | xhr.onload = evt => { 90 | onResponse(parseXHRResult(xhr)) 91 | } 92 | 93 | xhr.onerror = evt => { 94 | onResponse(errorResponse(xhr, 'Failed to make request.')) 95 | } 96 | 97 | xhr.ontimeout = evt => { 98 | onResponse(errorResponse(xhr, 'Request took longer than expected.')) 99 | } 100 | 101 | if (method === 'post' && body) { 102 | xhr.setRequestHeader('Content-Type', 'application/json') 103 | xhr.send(JSON.stringify(body)) 104 | } else { 105 | xhr.send() 106 | } 107 | 108 | return xhr 109 | } 110 | 111 | export function getRequest( 112 | url: string, 113 | queryParams: any = {}, 114 | onResponse: CallableFunction = function() {}, 115 | options: RequestOptions = DEFAULT_REQUEST_OPTIONS 116 | ): XMLHttpRequest { 117 | return request('get', url, queryParams, null, onResponse, options) 118 | } 119 | -------------------------------------------------------------------------------- /src/Controller/AdminController.php: -------------------------------------------------------------------------------- 1 | createForm(StockType::class, $stock); 32 | if ($request->getMethod() === "POST") { 33 | $form->handleRequest($request); 34 | if ($form->isValid()) { 35 | $this->stockPriceProvider->initStock($stock); 36 | $this->em->persist($stock); 37 | $this->em->flush(); 38 | return $this->redirect($this->generateUrl("stock_table")); 39 | } 40 | } 41 | 42 | return $this->render("Admin/add.html.twig", [ 43 | 'form' => $form->createView(), 44 | 'categories' => $this->stockRepository->getAllCategories(), 45 | ]); 46 | } 47 | 48 | /** 49 | * Edit a stock 50 | */ 51 | #[Route(path: '/edit/{id}', name: 'stock_edit')] 52 | public function editAction(Request $request, int $id): Response 53 | { 54 | $stock = $this->getStock($id); 55 | if (!$stock) { 56 | throw $this->createNotFoundException("Stock id " . $id . " not found!"); 57 | } 58 | 59 | $form = $this->createForm(StockType::class, $stock); 60 | if ($request->getMethod() === "POST") { 61 | $form->handleRequest($request); 62 | if ($form->isValid()) { 63 | $this->em->persist($stock); 64 | $this->em->flush(); 65 | return $this->redirect($this->generateUrl("stock_table")); 66 | } 67 | } 68 | 69 | return $this->render("Admin/edit.html.twig", [ 70 | 'form' => $form->createView(), 71 | 'categories' => $this->stockRepository->getAllCategories(), 72 | 'id' => $id, 73 | ]); 74 | } 75 | 76 | /** 77 | * Remove a stock 78 | */ 79 | #[Route(path: '/delete/{id}', name: 'stock_delete', requirements: ['id' => '[0-9]+'])] 80 | public function deleteAction(int $id): Response 81 | { 82 | $stock = $this->getStock($id); 83 | if ($stock) { 84 | $this->em->remove($stock); 85 | $this->em->flush(); 86 | } 87 | return $this->redirect($this->generateUrl("stock_table")); 88 | } 89 | 90 | private function getStock(int $id): ?Stock 91 | { 92 | return $this->stockRepository->findOneById($id); 93 | } 94 | 95 | 96 | /** 97 | * Edit a stock 98 | */ 99 | #[Route(path: '/favourite/{id}', name: 'stock_favourite')] 100 | public function favouriteAction(Request $request, int $id): Response 101 | { 102 | $stock = $this->getStock($id); 103 | if (!$stock) { 104 | throw $this->createNotFoundException("Stock id " . $id . " not found!"); 105 | } 106 | 107 | $stock->setFavourite(!$stock->isFavourite()); 108 | $this->em->persist($stock); 109 | $this->em->flush(); 110 | 111 | return $this->redirect($this->generateUrl('stock_table')); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Notifications/EarningsAlertNotifier.php: -------------------------------------------------------------------------------- 1 | stockEarningsProvider->getStocksWithEarnings(); 50 | foreach ($stocks as $stock) { 51 | if ($earningsDateLimit < $stock->getNextEarningsTime()) { 52 | break; // Since it's sorted, no need to check more stocks 53 | } 54 | 55 | // Minimum time between alerts 56 | if ($stock->getEarningsNotificationLastTime() > $alertDateLimit) { 57 | continue; 58 | } 59 | 60 | $this->notify($stock); 61 | $stock->setEarningsNotificationLastTime(new \DateTime()); 62 | $this->em->persist($stock); 63 | } 64 | 65 | $this->em->flush(); 66 | } 67 | 68 | private function notify(Stock $stock): void 69 | { 70 | $text = $this->twig->render('Notifications/earningsAlert.txt.twig', ['stock' => $stock]); 71 | $subject = substr($text, 0, strpos($text, "\n")); 72 | 73 | $event = new Event(); 74 | $eventTime = new DateTime($stock->getNextEarningsTime(), true); 75 | $event 76 | ->setSummary($stock->getName() . ' Earnings') 77 | ->setDescription($text) 78 | ->setOccurrence(new TimeSpan($eventTime, $eventTime)) 79 | ->addAttendee(new Attendee(new EmailAddress($this->recipient))) 80 | ->addAlarm(new Alarm( 81 | new DisplayAction('Reminder: '.$stock->getName().' Earnings in 12 hours!'), 82 | new RelativeTrigger(DateInterval::createFromDateString('-12 hours'))->withRelationToEnd() 83 | )) 84 | ->addAlarm(new Alarm( 85 | new DisplayAction('Reminder: '.$stock->getName().' Earnings in 15 minutes!'), 86 | new RelativeTrigger(DateInterval::createFromDateString('-15 minutes'))->withRelationToEnd() 87 | )) 88 | ; 89 | 90 | $calendar = new Calendar([$event]); 91 | $ics = new CalendarFactory()->createCalendar($calendar); 92 | 93 | $email = new Email() 94 | ->from($this->sender) 95 | ->to($this->recipient) 96 | ->subject($subject) 97 | ->text($text); 98 | 99 | $attachment = new DataPart((string) $ics, 'invite.ics', 'text/calendar', 'quoted-printable'); 100 | $attachment->asInline(); 101 | $attachment->getHeaders()->addParameterizedHeader('Content-Type', 'text/calendar', ['charset' => 'utf-8', 'method' => 'REQUEST']); 102 | $email->addPart($attachment); 103 | 104 | $this->mailer->send($email); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /assets/charts.ts: -------------------------------------------------------------------------------- 1 | import {getRequest, RequestResult} from "./request"; 2 | import * as Highcharts from "highcharts/highstock"; 3 | 4 | interface ChartData { 5 | volume: any, 6 | price: any, 7 | } 8 | 9 | export class StockChart { 10 | private chartContainer: HTMLElement; 11 | private stockId: number; 12 | private highStockChart: any = null; 13 | 14 | constructor(chartContainer: HTMLElement) { 15 | this.chartContainer = chartContainer; 16 | this.stockId = chartContainer.dataset['stockId'] as unknown as number; 17 | getRequest(this.getChartDataUrl(this.stockId, "1d"), {}, this.onDataLoaded.bind(this)); 18 | } 19 | 20 | public changePeriod(period: string) { 21 | if (null === this.highStockChart) { 22 | return; 23 | } 24 | 25 | this.highStockChart.showLoading('Loading data from server...'); 26 | getRequest(this.getChartDataUrl(this.stockId, period), {}, this.onDataRefreshLoaded.bind(this)); 27 | } 28 | 29 | private onDataLoaded(response: RequestResult) { 30 | if (response.status === 200) { 31 | const data = response.json(); 32 | this.initHighStock(this.stockId, data); 33 | } else { 34 | this.initHighStock(this.stockId, null); 35 | } 36 | } 37 | 38 | private onDataRefreshLoaded(response: RequestResult) { 39 | if (response.status === 200) { 40 | const data = response.json(); 41 | this.highStockChart.series[0].setData(data.price); 42 | this.highStockChart.series[1].setData(data.volume); 43 | this.highStockChart.hideLoading(); 44 | } else { 45 | this.highStockChart.series[0].setData([]); 46 | this.highStockChart.series[1].setData([]); 47 | this.highStockChart.showLoading('No data available'); 48 | } 49 | } 50 | 51 | private getChartDataUrl(stockId: number, period: string): string { 52 | return '/charts/' + stockId + '/' + period + '.json'; 53 | } 54 | 55 | private initHighStock(stockId: number, data: null|ChartData) { 56 | this.highStockChart = (Highcharts as any).stockChart("chart-" + stockId, { 57 | chart: { 58 | zooming: false 59 | }, 60 | navigator: { 61 | enabled: false 62 | }, 63 | scrollbar: { 64 | enabled: false 65 | }, 66 | rangeSelector: { 67 | enabled: false 68 | }, 69 | credits: { 70 | enabled: false 71 | }, 72 | xAxis: { 73 | crosshair: true, 74 | type: "datetime", 75 | minRange: 3600 * 1000 // one hour 76 | }, 77 | yAxis: [{ 78 | crosshair: true, 79 | labels: { 80 | align: 'right', 81 | x: -3 82 | }, 83 | title: { 84 | text: false 85 | }, 86 | height: '100%', 87 | lineWidth: 2, 88 | resize: { 89 | enabled: true 90 | } 91 | }, { 92 | labels: { 93 | align: 'right', 94 | x: -3 95 | }, 96 | title: { 97 | text: false 98 | }, 99 | top: '70%', 100 | height: '30%', 101 | offset: 0, 102 | lineWidth: 2 103 | }], 104 | tooltip: { 105 | split: true 106 | }, 107 | plotOptions: { 108 | series: { 109 | connectNulls: true 110 | } 111 | }, 112 | series: [{ 113 | type: 'area', 114 | threshold: null, 115 | name: 'Price', 116 | data: data.price, 117 | dataGrouping: { 118 | enabled: false, 119 | }, 120 | tooltip: { 121 | valueDecimals: 2 122 | } 123 | }, { 124 | type: 'column', 125 | name: 'Volume', 126 | data: data.volume, 127 | yAxis: 1, 128 | groupPadding: 0, 129 | minPointWidth: 3, 130 | color: "rgba(0,0,0,0.5)", 131 | dataGrouping: { 132 | enabled: false 133 | } 134 | }] 135 | }); 136 | if (null === data) { 137 | this.highStockChart.showLoading('No data available'); 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Controller/PanelController.php: -------------------------------------------------------------------------------- 1 | stockPriceProvider->hasToUpdate()) { 34 | $this->updateStockPrices(); 35 | } 36 | 37 | return $this->render("Panel/table.html.twig", $this->getStockTableVariables()); 38 | } 39 | 40 | private function getStockTableVariables(): array 41 | { 42 | $stockCategories = $this->stockPriceProvider->getCategorizedStocks(); 43 | $stocks = array_merge(... array_values($stockCategories)); 44 | $updateDates = array_map(fn (Stock $stock) => $stock->getUpdatedAt(), $stocks); 45 | $lastUpdateDate = max($updateDates); 46 | 47 | return [ 48 | 'categories' => $stockCategories, 49 | 'performance' => new PortfolioPerformance($this->stockPriceProvider, $stocks, 'EUR'), 50 | 'lastUpdateDate' => $lastUpdateDate, 51 | ]; 52 | } 53 | 54 | #[Route(path: '/change-history/{id}.svg', name: 'stock_change_history')] 55 | #[Cache(maxage: 3600, public: true, mustRevalidate: true)] 56 | public function getChangeHistorySVG(int $id): Response 57 | { 58 | $stock = $this->getStock($id); 59 | if (!$stock) { 60 | throw $this->createNotFoundException('Stock not found'); 61 | } 62 | 63 | $changeHistory = $this->stockHistoryRepo->getChangeHistory($stock); 64 | $response = $this->render("Panel/changeHistory.svg.twig", [ 65 | 'changeHistory' => $changeHistory, 66 | ]); 67 | $response->headers->set('Content-Type', 'image/svg+xml'); 68 | 69 | return $response; 70 | } 71 | 72 | /** 73 | * Show the stock panel 74 | */ 75 | #[Route(path: '/charts', name: 'stock_charts')] 76 | public function chartsAction(): Response 77 | { 78 | if ($this->stockPriceProvider->hasToUpdate()) { 79 | $this->updateStockPrices(); 80 | } 81 | 82 | $stocks = $this->stockPriceProvider->getStocks(); 83 | return $this->render("Panel/charts.html.twig", [ 84 | 'stocks' => $stocks, 85 | ]); 86 | } 87 | 88 | /** 89 | * Get JSON data for the chart 90 | */ 91 | #[Route(path: '/charts/{id}/{range}.json', name: 'stock_charts_data')] 92 | public function getChartData(int $id, string $range): Response 93 | { 94 | $stock = $this->getStock($id); 95 | if (!$stock) { 96 | throw $this->createNotFoundException('Stock not found'); 97 | } 98 | 99 | $symbol = $stock->getCurrentPriceSymbol(); 100 | if (!$symbol) { 101 | // Fallback when there is no current price set 102 | $symbol = $stock->getSymbols()[0] ?? null; 103 | } 104 | 105 | $chartsData = $this->financeApi->getChartsData($symbol, $range); 106 | 107 | return new JsonResponse($chartsData); 108 | } 109 | 110 | /** 111 | * Force stock update 112 | */ 113 | #[Route(path: '/update', name: 'stock_update')] 114 | public function updateAction(): Response 115 | { 116 | try { 117 | $this->stockPriceProvider->updateStocks(); 118 | } catch (\Exception $e) { 119 | return new Response($e->getMessage(), 500); 120 | } 121 | 122 | return $this->render("Panel/tableContent.html.twig", $this->getStockTableVariables()); 123 | } 124 | 125 | private function getStock(int $id): ?Stock 126 | { 127 | return $this->stockRepo->findOneById($id); 128 | } 129 | 130 | private function updateStockPrices(): void 131 | { 132 | try { 133 | $this->stockPriceProvider->updateStocks(); 134 | } catch (\Exception $e) { 135 | $this->addFlash('error', 'Could not update stock prices: '.$e->getMessage()); 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /assets/app.ts: -------------------------------------------------------------------------------- 1 | import * as Highcharts from 'highcharts/highstock'; 2 | import {Tab} from "bootstrap" 3 | import './styles/app.scss'; 4 | import {deleteCookie, setCookie} from "./utils"; 5 | import {StockChart} from "./charts"; 6 | import {getRequest, RequestResult} from "./request"; 7 | 8 | window.document.addEventListener("DOMContentLoaded", function() { 9 | let charts: Array = new Array(); 10 | 11 | // Refresh button 12 | var stockTable = window.document.getElementById('stockTable'); 13 | if (stockTable !== null) { 14 | new RefreshButton( 15 | window.document.getElementById('refreshButton'), 16 | window.document.getElementById('stockTable') 17 | ); 18 | } 19 | 20 | // Privacy button 21 | new PrivacyButton(window.document.getElementById('privacyButton')); 22 | 23 | // Delete buttons 24 | initDeleteButtons(); 25 | 26 | // Init tabs 27 | window.document.querySelectorAll('[data-bs-toggle="tab"]') 28 | .forEach((tabNode: Element) => { 29 | if (tabNode instanceof HTMLElement) { 30 | new Tab(tabNode); 31 | tabNode.addEventListener('shown.bs.tab', function (e) { 32 | const period = tabNode.dataset['period']; 33 | charts.forEach((chart: StockChart) => chart.changePeriod(period)); 34 | }); 35 | } 36 | }); 37 | 38 | // Init charts 39 | window.document.querySelectorAll('[data-stock-id]') 40 | .forEach((chartContainer: Element) => { 41 | if (chartContainer instanceof HTMLElement) { 42 | charts.push(new StockChart(chartContainer)); 43 | } 44 | }); 45 | 46 | // Global highcharts config 47 | (Highcharts as any).setOptions({ 48 | lang: { 49 | decimalPoint: ',', 50 | thousandsSep: '.' 51 | } 52 | }); 53 | }); 54 | 55 | function initDeleteButtons() { 56 | window.document.querySelectorAll('[data-delete-stock]') 57 | .forEach((deleteButton: Element) => { 58 | if (deleteButton instanceof HTMLButtonElement) { 59 | deleteButton.addEventListener('click', () => { 60 | const name = deleteButton.dataset['deleteStockName'] 61 | if (window.confirm('Position "'+name+'" entfernen?')) { 62 | window.location.href = '/delete/'+ deleteButton.dataset['deleteStock']; 63 | } 64 | }) 65 | } 66 | }); 67 | } 68 | 69 | declare global { 70 | interface Window { 71 | setFormStockCategory(button: HTMLButtonElement): void; 72 | } 73 | } 74 | 75 | window.setFormStockCategory = function(button: HTMLButtonElement) { 76 | const formField = document.getElementById('stock_category') 77 | if (formField instanceof HTMLInputElement) { 78 | formField.value = button.textContent; 79 | } 80 | } 81 | 82 | class RefreshButton { 83 | private readonly REFRESH_TIMEOUT = 5 * 60 * 1000; //Update every 5 minutes 84 | private button: HTMLButtonElement; 85 | private stockTable: HTMLElement; 86 | private updateTimeout: number = null; 87 | 88 | constructor(button: HTMLElement, stockTable: HTMLElement) { 89 | if (!(button instanceof HTMLButtonElement)) { 90 | return; 91 | } 92 | if (button.disabled) { 93 | return; 94 | } 95 | 96 | this.button = button; 97 | this.stockTable = stockTable; 98 | this.button.addEventListener('click', this.onClick.bind(this)) 99 | this.startAutoRefresh(); 100 | } 101 | 102 | private onClick(evt: MouseEvent) { 103 | this.haltAutoRefresh(); 104 | this.updateView(); 105 | } 106 | 107 | private updateView() { 108 | this.setLoading(true); 109 | getRequest('update', {}, 110 | this.onViewUpdate.bind(this) 111 | ); 112 | } 113 | 114 | private onViewUpdate(response: RequestResult) { 115 | if (response.status === 200) { 116 | this.stockTable.innerHTML = response.data; 117 | initDeleteButtons(); 118 | this.startAutoRefresh(); 119 | } else { 120 | alert('Could not update stock prices: ' + response.data); 121 | } 122 | 123 | this.setLoading(false); 124 | } 125 | 126 | private setLoading(isLoading: boolean) { 127 | this.button.disabled = isLoading; 128 | if (isLoading) { 129 | this.button.classList.add('loading'); 130 | this.stockTable.classList.add('loading'); 131 | } else { 132 | this.button.classList.remove('loading'); 133 | this.stockTable.classList.remove('loading'); 134 | } 135 | } 136 | 137 | private startAutoRefresh() { 138 | this.updateTimeout = window.setTimeout(this.updateView.bind(this), this.REFRESH_TIMEOUT); 139 | } 140 | 141 | private haltAutoRefresh() { 142 | if (null !== this.updateTimeout) { 143 | clearTimeout(this.updateTimeout); 144 | this.updateTimeout = null; 145 | } 146 | } 147 | } 148 | 149 | class PrivacyButton { 150 | private readonly PRIVACY_CSS_CLASS = 'privacyIsActive'; 151 | private readonly COOKIE_NAME = 'privacyIsActive'; 152 | private button: HTMLButtonElement; 153 | private bodyClasses: DOMTokenList; 154 | 155 | constructor(button: Element) { 156 | if (!(button instanceof HTMLButtonElement)) { 157 | return; 158 | } 159 | 160 | this.button = button; 161 | this.bodyClasses = window.document.body.classList; 162 | button.addEventListener('click', this.onClick.bind(this)); 163 | } 164 | 165 | private onClick(evt: MouseEvent) { 166 | this.setPrivacyActive(!this.isPrivacyActive()); 167 | } 168 | 169 | private isPrivacyActive(): boolean { 170 | return this.bodyClasses.contains(this.PRIVACY_CSS_CLASS); 171 | } 172 | 173 | private setPrivacyActive(isActive: boolean) { 174 | if (isActive) { 175 | this.bodyClasses.add(this.PRIVACY_CSS_CLASS); 176 | setCookie(this.COOKIE_NAME, '1'); 177 | } else { 178 | this.bodyClasses.remove(this.PRIVACY_CSS_CLASS); 179 | deleteCookie(this.COOKIE_NAME); 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/Provider/StockPriceProvider.php: -------------------------------------------------------------------------------- 1 | stockRepo = $em->getRepository(Stock::class); 39 | } 40 | 41 | /** 42 | * @return Stock[] 43 | */ 44 | public function getStocks(): array 45 | { 46 | return $this->stockRepo->getAll(); 47 | } 48 | 49 | /** 50 | * @return Stock[][] 51 | */ 52 | public function getCategorizedStocks(): array 53 | { 54 | $categories = []; 55 | $favourites = []; 56 | $watchlist = []; 57 | $uncategorized = []; 58 | $etfs = []; 59 | $stocks = $this->getStocks(); 60 | foreach ($stocks as $stock) { 61 | if ($stock->isFavourite()) { 62 | $favourites[] = $stock; 63 | continue; 64 | } 65 | 66 | if (!$stock->getQuantity()) { 67 | $watchlist[] = $stock; 68 | continue; 69 | } 70 | 71 | $category = $stock->getCategory(); 72 | if (!$category) { 73 | $uncategorized[] = $stock; 74 | continue; 75 | } 76 | 77 | if (self::ETFS_CATEGORY === $category) { 78 | $etfs[] = $stock; 79 | continue; 80 | } 81 | 82 | if (!isset($categories[$category])) { 83 | $categories[$category] = []; 84 | } 85 | 86 | $categories[$category][] = $stock; 87 | } 88 | 89 | $all = []; 90 | if ($favourites) { 91 | $all[self::FAVOURITES_CATEGORY] = $favourites; 92 | } 93 | ksort($categories); 94 | $all = array_merge($all, $categories); 95 | if ($uncategorized) { 96 | $all[self::DEFAULT_CATEGORY] = $uncategorized; 97 | } 98 | if ($etfs) { 99 | $all[self::ETFS_CATEGORY] = $etfs; 100 | } 101 | if ($watchlist) { 102 | $all[self::WATCHLIST_CATEGORY] = $watchlist; 103 | } 104 | 105 | return $all; 106 | } 107 | 108 | public function initStock(Stock $stock): Stock 109 | { 110 | $symbols = $stock->getSymbols(); 111 | $quotes = $this->fetchQuotes($symbols); 112 | $mostRecentPrice = $this->getMostRecentPrice($symbols, $quotes); 113 | if ($mostRecentPrice) { 114 | $this->updateStockPrice($stock, $mostRecentPrice); 115 | } 116 | 117 | return $stock; 118 | } 119 | 120 | public function updateStocks(): void 121 | { 122 | $stocks = $this->getStocks(); 123 | $symbols = array_merge(...array_map(fn (Stock $stock): array => $stock->getSymbols(), $stocks)); 124 | $quotes = $this->fetchQuotes($symbols); 125 | foreach ($stocks as $stock) { 126 | $mostRecentPrice = $this->getMostRecentPrice($stock->getSymbols(), $quotes); 127 | if (!$mostRecentPrice) { 128 | continue; 129 | } 130 | 131 | // Remember previous day's price 132 | if ( 133 | null === $stock->getCurrentPriceTime() 134 | || $stock->getCurrentPriceTime()->format('Y-m-d') < date('Y-m-d') 135 | || $stock->getCurrentPriceTime()->format('Y-m-d') < $mostRecentPrice->time->format('Y-m-d') 136 | ) { 137 | $stock->setLastDayPrice($stock->getCurrentPrice()); 138 | $stock->setLastDayPriceTime($stock->getCurrentPriceTime()); 139 | } 140 | 141 | // Update current price 142 | $this->updateStockPrice($stock, $mostRecentPrice); 143 | $this->em->persist($stock); 144 | } 145 | $this->em->flush(); 146 | 147 | $this->eventDispatcher->dispatch(new StockUpdateEvent()); 148 | } 149 | 150 | private function getMostRecentPrice(array $symbols, array $quotes): ?RecentPrice 151 | { 152 | $mostRecentPriceTime = null; 153 | $mostRecentPrice = null; 154 | foreach ($symbols as $symbol) { 155 | if (!isset($quotes[$symbol])) { 156 | continue; // Ignore unknown symbols 157 | } 158 | 159 | $quote = $quotes[$symbol]; 160 | $recentPrice = RecentPrice::fromQuote($quote); 161 | if (!$mostRecentPriceTime || $recentPrice->time > $mostRecentPriceTime) { 162 | $mostRecentPrice = $recentPrice; 163 | $mostRecentPriceTime = $recentPrice->time; 164 | } 165 | } 166 | 167 | return $mostRecentPrice; 168 | } 169 | 170 | public function hasToUpdate(): bool 171 | { 172 | $timeout = new \DateTime("-" . self::UPDATE_PERIOD_MINUTES . " minutes"); 173 | return $this->stockRepo->getLastUpdate() < $timeout; 174 | } 175 | 176 | /** 177 | * @return Quote[] Indexed by symbol 178 | */ 179 | private function fetchQuotes(array $symbols, int $try = 0): ?array 180 | { 181 | if (!$symbols) { 182 | return []; 183 | } 184 | 185 | try { 186 | $quotesIndex = []; 187 | $quotes = $this->api->getQuotes($symbols); 188 | foreach ($quotes as $quote) { 189 | $quotesIndex[$quote->getSymbol()] = $quote; 190 | } 191 | return $quotesIndex; 192 | } catch (ApiException) { 193 | // Retry if the query fails 194 | if ($try < self::FETCH_QUOTES_MAX_TRIES) { 195 | return $this->fetchQuotes($symbols, $try + 1); 196 | } 197 | } 198 | 199 | return []; 200 | } 201 | 202 | private function updateStockPrice(Stock $stock, RecentPrice $mostRecentPrice): void 203 | { 204 | try { 205 | $exchange = Exchange::from($mostRecentPrice->exchange); 206 | } catch (\ValueError) { 207 | $exchange = null; 208 | } 209 | 210 | // Currency conversion 211 | $stockCurrency = $stock->getCurrency(); 212 | if ($stockCurrency !== $mostRecentPrice->currency) { 213 | try { 214 | $mostRecentPrice->price = $this->convertPrice($mostRecentPrice->price, $mostRecentPrice->currency, $stockCurrency); 215 | $mostRecentPrice->priceLow = $this->convertPrice($mostRecentPrice->priceLow, $mostRecentPrice->currency, $stockCurrency); 216 | $mostRecentPrice->priceHigh = $this->convertPrice($mostRecentPrice->priceHigh, $mostRecentPrice->currency, $stockCurrency); 217 | $mostRecentPrice->change = $this->convertPrice($mostRecentPrice->change, $mostRecentPrice->currency, $stockCurrency); 218 | } catch (\UnexpectedValueException) { 219 | return; // Cloud not determine exchange rate 220 | } 221 | } 222 | 223 | $stock 224 | ->setCurrentPrice($mostRecentPrice->price) 225 | ->setCurrentPriceTime($mostRecentPrice->time) 226 | ->setCurrentPriceMarket($mostRecentPrice->market) 227 | ->setCurrentPriceExchange($exchange) 228 | ->setCurrentPriceSymbol($mostRecentPrice->symbol) 229 | ->setCurrentChange($mostRecentPrice->change) 230 | ->setUpdatedAt(new \DateTime()); 231 | 232 | // Dynamic threshold 233 | if (null !== $stock->getAlertDynamicThresholdPercent() && null !== $stock->getAlertComparator()) { 234 | if (Stock::COMPARATOR_ABOVE === $stock->getAlertComparator()) { 235 | $newThreshold = $mostRecentPrice->priceLow * (1 + abs($stock->getAlertDynamicThresholdPercent()) / 100); 236 | $stock->setAlertThreshold(min(array_filter([$newThreshold, $stock->getAlertThreshold()]))); 237 | } elseif (Stock::COMPARATOR_BELOW === $stock->getAlertComparator()) { 238 | $newThreshold = $mostRecentPrice->priceHigh * (1 - abs($stock->getAlertDynamicThresholdPercent()) / 100); 239 | $stock->setAlertThreshold(max(array_filter([$newThreshold, $stock->getAlertThreshold()]))); 240 | } 241 | } 242 | } 243 | 244 | public function convertPrice(float $price, string $fromCurrency, string $toCurrency): ?float 245 | { 246 | return $price * $this->getExchangeRate($fromCurrency, $toCurrency); 247 | } 248 | 249 | private function getExchangeRate(string $fromCurrency, string $toCurrency): float 250 | { 251 | if (!isset($this->exchangeRates[$fromCurrency.':'.$toCurrency])) { 252 | $quote = $this->api->getExchangeRate($fromCurrency, $toCurrency); 253 | if (null === $quote) { 254 | throw new \UnexpectedValueException("Could not find exchange rate for $fromCurrency $toCurrency"); 255 | } 256 | 257 | $this->exchangeRates[$fromCurrency.':'.$toCurrency] = (RecentPrice::fromQuote($quote))->price; 258 | } 259 | 260 | return $this->exchangeRates[$fromCurrency.':'.$toCurrency]; 261 | } 262 | 263 | public function setLastDayPrices(): void 264 | { 265 | $startDate = new \DateTime('-7 days'); 266 | $endDate = new \DateTime('today'); 267 | 268 | $stocks = $this->stockRepo->findAll(); 269 | foreach ($stocks as $stock) { 270 | $symbols = $stock->getSymbols(); 271 | foreach ($symbols as $symbol) { 272 | $historicalData = $this->api->getHistoricalQuoteData($symbol, ApiClient::INTERVAL_1_DAY, $startDate, $endDate); 273 | if ($historicalData) { 274 | /** @var HistoricalData $lastDayValues */ 275 | $lastDayValues = array_pop($historicalData); 276 | $quote = $this->api->getQuote($symbol); 277 | 278 | $lastDayPrice = $lastDayValues->getAdjClose() ?? $lastDayValues->getClose(); 279 | $lastDayPriceTime = $lastDayValues->getDate(); 280 | 281 | // Currency conversion 282 | $stockCurrency = $stock->getCurrency(); 283 | if ($stockCurrency !== $quote->getCurrency()) { 284 | try { 285 | $lastDayPrice = $this->convertPrice($lastDayPrice, $quote->getCurrency(), $stockCurrency); 286 | } catch (\UnexpectedValueException) { 287 | return; // Cloud not determine exchange rate 288 | } 289 | } 290 | 291 | $stock->setLastDayPrice($lastDayPrice); 292 | $stock->setLastDayPriceTime($lastDayPriceTime); 293 | $this->em->persist($stock); 294 | break; 295 | } 296 | } 297 | } 298 | 299 | $this->em->flush(); 300 | } 301 | 302 | public function updateStocksHistory(): void 303 | { 304 | $startDate = new \DateTime('-14 days'); 305 | $endDate = new \DateTime('today'); 306 | 307 | $stocks = $this->stockRepo->findAll(); 308 | foreach ($stocks as $stock) { 309 | $symbols = $stock->getSymbols(); 310 | foreach ($symbols as $symbol) { 311 | $quote = $this->api->getQuote($symbol); 312 | if ($quote) { 313 | $historicalDataPoints = $this->api->getHistoricalQuoteData($symbol, ApiClient::INTERVAL_1_DAY, $startDate, $endDate); 314 | foreach ($historicalDataPoints as $historicalData) { 315 | $price = $historicalData->getAdjClose() ?? $historicalData->getClose(); 316 | $priceDate = $historicalData->getDate(); 317 | 318 | // Currency conversion 319 | $stockCurrency = $stock->getCurrency(); 320 | if ($stockCurrency !== $quote->getCurrency()) { 321 | try { 322 | $price = $this->convertPrice($price, $quote->getCurrency(), $stockCurrency); 323 | } catch (\UnexpectedValueException) { 324 | continue; // Cloud not determine exchange rate 325 | } 326 | } 327 | 328 | $stockHistory = new StockHistory(); 329 | $stockHistory 330 | ->setStock($stock) 331 | ->setSymbol($symbol) 332 | ->setDate($priceDate) 333 | ->setPrice($price); 334 | $this->stockHistoryRepository->insertOrUpdate($stockHistory); 335 | } 336 | } 337 | } 338 | } 339 | } 340 | } 341 | -------------------------------------------------------------------------------- /symfony.lock: -------------------------------------------------------------------------------- 1 | { 2 | "composer/package-versions-deprecated": { 3 | "version": "1.11.99.4" 4 | }, 5 | "doctrine/cache": { 6 | "version": "2.1.1" 7 | }, 8 | "doctrine/collections": { 9 | "version": "1.6.8" 10 | }, 11 | "doctrine/common": { 12 | "version": "3.2.0" 13 | }, 14 | "doctrine/dbal": { 15 | "version": "3.2.0" 16 | }, 17 | "doctrine/deprecations": { 18 | "version": "v0.5.3" 19 | }, 20 | "doctrine/doctrine-bundle": { 21 | "version": "2.5", 22 | "recipe": { 23 | "repo": "github.com/symfony/recipes", 24 | "branch": "master", 25 | "version": "2.4", 26 | "ref": "032f52ed50a27762b78ca6a2aaf432958c473553" 27 | }, 28 | "files": [ 29 | "config/packages/doctrine.yaml", 30 | "config/packages/prod/doctrine.yaml", 31 | "config/packages/test/doctrine.yaml", 32 | "src/Entity/.gitignore", 33 | "src/Repository/.gitignore" 34 | ] 35 | }, 36 | "doctrine/event-manager": { 37 | "version": "1.1.1" 38 | }, 39 | "doctrine/inflector": { 40 | "version": "2.0.4" 41 | }, 42 | "doctrine/instantiator": { 43 | "version": "1.4.0" 44 | }, 45 | "doctrine/lexer": { 46 | "version": "1.2.1" 47 | }, 48 | "doctrine/orm": { 49 | "version": "2.10.3" 50 | }, 51 | "doctrine/persistence": { 52 | "version": "2.2.3" 53 | }, 54 | "doctrine/sql-formatter": { 55 | "version": "1.1.2" 56 | }, 57 | "friendsofphp/proxy-manager-lts": { 58 | "version": "v1.0.5" 59 | }, 60 | "guzzlehttp/guzzle": { 61 | "version": "7.4.1" 62 | }, 63 | "guzzlehttp/promises": { 64 | "version": "1.5.1" 65 | }, 66 | "guzzlehttp/psr7": { 67 | "version": "2.1.0" 68 | }, 69 | "laminas/laminas-code": { 70 | "version": "4.5.0" 71 | }, 72 | "myclabs/deep-copy": { 73 | "version": "1.10.2" 74 | }, 75 | "nikic/php-parser": { 76 | "version": "v4.13.2" 77 | }, 78 | "phar-io/manifest": { 79 | "version": "2.0.3" 80 | }, 81 | "phar-io/version": { 82 | "version": "3.1.0" 83 | }, 84 | "phpdocumentor/reflection-common": { 85 | "version": "2.2.0" 86 | }, 87 | "phpdocumentor/reflection-docblock": { 88 | "version": "5.3.0" 89 | }, 90 | "phpdocumentor/type-resolver": { 91 | "version": "1.6.0" 92 | }, 93 | "phpspec/prophecy": { 94 | "version": "v1.15.0" 95 | }, 96 | "phpunit/php-code-coverage": { 97 | "version": "9.2.10" 98 | }, 99 | "phpunit/php-file-iterator": { 100 | "version": "3.0.6" 101 | }, 102 | "phpunit/php-invoker": { 103 | "version": "3.1.1" 104 | }, 105 | "phpunit/php-text-template": { 106 | "version": "2.0.4" 107 | }, 108 | "phpunit/php-timer": { 109 | "version": "5.0.3" 110 | }, 111 | "phpunit/phpunit": { 112 | "version": "9.5", 113 | "recipe": { 114 | "repo": "github.com/symfony/recipes", 115 | "branch": "master", 116 | "version": "9.3", 117 | "ref": "a6249a6c4392e9169b87abf93225f7f9f59025e6" 118 | }, 119 | "files": [ 120 | ".env.test", 121 | "phpunit.xml.dist", 122 | "tests/bootstrap.php" 123 | ] 124 | }, 125 | "psr/cache": { 126 | "version": "1.0.1" 127 | }, 128 | "psr/container": { 129 | "version": "1.0.0" 130 | }, 131 | "psr/event-dispatcher": { 132 | "version": "1.0.0" 133 | }, 134 | "psr/http-client": { 135 | "version": "1.0.1" 136 | }, 137 | "psr/http-factory": { 138 | "version": "1.0.1" 139 | }, 140 | "psr/http-message": { 141 | "version": "1.0.1" 142 | }, 143 | "psr/log": { 144 | "version": "1.1.3" 145 | }, 146 | "ralouphie/getallheaders": { 147 | "version": "3.0.3" 148 | }, 149 | "scheb/yahoo-finance-api": { 150 | "version": "v4.2.0" 151 | }, 152 | "sebastian/cli-parser": { 153 | "version": "1.0.1" 154 | }, 155 | "sebastian/code-unit": { 156 | "version": "1.0.8" 157 | }, 158 | "sebastian/code-unit-reverse-lookup": { 159 | "version": "2.0.3" 160 | }, 161 | "sebastian/comparator": { 162 | "version": "4.0.6" 163 | }, 164 | "sebastian/complexity": { 165 | "version": "2.0.2" 166 | }, 167 | "sebastian/diff": { 168 | "version": "4.0.4" 169 | }, 170 | "sebastian/environment": { 171 | "version": "5.1.3" 172 | }, 173 | "sebastian/exporter": { 174 | "version": "4.0.4" 175 | }, 176 | "sebastian/global-state": { 177 | "version": "5.0.3" 178 | }, 179 | "sebastian/lines-of-code": { 180 | "version": "1.0.3" 181 | }, 182 | "sebastian/object-enumerator": { 183 | "version": "4.0.4" 184 | }, 185 | "sebastian/object-reflector": { 186 | "version": "2.0.4" 187 | }, 188 | "sebastian/recursion-context": { 189 | "version": "4.0.4" 190 | }, 191 | "sebastian/type": { 192 | "version": "2.3.4" 193 | }, 194 | "sebastian/version": { 195 | "version": "3.0.2" 196 | }, 197 | "sonata-project/intl-bundle": { 198 | "version": "2.11.2" 199 | }, 200 | "symfony/asset": { 201 | "version": "v6.0.1" 202 | }, 203 | "symfony/browser-kit": { 204 | "version": "v6.0.1" 205 | }, 206 | "symfony/cache": { 207 | "version": "v5.2.1" 208 | }, 209 | "symfony/cache-contracts": { 210 | "version": "v2.2.0" 211 | }, 212 | "symfony/config": { 213 | "version": "v5.2.1" 214 | }, 215 | "symfony/console": { 216 | "version": "5.1", 217 | "recipe": { 218 | "repo": "github.com/symfony/recipes", 219 | "branch": "master", 220 | "version": "5.1", 221 | "ref": "c6d02bdfba9da13c22157520e32a602dbee8a75c" 222 | }, 223 | "files": [ 224 | "bin/console" 225 | ] 226 | }, 227 | "symfony/css-selector": { 228 | "version": "v6.0.2" 229 | }, 230 | "symfony/dependency-injection": { 231 | "version": "v5.2.1" 232 | }, 233 | "symfony/deprecation-contracts": { 234 | "version": "v2.2.0" 235 | }, 236 | "symfony/doctrine-bridge": { 237 | "version": "v6.0.1" 238 | }, 239 | "symfony/dom-crawler": { 240 | "version": "v6.0.2" 241 | }, 242 | "symfony/dotenv": { 243 | "version": "v5.2.1" 244 | }, 245 | "symfony/error-handler": { 246 | "version": "v5.2.1" 247 | }, 248 | "symfony/event-dispatcher": { 249 | "version": "v5.2.1" 250 | }, 251 | "symfony/event-dispatcher-contracts": { 252 | "version": "v2.2.0" 253 | }, 254 | "symfony/filesystem": { 255 | "version": "v5.2.1" 256 | }, 257 | "symfony/finder": { 258 | "version": "v5.2.1" 259 | }, 260 | "symfony/flex": { 261 | "version": "1.0", 262 | "recipe": { 263 | "repo": "github.com/symfony/recipes", 264 | "branch": "master", 265 | "version": "1.0", 266 | "ref": "c0eeb50665f0f77226616b6038a9b06c03752d8e" 267 | }, 268 | "files": [ 269 | ".env" 270 | ] 271 | }, 272 | "symfony/form": { 273 | "version": "v6.0.1" 274 | }, 275 | "symfony/framework-bundle": { 276 | "version": "5.2", 277 | "recipe": { 278 | "repo": "github.com/symfony/recipes", 279 | "branch": "master", 280 | "version": "5.2", 281 | "ref": "6ec87563dcc85cd0c48856dcfbfc29610506d250" 282 | }, 283 | "files": [ 284 | "config/packages/cache.yaml", 285 | "config/packages/framework.yaml", 286 | "config/packages/test/framework.yaml", 287 | "config/preload.php", 288 | "config/routes/dev/framework.yaml", 289 | "config/services.yaml", 290 | "public/index.php", 291 | "src/Controller/.gitignore", 292 | "src/Kernel.php" 293 | ] 294 | }, 295 | "symfony/http-client-contracts": { 296 | "version": "v2.3.1" 297 | }, 298 | "symfony/http-foundation": { 299 | "version": "v5.2.1" 300 | }, 301 | "symfony/http-kernel": { 302 | "version": "v5.2.1" 303 | }, 304 | "symfony/intl": { 305 | "version": "v6.0.1" 306 | }, 307 | "symfony/mailer": { 308 | "version": "7.2", 309 | "recipe": { 310 | "repo": "github.com/symfony/recipes", 311 | "branch": "main", 312 | "version": "4.3", 313 | "ref": "09051cfde49476e3c12cd3a0e44289ace1c75a4f" 314 | }, 315 | "files": [ 316 | "config/packages/mailer.yaml" 317 | ] 318 | }, 319 | "symfony/monolog-bundle": { 320 | "version": "3.8", 321 | "recipe": { 322 | "repo": "github.com/symfony/recipes", 323 | "branch": "main", 324 | "version": "3.7", 325 | "ref": "213676c4ec929f046dfde5ea8e97625b81bc0578" 326 | }, 327 | "files": [ 328 | "config/packages/monolog.yaml" 329 | ] 330 | }, 331 | "symfony/options-resolver": { 332 | "version": "v6.0.0" 333 | }, 334 | "symfony/phpunit-bridge": { 335 | "version": "6.0", 336 | "recipe": { 337 | "repo": "github.com/symfony/recipes", 338 | "branch": "master", 339 | "version": "5.3", 340 | "ref": "97cb3dc7b0f39c7cfc4b7553504c9d7b7795de96" 341 | }, 342 | "files": [ 343 | ".env.test", 344 | "bin/phpunit", 345 | "phpunit.xml.dist", 346 | "tests/bootstrap.php" 347 | ] 348 | }, 349 | "symfony/polyfill-intl-grapheme": { 350 | "version": "v1.20.0" 351 | }, 352 | "symfony/polyfill-intl-icu": { 353 | "version": "v1.23.0" 354 | }, 355 | "symfony/polyfill-intl-normalizer": { 356 | "version": "v1.20.0" 357 | }, 358 | "symfony/polyfill-mbstring": { 359 | "version": "v1.20.0" 360 | }, 361 | "symfony/polyfill-php73": { 362 | "version": "v1.20.0" 363 | }, 364 | "symfony/polyfill-php80": { 365 | "version": "v1.20.0" 366 | }, 367 | "symfony/polyfill-php81": { 368 | "version": "v1.23.0" 369 | }, 370 | "symfony/property-access": { 371 | "version": "v6.0.0" 372 | }, 373 | "symfony/property-info": { 374 | "version": "v6.0.0" 375 | }, 376 | "symfony/proxy-manager-bridge": { 377 | "version": "v6.0.1" 378 | }, 379 | "symfony/routing": { 380 | "version": "5.1", 381 | "recipe": { 382 | "repo": "github.com/symfony/recipes", 383 | "branch": "master", 384 | "version": "5.1", 385 | "ref": "b4f3e7c95e38b606eef467e8a42a8408fc460c43" 386 | }, 387 | "files": [ 388 | "config/packages/prod/routing.yaml", 389 | "config/packages/routing.yaml", 390 | "config/routes.yaml" 391 | ] 392 | }, 393 | "symfony/runtime": { 394 | "version": "v6.0.0" 395 | }, 396 | "symfony/service-contracts": { 397 | "version": "v2.2.0" 398 | }, 399 | "symfony/string": { 400 | "version": "v5.2.1" 401 | }, 402 | "symfony/templating": { 403 | "version": "v6.0.1" 404 | }, 405 | "symfony/translation-contracts": { 406 | "version": "v3.0.0" 407 | }, 408 | "symfony/twig-bridge": { 409 | "version": "v6.0.1" 410 | }, 411 | "symfony/twig-bundle": { 412 | "version": "6.0", 413 | "recipe": { 414 | "repo": "github.com/symfony/recipes", 415 | "branch": "master", 416 | "version": "5.4", 417 | "ref": "bffbb8f1a849736e64006735afae730cb428b6ff" 418 | }, 419 | "files": [ 420 | "config/packages/twig.yaml", 421 | "templates/base.html.twig" 422 | ] 423 | }, 424 | "symfony/validator": { 425 | "version": "6.0", 426 | "recipe": { 427 | "repo": "github.com/symfony/recipes", 428 | "branch": "master", 429 | "version": "4.3", 430 | "ref": "3eb8df139ec05414489d55b97603c5f6ca0c44cb" 431 | }, 432 | "files": [ 433 | "config/packages/test/validator.yaml", 434 | "config/packages/validator.yaml" 435 | ] 436 | }, 437 | "symfony/var-dumper": { 438 | "version": "v5.2.1" 439 | }, 440 | "symfony/var-exporter": { 441 | "version": "v5.2.1" 442 | }, 443 | "symfony/web-profiler-bundle": { 444 | "version": "7.2", 445 | "recipe": { 446 | "repo": "github.com/symfony/recipes", 447 | "branch": "main", 448 | "version": "6.1", 449 | "ref": "8b51135b84f4266e3b4c8a6dc23c9d1e32e543b7" 450 | }, 451 | "files": [ 452 | "config/packages/web_profiler.yaml", 453 | "config/routes/web_profiler.yaml" 454 | ] 455 | }, 456 | "symfony/webpack-encore-bundle": { 457 | "version": "1.13", 458 | "recipe": { 459 | "repo": "github.com/symfony/recipes", 460 | "branch": "master", 461 | "version": "1.9", 462 | "ref": "10e67e9dc87263a25007130ee59975a36ea624fe" 463 | }, 464 | "files": [ 465 | "assets/app.js", 466 | "assets/bootstrap.js", 467 | "assets/controllers.json", 468 | "assets/controllers/hello_controller.js", 469 | "assets/styles/app.css", 470 | "config/packages/assets.yaml", 471 | "config/packages/prod/webpack_encore.yaml", 472 | "config/packages/test/webpack_encore.yaml", 473 | "config/packages/webpack_encore.yaml", 474 | "package.json", 475 | "webpack.config.js" 476 | ] 477 | }, 478 | "symfony/yaml": { 479 | "version": "v5.2.1" 480 | }, 481 | "theseer/tokenizer": { 482 | "version": "1.2.1" 483 | }, 484 | "twig/twig": { 485 | "version": "v3.3.4" 486 | }, 487 | "webmozart/assert": { 488 | "version": "1.10.0" 489 | } 490 | } 491 | -------------------------------------------------------------------------------- /src/Entity/Stock.php: -------------------------------------------------------------------------------- 1 | createdAt = new \DateTime(); 115 | } 116 | 117 | ////////////////////////////////////////////////////////////////////////////////////////// CONVENIENCE 118 | 119 | /** 120 | * Return invested money 121 | */ 122 | public function getInvestment(): ?float 123 | { 124 | if ($this->quantity && $this->initialPrice) { 125 | return $this->quantity * $this->initialPrice; 126 | } else { 127 | return null; 128 | } 129 | } 130 | 131 | /** 132 | * Get current value of investment 133 | */ 134 | public function getCurrentValue(): ?float 135 | { 136 | if ($this->quantity && $this->initialPrice && $this->currentPrice) { 137 | return $this->quantity * $this->currentPrice; 138 | } else { 139 | return null; 140 | } 141 | } 142 | 143 | /** 144 | * Return profit 145 | */ 146 | public function getProfit(): ?float 147 | { 148 | if ($this->quantity && $this->initialPrice && $this->currentPrice) { 149 | return $this->getCurrentValue() - $this->getInvestment(); 150 | } else { 151 | return null; 152 | } 153 | } 154 | 155 | /** 156 | * Return profit percentage 157 | */ 158 | public function getProfitPercent(): float 159 | { 160 | return $this->getProfit() / $this->getInvestment(); 161 | } 162 | 163 | /** 164 | * Get percent of current change 165 | */ 166 | public function getCurrentChangePercent(): float 167 | { 168 | $oldPrice = $this->currentPrice - $this->currentChange; 169 | if ($oldPrice) { 170 | return $this->currentChange / $oldPrice; 171 | } 172 | return 0; 173 | } 174 | 175 | public function getChangeSinceLastDayPercent(): float 176 | { 177 | if (null !== $this->lastDayPrice) { 178 | return $this->getChangeSinceLastDay() / $this->lastDayPrice; 179 | } 180 | return 0; 181 | } 182 | 183 | public function getChangeSinceLastDay(): float 184 | { 185 | if (null !== $this->lastDayPrice && null !== $this->currentPrice) { 186 | return $this->currentPrice - $this->lastDayPrice; 187 | } 188 | return 0; 189 | } 190 | 191 | public function getProfitSinceLastDay(): ?float 192 | { 193 | if ($this->quantity) { 194 | return $this->getChangeSinceLastDay() * $this->quantity; 195 | } else { 196 | return null; 197 | } 198 | } 199 | 200 | public function getProfitIndicator(): string 201 | { 202 | if ($this->getProfitPercent() > self::PROFIT_THRESHOLD) { 203 | return self::INDICATOR_UP; 204 | } 205 | if ($this->getProfitPercent() < -self::PROFIT_THRESHOLD) { 206 | return self::INDICATOR_DOWN; 207 | } 208 | 209 | return self::INDICATOR_NEUTRAL; 210 | } 211 | 212 | public function getChangeSinceLastDayIndicator(): string 213 | { 214 | if ($this->getChangeSinceLastDayPercent() > self::VERY_STRONG_CHANGE_THRESHOLD) { 215 | return self::INDICATOR_UP . ' ' . self::INDICATOR_VERY_STRONG; 216 | } 217 | if ($this->getChangeSinceLastDayPercent() > self::STRONG_CHANGE_THRESHOLD) { 218 | return self::INDICATOR_UP . ' ' . self::INDICATOR_STRONG; 219 | } 220 | if ($this->getChangeSinceLastDayPercent() > self::CHANGE_THRESHOLD) { 221 | return self::INDICATOR_UP; 222 | } 223 | 224 | if ($this->getChangeSinceLastDayPercent() < -self::VERY_STRONG_CHANGE_THRESHOLD) { 225 | return self::INDICATOR_DOWN . ' ' . self::INDICATOR_VERY_STRONG; 226 | } 227 | if ($this->getChangeSinceLastDayPercent() < -self::STRONG_CHANGE_THRESHOLD) { 228 | return self::INDICATOR_DOWN . ' ' . self::INDICATOR_STRONG; 229 | } 230 | if ($this->getChangeSinceLastDayPercent() < -self::CHANGE_THRESHOLD) { 231 | return self::INDICATOR_DOWN; 232 | } 233 | 234 | return self::INDICATOR_NEUTRAL; 235 | } 236 | 237 | ////////////////////////////////////////////////////////////////////////////////////////// GETTER / SETTER 238 | 239 | 240 | public function getId(): int 241 | { 242 | return $this->id; 243 | } 244 | 245 | public function getName(): string 246 | { 247 | return $this->name; 248 | } 249 | 250 | public function setName(string $name): self 251 | { 252 | $this->name = $name; 253 | return $this; 254 | } 255 | 256 | /** 257 | * @return string[] 258 | */ 259 | public function getSymbols(): array 260 | { 261 | return $this->symbols; 262 | } 263 | 264 | /** 265 | * @param string[] $symbols 266 | * @return $this 267 | */ 268 | public function setSymbols(array $symbols): self 269 | { 270 | $this->symbols = $symbols; 271 | return $this; 272 | } 273 | 274 | public function getCategory(): ?string 275 | { 276 | return $this->category; 277 | } 278 | 279 | public function setCategory(?string $category): self 280 | { 281 | $this->category = $category; 282 | return $this; 283 | } 284 | 285 | public function getCurrency(): string 286 | { 287 | return $this->currency; 288 | } 289 | 290 | public function setCurrency(string $currency): self 291 | { 292 | $this->currency = $currency; 293 | return $this; 294 | } 295 | 296 | public function getQuantity(): ?float 297 | { 298 | return $this->quantity; 299 | } 300 | 301 | public function setQuantity(?float $quantity): self 302 | { 303 | $this->quantity = $quantity; 304 | return $this; 305 | } 306 | 307 | public function getInitialPrice(): ?float 308 | { 309 | return $this->initialPrice; 310 | } 311 | 312 | public function setInitialPrice(?float $initialPrice): self 313 | { 314 | $this->initialPrice = $initialPrice; 315 | return $this; 316 | } 317 | 318 | public function getLastDayPrice(): ?float 319 | { 320 | return $this->lastDayPrice; 321 | } 322 | 323 | public function setLastDayPrice(?float $lastDayPrice): self 324 | { 325 | $this->lastDayPrice = $lastDayPrice; 326 | return $this; 327 | } 328 | 329 | public function getLastDayPriceTime(): ?\DateTimeInterface 330 | { 331 | return $this->lastDayPriceTime; 332 | } 333 | 334 | public function setLastDayPriceTime(?\DateTimeInterface $lastDayPriceTime): self 335 | { 336 | $this->lastDayPriceTime = $lastDayPriceTime; 337 | return $this; 338 | } 339 | 340 | public function getCurrentPrice(): ?float 341 | { 342 | return $this->currentPrice; 343 | } 344 | 345 | public function setCurrentPrice(?float $currentPrice): self 346 | { 347 | $this->currentPrice = $currentPrice; 348 | return $this; 349 | } 350 | 351 | public function getCurrentPriceSymbol(): ?string 352 | { 353 | return $this->currentPriceSymbol; 354 | } 355 | 356 | public function setCurrentPriceSymbol(?string $currentPriceSymbol): self 357 | { 358 | $this->currentPriceSymbol = $currentPriceSymbol; 359 | return $this; 360 | } 361 | 362 | public function getCurrentPriceExchange(): ?Exchange 363 | { 364 | return $this->currentPriceExchange; 365 | } 366 | 367 | public function setCurrentPriceExchange(?Exchange $currentPriceExchange): self 368 | { 369 | $this->currentPriceExchange = $currentPriceExchange; 370 | return $this; 371 | } 372 | 373 | public function getCurrentPriceMarket(): ?string 374 | { 375 | return $this->currentPriceMarket; 376 | } 377 | 378 | public function setCurrentPriceMarket(?string $currentPriceMarket): self 379 | { 380 | $this->currentPriceMarket = $currentPriceMarket; 381 | return $this; 382 | } 383 | 384 | public function getCurrentPriceTime(): ?\DateTimeInterface 385 | { 386 | return $this->currentPriceTime; 387 | } 388 | 389 | public function setCurrentPriceTime(?\DateTimeInterface $currentPriceTime): self 390 | { 391 | $this->currentPriceTime = $currentPriceTime; 392 | return $this; 393 | } 394 | 395 | public function getCurrentChange(): ?float 396 | { 397 | return $this->currentChange; 398 | } 399 | 400 | public function setCurrentChange(?float $currentChange): self 401 | { 402 | $this->currentChange = $currentChange; 403 | return $this; 404 | } 405 | 406 | public function getAlertThreshold(): ?float 407 | { 408 | return $this->alertThreshold; 409 | } 410 | 411 | public function setAlertThreshold(?float $alertThreshold): self 412 | { 413 | $this->alertThreshold = $alertThreshold; 414 | return $this; 415 | } 416 | 417 | public function getAlertDynamicThresholdPercent(): ?float 418 | { 419 | return $this->alertDynamicThresholdPercent; 420 | } 421 | 422 | public function setAlertDynamicThresholdPercent(?float $alertDynamicThresholdPercent): self 423 | { 424 | $this->alertDynamicThresholdPercent = $alertDynamicThresholdPercent; 425 | return $this; 426 | } 427 | 428 | public function getAlertComparator(): ?string 429 | { 430 | return $this->alertComparator; 431 | } 432 | 433 | public function setAlertComparator(?string $alertComparator): self 434 | { 435 | $this->alertComparator = $alertComparator; 436 | return $this; 437 | } 438 | 439 | public function getAlertLastTime(): ?\DateTimeInterface 440 | { 441 | return $this->alertLastTime; 442 | } 443 | 444 | public function setAlertLastTime(?\DateTimeInterface $alertLastTime): self 445 | { 446 | $this->alertLastTime = $alertLastTime; 447 | return $this; 448 | } 449 | 450 | public function getAlertMessage(): ?string 451 | { 452 | return $this->alertMessage; 453 | } 454 | 455 | public function setAlertMessage(?string $alertMessage): self 456 | { 457 | $this->alertMessage = $alertMessage; 458 | return $this; 459 | } 460 | 461 | public function getNextEarningsTime(): ?\DateTimeInterface 462 | { 463 | return $this->nextEarningsTime; 464 | } 465 | 466 | public function setNextEarningsTime(?\DateTimeInterface $nextEarningsTime): self 467 | { 468 | $this->hoursUntilEarnings = null; 469 | $this->daysUntilEarnings = null; 470 | $this->nextEarningsTime = $nextEarningsTime; 471 | return $this; 472 | } 473 | 474 | public function getEarningsNotificationLastTime(): ?\DateTimeInterface 475 | { 476 | return $this->earningsNotificationLastTime; 477 | } 478 | 479 | public function setEarningsNotificationLastTime(?\DateTimeInterface $earningsNotificationLastTime): self 480 | { 481 | $this->earningsNotificationLastTime = $earningsNotificationLastTime; 482 | return $this; 483 | } 484 | 485 | public function daysUntilEarnings(): ?int 486 | { 487 | if (null === $this->nextEarningsTime) { 488 | return null; 489 | } 490 | if (isset($this->daysUntilEarnings)) { 491 | return $this->daysUntilEarnings; 492 | } 493 | 494 | $interval = $this->nextEarningsTime->diff(new \DateTime()); 495 | $this->daysUntilEarnings = $interval->days; 496 | return $this->daysUntilEarnings; 497 | } 498 | 499 | public function hoursUntilEarnings(): ?int 500 | { 501 | if (null === $this->nextEarningsTime) { 502 | return null; 503 | } 504 | if (isset($this->hoursUntilEarnings)) { 505 | return $this->hoursUntilEarnings; 506 | } 507 | 508 | $interval = $this->nextEarningsTime->diff(new \DateTime()); 509 | $this->hoursUntilEarnings = $interval->d * 24 + $interval->h; 510 | return $this->hoursUntilEarnings; 511 | } 512 | 513 | public function getCreatedAt(): \DateTimeInterface 514 | { 515 | return $this->createdAt; 516 | } 517 | 518 | public function setCreatedAt(\DateTimeInterface $createdAt): self 519 | { 520 | $this->createdAt = $createdAt; 521 | return $this; 522 | } 523 | 524 | public function getUpdatedAt(): ?\DateTimeInterface 525 | { 526 | return $this->updatedAt; 527 | } 528 | 529 | public function setUpdatedAt(?\DateTimeInterface $updatedAt): self 530 | { 531 | $this->updatedAt = $updatedAt; 532 | return $this; 533 | } 534 | 535 | public function isDisplayChart(): bool 536 | { 537 | return $this->displayChart; 538 | } 539 | 540 | public function setDisplayChart(bool $displayChart): self 541 | { 542 | $this->displayChart = $displayChart; 543 | return $this; 544 | } 545 | 546 | public function isFavourite(): bool 547 | { 548 | return $this->favourite; 549 | } 550 | 551 | public function setFavourite(bool $favourite): self 552 | { 553 | $this->favourite = $favourite; 554 | return $this; 555 | } 556 | } 557 | --------------------------------------------------------------------------------