├── 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 |
6 |
--------------------------------------------------------------------------------
/assets/images/up.svg:
--------------------------------------------------------------------------------
1 |
2 |
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 |
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 |
7 |
--------------------------------------------------------------------------------
/assets/images/triple-up.svg:
--------------------------------------------------------------------------------
1 |
2 |
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 |
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 |
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 |
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 |
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 | | Wertpapier |
20 | Nächster Termin |
21 | Countdown |
22 |
23 |
24 |
25 | {% for stock in stocks %}
26 |
27 | |
28 | {{ stock.name }}
29 | {% if stock.daysUntilEarnings > -1 and stock.daysUntilEarnings < 3 %}💰{% endif %}
30 | |
31 | {{ stock.nextEarningsTime|sonata_format_datetime }} |
32 | {% if stock.daysUntilEarnings > 0 %}{{ stock.daysUntilEarnings }} Tage{% else %}{{ stock.hoursUntilEarnings }} Std.{% endif %} |
33 |
34 | {% endfor %}
35 |
36 |
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 | | Wertpapier |
14 | Anzahl |
15 | Einstand |
16 | Aktuell |
17 | Profit |
18 | Heute |
19 |
20 |
21 | {% for categoryName, stocks in categories %}
22 |
23 |
24 | | {{ categoryName }} |
25 |
26 | {% include "Panel/tableRows.html.twig" %}
27 |
28 | {% endfor %}
29 |
30 |
31 | |
32 | |
33 | |
34 | {{ performance.currentValue|sonata_number_format_currency(performance.currency) }} |
35 |
36 | {{ performance.profitPercent|sonata_number_format_percent({'fraction_digits': 2}, {'positive_prefix': '+'}) }}
37 | {{ performance.profit|sonata_number_format_currency(performance.currency, {}, {'positive_prefix': '+'}) }}
38 | |
39 |
40 | {{ performance.profitSinceLastDayPercent|sonata_number_format_percent({'fraction_digits': 2}, {'positive_prefix': '+'}) }}
41 | {{ performance.profitSinceLastDay|sonata_number_format_currency(performance.currency, {}, {'positive_prefix': '+'}) }}
42 | |
43 |
44 |
45 |
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 | 
21 |
22 | **Charts view**
23 |
24 | 
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 |
20 |
21 |
28 |
29 |
30 |
31 |
32 |
36 |
37 |
38 |
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 |
27 |
28 |
46 |
47 |
68 |
69 |
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 |
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 |
--------------------------------------------------------------------------------