├── .env.dev
├── migrations
└── .gitignore
├── src
├── Entity
│ ├── .gitignore
│ ├── Country.php
│ ├── Currency.php
│ ├── TransactionAttachment.php
│ ├── InstrumentPrice.php
│ ├── AssetPrice.php
│ ├── AssetNote.php
│ ├── Account.php
│ └── User.php
├── Repository
│ ├── .gitignore
│ ├── CountryRepository.php
│ ├── CurrencyRepository.php
│ ├── InstrumentRepository.php
│ ├── TransactionAttachmentRepository.php
│ ├── AccountRepository.php
│ ├── AssetNoteRepository.php
│ ├── UserRepository.php
│ ├── AssetPriceRepository.php
│ ├── TransactionRepository.php
│ └── InstrumentTermsRepository.php
├── Kernel.php
├── DataFixtures
│ ├── CurrencyFixture.php
│ ├── CountryFixture.php
│ ├── UserFixture.php
│ ├── AccountFixture.php
│ ├── AssetPriceFixture.php
│ ├── InstrumentFixture.php
│ └── AssetFixture.php
├── Service
│ ├── DataSources
│ │ └── DataSourceInterface.php
│ ├── CurrencyConversionService.php
│ └── FetchPrices.php
├── Controller
│ ├── LoginController.php
│ ├── PortfolioController.php
│ ├── AnalysisController.php
│ ├── RegistrationController.php
│ ├── CountryController.php
│ ├── CurrencyController.php
│ ├── ExecutionController.php
│ └── TransactionController.php
├── Form
│ ├── CountryType.php
│ ├── CurrencyType.php
│ ├── TransactionAttachmentType.php
│ ├── AccountType.php
│ ├── RegistrationFormType.php
│ ├── TransactionType.php
│ ├── AssetType.php
│ ├── AssetNoteType.php
│ └── InstrumentTermsType.php
├── Twig
│ └── AppExtension.php
└── Command
│ ├── DeletePricesCommand.php
│ └── FetchPricesCommand.php
├── config
├── routes.yaml
├── routes
│ ├── security.yaml
│ ├── framework.yaml
│ └── web_profiler.yaml
├── packages
│ ├── twig.yaml
│ ├── doctrine_migrations.yaml
│ ├── routing.yaml
│ ├── validator.yaml
│ ├── web_profiler.yaml
│ ├── cache.yaml
│ ├── framework.yaml
│ ├── doctrine.yaml
│ └── security.yaml
├── preload.php
├── bundles.php
├── services.yaml
└── instruments.yaml
├── .env.test
├── templates
├── country
│ ├── edit.html.twig
│ └── index.html.twig
├── currency
│ ├── edit.html.twig
│ └── index.html.twig
├── registration
│ └── register.html.twig
├── assetnote
│ ├── edit.html.twig
│ ├── index.html.twig
│ └── dialog.html.twig
├── account
│ ├── edit.html.twig
│ ├── details.html.twig
│ ├── index.html.twig
│ ├── positions.html.twig
│ ├── transactions.html.twig
│ └── trades.html.twig
├── transaction
│ └── edit.html.twig
├── macros.twig
├── asset
│ ├── edit.html.twig
│ └── index.html.twig
├── login
│ └── index.html.twig
├── assets.twig
├── instrument
│ ├── edit.html.twig
│ ├── editterms.html.twig
│ └── index.html.twig
├── execution
│ └── edit.html.twig
├── analysis
│ └── index.html.twig
├── base.html.twig
├── transactionattachment
│ └── show.html.twig
├── inc
│ └── navbar.html.twig
└── portfolio
│ └── index.html.twig
├── public
├── manifest.json
├── index.php
├── js
│ └── formtools.js
└── .htaccess
├── tests
├── bootstrap.php
├── CountriesWebTest.php
├── InstrumentTermsTest.php
├── InstrumentTest.php
├── CurrenciesWebTest.php
├── InstrumentPriceTest.php
├── AssetTest.php
├── AssetPriceTest.php
├── CurrencyConversionServiceTest.php
├── DataSourcesTest.php
└── ExecutionFormModelTest.php
├── phpstan.dist.neon
├── .gitignore
├── bin
├── console
└── phpunit
├── Dockerfile
├── package.json
├── phpunit.xml.dist
├── .github
└── workflows
│ └── symfony.yml
├── .env
├── composer.json
└── docs
└── index.md
/.env.dev:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/migrations/.gitignore:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/Entity/.gitignore:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/Repository/.gitignore:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/config/routes.yaml:
--------------------------------------------------------------------------------
1 | controllers:
2 | resource: routing.controllers
3 |
--------------------------------------------------------------------------------
/config/routes/security.yaml:
--------------------------------------------------------------------------------
1 | _security_logout:
2 | resource: security.route_loader.logout
3 | type: service
4 |
--------------------------------------------------------------------------------
/config/routes/framework.yaml:
--------------------------------------------------------------------------------
1 | when@dev:
2 | _errors:
3 | resource: '@FrameworkBundle/Resources/config/routing/errors.php'
4 | prefix: /_error
5 |
--------------------------------------------------------------------------------
/.env.test:
--------------------------------------------------------------------------------
1 | # define your env variables for the test env here
2 | KERNEL_CLASS='App\Kernel'
3 | APP_SECRET='$ecretf0rt3st'
4 | DATABASE_TYPE=Sqlite
5 | SYMFONY_DEPRECATIONS_HELPER=999999
6 |
--------------------------------------------------------------------------------
/config/packages/twig.yaml:
--------------------------------------------------------------------------------
1 | twig:
2 | file_name_pattern: '*.twig'
3 | form_themes: ['bootstrap_5_layout.html.twig']
4 |
5 | when@test:
6 | twig:
7 | strict_variables: true
8 |
--------------------------------------------------------------------------------
/templates/country/edit.html.twig:
--------------------------------------------------------------------------------
1 | {% extends 'base.html.twig' %}
2 |
3 | {% block title %}Country editor{% endblock %}
4 |
5 | {% block body %}
6 | {{ parent() }}
7 | {{ form(form) }}
8 | {% endblock %}
9 |
--------------------------------------------------------------------------------
/config/preload.php:
--------------------------------------------------------------------------------
1 | Currency editor
7 | {{ form(form) }}
8 | {% endblock %}
9 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "PHP Investment Tracker",
3 | "short_name": "PHP Invest",
4 | "start_url": ".",
5 | "display": "standalone",
6 | "theme_color": "#000000",
7 | "background_color": "#ffffff"
8 | }
9 |
--------------------------------------------------------------------------------
/public/index.php:
--------------------------------------------------------------------------------
1 | bootEnv(dirname(__DIR__).'/.env');
9 | }
10 |
11 | if ($_SERVER['APP_DEBUG']) {
12 | umask(0000);
13 | }
14 |
--------------------------------------------------------------------------------
/config/packages/routing.yaml:
--------------------------------------------------------------------------------
1 | framework:
2 | router:
3 | utf8: true
4 |
5 | # Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
6 | # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
7 | #default_uri: http://localhost
8 |
9 | when@prod:
10 | framework:
11 | router:
12 | strict_requirements: null
13 |
--------------------------------------------------------------------------------
/config/packages/validator.yaml:
--------------------------------------------------------------------------------
1 | framework:
2 | validation:
3 | email_validation_mode: html5
4 |
5 | # Enables validator auto-mapping support.
6 | # For instance, basic validation constraints will be inferred from Doctrine's metadata.
7 | #auto_mapping:
8 | # App\Entity\: []
9 |
10 | when@test:
11 | framework:
12 | validation:
13 | not_compromised_password: false
14 |
--------------------------------------------------------------------------------
/config/packages/web_profiler.yaml:
--------------------------------------------------------------------------------
1 | when@dev:
2 | web_profiler:
3 | toolbar: true
4 | intercept_redirects: false
5 |
6 | framework:
7 | profiler:
8 | only_exceptions: false
9 | collect_serializer_data: true
10 |
11 | when@test:
12 | web_profiler:
13 | toolbar: false
14 | intercept_redirects: false
15 |
16 | framework:
17 | profiler: { collect: false }
18 |
--------------------------------------------------------------------------------
/phpstan.dist.neon:
--------------------------------------------------------------------------------
1 | parameters:
2 | level: 4
3 | paths:
4 | - bin/
5 | - config/
6 | - public/
7 | - src/
8 | - tests/
9 | ignoreErrors:
10 | -
11 | message: '#^Call to function method_exists\(\) with ''Symfony\\\\Component\\\\Dotenv\\\\Dotenv'' and ''bootEnv'' will always evaluate to true\.$#'
12 | identifier: function.alreadyNarrowedType
13 | count: 1
14 | path: tests/bootstrap.php
--------------------------------------------------------------------------------
/tests/CountriesWebTest.php:
--------------------------------------------------------------------------------
1 | request('GET', '/country');
13 |
14 | $this->assertResponseIsSuccessful();
15 | $this->assertSelectorTextContains('h3', 'Administration of countries');
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | ###> symfony/framework-bundle ###
3 | /.env.local
4 | /.env.local.php
5 | /.env.*.local
6 | /config/secrets/prod/prod.decrypt.private.php
7 | /public/bundles/
8 | /node_modules/
9 | /var/
10 | /vendor/
11 | ###< symfony/framework-bundle ###
12 |
13 | ###> symfony/phpunit-bridge ###
14 | .phpunit.result.cache
15 | /phpunit.xml
16 | ###< symfony/phpunit-bridge ###
17 |
18 | ###> phpunit/phpunit ###
19 | /phpunit.xml
20 | /.phpunit.cache/
21 | ###< phpunit/phpunit ###
22 |
23 | ###> phpstan/phpstan ###
24 | phpstan.neon
25 | ###< phpstan/phpstan ###
26 |
27 | .phpunit.cache
28 | /public/npm/
29 |
--------------------------------------------------------------------------------
/public/js/formtools.js:
--------------------------------------------------------------------------------
1 | const table = document.getElementById('datatable');
2 |
3 | if (table) {
4 | table.addEventListener('click', e => {
5 | if (e.target.getAttribute('data-op') === "delete") {
6 | const name = e.target.getAttribute('data-name');
7 | if (confirm(`Do you really want to delete ${name}?`)) {
8 | const id = e.target.getAttribute('data-id')
9 |
10 | //console.log(`Deleting ${id}`);
11 | fetch(`${id}`, {method: 'DELETE'})
12 | .then(window.location.href = window.location.protocol +'//'+ window.location.host + window.location.pathname)
13 | }
14 | }
15 | });
16 | }
17 |
--------------------------------------------------------------------------------
/tests/InstrumentTermsTest.php:
--------------------------------------------------------------------------------
1 | assertEquals($it->getRatio(), 1);
14 |
15 | $it->setRatio(123);
16 | $this->assertEquals($it->getRatio(), 123);
17 |
18 | $it->setRatio(1);
19 | $this->assertEquals($it->getRatio(), 1);
20 |
21 | $it->setRatio(null);
22 | $this->assertEquals($it->getRatio(), 1);
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/DataFixtures/CurrencyFixture.php:
--------------------------------------------------------------------------------
1 | persist(new Currency("USD"));
20 | $manager->persist(new Currency("EUR", "EU0009652759"));
21 | $manager->flush();
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/bin/console:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env php
2 | persist(new Country("AT"));
20 | $manager->persist(new Country("DE"));
21 | $manager->persist(new Country("US"));
22 | $manager->flush();
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM php:8.4-cli
2 | LABEL maintainer Matthias Straka
3 |
4 | RUN apt-get update -y && \
5 | apt-get install -y --no-install-recommends npm
6 |
7 | COPY --from=ghcr.io/mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
8 |
9 | RUN install-php-extensions @composer apcu bcmath intl
10 |
11 | WORKDIR /app
12 | COPY . /app
13 |
14 | RUN composer install --no-dev
15 | RUN php bin/console doctrine:schema:create
16 | #RUN php bin/console doctrine:fixtures:load -n --group=seeder
17 |
18 | VOLUME [ "/app/var" ]
19 |
20 | # Setup up local environment with dummy secret
21 | RUN echo "APP_SECRET='DockerSecret'" > /app/.env.local
22 |
23 | EXPOSE 8000
24 |
25 | WORKDIR /app/public
26 | CMD php -S 0.0.0.0:8000
27 |
--------------------------------------------------------------------------------
/tests/InstrumentTest.php:
--------------------------------------------------------------------------------
1 | setEusipa(Instrument::EUSIPA_UNDERLYING);
15 | $this->assertEquals($i->getSupportedAccountTypes(), [ Account::TYPE_CASH ]);
16 | $this->assertEquals($i->getStatus(), Instrument::STATUS_ACTIVE);
17 | $this->assertEquals($i->getStatusName(), "Active");
18 | $i->setEusipa(Instrument::EUSIPA_CFD);
19 | $this->assertEquals($i->getSupportedAccountTypes(), [ Account::TYPE_MARGIN ]);
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/Repository/CountryRepository.php:
--------------------------------------------------------------------------------
1 | ['all' => true],
5 | Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
6 | Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
7 | Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
8 | Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
9 | Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
10 | Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
11 | Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
12 | Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
13 | ];
14 |
--------------------------------------------------------------------------------
/tests/CurrenciesWebTest.php:
--------------------------------------------------------------------------------
1 | request('GET', '/currency');
13 |
14 | $this->assertResponseIsSuccessful();
15 | $this->assertSelectorTextContains('h3', 'Currencies');
16 | }
17 |
18 | public function testApi(): void
19 | {
20 | $client = static::createClient();
21 | $eur = $client->request('GET', '/api/currency/EUR');
22 | $this->assertResponseIsSuccessful();
23 |
24 | $xxx = $client->request('GET', '/api/currency/XXX');
25 | $this->assertResponseStatusCodeSame(404);
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/DataFixtures/UserFixture.php:
--------------------------------------------------------------------------------
1 | setUsername("demo");
17 | $demo_user->setPassword("demo_pwd"); // not actually a hash
18 | $demo_user->setName("Demo User");
19 | $demo_user->setEmail("demo@mail.com");
20 | $demo_user->setCurrency("EUR");
21 | $manager->persist($demo_user);
22 |
23 | $manager->flush();
24 |
25 | $this->addReference(self::DEMO_USER_REFERENCE, $demo_user);
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/bin/phpunit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env php
2 | = 80000) {
10 | require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
11 | } else {
12 | define('PHPUNIT_COMPOSER_INSTALL', dirname(__DIR__).'/vendor/autoload.php');
13 | require PHPUNIT_COMPOSER_INSTALL;
14 | PHPUnit\TextUI\Command::main();
15 | }
16 | } else {
17 | if (!is_file(dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php')) {
18 | echo "Unable to find the `simple-phpunit.php` script in `vendor/symfony/phpunit-bridge/bin/`.\n";
19 | exit(1);
20 | }
21 |
22 | require dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php';
23 | }
24 |
--------------------------------------------------------------------------------
/src/Service/DataSources/DataSourceInterface.php:
--------------------------------------------------------------------------------
1 |
11 |
{{ form_row(registrationForm.name) }}
12 | {{ form_row(registrationForm.email) }}
13 | {{ form_row(registrationForm.username) }}
14 | {{ form_row(registrationForm.plainPassword, { label: 'Password' }) }}
15 | {{ form_row(registrationForm.currency) }}
16 |
17 |
18 |
19 |
20 | {{ form_end(registrationForm) }}
21 | {% endblock %}
22 |
--------------------------------------------------------------------------------
/tests/InstrumentPriceTest.php:
--------------------------------------------------------------------------------
1 | setDate($d);
15 | $this->assertEquals($d, $ap->getDate());
16 | }
17 |
18 | public function testOHLC(): void
19 | {
20 | $ap = new InstrumentPrice();
21 | $ap->setOHLC(1.2,5.7,-2.55,10000);
22 | $this->assertEquals(1.2, $ap->getOpen());
23 | $this->assertEquals(5.7, $ap->getHigh());
24 | $this->assertEquals(-2.55, $ap->getLow());
25 | $this->assertEquals(10000, $ap->getClose());
26 | $this->assertEquals('10000', $ap->getClose());
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/config/packages/framework.yaml:
--------------------------------------------------------------------------------
1 | # see https://symfony.com/doc/current/reference/configuration/framework.html
2 | framework:
3 | secret: '%env(APP_SECRET)%'
4 | http_method_override: false
5 | handle_all_throwables: true
6 |
7 | # Enables session support. Note that the session will ONLY be started if you read or write from it.
8 | # Remove or comment this section to explicitly disable session support.
9 | session:
10 | handler_id: null
11 | cookie_lifetime: 0
12 | cookie_secure: auto
13 | cookie_samesite: lax
14 |
15 | assets:
16 | packages:
17 | jsdelivr:
18 | base_url: 'https://cdn.jsdelivr.net/npm/'
19 |
20 | #esi: true
21 | #fragments: true
22 | php_errors:
23 | log: true
24 |
25 | when@test:
26 | framework:
27 | test: true
28 | session:
29 | storage_factory_id: session.storage.factory.mock_file
30 |
--------------------------------------------------------------------------------
/tests/AssetTest.php:
--------------------------------------------------------------------------------
1 | get(UserRepository::class);
14 | $asset_manager = static::getContainer()->get(AssetRepository::class);
15 |
16 | $appl = $asset_manager->findOneBy(['ISIN' => 'US0378331005']);
17 | $demo_user = $user_manager->findOneByEmail('demo@mail.com');
18 |
19 | $client->loginUser($demo_user);
20 | $crawler = $client->request('GET', '/asset/' . $appl->getId());
21 |
22 | $this->assertResponseIsSuccessful();
23 | $this->assertSame('ISINUS0378331005', $crawler->filter('dd')->text());
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/templates/assetnote/edit.html.twig:
--------------------------------------------------------------------------------
1 | {% extends 'base.html.twig' %}
2 |
3 | {% block title %}Note editor{% if asset %}: {{ asset.name }}{% endif %}{% endblock %}
4 |
5 | {% block body %}
6 | {{ parent() }}
7 | {{ form_start(form) }}
8 |
9 |
10 |
{{ form_row(form.title) }}
11 |
{{ form_row(form.date) }}
12 |
{{ form_row(form.type) }}
13 |
{{ form_row(form.asset) }}
14 |
{{ form_row(form.url) }}
15 |
{{ form_row(form.text) }}
16 |
17 |
18 | {{ form_errors(form) }}
19 |
20 |
21 | {{ form_widget(form.save) }}
22 | {{ form_widget(form.reset) }}
23 | {{ form_widget(form.back) }}
24 |
25 |
26 | {{ form_end(form) }}
27 |
28 |
31 |
32 | {% endblock %}
33 |
--------------------------------------------------------------------------------
/templates/account/edit.html.twig:
--------------------------------------------------------------------------------
1 | {% extends 'base.html.twig' %}
2 |
3 | {% block title %}Account editor{% endblock %}
4 |
5 | {% block body %}
6 | {{ parent() }}
7 | {{ form_start(form) }}
8 |
9 |
10 |
{{ form_row(form.name) }}
11 |
{{ form_row(form.type) }}
12 |
{{ form_row(form.timezone) }}
13 |
{{ form_row(form.currency) }}
14 |
{{ form_row(form.number) }}
15 |
{{ form_row(form.iban) }}
16 |
{{ form_row(form.star) }}
17 |
18 |
19 | {{ form_errors(form) }}
20 |
21 |
22 | {{ form_widget(form.save) }}
23 | {{ form_widget(form.reset) }}
24 | {{ form_widget(form.back) }}
25 |
26 |
27 | {{ form_end(form) }}
28 |
29 |
32 |
33 | {% endblock %}
34 |
--------------------------------------------------------------------------------
/templates/transaction/edit.html.twig:
--------------------------------------------------------------------------------
1 | {% extends 'base.html.twig' %}
2 |
3 | {% block title %}Transaction Editor{% endblock %}
4 |
5 | {% block body %}
6 | {{ parent() }}
7 | {{ form_start(form) }}
8 |
9 |
10 |
{{ form_row(form.time) }}
11 |
{{ form_row(form.transaction_id) }}
12 |
{{ form_row(form.consolidated) }}
13 |
{{ form_row(form.cash) }}
14 |
{{ form_row(form.consolidation) }}
15 |
{{ form_row(form.interest) }}
16 |
{{ form_row(form.notes) }}
17 |
18 |
19 | {{ form_errors(form) }}
20 |
21 |
22 | {{ form_widget(form.save) }}
23 | {{ form_widget(form.reset) }}
24 | {{ form_widget(form.back) }}
25 |
26 |
27 | {{ form_end(form) }}
28 |
29 |
32 |
33 | {% endblock %}
34 |
--------------------------------------------------------------------------------
/src/Entity/Country.php:
--------------------------------------------------------------------------------
1 | true, "comment" => "ISO 3166-1 Alpha-2 code"])]
17 | #[Assert\Country]
18 | private $Code;
19 |
20 | public function __construct($code)
21 | {
22 | $this->Code = $code;
23 | }
24 |
25 | public function getCode(): ?string
26 | {
27 | return $this->Code;
28 | }
29 |
30 | public function setCode(string $code): self
31 | {
32 | $this->Code = $code;
33 | return $this;
34 | }
35 |
36 | public function __toString(): string
37 | {
38 | return $this->Code;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/Controller/LoginController.php:
--------------------------------------------------------------------------------
1 | getLastAuthenticationError();
17 |
18 | // last username entered by the user
19 | $lastUsername = $authenticationUtils->getLastUsername();
20 |
21 | return $this->render('login/index.html.twig', [
22 | 'last_username' => $lastUsername,
23 | 'error' => $error,
24 | 'create_user' => true,
25 | ]);
26 | }
27 |
28 | #[Route('/logout', name: 'logout')]
29 | public function logout(): Response
30 | {
31 | return new Response();
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/tests/AssetPriceTest.php:
--------------------------------------------------------------------------------
1 | setDate($d);
15 | $this->assertEquals($d, $ap->getDate());
16 | }
17 |
18 | public function testVolume(): void
19 | {
20 | $ap = new AssetPrice();
21 | $this->assertEquals(0, $ap->getVolume());
22 | $ap->setVolume(1234);
23 | $this->assertEquals(1234, $ap->getVolume());
24 | }
25 |
26 | public function testOHLC(): void
27 | {
28 | $ap = new AssetPrice();
29 | $ap->setOHLC(1.2,5.7,-2.55,10000);
30 | $this->assertEquals(1.2, $ap->getOpen());
31 | $this->assertEquals(5.7, $ap->getHigh());
32 | $this->assertEquals(-2.55, $ap->getLow());
33 | $this->assertEquals(10000, $ap->getClose());
34 | $this->assertEquals('10000', $ap->getClose());
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/DataFixtures/AccountFixture.php:
--------------------------------------------------------------------------------
1 | setName("Demo Account");
26 | $account->setNumber("112358");
27 | $account->setCurrency("USD");
28 | $account->setTimezone("Europe/Berlin");
29 | $account->setOwner($this->getReference(UserFixture::DEMO_USER_REFERENCE, User::class));
30 | $manager->persist($account);
31 |
32 | $manager->flush();
33 |
34 | $this->addReference(self::DEMO_ACCOUNT_REFERENCE, $account);
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "php-invest",
3 | "private": true,
4 | "repository": "https://github.com/matthiasstraka/php-invest",
5 | "dependencies": {
6 | "bootstrap": "^5.3.0",
7 | "bootstrap-icons": "^1.13.0",
8 | "chart.js": "^3.7.0",
9 | "chartjs-adapter-date-fns": "^3.0.0",
10 | "chartjs-chart-financial": "^0.1.1",
11 | "chartjs-plugin-zoom": "^1.2.1",
12 | "datatables.net": "^1.13",
13 | "datatables.net-bs5": "^1.13",
14 | "date-fns": "^2.29.3",
15 | "flag-icons": "^6.6.0",
16 | "jquery": "^3.7.0",
17 | "postinstall": "*"
18 | },
19 | "scripts": {
20 | "postinstall": "postinstall"
21 | },
22 | "postinstall": {
23 | "jquery/dist/jquery.min.js": "link public/npm/js/jquery.min.js",
24 | "datatables.net/js/jquery.dataTables.min.js": "link public/npm/js/datatables.min.js",
25 | "datatables.net-bs5/js/dataTables.bootstrap5.min.js": "link public/npm/js/dataTables.bootstrap5.min.js",
26 | "datatables.net-bs5/css/dataTables.bootstrap5.min.css": "link public/npm/css/dataTables.bootstrap5.min.css"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | tests
22 |
23 |
24 |
25 |
26 |
27 | src
28 |
29 |
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/src/DataFixtures/AssetPriceFixture.php:
--------------------------------------------------------------------------------
1 | getRepository(Asset::class);
21 |
22 | $eurusd = $asset_manager->findOneBy(['ISIN' => 'EU0009652759']);
23 | $p = new AssetPrice();
24 | $p->setAsset($eurusd);
25 | $p->setDate(new \DateTime('2022-05-31'));
26 | $p->setOHLC(1.0780, 1.0785, 1.0681, 1.0735);
27 | $manager->persist($p);
28 |
29 | $p = new AssetPrice();
30 | $p->setAsset($eurusd);
31 | $p->setDate(new \DateTime('2022-05-30'));
32 | $p->setOHLC(1.0730, 1.0789, 1.0726, 1.0781);
33 | $manager->persist($p);
34 |
35 | $manager->flush();
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/templates/macros.twig:
--------------------------------------------------------------------------------
1 | {% macro infobox(name, value) %}
2 | {{name}}
{{value}}
3 | {% endmacro %}
4 |
5 | {% macro infoboxraw(name, value, class="col-sm-auto") %}
6 | {{name}}
{{value|raw}}
7 | {% endmacro %}
8 |
9 | {% macro transactionicon(t, style="") %}
10 | {% if t.direction > 0 %}
11 |
12 | {% elseif t.direction < 0 %}
13 |
14 | {% elseif t.execution_type == constant('App\\Entity\\Execution::TYPE_DIVIDEND') %}
15 |
16 | {% elseif t.execution_type == constant('App\\Entity\\Execution::TYPE_ACCUMULATION') %}
17 |
18 | {% else %}
19 | {# Unknown type #}
20 |
21 | {% endif %}
22 | {% endmacro %}
23 |
--------------------------------------------------------------------------------
/src/Form/CountryType.php:
--------------------------------------------------------------------------------
1 | entityManager = $entityManager;
20 | }
21 |
22 | public function configureOptions(OptionsResolver $resolver): void
23 | {
24 | $choices = [];
25 | foreach ($this->entityManager->getRepository(Country::class)->findAll() as $item)
26 | {
27 | $code = $item->getCode();
28 | $name = Countries::getName($item->getCode());
29 | $choices["$name ($code)"] = $code;
30 | }
31 | $resolver->setDefaults([
32 | 'choices' => $choices,
33 | //'data_class' => Country::class,
34 | ]);
35 | }
36 |
37 | public function getParent(): string
38 | {
39 | return ChoiceType::class;
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/templates/asset/edit.html.twig:
--------------------------------------------------------------------------------
1 | {% extends 'base.html.twig' %}
2 |
3 | {% block title %}Asset editor{% endblock %}
4 |
5 | {% block body %}
6 | {{ parent() }}
7 | {{ form_start(form) }}
8 |
9 |
10 |
{{ form_row(form.name) }}
11 |
{{ form_row(form.type) }}
12 |
{{ form_row(form.isin) }}
13 |
{{ form_row(form.symbol) }}
14 |
{{ form_row(form.currency) }}
15 |
{{ form_row(form.country) }}
16 |
{{ form_row(form.url) }}
17 |
{{ form_row(form.irurl) }}
18 |
{{ form_row(form.newsurl) }}
19 |
{{ form_row(form.pricedatasource) }}
20 |
{{ form_row(form.notes) }}
21 |
22 |
23 | {{ form_errors(form) }}
24 |
25 |
26 | {{ form_widget(form.save) }}
27 | {{ form_widget(form.reset) }}
28 | {{ form_widget(form.back) }}
29 |
30 |
31 | {{ form_end(form) }}
32 |
33 |
36 |
37 | {% endblock %}
38 |
--------------------------------------------------------------------------------
/src/Form/CurrencyType.php:
--------------------------------------------------------------------------------
1 | entityManager = $entityManager;
20 | }
21 |
22 | public function configureOptions(OptionsResolver $resolver): void
23 | {
24 | $choices = [];
25 | foreach ($this->entityManager->getRepository(Currency::class)->findAll() as $item)
26 | {
27 | $code = $item->getCode();
28 | $name = Currencies::getName($item->getCode());
29 | $choices["$name ($code)"] = $code;
30 | }
31 | $resolver->setDefaults([
32 | 'choices' => $choices,
33 | //'data_class' => Currency::class,
34 | ]);
35 | }
36 |
37 | public function getParent(): string
38 | {
39 | return ChoiceType::class;
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/config/services.yaml:
--------------------------------------------------------------------------------
1 | # This file is the entry point to configure your own services.
2 | # Files in the packages/ subdirectory configure your dependencies.
3 |
4 | # Put parameters here that don't need to change on each machine where the app is deployed
5 | # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
6 | parameters:
7 |
8 | imports:
9 | - { resource: 'instruments.yaml' }
10 |
11 | services:
12 | # default configuration for services in *this* file
13 | _defaults:
14 | autowire: true # Automatically injects dependencies in your services.
15 | autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
16 |
17 | # makes classes in src/ available to be used as services
18 | # this creates a service per class whose id is the fully-qualified class name
19 | App\:
20 | resource: '../src/'
21 | exclude:
22 | - '../src/DependencyInjection/'
23 | - '../src/Entity/'
24 | - '../src/Kernel.php'
25 |
26 | # add more service definitions when explicit configuration is needed
27 | # please note that last definitions always *replace* previous ones
28 |
29 | App\Service\CurrencyConversionService:
30 | public: true
31 |
--------------------------------------------------------------------------------
/templates/login/index.html.twig:
--------------------------------------------------------------------------------
1 | {% extends 'base.html.twig' %}
2 |
3 | {% block title %}Login{% endblock %}
4 |
5 | {% block body %}
6 | {{ parent() }}
7 | {% if error %}
8 | {{ error.messageKey|trans(error.messageData, 'security') }}
9 | {% endif %}
10 |
35 | {% endblock %}
36 |
--------------------------------------------------------------------------------
/templates/assets.twig:
--------------------------------------------------------------------------------
1 | {# Use these marcros to include CDN assets #}
2 |
3 | {% macro datatables() %}
4 |
5 |
6 |
7 | {% endmacro %}
8 |
9 | {% macro chart(with_financial=false) %}
10 | {# Keep at 3.7 to have a working financial chart #}
11 |
12 |
13 |
14 |
15 | {% if with_financial %}
16 |
17 | {% endif %}
18 | {% endmacro %}
19 |
20 | {% macro flag_icons() %}
21 |
22 | {% endmacro %}
23 |
--------------------------------------------------------------------------------
/templates/instrument/edit.html.twig:
--------------------------------------------------------------------------------
1 | {% extends 'base.html.twig' %}
2 |
3 | {% block title %}Instrument editor{% endblock %}
4 |
5 | {% block body %}
6 | {{ parent() }}
7 | {{ form_start(form) }}
8 |
9 |
10 |
{{ form_row(form.name) }}
11 |
{{ form_row(form.isin) }}
12 |
{{ form_row(form.currency) }}
13 |
{{ form_row(form.underlying) }}
14 |
{{ form_row(form.eusipa) }}
15 |
{{ form_row(form.direction) }}
16 |
{{ form_row(form.status) }}
17 |
{{ form_row(form.emissiondate) }}
18 |
{{ form_row(form.terminationdate) }}
19 |
{{ form_row(form.issuer) }}
20 |
{{ form_row(form.url) }}
21 |
{{ form_row(form.notes) }}
22 |
{{ form_row(form.executiontaxrate) }}
23 |
24 |
25 | {{ form_errors(form) }}
26 |
27 |
28 | {{ form_widget(form.save) }}
29 | {{ form_widget(form.reset) }}
30 | {{ form_widget(form.back) }}
31 |
32 |
33 | {{ form_end(form) }}
34 |
35 |
38 |
39 | {% endblock %}
40 |
--------------------------------------------------------------------------------
/src/Repository/TransactionAttachmentRepository.php:
--------------------------------------------------------------------------------
1 | getEntityManager()->createQueryBuilder();
26 | $q = $qb
27 | ->select(['ta.id', 'ta.name', 'ta.time_uploaded', 'length(ta.content) as size'])
28 | ->from('App\Entity\TransactionAttachment', 'ta')
29 | ->where('ta.transaction = :transaction')
30 | ->setParameter('transaction', $transaction)
31 | ->getQuery();
32 | return $q->getResult();
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/Controller/PortfolioController.php:
--------------------------------------------------------------------------------
1 | entityManager = $entityManager;
19 | }
20 |
21 | #[Route("/", name: "portfolio_list")]
22 | #[IsGranted("ROLE_USER")]
23 | public function index(): Response
24 | {
25 | $repo = $this->entityManager->getRepository(Execution::class);
26 | $portfolio_positions = $repo->getPositionsForUser($this->getUser());
27 | //var_dump($portfolio_positions);
28 |
29 | $total = ['value_total' => 0];
30 | foreach($portfolio_positions as &$pos)
31 | {
32 | $total['value_total'] = $total['value_total'] + $pos['value_total'];
33 | $pos['asset_type'] = Asset::typeNameFromValue($pos['asset_type']);
34 | }
35 |
36 | return $this->render('portfolio/index.html.twig', [
37 | 'positions' => $portfolio_positions,
38 | 'total' => $total,
39 | ]);
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/templates/instrument/editterms.html.twig:
--------------------------------------------------------------------------------
1 | {% extends 'base.html.twig' %}
2 |
3 | {% block title %}Instrument terms editor{% endblock %}
4 |
5 | {% block body %}
6 | {{ parent() }}
7 | {{ form_start(form) }}
8 |
9 |
10 |
{{ form_row(form.date) }}
11 | {% if form.ratio is defined %}
{{ form_row(form.ratio) }}
{% endif %}
12 | {% if form.interest_rate is defined %}
{{ form_row(form.interest_rate) }}
{% endif %}
13 | {% if form.strike is defined %}
{{ form_row(form.strike) }}
{% endif %}
14 | {% if form.barrier is defined %}
{{ form_row(form.barrier) }}
{% endif %}
15 | {% if form.cap is defined %}
{{ form_row(form.cap) }}
{% endif %}
16 | {% if form.bonus_level is defined %}
{{ form_row(form.bonus_level) }}
{% endif %}
17 | {% if form.reverse_level is defined %}
{{ form_row(form.reverse_level) }}
{% endif %}
18 | {% if form.margin is defined %}
{{ form_row(form.margin) }}
{% endif %}
19 |
20 |
21 | {{ form_errors(form) }}
22 |
23 |
24 | {{ form_widget(form.save) }}
25 | {{ form_widget(form.reset) }}
26 | {{ form_widget(form.back) }}
27 |
28 |
29 | {{ form_end(form) }}
30 |
31 |
34 |
35 | {% endblock %}
36 |
--------------------------------------------------------------------------------
/src/Repository/AccountRepository.php:
--------------------------------------------------------------------------------
1 | createQueryBuilder('a')
27 | ->select(
28 | 'a.id',
29 | '(COALESCE(SUM(t.portfolio), 0) + COALESCE(SUM(t.cash), 0) + COALESCE(SUM(t.commission), 0) + COALESCE(SUM(t.tax), 0) + COALESCE(SUM(t.interest), 0) + COALESCE(SUM(t.consolidation), 0)) as balance')
30 | ->leftJoin('App\Entity\Transaction', 't', Join::WITH, 'a.id = t.account')
31 | ->where('a.owner = :user')
32 | ->setParameter('user', $user)
33 | ->groupBy('a.id')
34 | ->getQuery();
35 | return $q->getResult();
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/config/packages/doctrine.yaml:
--------------------------------------------------------------------------------
1 | doctrine:
2 | dbal:
3 | url: '%env(resolve:DATABASE_URL)%'
4 |
5 | # IMPORTANT: You MUST configure your server version,
6 | # either here or in the DATABASE_URL env var (see .env file)
7 | #server_version: '16'
8 |
9 | profiling_collect_backtrace: '%kernel.debug%'
10 | orm:
11 | validate_xml_mapping: true
12 | naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
13 | controller_resolver:
14 | mappings:
15 | App:
16 | type: attribute
17 | is_bundle: false
18 | dir: '%kernel.project_dir%/src/Entity'
19 | prefix: 'App\Entity'
20 | alias: App
21 | dql:
22 | datetime_functions:
23 | year: DoctrineExtensions\Query\%env(resolve:DATABASE_TYPE)%\Year
24 |
25 | when@test:
26 | doctrine:
27 | dbal:
28 | # "TEST_TOKEN" is typically set by ParaTest
29 | dbname_suffix: '_test%env(default::TEST_TOKEN)%'
30 |
31 | when@prod:
32 | doctrine:
33 | orm:
34 | query_cache_driver:
35 | type: pool
36 | pool: doctrine.system_cache_pool
37 | result_cache_driver:
38 | type: pool
39 | pool: doctrine.result_cache_pool
40 |
41 | framework:
42 | cache:
43 | pools:
44 | doctrine.result_cache_pool:
45 | adapter: cache.app
46 | doctrine.system_cache_pool:
47 | adapter: cache.system
48 |
--------------------------------------------------------------------------------
/src/Entity/Currency.php:
--------------------------------------------------------------------------------
1 | true, "comment" => "ISO 4217 Code"])]
17 | #[Assert\Currency]
18 | private $Code;
19 |
20 | // ISIN that maps tracks the currency conversion to USD
21 | #[ORM\Column(type: Types::STRING, length: 12, nullable: true, options: ["fixed" => true])]
22 | #[Assert\Isin]
23 | private $isin_usd;
24 |
25 | public function __construct(string $code, ?string $isin = null)
26 | {
27 | $this->Code = $code;
28 | $this->isin_usd = $isin;
29 | }
30 |
31 | public function getCode(): string
32 | {
33 | return $this->Code;
34 | }
35 |
36 | public function setCode(string $code): self
37 | {
38 | $this->Code = $code;
39 | return $this;
40 | }
41 |
42 | public function getIsinUsd(): ?string
43 | {
44 | return $this->isin_usd;
45 | }
46 |
47 | public function setIsinUsd(?string $isin): self
48 | {
49 | $this->isin_usd = $isin;
50 | return $this;
51 | }
52 |
53 | public function __toString(): string
54 | {
55 | return $this->Code;
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/Repository/AssetNoteRepository.php:
--------------------------------------------------------------------------------
1 | createQueryBuilder('a')
30 | ->andWhere('a.exampleField = :val')
31 | ->setParameter('val', $value)
32 | ->orderBy('a.id', 'ASC')
33 | ->setMaxResults(10)
34 | ->getQuery()
35 | ->getResult()
36 | ;
37 | }
38 | */
39 |
40 | /*
41 | public function findOneBySomeField($value): ?Asset
42 | {
43 | return $this->createQueryBuilder('a')
44 | ->andWhere('a.exampleField = :val')
45 | ->setParameter('val', $value)
46 | ->getQuery()
47 | ->getOneOrNullResult()
48 | ;
49 | }
50 | */
51 | }
52 |
--------------------------------------------------------------------------------
/src/Form/TransactionAttachmentType.php:
--------------------------------------------------------------------------------
1 | setDefaults([
18 | 'data_class' => TransactionAttachment::class,
19 | ]);
20 | }
21 |
22 | public function buildForm(FormBuilderInterface $builder, array $options): void
23 | {
24 | $data = $options['data'];
25 |
26 | $builder
27 | ->add('file', FileType::class, [
28 | 'label' => 'Document',
29 | 'mapped' => false,
30 | 'required' => true,
31 | 'constraints' => [
32 | new File([
33 | 'maxSize' => '1024k',
34 | 'mimeTypes' => [
35 | 'application/pdf',
36 | 'application/x-pdf',
37 | ],
38 | 'mimeTypesMessage' => 'Please upload a valid PDF document',
39 | ])
40 | ],
41 | ])
42 | ->add('upload', SubmitType::class, ['label' => 'Upload', 'attr' => ['class' => 'btn btn-primary']])
43 | ;
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/templates/execution/edit.html.twig:
--------------------------------------------------------------------------------
1 | {% extends 'base.html.twig' %}
2 |
3 | {% block title %}Order Execution Editor{% endblock %}
4 |
5 | {% block body %}
6 | {{ parent() }}
7 | {{ form_start(form) }}
8 |
9 |
10 |
{{ form_row(form.instrument) }}
11 |
{{ form_row(form.time) }}
12 |
{{ form_row(form.direction) }}
13 |
{{ form_row(form.account) }}
14 |
{{ form_row(form.type) }}
15 |
{{ form_row(form.execution_id) }}
16 |
{{ form_row(form.transaction_id) }}
17 |
{{ form_row(form.marketplace) }}
18 |
{{ form_row(form.volume) }}
19 |
{{ form_row(form.currency) }}
20 |
{{ form_row(form.price) }}
21 |
{{ form_row(form.exchange_rate) }}
22 |
{{ form_row(form.commission) }}
23 |
{{ form_row(form.tax) }}
24 |
{{ form_row(form.interest) }}
25 |
{{ form_row(form.consolidated) }}
26 |
{{ form_row(form.notes) }}
27 |
28 |
29 | {{ form_errors(form) }}
30 |
31 |
32 | {{ form_widget(form.save) }}
33 | {{ form_widget(form.reset) }}
34 | {{ form_widget(form.back) }}
35 |
36 |
37 |
38 | {{ form_end(form) }}
39 |
40 |
43 |
44 | {% endblock %}
45 |
--------------------------------------------------------------------------------
/src/Twig/AppExtension.php:
--------------------------------------------------------------------------------
1 | entityManager = $entityManager;
17 | }
18 |
19 | public function getFilters(): array
20 | {
21 | return [
22 | new TwigFilter('flag_icon', [$this, 'flagIcon'], ['is_safe' => ['html']]),
23 | new TwigFilter('symbol_badge', [$this, 'symbolBadge'], ['is_safe' => ['html']]),
24 | new TwigFilter('asset_from_isin', [$this, 'assetFromIsin'], ['is_safe' => ['html']]),
25 | ];
26 | }
27 |
28 | public function flagIcon(?string $country): string
29 | {
30 | if ($country == null)
31 | return "";
32 |
33 | $country = strtolower($country);
34 | return "";
35 | }
36 |
37 | public function symbolBadge(?string $symbol): string
38 | {
39 | if ($symbol == null)
40 | return "";
41 |
42 | $symbol = strtoupper($symbol);
43 | return "$symbol";
44 | }
45 |
46 | public function assetFromIsin(?string $isin): int
47 | {
48 | if ($isin == null)
49 | return 0;
50 |
51 | $isin = strtoupper($isin);
52 |
53 | $repo = $this->entityManager->getRepository(Asset::class);
54 | $asset = $repo->findOneBy(['ISIN' => $isin]);
55 | if ($asset)
56 | {
57 | return $asset->getId();
58 | }
59 | return 0;
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/Controller/AnalysisController.php:
--------------------------------------------------------------------------------
1 | entityManager = $entityManager;
19 | }
20 |
21 | #[Route("/analysis", name: "analysis_index", methods: ["GET"])]
22 | #[IsGranted("ROLE_USER")]
23 | public function index(Request $request) {
24 | $user = $this->getUser();
25 |
26 | // TODO: what if accounts have different currencies?
27 |
28 | $qb = $this->entityManager->createQueryBuilder();
29 | $q = $qb->select('YEAR(t.time) as year, a.currency currency,
30 | SUM(COALESCE(t.cash, 0)) as cash,
31 | SUM(COALESCE(t.tax, 0)) as tax,
32 | SUM(COALESCE(t.commission, 0)) as commission,
33 | SUM(COALESCE(t.interest, 0)) as interest,
34 | SUM(COALESCE(t.consolidation, 0)) as consolidation')
35 | ->from('App\Entity\Account', 'a')
36 | ->innerJoin('App\Entity\Transaction', 't', Join::WITH, 'a.id = t.account')
37 | ->where('a.owner = :user')
38 | ->addGroupBy("year")
39 | ->addGroupBy("currency")
40 | ->setParameter('user', $user)
41 | ->getQuery();
42 |
43 | $data = $q->getResult();
44 | //dd($data);
45 |
46 | return $this->render('analysis/index.html.twig', ['data' => $data]);
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/.github/workflows/symfony.yml:
--------------------------------------------------------------------------------
1 | name: Symfony
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | jobs:
10 | symfony-tests:
11 | runs-on: ubuntu-latest
12 | steps:
13 | # To automatically get bug fixes and new Php versions for shivammathur/setup-php,
14 | # change this to (see https://github.com/shivammathur/setup-php#bookmark-versioning):
15 | - name: Setup PHP
16 | uses: shivammathur/setup-php@v2
17 | with:
18 | php-version: '8.4'
19 | - uses: actions/checkout@v5
20 | - name: Copy .env.test.local
21 | run: php -r "file_exists('.env.test.local') || copy('.env.test', '.env.test.local');"
22 | - name: Get Composer Cache Directory
23 | id: composer-cache
24 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
25 | - name: Cache Composer packages
26 | uses: actions/cache@v4
27 | with:
28 | path: ${{ steps.composer-cache.outputs.dir }}
29 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
30 | restore-keys: |
31 | ${{ runner.os }}-composer-
32 | - name: Install Dependencies
33 | run: composer install --no-ansi --no-interaction
34 |
35 | - name: Lint YAML files
36 | run: bin/console lint:yaml config --parse-tags
37 |
38 | - name: Lint TWIG files
39 | run: bin/console --env=test lint:twig
40 |
41 | - name: Lint Containers
42 | run: bin/console lint:container
43 |
44 | - name: Static Analysis
45 | run: vendor/bin/phpstan
46 |
47 | - name: Create Database and fill with data
48 | run: |
49 | bin/console --env=test doctrine:database:drop --force
50 | bin/console doctrine:migrations:migrate --no-interaction
51 | bin/console --env=test doctrine:fixtures:load -n
52 |
53 | - name: Execute tests (Unit and Feature tests) via PHPUnit
54 | run: bin/phpunit
55 |
--------------------------------------------------------------------------------
/src/Entity/TransactionAttachment.php:
--------------------------------------------------------------------------------
1 | transaction = $transaction;
36 | }
37 |
38 | public function setContent(string $filename, ?string $mimetype, $content)
39 | {
40 | $this->name = $filename;
41 | $this->mimetype = $mimetype;
42 | $this->content = $content;
43 | $this->time_uploaded = new \DateTime("now");
44 | }
45 |
46 | public function getName(): string
47 | {
48 | return $this->name;
49 | }
50 |
51 | public function getMimetype(): ?string
52 | {
53 | return $this->mimetype;
54 | }
55 |
56 | public function getContent()
57 | {
58 | return $this->content;
59 | }
60 |
61 | public function getTimeUploaded(): \DateTimeInterface
62 | {
63 | return $this->time_uploaded;
64 | }
65 |
66 | public function getTransaction()
67 | {
68 | return $this->transaction;
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/Form/AccountType.php:
--------------------------------------------------------------------------------
1 | setDefaults([
22 | 'data_class' => Account::class,
23 | ]);
24 | }
25 |
26 | public function buildForm(FormBuilderInterface $builder, array $options): void
27 | {
28 | $builder
29 | ->add('name', TextType::class)
30 | ->add('number', TextType::class, ['required' => false])
31 | ->add('iban', TextType::class, ['required' => false])
32 | ->add('type', ChoiceType::class, ['choices' => [
33 | 'Cash' => Account::TYPE_CASH,
34 | 'Margin' => Account::TYPE_MARGIN,
35 | ]])
36 | ->add('currency', CurrencyType::class)
37 | ->add('timezone', TimezoneType::class)
38 | ->add('star', CheckboxType::class, ['required' => false])
39 | ->add('save', SubmitType::class, ['label' => 'Submit', 'attr' => ['class' => 'btn btn-primary']])
40 | ->add('reset', ResetType::class, ['label' => 'Reset', 'attr' => ['class' => 'btn btn-secondary']])
41 | ->add('back', ButtonType::class, ['label' => 'Back', 'attr' => ['class' => 'btn btn-secondary']])
42 | ;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/tests/CurrencyConversionServiceTest.php:
--------------------------------------------------------------------------------
1 | get(CurrencyConversionService::class);
23 | $this->assertEquals("1", $cc->latestConversion("USD", "USD"));
24 | }
25 |
26 | public function testInvalid(): void
27 | {
28 | $cc = static::getContainer()->get(CurrencyConversionService::class);
29 | $this->assertEquals(null, $cc->latestConversion("XXX", "USD"));
30 | $this->assertEquals(null, $cc->latestConversion("USD", "XXX"));
31 | }
32 |
33 | public function testDirect(): void
34 | {
35 | $cc = static::getContainer()->get(CurrencyConversionService::class);
36 | $this->assertEquals(1.0735, $cc->latestConversion("EUR", "USD"));
37 | $this->assertEquals(0.9315, $cc->latestConversion("USD", "EUR"));
38 | }
39 |
40 | public function testDirectNoPrice(): void
41 | {
42 | $cc = static::getContainer()->get(CurrencyConversionService::class);
43 | $this->assertEquals(null, $cc->latestConversion("GBP", "USD"));
44 | $this->assertEquals(null, $cc->latestConversion("USD", "GBP"));
45 | }
46 |
47 | public function testDirectNoAsset(): void
48 | {
49 | $cc = static::getContainer()->get(CurrencyConversionService::class);
50 | $this->assertEquals(null, $cc->latestConversion("AUD", "USD"));
51 | $this->assertEquals(null, $cc->latestConversion("USD", "AUD"));
52 | }
53 |
54 | // TODO: Add tests for indirect price conversions (e.g. GBP->EUR)
55 | }
56 |
--------------------------------------------------------------------------------
/src/Service/CurrencyConversionService.php:
--------------------------------------------------------------------------------
1 | 'XC000A0E4TC6',
13 | 'EUR' => 'EU0009652759',
14 | 'GPB' => 'GB0031973075',
15 | ];
16 |
17 | private $entityManager;
18 |
19 | public function __construct(EntityManagerInterface $entityManager)
20 | {
21 | $this->entityManager = $entityManager;
22 | }
23 |
24 | public function latestConversion(string $from, string $to, int $scale = 4): ?string
25 | {
26 | if ($from == $to)
27 | {
28 | return "1";
29 | }
30 |
31 | if ($from == "USD")
32 | {
33 | $result = $this->latestConversion($to, "USD");
34 | return $result ? bcdiv("1", $result, $scale) : null;
35 | }
36 |
37 | if ($to == "USD" && array_key_exists($from, self::FX_USD_ISINS))
38 | {
39 | $ap = $this->entityManager->getRepository(AssetPrice::class);
40 | $asset_price = $ap->latestPriceByIsin(self::FX_USD_ISINS[$from]);
41 | if ($asset_price == null)
42 | {
43 | return null;
44 | }
45 |
46 | return $asset_price->getClose();
47 | }
48 |
49 | if ($to != "USD")
50 | {
51 | assert($from != "USD");
52 | // find exchange rate A -> USD -> B
53 | $fx_a = $this->latestConversion($from, "USD", 6);
54 | if ($fx_a != null)
55 | {
56 | $fx_b = $this->latestConversion("USD", $to, 6);
57 | if ($fx_b != null)
58 | {
59 | return bcmul($fx_a, $fx_b, $scale);
60 | }
61 | }
62 | }
63 |
64 | return null;
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/Command/DeletePricesCommand.php:
--------------------------------------------------------------------------------
1 | entityManager = $entityManager;
26 |
27 | parent::__construct();
28 | }
29 |
30 | protected function configure(): void
31 | {
32 | $this->addArgument('symbol', InputArgument::REQUIRED, 'Symbol of the asset');
33 | // TODO: Add date-range, single date, etc.
34 | }
35 |
36 | protected function execute(InputInterface $input, OutputInterface $output): int
37 | {
38 | $io = new SymfonyStyle($input, $output);
39 | $symbol = $input->getArgument('symbol');
40 |
41 | $a_repo = $this->entityManager->getRepository(Asset::class);
42 | $asset = $a_repo->findOneBySymbol($symbol);
43 | if (!$asset)
44 | {
45 | $io->error("Symbol '$symbol' not found");
46 | return Command::FAILURE;
47 | }
48 | $io->note("Deleting all prices for symbol '$symbol' that matches asset $asset");
49 |
50 | $ap_repo = $this->entityManager->getRepository(AssetPrice::class);
51 | $num = $ap_repo->deleteAssetPrices($asset);
52 | $this->entityManager->flush();
53 | $io->success("Deleted $num prices for symbol '$symbol'");
54 |
55 | return Command::SUCCESS;
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/templates/country/index.html.twig:
--------------------------------------------------------------------------------
1 | {% extends 'base.html.twig' %}
2 | {% import "assets.twig" as assets %}
3 |
4 | {% block includes %}
5 | {{ parent() }}
6 | {{ assets.datatables() }}
7 | {{ assets.flag_icons() }}
8 | {% endblock %}
9 |
10 | {% block title %}Administration of countries{% endblock %}
11 |
12 | {% block body %}
13 | {{ parent() }}
14 | List of countries that can be used inside the application.
15 | It is required to use ISO 3166-1 country codes.
16 |
17 | {% if is_granted('ROLE_ADMIN') %}
18 |
23 | {% endif %}
24 |
25 | {% if countries %}
26 |
27 |
28 |
29 | | Code |
30 | Name |
31 | Actions |
32 |
33 |
34 |
35 | {% for c in countries %}
36 |
37 | | {{ c.code|flag_icon }} {{ c.code }} |
38 | {{ c.code|country_name }} |
39 |
40 | {% if is_granted('ROLE_ADMIN') %}
41 |
42 | {% endif %}
43 | |
44 |
45 | {% endfor %}
46 |
47 |
48 | {% endif %}
49 | {% endblock %}
50 |
51 | {% block bodyscripts %}
52 | {{ parent() }}
53 |
58 |
59 | {% endblock %}
60 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
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 | # https://symfony.com/doc/current/configuration/secrets.html
13 | #
14 | # Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
15 | # https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
16 |
17 | ###> symfony/framework-bundle ###
18 | APP_ENV=prod
19 | APP_SECRET=
20 | ###< symfony/framework-bundle ###
21 |
22 | ###> doctrine/doctrine-bundle ###
23 | # Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
24 | # IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
25 | #
26 | DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
27 | # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
28 | # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
29 | # DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
30 | ###< doctrine/doctrine-bundle ###
31 |
32 | # Set the database type (Mysql, Sqlite, Postgresql, Oracle)
33 | # used in config/doctrine.yaml to include the correct extension functions for DoctrineExtensions
34 | #DATABASE_TYPE=Mysql
35 | DATABASE_TYPE=Sqlite
36 |
37 | # Set the Alpha Vantage API key for stock price downloads (get one form https://www.alphavantage.co/support/#api-key)
38 | ALPHAVANTAGE_KEY=
39 |
--------------------------------------------------------------------------------
/src/Form/RegistrationFormType.php:
--------------------------------------------------------------------------------
1 | add('username')
21 | ->add('name')
22 | ->add('email')
23 | ->add('currency', CurrencyType::class, ['help' => 'User\'s home currency. Other currencies will be converted to this currencies in overall sums (when supported).'])
24 | ->add('plainPassword', PasswordType::class, [
25 | // instead of being set onto the object directly,
26 | // this is read and encoded in the controller
27 | 'mapped' => false,
28 | 'attr' => ['autocomplete' => 'new-password'],
29 | 'constraints' => [
30 | new NotBlank(
31 | message: 'Please enter a password',
32 | ),
33 | new Length(
34 | min: 6,
35 | minMessage: 'Your password should be at least {{ limit }} characters',
36 | // max length allowed by Symfony for security reasons
37 | max: 4096,
38 | ),
39 | ],
40 | ])
41 | ;
42 | }
43 |
44 | public function configureOptions(OptionsResolver $resolver): void
45 | {
46 | $resolver->setDefaults([
47 | 'data_class' => User::class,
48 | ]);
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/DataFixtures/InstrumentFixture.php:
--------------------------------------------------------------------------------
1 | getRepository(Asset::class);
24 |
25 | $appl = $asset_manager->findOneBy(['ISIN' => 'US0378331005']);
26 |
27 | $appl_inst = new Instrument();
28 | $appl_inst->setName("Apple Mini-Future Long");
29 | $appl_inst->setISIN("DE000GX33NN1");
30 | $appl_inst->setEusipa(Instrument::EUSIPA_KNOCKOUT);
31 | $appl_inst->setCurrency("EUR");
32 | $appl_inst->setUnderlying($appl);
33 | $appl_inst->setExecutionTaxRate(0.0012); // 0.12 %
34 | $manager->persist($appl_inst);
35 |
36 | $appl_terms = new InstrumentTerms();
37 | $appl_terms->setInstrument($appl_inst);
38 | $appl_terms->setDate(\DateTime::createFromFormat('Y-m-d', '2022-05-25'));
39 | $appl_terms->setRatio(0.1);
40 | $appl_terms->setBarrier(25.808);
41 | $appl_terms->setStrike(25.1129);
42 | $manager->persist($appl_terms);
43 |
44 | $msft = $asset_manager->findOneBy(['ISIN' => 'US5949181045']);
45 |
46 | $msft_inst = new Instrument();
47 | $msft_inst->setName("Microsoft Corp.");
48 | $msft_inst->setISIN("US5949181045");
49 | $msft_inst->setEusipa(Instrument::EUSIPA_UNDERLYING);
50 | $msft_inst->setCurrency("USD");
51 | $msft_inst->setUnderlying($msft);
52 | $appl_inst->setExecutionTaxRate(0.0035); // 0.35 %
53 | $manager->persist($msft_inst);
54 |
55 | $manager->flush();
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/templates/analysis/index.html.twig:
--------------------------------------------------------------------------------
1 | {% extends 'base.html.twig' %}
2 | {% import "assets.twig" as assets %}
3 |
4 | {% block includes %}
5 | {{ parent() }}
6 | {{ assets.datatables() }}
7 | {% endblock %}
8 |
9 | {% block title %}Analysis{% endblock %}
10 |
11 | {% block body %}
12 | {{ parent() }}
13 |
14 |
15 |
16 | {% endblock %}
17 |
18 | {% block bodyscripts %}
19 | {{ parent() }}
20 |
51 | {% endblock %}
52 |
--------------------------------------------------------------------------------
/src/DataFixtures/AssetFixture.php:
--------------------------------------------------------------------------------
1 | setName("Apple Inc.");
21 | $appl->setISIN("US0378331005");
22 | $appl->setSymbol("AAPL");
23 | $appl->setType(Asset::TYPE_STOCK);
24 | $appl->setCurrency("USD");
25 | $appl->setCountry("US");
26 | $manager->persist($appl);
27 |
28 | $msft = new Asset();
29 | $msft->setName("Microsoft Corp.");
30 | $msft->setISIN("US5949181045");
31 | $msft->setSymbol("MSFT");
32 | $msft->setType(Asset::TYPE_STOCK);
33 | $msft->setCurrency("USD");
34 | $msft->setCountry("US");
35 | $manager->persist($msft);
36 |
37 | $sie = new Asset();
38 | $sie->setName("Siemens AG");
39 | $sie->setISIN("DE0007236101");
40 | $sie->setSymbol("SIE");
41 | $sie->setType(Asset::TYPE_STOCK);
42 | $sie->setCurrency("EUR");
43 | $sie->setCountry("DE");
44 | $sie->setPriceDataSource("xe:sie");
45 | $manager->persist($sie);
46 |
47 | $eurusd = new Asset();
48 | $eurusd->setName("EUR/USD");
49 | $eurusd->setISIN("EU0009652759");
50 | $eurusd->setSymbol("EURUSD");
51 | $eurusd->setType(Asset::TYPE_FX);
52 | $eurusd->setCurrency("USD");
53 | $manager->persist($eurusd);
54 |
55 | $gbpusd = new Asset();
56 | $gbpusd->setName("GBP/USD");
57 | $gbpusd->setISIN("GB0031973075");
58 | $gbpusd->setSymbol("GBPUSD");
59 | $gbpusd->setType(Asset::TYPE_FX);
60 | $gbpusd->setCurrency("USD");
61 | $manager->persist($gbpusd);
62 |
63 | $manager->flush();
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/templates/base.html.twig:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | {% block title %}Welcome!{% endblock %}
8 |
9 | {% block stylesheets %}
10 | {% endblock %}
11 |
12 | {% block includes %}
13 |
14 |
15 |
16 | {% endblock %}
17 |
18 |
19 | {{ include('inc/navbar.html.twig') }}
20 | {% for message in app.flashes('success') %}
21 |
22 | {{ message }}
23 |
24 |
25 | {% endfor %}
26 | {% for message in app.flashes('error') %}
27 |
28 | {{ message }}
29 |
30 |
31 | {% endfor %}
32 |
33 | {% block body %}
34 |
{{ block('title') }}
35 | {% endblock %}
36 |
37 | {% block bodyscripts %}
38 | {# see https://getbootstrap.com/docs/5.3/getting-started/introduction/ #}
39 |
40 | {% endblock %}
41 |
42 |
43 |
--------------------------------------------------------------------------------
/templates/account/details.html.twig:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
12 | {% import "macros.twig" as macros %}
13 |
14 |
15 | {% if account.number %}
16 | {{ macros.infobox('Number', account.number) }}
17 | {% endif %}
18 | {% if account.iban %}
19 | {{ macros.infobox('IBAN', account.iban) }}
20 | {% endif %}
21 | {{ macros.infobox('Type', account.typename) }}
22 | {% if balance %}
23 | {{ macros.infobox('Cash balance', balance | format_currency(account.currency)) }}
24 | {% endif %}
25 |
26 |
27 |
32 |
33 |
44 |
--------------------------------------------------------------------------------
/src/Service/FetchPrices.php:
--------------------------------------------------------------------------------
1 | entityManager = $entityManager;
23 |
24 | $this->datasources = [
25 | new Alphavantage($client),
26 | new Onvista($client),
27 | //new Marketwatch($client), // Must be last in list because it is the fallback
28 | ];
29 | }
30 |
31 | public function updatePrices(Asset $asset, \DateTimeInterface $startdate, ?\DateTimeInterface $enddate = null)
32 | {
33 | if ($enddate == null)
34 | {
35 | $enddate = new \DateTime("yesterday");
36 | }
37 |
38 | if ($startdate > $enddate)
39 | {
40 | return 0;
41 | }
42 |
43 | // try to find a datasource that accepts the asset
44 | $source = null;
45 | foreach ($this->datasources as $candidate)
46 | {
47 | if ($candidate->supports($asset))
48 | {
49 | $source = $candidate;
50 | break;
51 | }
52 | }
53 |
54 | if (is_null($source))
55 | {
56 | throw new \RuntimeException("Unsupported datasource for asset: " . $asset->getName());
57 | }
58 |
59 | $prices = $source->getPrices($asset, $startdate, $enddate);
60 |
61 | $num_prices = count($prices);
62 | if ($num_prices == 0)
63 | {
64 | return 0;
65 | }
66 | else
67 | {
68 | foreach ($prices as $price)
69 | {
70 | $this->entityManager->persist($price);
71 | }
72 | $this->entityManager->flush();
73 | return $num_prices;
74 | }
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/templates/currency/index.html.twig:
--------------------------------------------------------------------------------
1 | {% extends 'base.html.twig' %}
2 | {% import "assets.twig" as assets %}
3 |
4 | {% block includes %}
5 | {{ parent() }}
6 | {{ assets.datatables() }}
7 | {% endblock %}
8 |
9 | {% block title %}Currencies{% endblock %}
10 |
11 | {% block body %}
12 | {{ parent() }}
13 | List of currencies that can be used inside the application.
14 | It is required to use ISO 4217 currency codes.
15 |
16 | {% if is_granted('ROLE_ADMIN') %}
17 |
22 | {% endif %}
23 |
24 | {% if currencies %}
25 |
26 |
27 |
28 | | ISO 4217 Code | Name | ISIN (to USD) | Actions |
29 |
30 |
31 |
32 | {% for c in currencies %}
33 |
34 | | {{ c.code }} |
35 | {{ c.code|currency_name }} |
36 |
37 | {% set asset_id = c.isinUsd|asset_from_isin %}
38 | {% if asset_id > 0 %}
39 | {{ c.isinUsd }}
40 | {% endif %}
41 | |
42 |
43 | {% if is_granted('ROLE_ADMIN') %}
44 |
45 | {% endif %}
46 | |
47 |
48 | {% endfor %}
49 |
50 |
51 | {% endif %}
52 | {% endblock %}
53 |
54 | {% block bodyscripts %}
55 | {{ parent() }}
56 |
64 |
65 | {% endblock %}
66 |
--------------------------------------------------------------------------------
/src/Controller/RegistrationController.php:
--------------------------------------------------------------------------------
1 | entityManager = $entityManager;
21 | }
22 |
23 | #[Route('/register', name: 'app_register')]
24 | public function register(Request $request, UserPasswordHasherInterface $userPasswordHasherInterface): Response
25 | {
26 | $user = new User();
27 | $user->setCurrency("USD");
28 | $form = $this->createForm(RegistrationFormType::class, $user);
29 | $form->handleRequest($request);
30 |
31 | if ($form->isSubmitted() && $form->isValid()) {
32 | // encode the plain password
33 | $user->setPassword(
34 | $userPasswordHasherInterface->hashPassword(
35 | $user,
36 | $form->get('plainPassword')->getData()
37 | )
38 | );
39 |
40 | $num_users = $this->entityManager->getRepository(User::class)->countUsers();
41 | if ($num_users == 0)
42 | {
43 | // the first user automatically gets admin rights
44 | $user->setRoles(['ROLE_ADMIN']);
45 | }
46 |
47 | $this->entityManager->persist($user);
48 | $this->entityManager->flush();
49 | // do anything else you need here, like send an email
50 |
51 | $this->addFlash('success', "User {$user->getUserIdentifier()} created.");
52 |
53 | return $this->redirectToRoute('app_register');
54 | }
55 |
56 | return $this->render('registration/register.html.twig', [
57 | 'registrationForm' => $form->createView(),
58 | ]);
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/templates/transactionattachment/show.html.twig:
--------------------------------------------------------------------------------
1 | {% extends 'base.html.twig' %}
2 | {% import "assets.twig" as assets %}
3 |
4 | {% block includes %}
5 | {{ parent() }}
6 | {{ assets.datatables() }}
7 | {% endblock %}
8 |
9 | {% block title %}Attachments: {{ transaction.id }}{% endblock %}
10 |
11 | {% block body %}
12 |
13 |
19 |
20 | List of transaction attachments
21 |
22 | {% if attachments %}
23 |
24 |
25 |
26 | | Name |
27 | Date |
28 | Size |
29 | Actions |
30 |
31 |
32 |
33 | {% for a in attachments %}
34 |
35 | | {{ a.name }} |
36 | {{ a.time_uploaded | date('Y-m-d H:i:s') }} |
37 | {{ (a.size/1024) | number_format(1) }} kB |
38 |
39 |
40 |
41 | |
42 |
43 | {% endfor %}
44 |
45 |
46 | {% endif %}
47 | Upload
48 | {{ form_start(upload) }}
49 |
50 |
{{ form_widget(upload.file) }}
51 |
{{ form_widget(upload.upload) }}
52 |
53 | {{ form_end(upload) }}
54 | {% endblock %}
55 |
56 | {% block bodyscripts %}
57 | {{ parent() }}
58 |
66 |
67 | {% endblock %}
--------------------------------------------------------------------------------
/templates/assetnote/index.html.twig:
--------------------------------------------------------------------------------
1 | {% extends 'base.html.twig' %}
2 | {% import "assets.twig" as assets %}
3 |
4 | {% block includes %}
5 | {{ parent() }}
6 | {{ assets.datatables() }}
7 | {% endblock %}
8 |
9 | {% block title %}Asset notes/news overview{% endblock %}
10 |
11 | {% block body %}
12 | {{ parent() }}
13 |
14 |
19 |
20 | {% if notes %}
21 |
22 |
23 |
24 | | Date |
25 | Asset |
26 | Type |
27 | Title |
28 | Text |
29 | Actions |
30 |
31 |
32 |
33 | {% for note in notes %}
34 |
35 | | {{ note.date | date("Y-m-d") }} |
36 | {% if note.asset %}{{ note.asset.symbol }}{{ note.asset.name }}{% endif %} |
37 | {{ note.typename }} |
38 | {{ note.title }} |
39 | {{ note.text | u.truncate(40, '...') }} |
40 |
41 |
42 |
43 | |
44 |
45 | {% endfor %}
46 |
47 |
48 | {% include 'assetnote/dialog.html.twig' %}
49 | {% endif %}
50 | {% endblock %}
51 |
52 | {% block bodyscripts %}
53 | {{ parent() }}
54 |
62 |
63 | {% endblock %}
64 |
--------------------------------------------------------------------------------
/templates/assetnote/dialog.html.twig:
--------------------------------------------------------------------------------
1 |
16 |
54 |
--------------------------------------------------------------------------------
/config/packages/security.yaml:
--------------------------------------------------------------------------------
1 | security:
2 | # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
3 | password_hashers:
4 | Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
5 | App\Entity\User:
6 | algorithm: auto
7 |
8 | # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
9 | providers:
10 | # used to reload user from session & other features (e.g. switch_user)
11 | app_user_provider:
12 | entity:
13 | class: App\Entity\User
14 | property: username
15 | firewalls:
16 | dev:
17 | pattern: ^/(_(profiler|wdt)|css|images|js)/
18 | security: false
19 | main:
20 | lazy: true
21 | provider: app_user_provider
22 |
23 | # activate different ways to authenticate
24 | # https://symfony.com/doc/current/security.html#the-firewall
25 | form_login:
26 | login_path: login
27 | check_path: login
28 |
29 | remember_me:
30 | secret: '%kernel.secret%' # required
31 | lifetime: 604800 # 1 week in seconds
32 | token_provider:
33 | doctrine: true
34 |
35 | logout:
36 | path: logout
37 | target: /
38 |
39 | # https://symfony.com/doc/current/security/impersonating_user.html
40 | # switch_user: true
41 |
42 | # Easy way to control access for large sections of your site
43 | # Note: Only the *first* access control that matches will be used
44 | access_control:
45 | # - { path: ^/admin, roles: ROLE_ADMIN }
46 | # - { path: ^/profile, roles: ROLE_USER }
47 |
48 | when@test:
49 | security:
50 | password_hashers:
51 | # By default, password hashers are resource intensive and take time. This is
52 | # important to generate secure password hashes. In tests however, secure hashes
53 | # are not important, waste resources and increase test times. The following
54 | # reduces the work factor to the lowest possible values.
55 | Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
56 | algorithm: auto
57 | cost: 4 # Lowest possible value for bcrypt
58 | time_cost: 3 # Lowest possible value for argon
59 | memory_cost: 10 # Lowest possible value for argon
60 |
--------------------------------------------------------------------------------
/config/instruments.yaml:
--------------------------------------------------------------------------------
1 | # Defines financial instruments and their properties
2 | parameters:
3 | app.instruments:
4 | 0:
5 | name: Underlying
6 | account_types:
7 | - cash
8 |
9 | # Define terms for each "Underlying" class based on Asset type ID
10 | 2:
11 | name: Bond
12 | account_types:
13 | - cash
14 | terms:
15 | - interest_rate
16 | - termination_date
17 |
18 | 30:
19 | name: CDF
20 | account_types:
21 | - margin
22 | terms:
23 | - margin
24 | - interest_rate
25 | - date
26 | - ratio
27 |
28 | 1200:
29 | name: Discount Certificate
30 | account_types:
31 | - cash
32 | terms:
33 | - cap
34 | - ratio
35 | - termination_date
36 |
37 | 1250:
38 | name: Capped Bonus Certificate
39 | account_types:
40 | - cash
41 | terms:
42 | - barrier
43 | - bonus_level
44 | - cap
45 | - ratio
46 | - reverse_level
47 | - termination_date
48 |
49 | 1300:
50 | name: Tracker
51 | account_types:
52 | - cash
53 | terms:
54 | - interest_rate
55 | - ratio
56 |
57 | 1320:
58 | name: Bonus Certificate
59 | account_types:
60 | - cash
61 | terms:
62 | - barrier
63 | - bonus_level
64 | - ratio
65 | - reverse_level
66 | - termination_date
67 |
68 | 2100:
69 | name: Warrant
70 | account_types:
71 | - cash
72 | terms:
73 | - strike
74 | - ratio
75 | - termination_date
76 |
77 | 2110:
78 | name: Spread Warrant
79 | account_types:
80 | - cash
81 | terms:
82 | - cap
83 | - ratio
84 | - strike
85 | - termination_date
86 |
87 | 2200:
88 | name: Knockout
89 | account_types:
90 | - cash
91 | terms:
92 | - date
93 | - interest_rate
94 | - ratio
95 | - strike
96 |
97 | 2210:
98 | name: Mini Future
99 | account_types:
100 | - cash
101 | terms:
102 | - barrier
103 | - date
104 | - interest_rate
105 | - ratio
106 | - strike
107 |
108 | 2300:
109 | name: Constant Leverage
110 | account_types:
111 | - cash
112 | terms:
113 | - barrier
114 | - date
115 | - interest_rate
116 | - ratio
117 | - strike
118 |
--------------------------------------------------------------------------------
/src/Repository/UserRepository.php:
--------------------------------------------------------------------------------
1 | setPassword($newHashedPassword);
35 | $this->getEntityManager()->persist($user);
36 | $this->getEntityManager()->flush();
37 | }
38 |
39 | public function countUsers(): int
40 | {
41 | return $this->createQueryBuilder('u')
42 | ->select('COUNT(u.id)')
43 | ->getQuery()
44 | ->getSingleScalarResult();
45 | }
46 |
47 | // /**
48 | // * @return User[] Returns an array of User objects
49 | // */
50 | /*
51 | public function findByExampleField($value)
52 | {
53 | return $this->createQueryBuilder('u')
54 | ->andWhere('u.exampleField = :val')
55 | ->setParameter('val', $value)
56 | ->orderBy('u.id', 'ASC')
57 | ->setMaxResults(10)
58 | ->getQuery()
59 | ->getResult()
60 | ;
61 | }
62 | */
63 |
64 | /*
65 | public function findOneBySomeField($value): ?User
66 | {
67 | return $this->createQueryBuilder('u')
68 | ->andWhere('u.exampleField = :val')
69 | ->setParameter('val', $value)
70 | ->getQuery()
71 | ->getOneOrNullResult()
72 | ;
73 | }
74 | */
75 | }
76 |
--------------------------------------------------------------------------------
/src/Form/TransactionType.php:
--------------------------------------------------------------------------------
1 | setDefaults([
23 | 'data_class' => Transaction::class,
24 | ]);
25 | }
26 |
27 | public function buildForm(FormBuilderInterface $builder, array $options): void
28 | {
29 | $data = $options['data'];
30 | $currency = $data->getAccount()->getCurrency();
31 |
32 | $builder
33 | ->add('time', DateTimeType::class, ['label' => 'Time', 'date_widget' => 'single_text', 'time_widget' => 'single_text', 'with_seconds' => true])
34 | ->add('transaction_id', NumberType::class, ['html5' => true, 'required' => false, 'help' => 'Transaction ID used by your broker'])
35 | ->add('cash', MoneyType::class, ['required' => false, 'html5' => false, 'currency' => $currency, 'scale' => 4, 'help' => 'Cash input/output'])
36 | ->add('consolidation', MoneyType::class, ['required' => false, 'html5' => false, 'currency' => $currency, 'scale' => 4, 'help' => 'Correction if there is a balance mismatch'])
37 | ->add('interest', MoneyType::class, ['required' => false, 'html5' => false, 'currency' => $currency, 'scale' => 4, 'help' => 'Interest costs (usually negative)'])
38 | ->add('consolidated', CheckboxType::class, ['required' => false, 'help' => 'Check if this transaction matches with your broker'])
39 | ->add('notes', TextareaType::class, ['required' => false])
40 | ->add('save', SubmitType::class, ['label' => 'Submit', 'attr' => ['class' => 'btn btn-primary']])
41 | ->add('reset', ResetType::class, ['label' => 'Reset', 'attr' => ['class' => 'btn btn-secondary']])
42 | ->add('back', ButtonType::class, ['label' => 'Back', 'attr' => ['class' => 'btn btn-secondary']])
43 | ;
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/Form/AssetType.php:
--------------------------------------------------------------------------------
1 | setDefaults([
22 | 'data_class' => Asset::class,
23 | ]);
24 | }
25 |
26 | public function buildForm(FormBuilderInterface $builder, array $options): void
27 | {
28 | $builder
29 | ->add('isin', TextType::class, ['label' => 'ISIN'])
30 | ->add('name', TextType::class)
31 | ->add('symbol', TextType::class, ['label' => 'Asset symbol'])
32 | ->add('type', ChoiceType::class, ['choices' => [
33 | 'Bond' => Asset::TYPE_BOND,
34 | 'Commodity' => Asset::TYPE_COMMODITY,
35 | 'Crypto' => Asset::TYPE_CRYPTO,
36 | 'Fund' => Asset::TYPE_FUND,
37 | 'Foreign Exchange' => Asset::TYPE_FX,
38 | 'Index' => Asset::TYPE_INDEX,
39 | 'Stock' => Asset::TYPE_STOCK,
40 | ]])
41 | ->add('currency', CurrencyType::class)
42 | ->add('country', CountryType::class, ['required' => false])
43 | ->add('url', UrlType::class, ['label' => 'Information Link', 'required' => false])
44 | ->add('irurl', UrlType::class, ['label' => 'Investor Relations Link', 'required' => false])
45 | ->add('newsurl', UrlType::class, ['label' => 'News Link', 'required' => false])
46 | ->add('pricedatasource', TextType::class, ['label' => 'Price datasource expression (e.g. AV/AAPL, OV/86627)', 'required' => false])
47 | ->add('notes', TextareaType::class, ['required' => false])
48 | ->add('save', SubmitType::class, ['label' => 'Submit', 'attr' => ['class' => 'btn btn-primary']])
49 | ->add('reset', ResetType::class, ['label' => 'Reset', 'attr' => ['class' => 'btn btn-secondary']])
50 | ->add('back', ButtonType::class, ['label' => 'Back', 'attr' => ['class' => 'btn btn-secondary']])
51 | ;
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/templates/account/index.html.twig:
--------------------------------------------------------------------------------
1 | {% extends 'base.html.twig' %}
2 | {% import "assets.twig" as assets %}
3 |
4 | {% block includes %}
5 | {{ parent() }}
6 | {{ assets.datatables() }}
7 | {% endblock %}
8 |
9 | {% block title %}Accounts{% endblock %}
10 |
11 | {% block body %}
12 | {{ parent() }}
13 |
18 |
19 | {% if accounts %}
20 |
21 |
22 |
23 | | Name |
24 | Number |
25 | Type |
26 | Balance |
27 | Actions |
28 |
29 |
30 |
31 | {% for a in accounts %}
32 |
33 | | {% if a.star %}{% else %}{% endif %} {{ a.name }} |
34 | {{ a.number }} |
35 | {{ a.typename }} |
36 | {{ account_balance[a.id] | format_currency(a.currency) }} |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | |
45 |
46 | {% endfor %}
47 |
48 |
49 | {% endif %}
50 | {% endblock %}
51 |
52 | {% block bodyscripts %}
53 | {{ parent() }}
54 |
62 |
63 | {% endblock %}
64 |
--------------------------------------------------------------------------------
/src/Repository/AssetPriceRepository.php:
--------------------------------------------------------------------------------
1 | getEntityManager()
32 | ->createQuery($dql)
33 | ->setParameter('aid', $asset);
34 | return $q->getOneOrNullResult();
35 | }
36 |
37 | public function latestPriceByIsin(string $isin): ?AssetPrice
38 | {
39 | $dql = <<getEntityManager()
47 | ->createQuery($dql)
48 | ->setParameter('isin', $isin);
49 | return $q->getOneOrNullResult();
50 | }
51 |
52 | public function mostRecentPrices(Asset $asset, \DateTimeInterface $from_date)
53 | {
54 | $dql = <<= :fromdate
58 | SQL;
59 | $q = $this->getEntityManager()
60 | ->createQuery($dql)
61 | ->setParameter('aid', $asset)
62 | ->setParameter('fromdate', $from_date);
63 | return $q->getResult();
64 | }
65 |
66 | public function deleteAssetPrices(Asset $asset)
67 | {
68 | $dql = <<getEntityManager()
73 | ->createQuery($dql)
74 | ->setParameter('aid', $asset);
75 | return $q->execute();
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/Repository/TransactionRepository.php:
--------------------------------------------------------------------------------
1 | getEntityManager()->createQueryBuilder();
27 | $q = $qb
28 | ->select('t')
29 | ->from('App\Entity\Transaction', 't')
30 | ->leftJoin('App\Entity\Execution', 'e', Join::WITH, 't.id = e.transaction')
31 | ->where('t.account = :account')
32 | ->andWhere('e.transaction IS NULL')
33 | ->setParameter('account', $account)
34 | ->getQuery();
35 | return $q->getResult();
36 | }
37 |
38 |
39 | public function getAccountBalance(Account $account)
40 | {
41 | $q = $this->createQueryBuilder('t')
42 | ->select(
43 | '(COALESCE(SUM(t.portfolio), 0) + COALESCE(SUM(t.cash), 0) + COALESCE(SUM(t.commission), 0) + COALESCE(SUM(t.tax), 0) + COALESCE(SUM(t.interest), 0) + COALESCE(SUM(t.consolidation), 0)) as balance')
44 | ->where('t.account = :account')
45 | ->setParameter('account', $account)
46 | ->getQuery();
47 | return $q->getSingleScalarResult();
48 | }
49 |
50 | // /**
51 | // * @return Transaction[] Returns an array of Transaction objects
52 | // */
53 | /*
54 | public function findByExampleField($value)
55 | {
56 | return $this->createQueryBuilder('t')
57 | ->andWhere('t.exampleField = :val')
58 | ->setParameter('val', $value)
59 | ->orderBy('t.id', 'ASC')
60 | ->setMaxResults(10)
61 | ->getQuery()
62 | ->getResult()
63 | ;
64 | }
65 | */
66 |
67 | /*
68 | public function findOneBySomeField($value): ?Transaction
69 | {
70 | return $this->createQueryBuilder('t')
71 | ->andWhere('t.exampleField = :val')
72 | ->setParameter('val', $value)
73 | ->getQuery()
74 | ->getOneOrNullResult()
75 | ;
76 | }
77 | */
78 | }
79 |
--------------------------------------------------------------------------------
/templates/account/positions.html.twig:
--------------------------------------------------------------------------------
1 | {% extends 'base.html.twig' %}
2 | {% import "assets.twig" as assets %}
3 |
4 | {% block includes %}
5 | {{ parent() }}
6 | {{ assets.datatables() }}
7 | {{ assets.flag_icons() }}
8 | {% endblock %}
9 |
10 | {% block title %}{{ account.name }} Positions{% endblock %}
11 |
12 | {% block body %}
13 |
14 | {% include 'account/details.html.twig' with {'current_page': 'positions'} %}
15 |
16 |
17 |
18 |
19 | | Instrument |
20 | Asset |
21 | Type |
22 | Country |
23 | Termination |
24 | Units |
25 | Value |
26 | Actions |
27 |
28 |
29 |
30 | {% for p in positions %}
31 |
32 | | {{ p['instrument'].name }} |
33 |
34 | {{ p['assetsymbol'] }}
35 | {{ p['assetname'] }}
36 | |
37 | {{ p['instrument'].eusipaname }} |
38 | {{ p['assetcountry'] | flag_icon }} |
39 | {% if p['instrument'].terminationdate %}{{ p['instrument'].terminationdate | date("Y-m-d") }} {% endif %} |
40 | {{ p['units'] | number_format(2) }} |
41 | {{ p['totalvalue'] | format_currency(p['instrument'].currency) }} |
42 |
43 |
44 |
45 | |
46 |
47 | {% endfor %}
48 |
49 |
50 |
51 | | Total {{ positions | length }} positions |
52 |
53 |
54 |
55 | {% endblock %}
56 |
57 | {% block bodyscripts %}
58 | {{ parent() }}
59 |
70 | {% endblock %}
71 |
--------------------------------------------------------------------------------
/src/Entity/InstrumentPrice.php:
--------------------------------------------------------------------------------
1 | "Days since 1970-01-01"])]
18 | private $date;
19 |
20 | #[ORM\Column(type: "decimal", precision: 10, scale: 4)]
21 | private $open;
22 |
23 | #[ORM\Column(type: "decimal", precision: 10, scale: 4)]
24 | private $high;
25 |
26 | #[ORM\Column(type: "decimal", precision: 10, scale: 4)]
27 | private $low;
28 |
29 | #[ORM\Column(type: "decimal", precision: 10, scale: 4)]
30 | private $close;
31 |
32 | public function getInstrument(): Instrument
33 | {
34 | return $this->instrument;
35 | }
36 |
37 | public function setInstrument(Instrument $instrument): self
38 | {
39 | $this->instrument = $instrument;
40 |
41 | return $this;
42 | }
43 |
44 | public function getDate(): \DateTimeInterface
45 | {
46 | return $this->date;
47 | }
48 |
49 | public function setDate(\DateTimeInterface $date): self
50 | {
51 | $this->date = $date;
52 |
53 | return $this;
54 | }
55 |
56 | public function getOpen(): string
57 | {
58 | return $this->open;
59 | }
60 |
61 | public function getHigh(): string
62 | {
63 | return $this->high;
64 | }
65 |
66 | public function getLow(): string
67 | {
68 | return $this->low;
69 | }
70 |
71 | public function getClose(): string
72 | {
73 | return $this->close;
74 | }
75 |
76 | public function setOHLC(string $open, string $high, string $low, string $close): self
77 | {
78 | $this->open = $open;
79 | $this->high = $high;
80 | $this->low = $low;
81 | $this->close = $close;
82 |
83 | return $this;
84 | }
85 |
86 | public function setOpen(string $open): self
87 | {
88 | $this->open = $open;
89 |
90 | return $this;
91 | }
92 |
93 | public function setHigh(string $high): self
94 | {
95 | $this->high = $high;
96 |
97 | return $this;
98 | }
99 |
100 | public function setLow(string $low): self
101 | {
102 | $this->low = $low;
103 |
104 | return $this;
105 | }
106 |
107 | public function setClose(string $close): self
108 | {
109 | $this->close = $close;
110 |
111 | return $this;
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/templates/instrument/index.html.twig:
--------------------------------------------------------------------------------
1 | {% extends 'base.html.twig' %}
2 | {% import "assets.twig" as assets %}
3 |
4 | {% block includes %}
5 | {{ parent() }}
6 | {{ assets.datatables() }}
7 | {% endblock %}
8 |
9 | {% block title %}Instruments{% endblock %}
10 |
11 | {% block body %}
12 | {{ parent() }}
13 |
18 |
19 | {% if instruments %}
20 |
21 |
22 |
23 | | Name |
24 | ISIN |
25 | Underlying |
26 | EUSIPA |
27 | Direction |
28 | Status |
29 | Termination |
30 | Actions |
31 |
32 |
33 |
34 | {% for inst in instruments %}
35 |
36 | | {{ inst.name }} |
37 | {{ inst.isin }} |
38 |
39 | {{ inst.underlying.symbol }}
40 | {{ inst.underlying.name }}
41 | |
42 | {{ inst.eusipaname }} |
43 | {{ inst.directionname }} |
44 | {{ inst.statusname }} |
45 | {% if inst.terminationDate %} {{ inst.terminationDate|date("Y-m-d") }}{% endif %} |
46 |
47 |
48 |
49 |
50 | {% if inst.url %}
51 |
52 | {% endif %}
53 | |
54 |
55 | {% endfor %}
56 |
57 |
58 | {% endif %}
59 | {% endblock %}
60 |
61 | {% block bodyscripts %}
62 | {{ parent() }}
63 |
71 |
72 | {% endblock %}
73 |
--------------------------------------------------------------------------------
/templates/inc/navbar.html.twig:
--------------------------------------------------------------------------------
1 | {# see https://bootswatch.com/cosmo/ #}
2 |
51 |
--------------------------------------------------------------------------------
/src/Controller/CountryController.php:
--------------------------------------------------------------------------------
1 | entityManager = $entityManager;
23 | }
24 |
25 | #[Route("/country", name: "country_list")]
26 | public function index(): Response
27 | {
28 | $countries = $this->entityManager
29 | ->getRepository(Country::class)
30 | ->findAll();
31 | return $this->render('country/index.html.twig', [
32 | 'controller_name' => 'CountryController',
33 | 'countries' => $countries
34 | ]);
35 | }
36 |
37 | #[Route("/country/new", name: "country_new")]
38 | #[IsGranted('ROLE_ADMIN')]
39 | public function new(Request $request) {
40 | $country = new Country("");
41 |
42 | $form = $this->createFormBuilder($country)
43 | ->add('code', CountryType::class, ['label' => 'Choose a country to add'])
44 | ->add('save', SubmitType::class, ['label' => 'Create', 'attr' => ['class' => 'btn btn-primary']])
45 | ->getForm();
46 |
47 | $form->handleRequest($request);
48 |
49 | if ($form->isSubmitted() && $form->isValid()) {
50 | $country = $form->getData();
51 |
52 | $this->entityManager->persist($country);
53 | $this->entityManager->flush();
54 |
55 | return $this->redirectToRoute('country_list');
56 | }
57 |
58 | return $this->render('country/edit.html.twig', ['form' => $form]);
59 | }
60 |
61 | #[Route('/api/country/{id}', name: 'country_delete', methods: ['DELETE'])]
62 | #[IsGranted('ROLE_ADMIN')]
63 | public function delete(Request $request, Country $country) {
64 | try
65 | {
66 | $this->entityManager->remove($country);
67 | $this->entityManager->flush();
68 | $this->addFlash('success', "Country {$country->getCode()} deleted.");
69 | return new JsonResponse(['message' => 'ok']);
70 | }
71 | catch (\Exception $e)
72 | {
73 | $this->addFlash('error', $e->getMessage());
74 | return new JsonResponse(['message' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
75 | }
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/templates/account/transactions.html.twig:
--------------------------------------------------------------------------------
1 | {% extends 'base.html.twig' %}
2 | {% import "assets.twig" as assets %}
3 |
4 | {% block includes %}
5 | {{ parent() }}
6 | {{ assets.datatables() }}
7 | {% endblock %}
8 |
9 | {% block title %}{{ account.name }} Transactions{% endblock %}
10 |
11 | {% block body %}
12 |
13 | {% include 'account/details.html.twig' with {'current_page': 'transactions'} %}
14 |
15 |
16 |
17 |
18 | | Date/Time |
19 | Transaction |
20 | Cash |
21 | Consolidation |
22 | Interest |
23 | Notes |
24 | Actions |
25 |
26 |
27 |
28 | {% for t in transactions %}
29 |
30 | | {{ t.time | date('Y-m-d H:i') }} |
31 | {{ t.transactionid }}{% else %}class="badge bg-warning" style="float:right">{% endif %} |
32 | {% if t.cash %}{{ t.cash | format_currency(account.currency) }}{% else %} | {% endif %} |
33 | {% if t.consolidation %}{{ t.consolidation | format_currency(account.currency) }}{% else %} | {% endif %} |
34 | {% if t.interest %}{{ t.interest | format_currency(account.currency) }}{% else %} | {% endif %} |
35 | {{ t.notes | u.truncate(50, '...') }} |
36 |
37 |
38 |
39 |
40 | |
41 |
42 | {% endfor %}
43 |
44 |
45 |
46 | | Total {{ transactions | length }} transactions |
47 | {{ total['cash'] | format_currency(account.currency) }} |
48 | {{ total['consolidation'] | format_currency(account.currency) }} |
49 | {{ total['interest'] | format_currency(account.currency) }} |
50 | |
51 |
52 |
53 |
54 | {% endblock %}
55 |
56 | {% block bodyscripts %}
57 | {{ parent() }}
58 |
66 |
67 | {% endblock %}
68 |
--------------------------------------------------------------------------------
/src/Repository/InstrumentTermsRepository.php:
--------------------------------------------------------------------------------
1 | getEntityManager()
34 | ->createQuery($dql)
35 | ->setParameter('instrument', $instrument);
36 | return $q->getOneOrNullResult();
37 | }
38 |
39 | /**
40 | * @throws ORMException
41 | * @throws OptimisticLockException
42 | */
43 | public function add(InstrumentTerms $entity, bool $flush = true): void
44 | {
45 | $this->getEntityManager()->persist($entity);
46 | if ($flush) {
47 | $this->getEntityManager()->flush();
48 | }
49 | }
50 |
51 | /**
52 | * @throws ORMException
53 | * @throws OptimisticLockException
54 | */
55 | public function remove(InstrumentTerms $entity, bool $flush = true): void
56 | {
57 | $this->getEntityManager()->remove($entity);
58 | if ($flush) {
59 | $this->getEntityManager()->flush();
60 | }
61 | }
62 |
63 | // /**
64 | // * @return InstrumentTerms[] Returns an array of InstrumentTerms objects
65 | // */
66 | /*
67 | public function findByExampleField($value)
68 | {
69 | return $this->createQueryBuilder('i')
70 | ->andWhere('i.exampleField = :val')
71 | ->setParameter('val', $value)
72 | ->orderBy('i.id', 'ASC')
73 | ->setMaxResults(10)
74 | ->getQuery()
75 | ->getResult()
76 | ;
77 | }
78 | */
79 |
80 | /*
81 | public function findOneBySomeField($value): ?InstrumentTerms
82 | {
83 | return $this->createQueryBuilder('i')
84 | ->andWhere('i.exampleField = :val')
85 | ->setParameter('val', $value)
86 | ->getQuery()
87 | ->getOneOrNullResult()
88 | ;
89 | }
90 | */
91 | }
92 |
--------------------------------------------------------------------------------
/src/Form/AssetNoteType.php:
--------------------------------------------------------------------------------
1 | setDefaults([
26 | 'data_class' => AssetNote::class,
27 | 'asset_editable' => true,
28 | ]);
29 | }
30 |
31 | public function buildForm(FormBuilderInterface $builder, array $options): void
32 | {
33 | $builder
34 | ->add('type', ChoiceType::class, ['choices' => [
35 | 'Note' => AssetNote::TYPE_NOTE,
36 | 'News' => AssetNote::TYPE_NEWS,
37 | 'Event' => AssetNote::TYPE_EVENT,
38 | ]])
39 | ->add('date', DateType::class, ['required' => true, 'widget' => 'single_text']);
40 | if ($options['asset_editable'])
41 | {
42 | $builder->add('asset', EntityType::class, [
43 | 'class' => Asset::class,
44 | 'choice_label' => function ($asset) {
45 | return sprintf("%s [%s] (%s)", $asset->getName(), $asset->getSymbol(), $asset->getIsin());
46 | },
47 | 'query_builder' => function (AssetRepository $ar) {
48 | return $ar->createQueryBuilder('a')
49 | ->orderBy('a.name', 'ASC');
50 | },
51 | 'group_by' => function($val, $key, $index) { return $val->getTypeName(); },
52 | 'required' => false,
53 | ]);
54 | } else {
55 | $builder->add('asset', TextType::class, ['disabled' =>'true']);
56 | }
57 | $builder
58 | ->add('title', TextType::class, ['required' => true, 'trim' => true])
59 | ->add('text', TextareaType::class, [
60 | 'required' => false,
61 | 'attr' => ['rows' => 5],
62 | ])
63 | ->add('url', UrlType::class, ['label' => 'Information Link', 'required' => false])
64 | ->add('save', SubmitType::class, ['label' => 'Submit', 'attr' => ['class' => 'btn btn-primary']])
65 | ->add('reset', ResetType::class, ['label' => 'Reset', 'attr' => ['class' => 'btn btn-secondary']])
66 | ->add('back', ButtonType::class, ['label' => 'Back', 'attr' => ['class' => 'btn btn-secondary']])
67 | ;
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/templates/asset/index.html.twig:
--------------------------------------------------------------------------------
1 | {% extends 'base.html.twig' %}
2 | {% import "assets.twig" as assets %}
3 |
4 | {% block includes %}
5 | {{ parent() }}
6 | {{ assets.datatables() }}
7 | {{ assets.flag_icons() }}
8 | {% endblock %}
9 |
10 | {% block title %}Assets{% endblock %}
11 |
12 | {% block body %}
13 | {{ parent() }}
14 |
20 |
21 | {% if assets %}
22 |
23 |
24 |
25 | | Name |
26 | Symbol |
27 | ISIN |
28 | Type |
29 | Country |
30 | Last price |
31 | Actions |
32 |
33 |
34 |
35 | {% for a in assets %}
36 |
37 | | {{ a.asset.name }} |
38 | {{ a.asset.symbol | symbol_badge }} |
39 | {{ a.asset.isin }} |
40 | {{ a.asset.typename }} |
41 | {{ a.asset.country | flag_icon }} |
42 | {% if a.price %}
43 | {{ a.price.close | format_currency(a.asset.currency) }} |
44 | {% else %}
45 | |
46 | {% endif %}
47 |
48 |
49 |
50 | {% if a.asset.url %}
51 |
52 | {% endif %}
53 | {% if a.asset.irurl %}
54 |
55 | {% endif %}
56 | {% if a.asset.newsurl %}
57 |
58 | {% endif %}
59 | |
60 |
61 | {% endfor %}
62 |
63 |
64 | {% endif %}
65 | {% endblock %}
66 |
67 | {% block bodyscripts %}
68 | {{ parent() }}
69 |
80 |
81 | {% endblock %}
82 |
--------------------------------------------------------------------------------
/src/Entity/AssetPrice.php:
--------------------------------------------------------------------------------
1 | true])]
39 | private int $volume = 0;
40 |
41 | public function getAsset(): Asset
42 | {
43 | return $this->asset;
44 | }
45 |
46 | public function setAsset(Asset $asset): self
47 | {
48 | $this->asset = $asset;
49 |
50 | return $this;
51 | }
52 |
53 | public function getDate(): \DateTimeInterface
54 | {
55 | return $this->date;
56 | }
57 |
58 | public function setDate(\DateTimeInterface $date): self
59 | {
60 | $this->date = $date;
61 |
62 | return $this;
63 | }
64 |
65 | public function getOpen(): string
66 | {
67 | return $this->open;
68 | }
69 |
70 | public function getHigh(): string
71 | {
72 | return $this->high;
73 | }
74 |
75 | public function getLow(): string
76 | {
77 | return $this->low;
78 | }
79 |
80 | public function getClose(): string
81 | {
82 | return $this->close;
83 | }
84 |
85 | public function setOHLC(string $open, string $high, string $low, string $close): self
86 | {
87 | $this->open = $open;
88 | $this->high = $high;
89 | $this->low = $low;
90 | $this->close = $close;
91 |
92 | return $this;
93 | }
94 |
95 | public function getVolume(): int
96 | {
97 | return $this->volume;
98 | }
99 |
100 | public function setOpen(string $open): self
101 | {
102 | $this->open = $open;
103 |
104 | return $this;
105 | }
106 |
107 | public function setHigh(string $high): self
108 | {
109 | $this->high = $high;
110 |
111 | return $this;
112 | }
113 |
114 | public function setLow(string $low): self
115 | {
116 | $this->low = $low;
117 |
118 | return $this;
119 | }
120 |
121 | public function setClose(string $close): self
122 | {
123 | $this->close = $close;
124 |
125 | return $this;
126 | }
127 |
128 | public function setVolume(int $volume): self
129 | {
130 | $this->volume = $volume;
131 |
132 | return $this;
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "matthiastraka/php-invest",
3 | "type": "project",
4 | "description": "PHP Stock market portfolio manager",
5 | "keywords": ["php", "portfolio", "stock"],
6 | "license": "GPL-3.0-or-later",
7 | "authors": [
8 | {
9 | "name": "Matthias Straka"
10 | }
11 | ],
12 | "minimum-stability": "stable",
13 | "prefer-stable": true,
14 | "require": {
15 | "php": ">=8.4.0",
16 | "ext-bcmath": "*",
17 | "ext-ctype": "*",
18 | "ext-iconv": "*",
19 | "beberlei/doctrineextensions": "^1.3",
20 | "doctrine/doctrine-bundle": "^3.1",
21 | "doctrine/doctrine-migrations-bundle": "^3.0",
22 | "league/commonmark": "^2.6",
23 | "symfony/apache-pack": "^1.0",
24 | "symfony/asset": "^8.0",
25 | "symfony/console": "^8.0",
26 | "symfony/dotenv": "^8.0",
27 | "symfony/flex": "^2.2",
28 | "symfony/form": "^8.0",
29 | "symfony/framework-bundle": "^8.0",
30 | "symfony/http-client": "^8.0",
31 | "symfony/mime": "^8.0",
32 | "symfony/runtime": "^8.0",
33 | "symfony/security-bundle": "^8.0",
34 | "symfony/twig-bundle": "^8.0",
35 | "symfony/validator": "^8.0",
36 | "symfony/yaml": "^8.0",
37 | "twig/extra-bundle": "^3.6",
38 | "twig/intl-extra": "^3.6",
39 | "twig/markdown-extra": "^3.6",
40 | "twig/string-extra": "^3.6"
41 | },
42 | "require-dev": {
43 | "doctrine/doctrine-fixtures-bundle": "^4.3",
44 | "phpstan/extension-installer": "^1.4",
45 | "phpstan/phpstan": "^2.1",
46 | "phpstan/phpstan-doctrine": "^2.0",
47 | "phpunit/phpunit": "^10",
48 | "symfony/browser-kit": "^8.0",
49 | "symfony/css-selector": "^8.0",
50 | "symfony/maker-bundle": "^1.65",
51 | "symfony/phpunit-bridge": "^8.0",
52 | "symfony/stopwatch": "^8.0",
53 | "symfony/web-profiler-bundle": "^8.0"
54 | },
55 | "config": {
56 | "optimize-autoloader": true,
57 | "preferred-install": {
58 | "*": "dist"
59 | },
60 | "sort-packages": true,
61 | "allow-plugins": {
62 | "symfony/flex": true,
63 | "symfony/runtime": true,
64 | "phpstan/extension-installer": true
65 | }
66 | },
67 | "autoload": {
68 | "psr-4": {
69 | "App\\": "src/"
70 | }
71 | },
72 | "autoload-dev": {
73 | "psr-4": {
74 | "App\\Tests\\": "tests/"
75 | }
76 | },
77 | "replace": {
78 | "symfony/polyfill-ctype": "*",
79 | "symfony/polyfill-iconv": "*",
80 | "symfony/polyfill-php72": "*"
81 | },
82 | "scripts": {
83 | "auto-scripts": {
84 | "cache:clear": "symfony-cmd",
85 | "assets:install %PUBLIC_DIR%": "symfony-cmd"
86 | },
87 | "post-install-cmd": [
88 | "@auto-scripts",
89 | "npm install --omit=dev"
90 | ],
91 | "post-update-cmd": [
92 | "@auto-scripts"
93 | ]
94 | },
95 | "conflict": {
96 | "symfony/symfony": "*"
97 | },
98 | "extra": {
99 | "symfony": {
100 | "allow-contrib": false,
101 | "require": "^8.0"
102 | }
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/src/Command/FetchPricesCommand.php:
--------------------------------------------------------------------------------
1 | entityManager = $entityManager;
30 | $this->httpClient = $httpClient;
31 |
32 | parent::__construct();
33 | }
34 |
35 | protected function configure(): void
36 | {
37 | $this->addArgument('symbol', InputArgument::REQUIRED, 'Only fetch a specific symbol');
38 | }
39 |
40 | protected function execute(InputInterface $input, OutputInterface $output): int
41 | {
42 | $io = new SymfonyStyle($input, $output);
43 | $symbol = $input->getArgument('symbol');
44 |
45 | $a_repo = $this->entityManager->getRepository(Asset::class);
46 | $asset = $a_repo->findOneBySymbol($symbol);
47 | if (!$asset)
48 | {
49 | $io->error("Symbol $symbol not found");
50 | return Command::FAILURE;
51 | }
52 | else
53 | {
54 | $io->note("Symbol $symbol matches asset $asset");
55 | }
56 |
57 | $ap_repo = $this->entityManager->getRepository(AssetPrice::class);
58 | $last_price = $ap_repo->latestPrice($asset);
59 |
60 | $end_day = new \DateTime('yesterday');
61 | if ($last_price)
62 | {
63 | $start_day = $last_price->getDate()->add(new \DateInterval('P1D'));
64 | }
65 | else
66 | {
67 | // get one year worth of data
68 | $start_day = (new \DateTime('NOW'))->sub(new \DateInterval('P1Y'));
69 | }
70 |
71 | if ($start_day > $end_day)
72 | {
73 | $io->success("Prices are already up to date");
74 | return Command::SUCCESS;
75 | }
76 |
77 | $io->note("Fetching prices from {$start_day->format('Y-m-d')} to {$end_day->format('Y-m-d')}");
78 |
79 | $service = new FetchPrices($this->entityManager, $this->httpClient);
80 |
81 | try
82 | {
83 | $num_prices = $service->updatePrices($asset, $start_day, $end_day);
84 |
85 | if ($num_prices == 0)
86 | {
87 | $io->success("No prices fetched");
88 | }
89 | else
90 | {
91 | $io->success("Added $num_prices daily prices");
92 | }
93 | return Command::SUCCESS;
94 | }
95 | catch (\Exception $ex)
96 | {
97 | $io->error($ex->getMessage());
98 | return Command::FAILURE;
99 | }
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/src/Form/InstrumentTermsType.php:
--------------------------------------------------------------------------------
1 | setDefaults([
21 | 'data_class' => InstrumentTerms::class,
22 | 'currency' => '',
23 | 'available_terms' => [],
24 | ]);
25 |
26 | $resolver->setAllowedTypes('currency', 'string');
27 | $resolver->setAllowedTypes('available_terms', 'array');
28 | }
29 |
30 | public function buildForm(FormBuilderInterface $builder, array $options): void
31 | {
32 | $currency = $options['currency'];
33 | $available_terms = $options['available_terms'];
34 | $builder
35 | ->add('date', DateType::class, ['required' => true, 'widget' => 'single_text'])
36 | ->add('ratio', NumberType::class, ['required' => false, 'html5' => false, 'scale' => 4, 'help' => 'Ratio (e.g. 10% is 0.1)']);
37 | if (in_array('cap', $available_terms))
38 | {
39 | $builder->add('cap', MoneyType::class, [
40 | 'required' => false, 'html5' => false, 'currency' => $currency, 'scale' => 4]);
41 | }
42 | if (in_array('strike', $available_terms))
43 | {
44 | $builder->add('strike', MoneyType::class, [
45 | 'required' => false, 'html5' => false, 'currency' => $currency, 'scale' => 4]);
46 | }
47 | if (in_array('bonus_level', $available_terms))
48 | {
49 | $builder->add('bonus_level', MoneyType::class, [
50 | 'required' => false, 'html5' => false, 'currency' => $currency, 'scale' => 4]);
51 | }
52 | if (in_array('reverse_level', $available_terms))
53 | {
54 | $builder->add('reverse_level', MoneyType::class, [
55 | 'required' => false, 'html5' => false, 'currency' => $currency, 'scale' => 4]);
56 | }
57 | if (in_array('barrier', $available_terms))
58 | {
59 | $builder->add('barrier', MoneyType::class, [
60 | 'required' => false, 'html5' => false, 'currency' => $currency, 'scale' => 4]);
61 | }
62 | if (in_array('interest_rate', $available_terms))
63 | {
64 | $builder->add('interest_rate', NumberType::class, [
65 | 'required' => false, 'html5' => false, 'scale' => 4, 'help' => 'Interest rate (e.g. 3% is 0.03)']);
66 | }
67 | if (in_array('margin', $available_terms))
68 | {
69 | $builder->add('margin', NumberType::class, [
70 | 'required' => false, 'html5' => false, 'scale' => 4, 'help' => 'Margin rate (e.g. 20% is 0.2)']);
71 | }
72 | $builder
73 | ->add('save', SubmitType::class, ['label' => 'Submit', 'attr' => ['class' => 'btn btn-primary']])
74 | ->add('reset', ResetType::class, ['label' => 'Reset', 'attr' => ['class' => 'btn btn-secondary']])
75 | ->add('back', ButtonType::class, ['label' => 'Back', 'attr' => ['class' => 'btn btn-secondary']])
76 | ;
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/public/.htaccess:
--------------------------------------------------------------------------------
1 | # Use the front controller as index file. It serves as a fallback solution when
2 | # every other rewrite/redirect fails (e.g. in an aliased environment without
3 | # mod_rewrite). Additionally, this reduces the matching process for the
4 | # start page (path "/") because otherwise Apache will apply the rewriting rules
5 | # to each configured DirectoryIndex file (e.g. index.php, index.html, index.pl).
6 | DirectoryIndex index.php
7 |
8 | # By default, Apache does not evaluate symbolic links if you did not enable this
9 | # feature in your server configuration. Uncomment the following line if you
10 | # install assets as symlinks or if you experience problems related to symlinks
11 | # when compiling LESS/Sass/CoffeScript assets.
12 | # Options +SymLinksIfOwnerMatch
13 |
14 | # Disabling MultiViews prevents unwanted negotiation, e.g. "/index" should not resolve
15 | # to the front controller "/index.php" but be rewritten to "/index.php/index".
16 |
17 | Options -MultiViews
18 |
19 |
20 |
21 | # This Option needs to be enabled for RewriteRule, otherwise it will show an error like
22 | # 'Options FollowSymLinks or SymLinksIfOwnerMatch is off which implies that RewriteRule directive is forbidden'
23 | Options +SymLinksIfOwnerMatch
24 |
25 | RewriteEngine On
26 |
27 | # Determine the RewriteBase automatically and set it as environment variable.
28 | # If you are using Apache aliases to do mass virtual hosting or installed the
29 | # project in a subdirectory, the base path will be prepended to allow proper
30 | # resolution of the index.php file and to redirect to the correct URI. It will
31 | # work in environments without path prefix as well, providing a safe, one-size
32 | # fits all solution. But as you do not need it in this case, you can comment
33 | # the following 2 lines to eliminate the overhead.
34 | RewriteCond %{REQUEST_URI}::$0 ^(/.+)/(.*)::\2$
35 | RewriteRule .* - [E=BASE:%1]
36 |
37 | # Sets the HTTP_AUTHORIZATION header removed by Apache
38 | RewriteCond %{HTTP:Authorization} .+
39 | RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0]
40 |
41 | # Redirect to URI without front controller to prevent duplicate content
42 | # (with and without `/index.php`). Only do this redirect on the initial
43 | # rewrite by Apache and not on subsequent cycles. Otherwise we would get an
44 | # endless redirect loop (request -> rewrite to front controller ->
45 | # redirect -> request -> ...).
46 | # So in case you get a "too many redirects" error or you always get redirected
47 | # to the start page because your Apache does not expose the REDIRECT_STATUS
48 | # environment variable, you have 2 choices:
49 | # - disable this feature by commenting the following 2 lines or
50 | # - use Apache >= 2.3.9 and replace all L flags by END flags and remove the
51 | # following RewriteCond (best solution)
52 | RewriteCond %{ENV:REDIRECT_STATUS} =""
53 | RewriteRule ^index\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=308,L]
54 |
55 | # If the requested filename exists, simply serve it.
56 | # We only want to let Apache serve files and not directories.
57 | # Rewrite all other queries to the front controller.
58 | RewriteCond %{REQUEST_FILENAME} !-f
59 | RewriteRule ^ %{ENV:BASE}/index.php [L]
60 |
61 |
62 |
63 |
64 | # When mod_rewrite is not available, we instruct a temporary redirect of
65 | # the start page to the front controller explicitly so that the website
66 | # and the generated links can still be used.
67 | RedirectMatch 307 ^/$ /index.php/
68 | # RedirectTemp cannot be used instead
69 |
70 |
71 |
--------------------------------------------------------------------------------
/templates/account/trades.html.twig:
--------------------------------------------------------------------------------
1 | {% extends 'base.html.twig' %}
2 | {% import "assets.twig" as assets %}
3 | {% import "macros.twig" as macros %}
4 |
5 | {% block includes %}
6 | {{ parent() }}
7 | {{ assets.datatables() }}
8 | {% endblock %}
9 |
10 | {% block title %}{{ account.name }} Recent Trades{% endblock %}
11 |
12 | {% block body %}
13 |
14 | {% include 'account/details.html.twig' with {'current_page': 'trades'} %}
15 |
16 |
17 |
18 |
19 | | Date/Time |
20 | Type |
21 | Instrument |
22 | Volume |
23 | Price |
24 | Cashflow |
25 | IDs |
26 | Notes |
27 | Actions |
28 |
29 |
30 |
31 | {% for t in trades %}
32 | {% if t.direction > 0 %}
33 | {% set colorstyle = "color: green" %}
34 | {% elseif t.direction < 0 %}
35 | {% set colorstyle = "color: red" %}
36 | {% else %}
37 | {% set colorstyle = "" %}
38 | {% endif %}
39 |
40 | | {{ t.time | date('Y-m-d H:i') }} |
41 | {{ macros.transactionicon(t, colorstyle)}} |
42 | {{ t.instrument_name }} |
43 |
44 | {% if t.volume %} {{t.volume | number_format(2)}} {% endif %}
45 | |
46 | {% if t.price %} {{t.price | format_currency(t.execution_currency)}} {% endif %} |
47 |
50 | {% if t.cashflow %}
51 | {{t.cashflow | format_currency(account.currency)}}
52 | {% endif %}
53 | |
54 |
55 | {% if t.transaction_id %}{{ t.transaction_id }}{% else %}-{% endif %} /
56 | {% if t.execution_id %}{{ t.execution_id }}{% else %}-{% endif %}
57 | {% else %}class="badge bg-warning" style="float:right">{% endif %}
58 | |
59 | {{t.notes | u.truncate(40, '...')}} |
60 |
61 |
62 |
63 |
64 | |
65 |
66 | {% endfor %}
67 |
68 |
69 |
70 | {% endblock %}
71 |
72 | {% block bodyscripts %}
73 | {{ parent() }}
74 |
82 |
83 | {% endblock %}
84 |
--------------------------------------------------------------------------------
/src/Controller/CurrencyController.php:
--------------------------------------------------------------------------------
1 | entityManager = $entityManager;
25 | }
26 |
27 | #[Route('/currency', name: 'currency_list')]
28 | public function index(): Response
29 | {
30 | $currencies = $this->entityManager
31 | ->getRepository(Currency::class)
32 | ->findAll();
33 |
34 | return $this->render('currency/index.html.twig', [
35 | 'controller_name' => 'CurrencyController',
36 | 'currencies' => $currencies
37 | ]);
38 | }
39 |
40 | #[Route('/currency/new', name: 'currency_new')]
41 | #[IsGranted('ROLE_ADMIN')]
42 | public function new(Request $request) {
43 | $currency = new Currency("");
44 |
45 | $form = $this->createFormBuilder($currency)
46 | ->add('code', CurrencyType::class, ['label' => 'Choose a currency to add'])
47 | ->add('isinUsd', TextType::class, [
48 | 'label' => 'ISIN for USD-pair',
49 | 'required' => false,
50 | 'help' => 'ISIN of the currency pair with USD (e.g. EUR/USD uses ISIN EU0009652759)',
51 | ])
52 | ->add('save', SubmitType::class, ['label' => 'Create', 'attr' => ['class' => 'btn btn-primary']])
53 | ->getForm();
54 |
55 | $form->handleRequest($request);
56 |
57 | if ($form->isSubmitted() && $form->isValid()) {
58 | $currency = $form->getData();
59 |
60 | $this->entityManager->persist($currency);
61 | $this->entityManager->flush();
62 |
63 | return $this->redirectToRoute('currency_list');
64 | }
65 |
66 | return $this->render('currency/edit.html.twig', ['form' => $form]);
67 | }
68 |
69 | #[Route('/api/currency/{id}', name: 'currency_read', methods: ['GET'])]
70 | #[Cache(public: true, maxage: 60)]
71 | public function read(?Currency $currency) : JsonResponse {
72 | if (is_null($currency))
73 | {
74 | return new JsonResponse(['message' => 'Currency not found'], Response::HTTP_NOT_FOUND);
75 | }
76 | return new JsonResponse([
77 | 'code' => $currency->getCode(),
78 | 'isin' => $currency->getIsinUsd(),
79 | ]);
80 | }
81 |
82 | #[Route('/api/currency/{id}', name: 'currency_delete', methods: ['DELETE'])]
83 | #[IsGranted('ROLE_ADMIN')]
84 | public function delete(Request $request, Currency $currency) {
85 | try
86 | {
87 | $this->entityManager->remove($currency);
88 | $this->entityManager->flush();
89 | $this->addFlash('success', "Currency {$currency->getCode()} deleted.");
90 | return new JsonResponse(['message' => 'ok']);
91 | }
92 | catch (\Exception $e)
93 | {
94 | $this->addFlash('error', $e->getMessage());
95 | return new JsonResponse(['message' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
96 | }
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/tests/DataSourcesTest.php:
--------------------------------------------------------------------------------
1 | assertSame(Onvista::ParseDatasourceString(null), null);
18 | $this->assertSame(Onvista::ParseDatasourceString(""), null);
19 | $this->assertSame(Onvista::ParseDatasourceString("MW/AAPL"), null);
20 | $config = ['provider'=>'onvista', 'idInstrument' => '12345'];
21 | $this->assertSame(Onvista::ParseDatasourceString("OV/12345"), $config);
22 | $this->assertSame(Onvista::ParseDatasourceString('{"provider":"onvista","idInstrument":"12345"}'), $config);
23 | $config = ['provider' => 'onvista', 'idInstrument' => '12345', 'idNotation' => 678];
24 | $this->assertSame(Onvista::ParseDatasourceString("OV/12345@678"), $config);
25 | $config = ['provider' => 'onvista', 'idInstrument' => 'EURUSD', 'idNotation' => 678];
26 | $this->assertSame(Onvista::ParseDatasourceString("OV/EURUSD@678"), $config);
27 | }
28 |
29 | public function testOnvista(): void
30 | {
31 | $mockResponse = << 200, 'response_headers' => ['Content-Type: application/json']])
44 | ]);
45 | $source = new Onvista($httpClient);
46 |
47 | $appl = new Asset();
48 | $appl->setName("Apple Inc.");
49 | $appl->setISIN("US0378331005");
50 | $appl->setSymbol("AAPL");
51 | $appl->setType(Asset::TYPE_STOCK);
52 | $appl->setCurrency("USD");
53 | $appl->setCountry("US");
54 |
55 | $appl->setPriceDataSource('{"provider": "other"}');
56 | $this->assertSame($source->supports($appl), false);
57 |
58 | $appl->setPriceDataSource('{"provider": "onvista"}');
59 | $this->assertSame($source->supports($appl), false);
60 |
61 | $appl->setPriceDataSource('{"provider": "onvista", "idInstrument": 86627}');
62 | $this->assertSame($source->supports($appl), true);
63 |
64 | $prices = $source->getPrices($appl,
65 | \DateTime::createFromFormat('U', 1665403200),
66 | \DateTime::createFromFormat('U', 1665489500));
67 | $this->assertSame(count($prices), 1);
68 | $this->assertSame($prices[0]->getOpen(), '5');
69 | $this->assertSame($prices[0]->getHigh(), '100');
70 | $this->assertSame($prices[0]->getLow(), '1');
71 | $this->assertSame($prices[0]->getClose(), '10');
72 | $this->assertSame($prices[0]->getVolume(), 11);
73 | }
74 |
75 | public function testAlphavantage(): void
76 | {
77 | $this->assertSame(Alphavantage::ParseDatasourceString(null), null);
78 | $this->assertSame(Alphavantage::ParseDatasourceString(""), null);
79 | $this->assertSame(Alphavantage::ParseDatasourceString("MW/AAPL"), null);
80 | $aapl = ['provider'=>'alphavantage', 'symbol' => 'AAPL'];
81 | $this->assertSame(Alphavantage::ParseDatasourceString("AV/AAPL"), $aapl);
82 | $msft = ['provider'=>'alphavantage', 'symbol' => 'MSFT'];
83 | $this->assertSame(Alphavantage::ParseDatasourceString('{"provider":"alphavantage","symbol":"MSFT"}'), $msft);
84 | $sie = ['provider'=>'alphavantage', 'symbol' => 'SIE.DEX'];
85 | $this->assertSame(Alphavantage::ParseDatasourceString("AV/SIE.DEX"), $sie);
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/src/Entity/AssetNote.php:
--------------------------------------------------------------------------------
1 | true])]
35 | #[Assert\Choice(choices: [
36 | self::TYPE_NOTE,
37 | self::TYPE_NEWS,
38 | self::TYPE_EVENT,
39 | ])]
40 | private $type = self::TYPE_NOTE;
41 |
42 | #[ORM\Column(type: "text", length: 65535, nullable: true)]
43 | private $text;
44 |
45 | #[ORM\Column(type: "string", length: 2048, nullable: true)]
46 | #[Assert\Url]
47 | private $url;
48 |
49 | #[ORM\ManyToOne(targetEntity: User::class)]
50 | #[ORM\JoinColumn(nullable: true)]
51 | private $author;
52 |
53 | public function getId(): int
54 | {
55 | return $this->id;
56 | }
57 |
58 | public function getAsset(): ?Asset
59 | {
60 | return $this->asset;
61 | }
62 |
63 | public function setAsset(?Asset $asset): self
64 | {
65 | $this->asset = $asset;
66 |
67 | return $this;
68 | }
69 |
70 | public function getAuthor(): ?User
71 | {
72 | return $this->author;
73 | }
74 |
75 | public function setAuthor(?User $user): self
76 | {
77 | $this->author = $user;
78 |
79 | return $this;
80 | }
81 |
82 | public function getType(): int
83 | {
84 | return $this->type;
85 | }
86 |
87 | public function setType(int $type): self
88 | {
89 | $this->type = $type;
90 |
91 | return $this;
92 | }
93 |
94 | public static function typeNameFromValue(int $type): string
95 | {
96 | switch ($type) {
97 | case self::TYPE_NOTE:
98 | return "Note";
99 | case self::TYPE_NEWS:
100 | return "News";
101 | case self::TYPE_EVENT:
102 | return "Event";
103 | default:
104 | return "Unknown";
105 | }
106 | }
107 |
108 | public function getTypeName(): string
109 | {
110 | return self::typeNameFromValue($this->type);
111 | }
112 |
113 | public function getDate(): \DateTimeInterface
114 | {
115 | return $this->date;
116 | }
117 |
118 | public function setDate(\DateTimeInterface $date): self
119 | {
120 | $this->date = $date;
121 |
122 | return $this;
123 | }
124 |
125 | public function getTitle(): string
126 | {
127 | return $this->title;
128 | }
129 |
130 | public function setTitle(string $title): self
131 | {
132 | $this->title = $title;
133 |
134 | return $this;
135 | }
136 |
137 | public function getText(): ?string
138 | {
139 | return $this->text;
140 | }
141 |
142 | public function setText(?string $text): self
143 | {
144 | $this->text = $text;
145 |
146 | return $this;
147 | }
148 |
149 | public function getUrl(): ?string
150 | {
151 | return $this->url;
152 | }
153 |
154 | public function setUrl(?string $url): self
155 | {
156 | $this->url = $url;
157 |
158 | return $this;
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/src/Controller/ExecutionController.php:
--------------------------------------------------------------------------------
1 | entityManager = $entityManager;
24 | }
25 |
26 | #[Route("/execution/new", name: "execution_new")]
27 | #[IsGranted("ROLE_USER")]
28 | public function newExecution(Request $request): Response
29 | {
30 | $instrument_id = intval($request->query->get('instrument'));
31 | $direction = $request->query->get('direction');
32 | $account = $request->query->get('account');
33 |
34 | $data = new ExecutionFormModel();
35 |
36 | if ($instrument_id > 0)
37 | {
38 | $data->instrument = $this->entityManager->getRepository(Instrument::class)->find($instrument_id);
39 | $data->currency = $data->instrument->getCurrency();
40 | }
41 |
42 | if ($account > 0)
43 | {
44 | $data->account = $this->entityManager->getRepository(Account::class)->find($account);
45 | }
46 |
47 | switch ($direction)
48 | {
49 | case "open":
50 | $data->direction = 1;
51 | break;
52 | case "close":
53 | $data->direction = -1;
54 | break;
55 | default:
56 | $data->direction = 0;
57 | $data->type = Execution::TYPE_DIVIDEND;
58 | break;
59 | }
60 |
61 | $data->time = new \DateTime();
62 |
63 | $form = $this->createForm(ExecutionType::class, $data);
64 |
65 | $form->handleRequest($request);
66 |
67 | if ($form->isSubmitted() && $form->isValid()) {
68 | $execution = new Execution();
69 | $execution->setTransaction(new Transaction());
70 |
71 | $data = $form->getData();
72 | $data->populateExecution($execution);
73 |
74 | $this->entityManager->persist($execution);
75 | $this->entityManager->flush();
76 |
77 | return $this->redirectToRoute('instrument_show', ["id" => $data->instrument->getId()]);
78 | }
79 |
80 | return $this->render('execution/edit.html.twig', ['form' => $form]);
81 | }
82 |
83 | #[Route("/execution/edit/{id}", name: "execution_edit", methods: ["GET", "POST"])]
84 | #[IsGranted("ROLE_USER")]
85 | public function edit(Request $request, ?Execution $execution) {
86 | $data = new ExecutionFormModel();
87 | $data->fromExecution($execution);
88 |
89 | $form = $this->createForm(ExecutionType::class, $data);
90 |
91 | $form->handleRequest($request);
92 |
93 | if ($form->isSubmitted() && $form->isValid()) {
94 | $data = $form->getData();
95 | $data->populateExecution($execution);
96 |
97 | $this->entityManager->persist($execution);
98 | $this->entityManager->flush();
99 |
100 | $redirect = $request->request->get('referer');
101 | if ($redirect) {
102 | return $this->redirect($redirect);
103 | } else {
104 | return $this->redirectToRoute('instrument_show', ["id" => $data->instrument->getId()]);
105 | }
106 | }
107 |
108 | return $this->render('execution/edit.html.twig', ['form' => $form]);
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/templates/portfolio/index.html.twig:
--------------------------------------------------------------------------------
1 | {% extends "base.html.twig" %}
2 | {% import "assets.twig" as assets %}
3 |
4 | {% block includes %}
5 | {{ parent() }}
6 | {{ assets.datatables() }}
7 | {{ assets.flag_icons() }}
8 | {% endblock %}
9 |
10 | {% block title %}Portfolio Overview{% endblock %}
11 |
12 | {% block body %}
13 | {{ parent() }}
14 |
19 |
20 | {% if positions %}
21 |
22 |
23 |
24 | | Instrument |
25 | Asset |
26 | Co. |
27 | EUSIPA Type |
28 | Termination |
29 | Account |
30 | Units |
31 | Value |
32 | Underlying |
33 | Actions |
34 |
35 |
36 |
37 | {% set last_currency = "" %}
38 | {% for p in positions %}
39 |
40 | | {{ p['instrument'].name }} |
41 |
42 | {{ p['asset_symbol'] }}
43 | {{ p['asset_name'] }}
44 | |
45 | {{ p['asset_country'] | flag_icon }} |
46 |
47 | {{ p['asset_type'] }} / {{ p['instrument'].eusipaname }}
48 | |
49 | {% if p['instrument'].terminationdate %}{{ p['instrument'].terminationdate | date("Y-m-d") }} {% endif %} |
50 | {{ p['account_name'] }} |
51 | {{ p['units'] | number_format(2) }} |
52 | {{ p['value_total'] | format_currency(p['instrument'].currency) }} |
53 | {% if p['value_underlying'] %}
54 | {{ p['value_underlying'] | format_currency(p['asset_currency']) }} |
55 | {% else %}
56 | |
57 | {% endif %}
58 |
59 |
60 |
61 | |
62 |
63 | {% set last_currency = p['instrument'].currency %}
64 | {% endfor %}
65 |
66 |
67 |
68 | | Total {{ positions | length }} positions |
69 | {{ total['value_total'] | format_currency(last_currency) }} | {# TODO: Needs currency conversion / multi currency into a total sum #}
70 | |
71 |
72 |
73 |
74 | {% endif %}
75 | {% endblock %}
76 |
77 | {% block bodyscripts %}
78 | {{ parent() }}
79 |
90 |
91 | {% endblock %}
92 |
--------------------------------------------------------------------------------
/src/Controller/TransactionController.php:
--------------------------------------------------------------------------------
1 | entityManager = $entityManager;
22 | }
23 |
24 | #[Route("/transaction/new", name: "transaction_new")]
25 | #[IsGranted("ROLE_USER")]
26 | public function new(Request $request) {
27 | $account_id = intval($request->query->get('account'));
28 |
29 | $account = $this->entityManager->getRepository(Account::class)->findOneBy(['id' => $account_id, 'owner' => $this->getUser()]);
30 |
31 | if ($account == null)
32 | {
33 | return new Response('Invalid account', Response::HTTP_BAD_REQUEST);
34 | }
35 |
36 | $transaction = new Transaction();
37 | $transaction->setAccount($account);
38 | $transaction->setTime(new \DateTime());
39 |
40 | $form = $this->createForm(TransactionType::class, $transaction);
41 |
42 | $form->handleRequest($request);
43 |
44 | if ($form->isSubmitted() && $form->isValid()) {
45 | $transaction = $form->getData();
46 |
47 | $this->entityManager->persist($transaction);
48 | $this->entityManager->flush();
49 |
50 | return $this->redirectToRoute('account_transactions', ['id' => $account->getId()]);
51 | }
52 |
53 | return $this->render('transaction/edit.html.twig', ['form' => $form]);
54 | }
55 |
56 | #[Route("/transaction/{id}/edit", name: "transaction_edit", methods: ["GET", "POST"])]
57 | #[IsGranted("ROLE_USER")]
58 | public function edit(Transaction $transaction, Request $request) {
59 | $account = $transaction->getAccount();
60 | if ($account->getOwner() != $this->getUser())
61 | {
62 | $this->addFlash('error', 'You do not own this account');
63 | return $this->redirectToRoute('account_transactions', ['id' => $account->getId()]);
64 | }
65 |
66 | $form = $this->createForm(TransactionType::class, $transaction);
67 |
68 | $form->handleRequest($request);
69 |
70 | if ($form->isSubmitted() && $form->isValid()) {
71 | $transaction = $form->getData();
72 |
73 | $this->entityManager->persist($transaction);
74 | $this->entityManager->flush();
75 |
76 | return $this->redirectToRoute('account_transactions', ['id' => $account->getId()]);
77 | }
78 |
79 | return $this->render('transaction/edit.html.twig', ['form' => $form]);
80 | }
81 |
82 | #[Route("/api/transaction/{id}", name: "transaction_delete", methods: ["DELETE"])]
83 | #[IsGranted("ROLE_USER")]
84 | public function delete(Transaction $trans) {
85 | try
86 | {
87 | if ($trans->getAccount()->getOwner() != $this->getUser())
88 | {
89 | $this->addFlash('error', 'You do not own this account');
90 | return $this->redirectToRoute('account_list');
91 | }
92 | $this->entityManager->remove($trans);
93 | $this->entityManager->flush();
94 | $this->addFlash('success', "Transaction deleted.");
95 | return new JsonResponse(['message' => 'ok']);
96 | }
97 | catch (\Exception $e)
98 | {
99 | $this->addFlash('error', $e->getMessage());
100 | return new JsonResponse(['message' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
101 | }
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | # PHP-Invest documentation
2 |
3 | ## Definitions
4 | * **Asset**:
5 | A financial asset like stocks, bonds, currency or index.
6 | Assets cannot be traded directly but require an instrument.
7 | Price data can be downloaded for an asset from multiple data sources.
8 | * **Instrument**:
9 | A tradable instrument on an asset (e.g. the underlying stock or a derivative)
10 | * **Account**:
11 | An account/portfolio at a broker (or a virtual portfolio)
12 | * * Cash account: A type of account where you deposit cash that can be used to acquire several instruments
13 | * **User**: A user of the system.
14 | A user can own serveral accounts and track trades across them.
15 | Shared accounts may be added in a future version.
16 |
17 | ## Assets
18 | ### Downloading price data
19 | You can download price data from the internet using multiple data sources.
20 | In order to configure the data source, you need to fill in the `Price datasource expression` field of the asset. If the field is empty, a best guess is made using the asset symbol and country code.
21 |
22 | Currently, two data sources are implemented:
23 |
24 |
35 |
36 | #### Alphavantage
37 | [alphavantage.co](https://www.alphavantage.co/) requires a free (or paid) API key in order to access price data from this website.
38 | You can [request a key](https://www.alphavantage.co/support/#api-key) yourself. This key needs to be entered in your `.env.local` file (e.g. a line with `ALPHAVANTAGE_KEY=12345`).
39 |
40 | In order to use AlphaVantage for downloading price-data, use the `Price datasource expression` field of the asset and enter the following expression with an `AV/` prefix:
41 | ```
42 | AV/
43 | ```
44 | where `` is the ticker symbol (e.g. `AAPL` for Apple stock).
45 | It is also possible to use a JSON string in the format
46 | ```json
47 | {"provider": "alphavantage", "symbol": ""}
48 | ```
49 | Please refer to the [AlphaVantage documentation](https://www.alphavantage.co/documentation/#daily) for details on symbol names.
50 |
51 | #### Onvista.de
52 | [onvista.de](https://www.onvista.de/) requires no special setup to download daily price data.
53 | In order to use Onvista for downloading price-data, use the `Price datasource expression` field of the asset and enter following expressing with an *onvista instrument id*:
54 | ```
55 | OV/idInstrument
56 | ```
57 | or a JSON string with additional option (see below):
58 | ```json
59 | {"provider":"onvista", "idInstrument": "idInstrument"}
60 | ```
61 | where `idInstrument` is a string/number that identifies the instrument (e.g. 86627 for Apple stock: `OV/86627`).
62 |
63 | Optionally, you can add an `idNotation` via
64 | ```
65 | OV/idInstrument@idNotation
66 | ```
67 | or
68 | ```json
69 | {"provider":"onvista", "idInstrument": "idInstrument", "idNotation": idNotation}
70 | ```
71 | to select a specific market place (e.g. for Apple: `OV/86627@253929`)
72 |
73 | Currently, the `idInstrument` and `idNotation` can be found out by analyzing network traffic of your webbrowser by evaluating calls to the *chart_history* API calls when looking at the charts of the asset.
74 | Search functionality will be added at a later time (feel free to add a PR for this).
75 |
76 | There are additional (optional) properties you can set:
77 | * `type`: Type like `FUND` or `STOCK` when auto-type detection fails
78 | * `scale`: Multiplies price data by this value (defaults to 1)
79 |
80 | An expression with all optional fields use may look like this:
81 | ```json
82 | {"provider":"onvista", "idInstrument": "86627", "type": "STOCK", "idNotation": 253929, "scale": 1}
83 | ```
84 |
--------------------------------------------------------------------------------
/src/Entity/Account.php:
--------------------------------------------------------------------------------
1 | self::TYPE_CASH, "unsigned" => true])]
33 | private $type = self::TYPE_CASH;
34 |
35 | #[ORM\Column(type: "string", length: 3, options: ["fixed" => true, "comment" => "ISO 4217 Code"])]
36 | #[Assert\NotBlank]
37 | #[Assert\Currency]
38 | private $currency;
39 |
40 | #[ORM\Column(type: "string")]
41 | #[Assert\Timezone]
42 | private $timezone;
43 |
44 | #[ORM\ManyToOne(targetEntity: User::class)]
45 | #[ORM\JoinColumn(nullable: false)]
46 | private $owner;
47 |
48 | #[ORM\Column(type: "boolean", options: ["default" => false, "comment" => "User's favorite"])]
49 | private $star = false;
50 |
51 | public function getId(): ?int
52 | {
53 | return $this->id;
54 | }
55 |
56 | public function getName(): ?string
57 | {
58 | return $this->name;
59 | }
60 |
61 | public function setName(string $name): self
62 | {
63 | $this->name = $name;
64 |
65 | return $this;
66 | }
67 |
68 | public function getNumber(): ?string
69 | {
70 | return $this->number;
71 | }
72 |
73 | public function setNumber(?string $number): self
74 | {
75 | $this->number = $number;
76 |
77 | return $this;
78 | }
79 |
80 | public function getIban(): ?string
81 | {
82 | return $this->iban;
83 | }
84 |
85 | public function setIban(?string $iban): self
86 | {
87 | $this->iban = $iban;
88 |
89 | return $this;
90 | }
91 |
92 | public function getType(): int
93 | {
94 | return $this->type;
95 | }
96 |
97 | public function setType(int $type): self
98 | {
99 | $this->type = $type;
100 |
101 | return $this;
102 | }
103 |
104 | public static function typeNameFromValue(int $type): string
105 | {
106 | switch ($type) {
107 | case self::TYPE_CASH:
108 | return "Cash";
109 | case self::TYPE_MARGIN:
110 | return "Margin";
111 | default:
112 | return "Unknown";
113 | }
114 | }
115 |
116 | public function getTypeName(): string
117 | {
118 | return self::typeNameFromValue($this->type);
119 | }
120 |
121 | public function getCurrency(): ?string
122 | {
123 | return $this->currency;
124 | }
125 |
126 | public function setCurrency(string $currency): self
127 | {
128 | $this->currency = $currency;
129 |
130 | return $this;
131 | }
132 |
133 | public function getTimezone(): ?string
134 | {
135 | return $this->timezone;
136 | }
137 |
138 | public function setTimezone(string $timezone): self
139 | {
140 | $this->timezone = $timezone;
141 |
142 | return $this;
143 | }
144 |
145 | public function hasStar(): bool
146 | {
147 | return $this->star;
148 | }
149 |
150 | public function setStar(bool $star): self
151 | {
152 | $this->star = $star;
153 |
154 | return $this;
155 | }
156 |
157 | public function getOwner(): ?User
158 | {
159 | return $this->owner;
160 | }
161 |
162 | public function setOwner(?User $owner): self
163 | {
164 | $this->owner = $owner;
165 |
166 | return $this;
167 | }
168 |
169 | public function __toString(): string
170 | {
171 | return $this->name;
172 | }
173 | }
174 |
--------------------------------------------------------------------------------
/src/Entity/User.php:
--------------------------------------------------------------------------------
1 | true, "comment" => "ISO 4217 Code", "default" => "USD"])]
36 | #[Assert\Currency]
37 | private $currency;
38 |
39 | /**
40 | * @var string The hashed password
41 | */
42 | #[ORM\Column(type: Types::STRING)]
43 | private $password;
44 |
45 | public function getId(): ?int
46 | {
47 | return $this->id;
48 | }
49 |
50 | public function setUsername(string $username): self
51 | {
52 | $this->username = $username;
53 |
54 | return $this;
55 | }
56 |
57 | /**
58 | * A visual identifier that represents this user.
59 | *
60 | * @see UserInterface
61 | */
62 | public function getUserIdentifier(): string
63 | {
64 | return (string) $this->username;
65 | }
66 |
67 | public function getUsername(): string
68 | {
69 | return (string) $this->username;
70 | }
71 |
72 | /**
73 | * @see UserInterface
74 | */
75 | public function getRoles(): array
76 | {
77 | $roles = $this->roles;
78 | // guarantee every user at least has ROLE_USER
79 | $roles[] = 'ROLE_USER';
80 |
81 | return array_unique($roles);
82 | }
83 |
84 | public function setRoles(array $roles): self
85 | {
86 | $this->roles = $roles;
87 |
88 | return $this;
89 | }
90 |
91 | /**
92 | * @see PasswordAuthenticatedUserInterface
93 | */
94 | public function getPassword(): string
95 | {
96 | return $this->password;
97 | }
98 |
99 | public function setPassword(string $password): self
100 | {
101 | $this->password = $password;
102 |
103 | return $this;
104 | }
105 |
106 | /**
107 | * Returning a salt is only needed, if you are not using a modern
108 | * hashing algorithm (e.g. bcrypt or sodium) in your security.yaml.
109 | *
110 | * @see UserInterface
111 | */
112 | public function getSalt(): ?string
113 | {
114 | return null;
115 | }
116 |
117 | /**
118 | * @see UserInterface
119 | */
120 | public function eraseCredentials(): void
121 | {
122 | // If you store any temporary, sensitive data on the user, clear it here
123 | // $this->plainPassword = null;
124 | }
125 |
126 | public function getName(): ?string
127 | {
128 | return $this->name;
129 | }
130 |
131 | public function setName(string $name): self
132 | {
133 | $this->name = $name;
134 |
135 | return $this;
136 | }
137 |
138 | public function getEmail(): ?string
139 | {
140 | return $this->email;
141 | }
142 |
143 | public function setEmail(string $email): self
144 | {
145 | $this->email = $email;
146 |
147 | return $this;
148 | }
149 |
150 | public function getCurrency(): string
151 | {
152 | return $this->currency;
153 | }
154 |
155 | public function setCurrency(string $currency): self
156 | {
157 | $this->currency = $currency;
158 |
159 | return $this;
160 | }
161 | }
162 |
--------------------------------------------------------------------------------
/tests/ExecutionFormModelTest.php:
--------------------------------------------------------------------------------
1 | setTransaction($transaction);
18 |
19 | $execution->setPrice(123);
20 | $execution->setVolume(15);
21 | $execution->setCurrency("EUR");
22 | $transaction->setTime(new \DateTime());
23 | $transaction->setTax(33.6);
24 | // TODO: Set remaining values to correctly check for total prices
25 |
26 | $data = new ExecutionFormModel();
27 | $data->fromExecution($execution);
28 | $this->assertSame($data->price, $execution->getPrice());
29 | $this->assertSame($data->volume, $execution->getVolume());
30 | $this->assertSame($data->currency, $execution->getCurrency());
31 | $this->assertSame($data->exchange_rate, $execution->getExchangeRate());
32 | $this->assertSame($data->tax, $transaction->getTax());
33 | $this->assertSame($data->commission, null);
34 | $this->assertSame($data->interest, null);
35 |
36 | $execution2 = new Execution();
37 | $transaction2 = new Transaction();
38 | $execution2->setTransaction($transaction2);
39 | $data->populateExecution($execution2);
40 |
41 | $this->assertSame($execution2->getPrice(), $execution->getPrice());
42 | $this->assertSame($execution2->getVolume(), $execution->getVolume());
43 | $this->assertSame($execution2->getCurrency(), $execution->getCurrency());
44 | $this->assertSame($execution2->getExchangeRate(), $execution->getExchangeRate());
45 | $this->assertSame($transaction2->getTax(), $transaction->getTax());
46 | $this->assertSame($transaction2->getCommission(), $transaction->getCommission());
47 | $this->assertSame($transaction2->getInterest(), $transaction->getInterest());
48 | }
49 |
50 | public function testTaxCalculation(): void
51 | {
52 | $execution = new Execution();
53 | $transaction = new Transaction();
54 | $instrument = new Instrument();
55 | $execution->setTransaction($transaction);
56 | $execution->setInstrument($instrument);
57 | $instrument->setExecutionTaxRate(0.0012); // 0.12 %
58 |
59 | $transaction->setTime(new \DateTime());
60 | $execution->setPrice(123);
61 | $execution->setVolume(15);
62 | $execution->setCurrency("EUR");
63 |
64 | // should keep tax 0
65 | $transaction->setTax(0);
66 | $data = new ExecutionFormModel();
67 | $data->fromExecution($execution);
68 | $data->populateExecution($execution);
69 | $this->assertSame($transaction->getTax(), strval(0));
70 |
71 | // should keep the inserted tax value
72 | $transaction->setTax(-1.23);
73 | $data = new ExecutionFormModel();
74 | $data->fromExecution($execution);
75 | $data->populateExecution($execution);
76 | $this->assertSame($transaction->getTax(), strval(-1.23));
77 |
78 | // should add calculated tax (open)
79 | $calculatedTax = is_numeric($transaction->getPortfolio()) && is_numeric($execution->getInstrument()->getExecutionTaxRate()) ? $transaction->getPortfolio() * $execution->getInstrument()->getExecutionTaxRate() : null; // -1845 * 0.0012 = '-2.214'
80 | $transaction->setTax(null);
81 | $data = new ExecutionFormModel();
82 | $data->fromExecution($execution);
83 | $data->populateExecution($execution);
84 | $this->assertSame($transaction->getTax(), strval($calculatedTax));
85 |
86 | // should add calculated tax (close)
87 | $transaction->setTax(null);
88 | $execution->setDirection(-1);
89 | $data = new ExecutionFormModel();
90 | $data->fromExecution($execution);
91 | $data->populateExecution($execution);
92 | $this->assertSame($transaction->getTax(), strval($calculatedTax));
93 |
94 | // should add calculated tax (neutral)
95 | $transaction->setTax(null);
96 | $execution->setDirection(0);
97 | $data = new ExecutionFormModel();
98 | $data->fromExecution($execution);
99 | $data->populateExecution($execution);
100 | $this->assertSame($transaction->getTax(), null);
101 | }
102 | }
103 |
--------------------------------------------------------------------------------