├── .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 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 32 | 33 |
Keep me logged in
27 | 28 | {% if create_user %} 29 | New user 30 | {% endif %} 31 |
34 |
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 | 30 | 31 | 32 | 33 | 34 | 35 | {% for c in countries %} 36 | 37 | 38 | 39 | 44 | 45 | {% endfor %} 46 | 47 |
CodeNameActions
{{ c.code|flag_icon }} {{ c.code }}{{ c.code|country_name }} 40 | {% if is_granted('ROLE_ADMIN') %} 41 | 42 | {% endif %} 43 |
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 | 25 | {% endfor %} 26 | {% for message in app.flashes('error') %} 27 | 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 |
28 | Account 29 | Trade 30 | Cash transaction 31 |
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 | 29 | 30 | 31 | 32 | {% for c in currencies %} 33 | 34 | 35 | 36 | 42 | 47 | 48 | {% endfor %} 49 | 50 |
ISO 4217 CodeNameISIN (to USD)Actions
{{ c.code }}{{ c.code|currency_name }} 37 | {% set asset_id = c.isinUsd|asset_from_isin %} 38 | {% if asset_id > 0 %} 39 | {{ c.isinUsd }} 40 | {% endif %} 41 | 43 | {% if is_granted('ROLE_ADMIN') %} 44 | 45 | {% endif %} 46 |
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 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | {% for a in attachments %} 34 | 35 | 36 | 37 | 38 | 42 | 43 | {% endfor %} 44 | 45 |
NameDateSizeActions
{{ a.name }}{{ a.time_uploaded | date('Y-m-d H:i:s') }}{{ (a.size/1024) | number_format(1) }} kB 39 | 40 | 41 |
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 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | {% for note in notes %} 34 | 35 | 36 | 37 | 38 | 39 | 40 | 44 | 45 | {% endfor %} 46 | 47 |
DateAssetTypeTitleTextActions
{{ note.date | date("Y-m-d") }}{% if note.asset %}{{ note.asset.symbol }}{{ note.asset.name }}{% endif %}{{ note.typename }}{{ note.title }}{{ note.text | u.truncate(40, '...') }} 41 |   42 |   43 |
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 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | {% for a in accounts %} 32 | 33 | 34 | 35 | 36 | 37 | 45 | 46 | {% endfor %} 47 | 48 |
NameNumberTypeBalanceActions
{% if a.star %}{% else %}{% endif %} {{ a.name }}{{ a.number }}{{ a.typename }}{{ account_balance[a.id] | format_currency(a.currency) }} 38 |   39 |   40 |   41 |    42 |   43 | 44 |
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 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {% for p in positions %} 31 | 32 | 33 | 37 | 38 | 39 | 40 | 41 | 42 | 46 | 47 | {% endfor %} 48 | 49 | 50 | 51 | 52 | 53 | 54 |
InstrumentAssetTypeCountryTerminationUnitsValueActions
{{ p['instrument'].name }} 34 | {{ p['assetsymbol'] }} 35 | {{ p['assetname'] }} 36 | {{ p['instrument'].eusipaname }}{{ p['assetcountry'] | flag_icon }}{% if p['instrument'].terminationdate %}{{ p['instrument'].terminationdate | date("Y-m-d") }} {% endif %}{{ p['units'] | number_format(2) }}{{ p['totalvalue'] | format_currency(p['instrument'].currency) }} 43 |   44 |   45 |
Total {{ positions | length }} positions
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 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | {% for inst in instruments %} 35 | 36 | 37 | 38 | 42 | 43 | 44 | 45 | 46 | 54 | 55 | {% endfor %} 56 | 57 |
NameISINUnderlyingEUSIPADirectionStatusTerminationActions
{{ inst.name }}{{ inst.isin }} 39 | {{ inst.underlying.symbol }} 40 | {{ inst.underlying.name }} 41 | {{ inst.eusipaname }}{{ inst.directionname }}{{ inst.statusname }}{% if inst.terminationDate %} {{ inst.terminationDate|date("Y-m-d") }}{% endif %} 47 |   48 |   49 |   50 | {% if inst.url %} 51 |   52 | {% endif %} 53 |
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 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | {% for t in transactions %} 29 | 30 | 31 | 32 | {% if t.cash %} 33 | {% if t.consolidation %} 34 | {% if t.interest %} 35 | 36 | 41 | 42 | {% endfor %} 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 |
Date/TimeTransactionCashConsolidationInterestNotesActions
{{ t.time | date('Y-m-d H:i') }}{{ t.transactionid }}{% else %}class="badge bg-warning" style="float:right">{% endif %}{{ t.cash | format_currency(account.currency) }}{% else %}{% endif %}{{ t.consolidation | format_currency(account.currency) }}{% else %}{% endif %}{{ t.interest | format_currency(account.currency) }}{% else %}{% endif %}{{ t.notes | u.truncate(50, '...') }} 37 |   38 | 39 |   40 |
Total {{ transactions | length }} transactions{{ total['cash'] | format_currency(account.currency) }}{{ total['consolidation'] | format_currency(account.currency) }}{{ total['interest'] | format_currency(account.currency) }}
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 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | {% for a in assets %} 36 | 37 | 38 | 39 | 40 | 41 | 42 | {% if a.price %} 43 | 44 | {% else %} 45 | 46 | {% endif %} 47 | 60 | 61 | {% endfor %} 62 | 63 |
NameSymbolISINTypeCountryLast priceActions
{{ a.asset.name }}{{ a.asset.symbol | symbol_badge }}{{ a.asset.isin }}{{ a.asset.typename }}{{ a.asset.country | flag_icon }}{{ a.price.close | format_currency(a.asset.currency) }} 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 |
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 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 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 | 41 | 42 | 43 | 46 | 47 | 54 | 59 | 60 | 65 | 66 | {% endfor %} 67 | 68 |
Date/TimeTypeInstrumentVolumePriceCashflowIDsNotesActions
{{ t.time | date('Y-m-d H:i') }}{{ macros.transactionicon(t, colorstyle)}}{{ t.instrument_name }} 44 | {% if t.volume %} {{t.volume | number_format(2)}} {% endif %} 45 | {% if t.price %} {{t.price | format_currency(t.execution_currency)}} {% endif %} 50 | {% if t.cashflow %} 51 | {{t.cashflow | format_currency(account.currency)}} 52 | {% endif %} 53 | 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 | {{t.notes | u.truncate(40, '...')}} 61 |   62 | 63 |   64 |
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 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | {% set last_currency = "" %} 38 | {% for p in positions %} 39 | 40 | 41 | 45 | 46 | 49 | 50 | 51 | 52 | 53 | {% if p['value_underlying'] %} 54 | 55 | {% else %} 56 | 62 | 63 | {% set last_currency = p['instrument'].currency %} 64 | {% endfor %} 65 | 66 | 67 | 68 | 69 | {# TODO: Needs currency conversion / multi currency into a total sum #} 70 | 71 | 72 | 73 |
InstrumentAssetCo.EUSIPA TypeTerminationAccountUnitsValueUnderlyingActions
{{ p['instrument'].name }} 42 | {{ p['asset_symbol'] }} 43 | {{ p['asset_name'] }} 44 | {{ p['asset_country'] | flag_icon }} 47 | {{ p['asset_type'] }} / {{ p['instrument'].eusipaname }} 48 | {% if p['instrument'].terminationdate %}{{ p['instrument'].terminationdate | date("Y-m-d") }} {% endif %}{{ p['account_name'] }}{{ p['units'] | number_format(2) }}{{ p['value_total'] | format_currency(p['instrument'].currency) }}{{ p['value_underlying'] | format_currency(p['asset_currency']) }} 57 | {% endif %} 58 | 59 |   60 |   61 |
Total {{ positions | length }} positions{{ total['value_total'] | format_currency(last_currency) }}
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 | --------------------------------------------------------------------------------