From fcbe2653b1bf6d3bb17d46ad2f249d272349d290 Mon Sep 17 00:00:00 2001
From: William Arin <williamarin.dev@gmail.com>
Date: Fri, 20 Jun 2025 21:16:47 +0800
Subject: [PATCH 1/3] feat: add ChartLayout entity

---
 LICENSE                                       |   2 +-
 composer.json                                 |   4 +-
 composer.lock                                 | 416 +++++++++++++++++-
 config/app.yaml                               |  17 +
 src/Domain/Chart/Dto/IndicatorRequest.php     |  31 ++
 src/Domain/Chart/Entity/ChartLayout.php       |  96 ++++
 .../Repository/ChartLayoutRepository.php      |  36 ++
 7 files changed, 599 insertions(+), 3 deletions(-)
 create mode 100644 src/Domain/Chart/Dto/IndicatorRequest.php
 create mode 100644 src/Domain/Chart/Entity/ChartLayout.php
 create mode 100644 src/Domain/Chart/Repository/ChartLayoutRepository.php

diff --git a/LICENSE b/LICENSE
index 9af3798..f49ac42 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
 MIT License
 
-Copyright (c) 2025 William Arin
+Copyright (c) 2025-present William Arin
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
diff --git a/composer.json b/composer.json
index 8b0164a..8f2d0e0 100644
--- a/composer.json
+++ b/composer.json
@@ -43,7 +43,9 @@
         "symfony/doctrine-messenger": "^7.3",
         "symfony/orm-pack": "^2.4",
         "symfony/cache": "^7.3",
-        "williarin/cook": "^2.0"
+        "williarin/cook": "^2.0",
+        "stof/doctrine-extensions-bundle": "^1.14",
+        "symfony/uid": "^7.3"
     },
     "require-dev": {
         "roave/security-advisories": "dev-latest",
diff --git a/composer.lock b/composer.lock
index 16d200f..71393e7 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,8 +4,58 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "d90834815cdcdfaf0537cc32de39ca0b",
+    "content-hash": "ffc1574a6e30ec484aa4dd3f572aed29",
     "packages": [
+        {
+            "name": "behat/transliterator",
+            "version": "v1.5.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/Behat/Transliterator.git",
+                "reference": "baac5873bac3749887d28ab68e2f74db3a4408af"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/Behat/Transliterator/zipball/baac5873bac3749887d28ab68e2f74db3a4408af",
+                "reference": "baac5873bac3749887d28ab68e2f74db3a4408af",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "require-dev": {
+                "chuyskywalker/rolling-curl": "^3.1",
+                "php-yaoi/php-yaoi": "^1.0",
+                "phpunit/phpunit": "^8.5.25 || ^9.5.19"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Behat\\Transliterator\\": "src/Behat/Transliterator"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "Artistic-1.0"
+            ],
+            "description": "String transliterator",
+            "keywords": [
+                "i18n",
+                "slug",
+                "transliterator"
+            ],
+            "support": {
+                "issues": "https://github.com/Behat/Transliterator/issues",
+                "source": "https://github.com/Behat/Transliterator/tree/v1.5.0"
+            },
+            "abandoned": true,
+            "time": "2022-03-30T09:27:43+00:00"
+        },
         {
             "name": "ccxt/ccxt",
             "version": "v4.4.89",
@@ -1693,6 +1743,136 @@
             },
             "time": "2020-11-24T22:02:12+00:00"
         },
+        {
+            "name": "gedmo/doctrine-extensions",
+            "version": "v3.20.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/doctrine-extensions/DoctrineExtensions.git",
+                "reference": "ea1d37586b8e4bae2a815feb38b177894b12c44c"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/doctrine-extensions/DoctrineExtensions/zipball/ea1d37586b8e4bae2a815feb38b177894b12c44c",
+                "reference": "ea1d37586b8e4bae2a815feb38b177894b12c44c",
+                "shasum": ""
+            },
+            "require": {
+                "behat/transliterator": "^1.2",
+                "doctrine/collections": "^1.2 || ^2.0",
+                "doctrine/deprecations": "^1.0",
+                "doctrine/event-manager": "^1.2 || ^2.0",
+                "doctrine/persistence": "^2.2 || ^3.0 || ^4.0",
+                "php": "^7.4 || ^8.0",
+                "psr/cache": "^1 || ^2 || ^3",
+                "psr/clock": "^1",
+                "symfony/cache": "^5.4 || ^6.0 || ^7.0"
+            },
+            "conflict": {
+                "doctrine/annotations": "<1.13 || >=3.0",
+                "doctrine/common": "<2.13 || >=4.0",
+                "doctrine/dbal": "<3.7 || >=5.0",
+                "doctrine/mongodb-odm": "<2.3 || >=3.0",
+                "doctrine/orm": "<2.20 || >=3.0 <3.3 || >=4.0"
+            },
+            "require-dev": {
+                "doctrine/annotations": "^1.13 || ^2.0",
+                "doctrine/cache": "^1.11 || ^2.0",
+                "doctrine/common": "^2.13 || ^3.0",
+                "doctrine/dbal": "^3.7 || ^4.0",
+                "doctrine/doctrine-bundle": "^2.3",
+                "doctrine/mongodb-odm": "^2.3",
+                "doctrine/orm": "^2.20 || ^3.3",
+                "friendsofphp/php-cs-fixer": "^3.70",
+                "nesbot/carbon": "^2.71 || ^3.0",
+                "phpstan/phpstan": "^2.1.1",
+                "phpstan/phpstan-doctrine": "^2.0.1",
+                "phpstan/phpstan-phpunit": "^2.0.3",
+                "phpunit/phpunit": "^9.6",
+                "rector/rector": "^2.0.6",
+                "symfony/console": "^5.4 || ^6.0 || ^7.0",
+                "symfony/doctrine-bridge": "^5.4 || ^6.0 || ^7.0",
+                "symfony/phpunit-bridge": "^6.0 || ^7.0",
+                "symfony/uid": "^5.4 || ^6.0 || ^7.0",
+                "symfony/yaml": "^5.4 || ^6.0 || ^7.0"
+            },
+            "suggest": {
+                "doctrine/mongodb-odm": "to use the extensions with the MongoDB ODM",
+                "doctrine/orm": "to use the extensions with the ORM"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "3.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Gedmo\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Gediminas Morkevicius",
+                    "email": "gediminas.morkevicius@gmail.com"
+                },
+                {
+                    "name": "Gustavo Falco",
+                    "email": "comfortablynumb84@gmail.com"
+                },
+                {
+                    "name": "David Buchmann",
+                    "email": "david@liip.ch"
+                }
+            ],
+            "description": "Doctrine behavioral extensions",
+            "homepage": "http://gediminasm.org/",
+            "keywords": [
+                "Blameable",
+                "behaviors",
+                "doctrine",
+                "extensions",
+                "gedmo",
+                "loggable",
+                "nestedset",
+                "odm",
+                "orm",
+                "sluggable",
+                "sortable",
+                "timestampable",
+                "translatable",
+                "tree",
+                "uploadable"
+            ],
+            "support": {
+                "docs": "https://github.com/doctrine-extensions/DoctrineExtensions/tree/main/doc",
+                "issues": "https://github.com/doctrine-extensions/DoctrineExtensions/issues",
+                "source": "https://github.com/doctrine-extensions/DoctrineExtensions/tree/v3.20.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/l3pp4rd",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/mbabker",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/phansys",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/stof",
+                    "type": "github"
+                }
+            ],
+            "time": "2025-04-04T17:19:27+00:00"
+        },
         {
             "name": "guzzlehttp/psr7",
             "version": "2.7.1",
@@ -3317,6 +3497,87 @@
             ],
             "time": "2024-06-11T12:45:25+00:00"
         },
+        {
+            "name": "stof/doctrine-extensions-bundle",
+            "version": "v1.14.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/stof/StofDoctrineExtensionsBundle.git",
+                "reference": "bdf3eb10baeb497ac5985b8f78a6cf55862c2662"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/stof/StofDoctrineExtensionsBundle/zipball/bdf3eb10baeb497ac5985b8f78a6cf55862c2662",
+                "reference": "bdf3eb10baeb497ac5985b8f78a6cf55862c2662",
+                "shasum": ""
+            },
+            "require": {
+                "gedmo/doctrine-extensions": "^3.20.0",
+                "php": "^8.1",
+                "symfony/cache": "^6.4 || ^7.0",
+                "symfony/config": "^6.4 || ^7.0",
+                "symfony/dependency-injection": "^6.4 || ^7.0",
+                "symfony/event-dispatcher": "^6.4 || ^7.0",
+                "symfony/http-kernel": "^6.4 || ^7.0",
+                "symfony/translation-contracts": "^2.5 || ^3.5"
+            },
+            "require-dev": {
+                "phpstan/phpstan": "^2.1",
+                "phpstan/phpstan-deprecation-rules": "^2.0",
+                "phpstan/phpstan-phpunit": "^2.0",
+                "phpstan/phpstan-strict-rules": "^2.0",
+                "phpstan/phpstan-symfony": "^2.0",
+                "symfony/mime": "^6.4 || ^7.0",
+                "symfony/phpunit-bridge": "^v6.4.1 || ^7.0.1",
+                "symfony/security-core": "^6.4 || ^7.0"
+            },
+            "suggest": {
+                "doctrine/doctrine-bundle": "to use the ORM extensions",
+                "doctrine/mongodb-odm-bundle": "to use the MongoDB ODM extensions",
+                "symfony/mime": "To use the Mime component integration for Uploadable"
+            },
+            "type": "symfony-bundle",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "1.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Stof\\DoctrineExtensionsBundle\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christophe Coevoet",
+                    "email": "stof@notk.org"
+                }
+            ],
+            "description": "Integration of the gedmo/doctrine-extensions with Symfony",
+            "homepage": "https://github.com/stof/StofDoctrineExtensionsBundle",
+            "keywords": [
+                "behaviors",
+                "doctrine2",
+                "extensions",
+                "gedmo",
+                "loggable",
+                "nestedset",
+                "sluggable",
+                "sortable",
+                "timestampable",
+                "translatable",
+                "tree"
+            ],
+            "support": {
+                "issues": "https://github.com/stof/StofDoctrineExtensionsBundle/issues",
+                "source": "https://github.com/stof/StofDoctrineExtensionsBundle/tree/v1.14.0"
+            },
+            "time": "2025-05-01T08:00:32+00:00"
+        },
         {
             "name": "symfony/cache",
             "version": "v7.3.0",
@@ -5942,6 +6203,85 @@
             ],
             "time": "2025-02-20T12:04:08+00:00"
         },
+        {
+            "name": "symfony/polyfill-uuid",
+            "version": "v1.32.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-uuid.git",
+                "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2",
+                "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "provide": {
+                "ext-uuid": "*"
+            },
+            "suggest": {
+                "ext-uuid": "For best performance"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Uuid\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Grégoire Pineau",
+                    "email": "lyrixx@lyrixx.info"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill for uuid functions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "polyfill",
+                "portable",
+                "uuid"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-uuid/tree/v1.32.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-09-09T11:45:10+00:00"
+        },
         {
             "name": "symfony/property-access",
             "version": "v7.3.0",
@@ -6723,6 +7063,80 @@
             ],
             "time": "2025-03-30T12:17:06+00:00"
         },
+        {
+            "name": "symfony/uid",
+            "version": "v7.3.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/uid.git",
+                "reference": "7beeb2b885cd584cd01e126c5777206ae4c3c6a3"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/uid/zipball/7beeb2b885cd584cd01e126c5777206ae4c3c6a3",
+                "reference": "7beeb2b885cd584cd01e126c5777206ae4c3c6a3",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2",
+                "symfony/polyfill-uuid": "^1.15"
+            },
+            "require-dev": {
+                "symfony/console": "^6.4|^7.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Uid\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Grégoire Pineau",
+                    "email": "lyrixx@lyrixx.info"
+                },
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Provides an object-oriented API to generate and represent UIDs",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "UID",
+                "ulid",
+                "uuid"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/uid/tree/v7.3.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-05-24T14:28:13+00:00"
+        },
         {
             "name": "symfony/validator",
             "version": "v7.3.0",
diff --git a/config/app.yaml b/config/app.yaml
index 481fb62..17c7581 100644
--- a/config/app.yaml
+++ b/config/app.yaml
@@ -17,3 +17,20 @@ framework:
         routing:
             'Stochastix\Domain\Backtesting\Message\RunBacktestMessage': stochastix
             'Stochastix\Domain\Data\Message\DownloadDataMessage': stochastix
+
+stof_doctrine_extensions:
+    default_locale: en_US
+    orm:
+        default:
+            timestampable: true
+
+doctrine:
+    orm:
+        mappings:
+            StochastixBundle:
+                is_bundle: true
+                type: attribute
+                dir: 'src/'
+                prefix: 'Stochastix\'
+                alias: StochastixBundle
+
diff --git a/src/Domain/Chart/Dto/IndicatorRequest.php b/src/Domain/Chart/Dto/IndicatorRequest.php
new file mode 100644
index 0000000..3664aad
--- /dev/null
+++ b/src/Domain/Chart/Dto/IndicatorRequest.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Stochastix\Domain\Chart\Dto;
+
+use Symfony\Component\Validator\Constraints as Assert;
+
+/**
+ * A Data Transfer Object representing the configuration for a single
+ * indicator to be displayed on a chart.
+ */
+final readonly class IndicatorRequest
+{
+    /**
+     * @param string $key A unique identifier for this indicator instance on the chart (e.g., "ema_fast").
+     * @param string $type The type of the indicator, used to determine how to instantiate it (e.g., "talib").
+     * @param string $function The specific function to call (e.g., "ema", "rsi").
+     * @param array<string, mixed> $params The parameters for the indicator function (e.g., ["timePeriod" => 20]).
+     * @param string $source The OHLCV field to use as input (e.g., "close", "high").
+     */
+    public function __construct(
+        #[Assert\NotBlank]
+        public string $key,
+        #[Assert\NotBlank]
+        public string $type,
+        #[Assert\NotBlank]
+        public string $function,
+        public array $params = [],
+        public string $source = 'close',
+    ) {
+    }
+}
diff --git a/src/Domain/Chart/Entity/ChartLayout.php b/src/Domain/Chart/Entity/ChartLayout.php
new file mode 100644
index 0000000..9e73853
--- /dev/null
+++ b/src/Domain/Chart/Entity/ChartLayout.php
@@ -0,0 +1,96 @@
+<?php
+
+namespace Stochastix\Domain\Chart\Entity;
+
+use Doctrine\DBAL\Types\Types;
+use Doctrine\ORM\Mapping as ORM;
+use Gedmo\Timestampable\Traits\Timestampable;
+use Stochastix\Domain\Chart\Repository\ChartLayoutRepository;
+use Symfony\Component\Uid\Ulid;
+
+#[ORM\Entity(repositoryClass: ChartLayoutRepository::class)]
+#[ORM\Table(name: 'chart_layout')]
+class ChartLayout
+{
+    use Timestampable;
+
+    #[ORM\Id]
+    #[ORM\Column(type: 'ulid', unique: true)]
+    #[ORM\GeneratedValue(strategy: 'CUSTOM')]
+    #[ORM\CustomIdGenerator(class: 'doctrine.ulid_generator')]
+    private ?Ulid $id = null;
+
+    #[ORM\Column(type: Types::STRING, length: 255)]
+    private string $name;
+
+    #[ORM\Column(type: Types::STRING, length: 255)]
+    private string $symbol;
+
+    #[ORM\Column(type: Types::STRING, length: 10)]
+    private string $timeframe;
+
+    /**
+     * @var array<array<string, mixed>>
+     */
+    #[ORM\Column(type: Types::JSON)]
+    private array $indicators = [];
+
+    public function getId(): ?Ulid
+    {
+        return $this->id;
+    }
+
+    public function getName(): string
+    {
+        return $this->name;
+    }
+
+    public function setName(string $name): self
+    {
+        $this->name = $name;
+
+        return $this;
+    }
+
+    public function getSymbol(): string
+    {
+        return $this->symbol;
+    }
+
+    public function setSymbol(string $symbol): self
+    {
+        $this->symbol = $symbol;
+
+        return $this;
+    }
+
+    public function getTimeframe(): string
+    {
+        return $this->timeframe;
+    }
+
+    public function setTimeframe(string $timeframe): self
+    {
+        $this->timeframe = $timeframe;
+
+        return $this;
+    }
+
+    /**
+     * @return array<array<string, mixed>>
+     */
+    public function getIndicators(): array
+    {
+        return $this->indicators;
+    }
+
+    /**
+     * @param array<array<string, mixed>> $indicators
+     */
+    public function setIndicators(array $indicators): self
+    {
+        $this->indicators = $indicators;
+
+        return $this;
+    }
+}
diff --git a/src/Domain/Chart/Repository/ChartLayoutRepository.php b/src/Domain/Chart/Repository/ChartLayoutRepository.php
new file mode 100644
index 0000000..d273e59
--- /dev/null
+++ b/src/Domain/Chart/Repository/ChartLayoutRepository.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Stochastix\Domain\Chart\Repository;
+
+use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
+use Doctrine\Persistence\ManagerRegistry;
+use Stochastix\Domain\Chart\Entity\ChartLayout;
+
+/**
+ * @extends ServiceEntityRepository<ChartLayout>
+ */
+class ChartLayoutRepository extends ServiceEntityRepository
+{
+    public function __construct(ManagerRegistry $registry)
+    {
+        parent::__construct($registry, ChartLayout::class);
+    }
+
+    public function save(ChartLayout $entity, bool $flush = false): void
+    {
+        $this->getEntityManager()->persist($entity);
+
+        if ($flush) {
+            $this->getEntityManager()->flush();
+        }
+    }
+
+    public function remove(ChartLayout $entity, bool $flush = false): void
+    {
+        $this->getEntityManager()->remove($entity);
+
+        if ($flush) {
+            $this->getEntityManager()->flush();
+        }
+    }
+}

From deedc35d885ca9784392064b3b273f66b84a2f9d Mon Sep 17 00:00:00 2001
From: William Arin <williamarin.dev@gmail.com>
Date: Fri, 20 Jun 2025 21:47:07 +0800
Subject: [PATCH 2/3] feat: add endpoints to handle a chart layout and fetch
 indicators

---
 docs/api.md                                   | 179 ++++++++++++++++
 .../Chart/Controller/CreateLayoutAction.php   |  31 +++
 .../Chart/Controller/DeleteLayoutAction.php   |  26 +++
 .../GetAvailableIndicatorsAction.php          |  23 ++
 .../Controller/GetChartIndicatorsAction.php   |  42 ++++
 .../Chart/Controller/GetLayoutAction.php      |  29 +++
 .../Chart/Controller/ListLayoutsAction.php    |  30 +++
 .../Chart/Controller/UpdateLayoutAction.php   |  31 +++
 .../Chart/Dto/ChartIndicatorRequestDto.php    |  38 ++++
 src/Domain/Chart/Dto/SaveLayoutRequestDto.php |  33 +++
 .../Chart/Service/ChartIndicatorService.php   | 199 ++++++++++++++++++
 .../Chart/Service/ChartLayoutService.php      |  94 +++++++++
 .../Service/IndicatorDiscoveryService.php     | 125 +++++++++++
 .../Indicator/Attribute/AsIndicator.php       |  21 ++
 .../Indicator/Model/IndicatorInterface.php    |   2 +
 15 files changed, 903 insertions(+)
 create mode 100644 src/Domain/Chart/Controller/CreateLayoutAction.php
 create mode 100644 src/Domain/Chart/Controller/DeleteLayoutAction.php
 create mode 100644 src/Domain/Chart/Controller/GetAvailableIndicatorsAction.php
 create mode 100644 src/Domain/Chart/Controller/GetChartIndicatorsAction.php
 create mode 100644 src/Domain/Chart/Controller/GetLayoutAction.php
 create mode 100644 src/Domain/Chart/Controller/ListLayoutsAction.php
 create mode 100644 src/Domain/Chart/Controller/UpdateLayoutAction.php
 create mode 100644 src/Domain/Chart/Dto/ChartIndicatorRequestDto.php
 create mode 100644 src/Domain/Chart/Dto/SaveLayoutRequestDto.php
 create mode 100644 src/Domain/Chart/Service/ChartIndicatorService.php
 create mode 100644 src/Domain/Chart/Service/ChartLayoutService.php
 create mode 100644 src/Domain/Chart/Service/IndicatorDiscoveryService.php
 create mode 100644 src/Domain/Indicator/Attribute/AsIndicator.php

diff --git a/docs/api.md b/docs/api.md
index 2259848..565af1a 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -537,3 +537,182 @@ This family of endpoints provides access to the raw, high-resolution time-series
     * **Error Responses:**
         * `404 Not Found`: If the `runId` is invalid or if the requested `{metric}` plot does not exist for that run.
         * `500 Internal Server Error`.
+
+---
+
+### Chart Exploration & Layouts
+
+This section details the endpoints for managing persistent chart layouts and fetching on-demand indicator data for interactive analysis.
+
+* **`GET /api/charts/available-indicators`**
+    * **Description:** Fetches a categorized list of all indicators available for plotting. This includes "Built-in" (from the TA-Lib library) and "Custom" (user-defined PHP classes) indicators.
+    * **Requires:** None
+    * **Request Body:** None
+    * **Success Response:** `200 OK`
+    * **Example Success Response Body:**
+        ```json
+        {
+          "builtIn": [
+            {
+              "name": "Simple Moving Average",
+              "description": null,
+              "alias": "sma",
+              "type": "talib",
+              "inputs": [{"name": "timePeriod", "type": "integer"}]
+            },
+            {
+              "name": "Relative Strength Index",
+              "description": null,
+              "alias": "rsi",
+              "type": "talib",
+              "inputs": [{"name": "timePeriod", "type": "integer"}]
+            }
+          ],
+          "custom": [
+            {
+                "name": "My Custom Volatility",
+                "description": "A custom volatility indicator.",
+                "alias": "App\\Indicator\\MyVolatilityIndicator",
+                "type": "custom",
+                "inputs": [
+                    { "name": "period", "description": "Lookback period", "type": "integer", "defaultValue": 14 }
+                ]
+            }
+          ]
+        }
+        ```
+    * **Error Responses:**
+        * `500 Internal Server Error`: If an error occurs during indicator discovery.
+
+* **`POST /api/charts/indicators`**
+    * **Description:** The core on-demand calculation endpoint. It takes a market context (symbol, timeframe) and a list of indicator configurations, and returns the OHLCV data for the requested window along with the calculated indicator series data.
+    * **Requires:** None
+    * **Request Body:** (`Stochastix\Domain\Chart\Dto\ChartIndicatorRequestDto`)
+    * **Example Request Body:**
+        ```json
+        {
+          "exchangeId": "binance",
+          "symbol": "BTC/USDT",
+          "timeframe": "1h",
+          "countback": 500,
+          "indicators": [
+            {
+              "key": "ema_fast",
+              "type": "talib",
+              "function": "ema",
+              "params": { "timePeriod": 10 },
+              "source": "close"
+            },
+            {
+              "key": "rsi_14",
+              "type": "talib",
+              "function": "rsi",
+              "params": { "timePeriod": 14 },
+              "source": "close"
+            }
+          ]
+        }
+        ```
+    * **Success Response:** `200 OK`
+    * **Example Success Response Body:**
+        ```json
+        {
+          "ohlcv": [
+            { "time": 1738454400, "open": 3000.0, "high": 3005.0, "low": 2998.0, "close": 3000.0 }
+          ],
+          "indicators": {
+            "ema_fast": {
+              "value": [{ "time": 1738454400, "value": 2995.5 }]
+            },
+            "rsi_14": {
+              "value": [{ "time": 1738454400, "value": 55.45 }]
+            }
+          }
+        }
+        ```
+    * **Error Responses:**
+        * `404 Not Found`: If the underlying market data file (`.stchx`) does not exist.
+        * `500 Internal Server Error`: For any calculation errors.
+
+* **`POST /api/charts/layouts`**
+    * **Description:** Creates and saves a new chart layout.
+    * **Requires:** None
+    * **Request Body:** (`Stochastix\Domain\Chart\Dto\SaveLayoutRequestDto`)
+    * **Example Request Body:**
+        ```json
+        {
+            "name": "My ETH Volatility Layout",
+            "symbol": "ETH/USDT",
+            "timeframe": "4h",
+            "indicators": [
+                { "key": "bbands_20", "type": "talib", "function": "bbands", "params": { "timePeriod": 20, "nbDevUp": 2, "nbDevDn": 2 } },
+                { "key": "atr_14", "type": "talib", "function": "atr", "params": { "timePeriod": 14 } }
+            ]
+        }
+        ```
+    * **Success Response:** `201 Created`
+    * **Success Response Body:** The full, newly created `ChartLayout` entity, including its new ID and timestamps.
+
+* **`GET /api/charts/layouts`**
+    * **Description:** Retrieves a list of all saved chart layouts, sorted by most recently updated.
+    * **Requires:** None
+    * **Request Body:** None
+    * **Success Response:** `200 OK`
+    * **Example Success Response Body:**
+        ```json
+        [
+            {
+                "id": "01J0Z7X2J8Y4Z6T8A9E1G4H2J8",
+                "name": "My BTC Daily Setup",
+                "symbol": "BTC/USDT",
+                "timeframe": "1d",
+                "indicators": [
+                    {
+                        "key": "ema_fast",
+                        "type": "talib",
+                        "function": "ema",
+                        "params": {
+                            "timePeriod": 12
+                        },
+                        "source": "close"
+                    },
+                    {
+                        "key": "rsi_main",
+                        "type": "talib",
+                        "function": "rsi",
+                        "params": {
+                            "timePeriod": 14
+                        },
+                        "source": "close"
+                    }
+                ],
+                "createdAt": "2025-06-20T14:30:00+00:00",
+                "updatedAt": "2025-06-20T15:00:00+00:00"
+            }
+        ]
+        ```
+
+* **`GET /api/charts/layouts/{id}`**
+    * **Description:** Retrieves the full configuration of a single chart layout by its ID.
+    * **URL Parameters:**
+        * `id` (string, required): The ULID of the chart layout.
+    * **Success Response:** `200 OK`
+    * **Success Response Body:** A single `ChartLayout` object.
+
+* **`PUT /api/charts/layouts/{id}`**
+    * **Description:** Updates an existing chart layout. The entire new layout configuration must be sent.
+    * **URL Parameters:**
+        * `id` (string, required): The ULID of the chart layout to update.
+    * **Request Body:** (`Stochastix\Domain\Chart\Dto\SaveLayoutRequestDto`)
+    * **Success Response:** `200 OK`
+    * **Success Response Body:** The full, updated `ChartLayout` object.
+    * **Error Responses:**
+        * `404 Not Found`: If the layout ID does not exist.
+
+* **`DELETE /api/charts/layouts/{id}`**
+    * **Description:** Deletes a chart layout.
+    * **URL Parameters:**
+        * `id` (string, required): The ULID of the chart layout to delete.
+    * **Success Response:** `240 No Content`
+    * **Error Responses:**
+        * `404 Not Found`: If the layout ID does not exist.
diff --git a/src/Domain/Chart/Controller/CreateLayoutAction.php b/src/Domain/Chart/Controller/CreateLayoutAction.php
new file mode 100644
index 0000000..e6c6f64
--- /dev/null
+++ b/src/Domain/Chart/Controller/CreateLayoutAction.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Stochastix\Domain\Chart\Controller;
+
+use Stochastix\Domain\Chart\Dto\SaveLayoutRequestDto;
+use Stochastix\Domain\Chart\Service\ChartLayoutService;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Attribute\AsController;
+use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
+use Symfony\Component\Routing\Attribute\Route;
+use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
+
+#[AsController]
+#[Route('/api/charts/layouts', name: 'stochastix_api_charts_layouts_create', methods: ['POST'])]
+class CreateLayoutAction extends AbstractController
+{
+    public function __construct(private readonly ChartLayoutService $layoutService)
+    {
+    }
+
+    public function __invoke(#[MapRequestPayload] SaveLayoutRequestDto $requestDto): JsonResponse
+    {
+        $layout = $this->layoutService->createLayout($requestDto);
+
+        return $this->json($layout, Response::HTTP_CREATED, [], [
+            AbstractNormalizer::GROUPS => ['layout:read'],
+        ]);
+    }
+}
diff --git a/src/Domain/Chart/Controller/DeleteLayoutAction.php b/src/Domain/Chart/Controller/DeleteLayoutAction.php
new file mode 100644
index 0000000..435951c
--- /dev/null
+++ b/src/Domain/Chart/Controller/DeleteLayoutAction.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Stochastix\Domain\Chart\Controller;
+
+use Stochastix\Domain\Chart\Service\ChartLayoutService;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Attribute\AsController;
+use Symfony\Component\Routing\Attribute\Route;
+
+#[AsController]
+#[Route('/api/charts/layouts/{id}', name: 'stochastix_api_charts_layouts_delete', methods: ['DELETE'])]
+class DeleteLayoutAction extends AbstractController
+{
+    public function __construct(private readonly ChartLayoutService $layoutService)
+    {
+    }
+
+    public function __invoke(string $id): Response
+    {
+        $this->layoutService->deleteLayout($id);
+
+        return new Response(null, Response::HTTP_NO_CONTENT);
+    }
+}
+
diff --git a/src/Domain/Chart/Controller/GetAvailableIndicatorsAction.php b/src/Domain/Chart/Controller/GetAvailableIndicatorsAction.php
new file mode 100644
index 0000000..81e24a5
--- /dev/null
+++ b/src/Domain/Chart/Controller/GetAvailableIndicatorsAction.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace Stochastix\Domain\Chart\Controller;
+
+use Stochastix\Domain\Chart\Service\IndicatorDiscoveryService;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpKernel\Attribute\AsController;
+use Symfony\Component\Routing\Attribute\Route;
+
+#[AsController]
+#[Route('/api/charts/available-indicators', name: 'stochastix_api_charts_available_indicators', methods: ['GET'])]
+class GetAvailableIndicatorsAction extends AbstractController
+{
+    public function __construct(private readonly IndicatorDiscoveryService $discoveryService)
+    {
+    }
+
+    public function __invoke(): JsonResponse
+    {
+        return $this->json($this->discoveryService->getAvailableIndicators());
+    }
+}
diff --git a/src/Domain/Chart/Controller/GetChartIndicatorsAction.php b/src/Domain/Chart/Controller/GetChartIndicatorsAction.php
new file mode 100644
index 0000000..8188221
--- /dev/null
+++ b/src/Domain/Chart/Controller/GetChartIndicatorsAction.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Stochastix\Domain\Chart\Controller;
+
+use Psr\Log\LoggerInterface;
+use Stochastix\Domain\Chart\Dto\ChartIndicatorRequestDto;
+use Stochastix\Domain\Chart\Service\ChartIndicatorService;
+use Stochastix\Domain\Data\Exception\DataFileNotFoundException;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Attribute\AsController;
+use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
+use Symfony\Component\Routing\Attribute\Route;
+
+#[AsController]
+#[Route('/api/charts/indicators', name: 'stochastix_api_charts_indicators_get', methods: ['POST'])]
+class GetChartIndicatorsAction extends AbstractController
+{
+    public function __construct(
+        private readonly ChartIndicatorService $chartIndicatorService,
+        private readonly LoggerInterface $logger,
+    ) {
+    }
+
+    public function __invoke(#[MapRequestPayload] ChartIndicatorRequestDto $requestDto): JsonResponse
+    {
+        try {
+            $data = $this->chartIndicatorService->getIndicatorData($requestDto);
+            return $this->json($data);
+        } catch (DataFileNotFoundException $e) {
+            $this->logger->warning($e->getMessage());
+            return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND);
+        } catch (\Throwable $e) {
+            $this->logger->error('An unexpected error occurred while fetching chart indicator data: {message}', [
+                'message' => $e->getMessage(),
+                'trace' => $e->getTraceAsString(),
+            ]);
+            return $this->json(['error' => 'An internal error occurred.'], Response::HTTP_INTERNAL_SERVER_ERROR);
+        }
+    }
+}
diff --git a/src/Domain/Chart/Controller/GetLayoutAction.php b/src/Domain/Chart/Controller/GetLayoutAction.php
new file mode 100644
index 0000000..f8ea8da
--- /dev/null
+++ b/src/Domain/Chart/Controller/GetLayoutAction.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Stochastix\Domain\Chart\Controller;
+
+use Stochastix\Domain\Chart\Service\ChartLayoutService;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Attribute\AsController;
+use Symfony\Component\Routing\Attribute\Route;
+use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
+
+#[AsController]
+#[Route('/api/charts/layouts/{id}', name: 'stochastix_api_charts_layouts_get', methods: ['GET'])]
+class GetLayoutAction extends AbstractController
+{
+    public function __construct(private readonly ChartLayoutService $layoutService)
+    {
+    }
+
+    public function __invoke(string $id): JsonResponse
+    {
+        $layout = $this->layoutService->findLayoutById($id);
+
+        return $this->json($layout, Response::HTTP_OK, [], [
+            AbstractNormalizer::GROUPS => ['layout:read'],
+        ]);
+    }
+}
diff --git a/src/Domain/Chart/Controller/ListLayoutsAction.php b/src/Domain/Chart/Controller/ListLayoutsAction.php
new file mode 100644
index 0000000..0addbc0
--- /dev/null
+++ b/src/Domain/Chart/Controller/ListLayoutsAction.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Stochastix\Domain\Chart\Controller;
+
+use Stochastix\Domain\Chart\Repository\ChartLayoutRepository;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Attribute\AsController;
+use Symfony\Component\Routing\Attribute\Route;
+use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
+
+#[AsController]
+#[Route('/api/charts/layouts', name: 'stochastix_api_charts_layouts_list', methods: ['GET'])]
+class ListLayoutsAction extends AbstractController
+{
+    public function __construct(private readonly ChartLayoutRepository $repository)
+    {
+    }
+
+    public function __invoke(): JsonResponse
+    {
+        $layouts = $this->repository->findBy([], ['updatedAt' => 'DESC']);
+
+        return $this->json($layouts, Response::HTTP_OK, [], [
+            // Note: We might want a smaller 'layout:list' group later
+            AbstractNormalizer::GROUPS => ['layout:read'],
+        ]);
+    }
+}
diff --git a/src/Domain/Chart/Controller/UpdateLayoutAction.php b/src/Domain/Chart/Controller/UpdateLayoutAction.php
new file mode 100644
index 0000000..f028ea5
--- /dev/null
+++ b/src/Domain/Chart/Controller/UpdateLayoutAction.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Stochastix\Domain\Chart\Controller;
+
+use Stochastix\Domain\Chart\Dto\SaveLayoutRequestDto;
+use Stochastix\Domain\Chart\Service\ChartLayoutService;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Attribute\AsController;
+use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
+use Symfony\Component\Routing\Attribute\Route;
+use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
+
+#[AsController]
+#[Route('/api/charts/layouts/{id}', name: 'stochastix_api_charts_layouts_update', methods: ['PUT'])]
+class UpdateLayoutAction extends AbstractController
+{
+    public function __construct(private readonly ChartLayoutService $layoutService)
+    {
+    }
+
+    public function __invoke(string $id, #[MapRequestPayload] SaveLayoutRequestDto $requestDto): JsonResponse
+    {
+        $layout = $this->layoutService->updateLayout($id, $requestDto);
+
+        return $this->json($layout, Response::HTTP_OK, [], [
+            AbstractNormalizer::GROUPS => ['layout:read'],
+        ]);
+    }
+}
diff --git a/src/Domain/Chart/Dto/ChartIndicatorRequestDto.php b/src/Domain/Chart/Dto/ChartIndicatorRequestDto.php
new file mode 100644
index 0000000..8a335c3
--- /dev/null
+++ b/src/Domain/Chart/Dto/ChartIndicatorRequestDto.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace Stochastix\Domain\Chart\Dto;
+
+use Symfony\Component\Validator\Constraints as Assert;
+
+/**
+ * A Data Transfer Object representing a request to fetch chart data
+ * along with on-demand calculated indicators.
+ */
+final readonly class ChartIndicatorRequestDto
+{
+    /**
+     * @param string $exchangeId The exchange identifier (e.g., "binance").
+     * @param string $symbol The trading symbol (e.g., "BTC/USDT").
+     * @param string $timeframe The chart timeframe (e.g., "1h", "4h").
+     * @param int|null $fromTimestamp Optional start of the visible data window (Unix timestamp).
+     * @param int|null $toTimestamp Optional end of the visible data window (Unix timestamp).
+     * @param int|null $countback Optional number of bars to fetch counting backwards from toTimestamp.
+     * @param IndicatorRequest[] $indicators The list of indicators to calculate.
+     */
+    public function __construct(
+        #[Assert\NotBlank]
+        public string $exchangeId,
+        #[Assert\NotBlank]
+        public string $symbol,
+        #[Assert\NotBlank]
+        public string $timeframe,
+        public ?int $fromTimestamp = null,
+        public ?int $toTimestamp = null,
+        #[Assert\Positive]
+        public ?int $countback = 1000,
+        #[Assert\Valid]
+        #[Assert\All(new Assert\Type(type: IndicatorRequest::class))]
+        public array $indicators = [],
+    ) {
+    }
+}
diff --git a/src/Domain/Chart/Dto/SaveLayoutRequestDto.php b/src/Domain/Chart/Dto/SaveLayoutRequestDto.php
new file mode 100644
index 0000000..8ae5571
--- /dev/null
+++ b/src/Domain/Chart/Dto/SaveLayoutRequestDto.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace Stochastix\Domain\Chart\Dto;
+
+use Symfony\Component\Validator\Constraints as Assert;
+
+/**
+ * A Data Transfer Object used for creating or updating a ChartLayout.
+ * This object is mapped directly from the API request payload.
+ */
+final readonly class SaveLayoutRequestDto
+{
+    /**
+     * @param string $name The user-defined name for the layout.
+     * @param string $symbol The symbol associated with the layout (e.g., "BTC/USDT").
+     * @param string $timeframe The timeframe for the layout (e.g., "1h", "4h").
+     * @param IndicatorRequest[] $indicators An array of indicator configurations.
+     */
+    public function __construct(
+        #[Assert\NotBlank]
+        #[Assert\Length(min: 3, max: 255)]
+        public string $name,
+        #[Assert\NotBlank]
+        public string $symbol,
+        #[Assert\NotBlank]
+        public string $timeframe,
+        #[Assert\Valid]
+        #[Assert\All(new Assert\Type(type: IndicatorRequest::class))]
+        public array $indicators,
+    ) {
+    }
+}
+
diff --git a/src/Domain/Chart/Service/ChartIndicatorService.php b/src/Domain/Chart/Service/ChartIndicatorService.php
new file mode 100644
index 0000000..b66a840
--- /dev/null
+++ b/src/Domain/Chart/Service/ChartIndicatorService.php
@@ -0,0 +1,199 @@
+<?php
+
+namespace Stochastix\Domain\Chart\Service;
+
+use Ds\Map;
+use Ds\Vector;
+use Psr\Log\LoggerInterface;
+use Stochastix\Domain\Chart\Dto\ChartIndicatorRequestDto;
+use Stochastix\Domain\Chart\Dto\IndicatorRequest;
+use Stochastix\Domain\Common\Enum\AppliedPriceEnum;
+use Stochastix\Domain\Common\Enum\OhlcvEnum;
+use Stochastix\Domain\Common\Enum\TALibFunctionEnum;
+use Stochastix\Domain\Common\Model\Series;
+use Stochastix\Domain\Data\Exception\DataFileNotFoundException;
+use Stochastix\Domain\Data\Service\BinaryStorageInterface;
+use Stochastix\Domain\Indicator\Model\TALibIndicator;
+use Symfony\Component\DependencyInjection\Attribute\Autowire;
+
+/**
+ * Orchestrates the on-demand loading of market data and calculation of
+ * indicators for interactive charting.
+ */
+final readonly class ChartIndicatorService
+{
+    public function __construct(
+        private BinaryStorageInterface $binaryStorage,
+        private LoggerInterface $logger,
+        #[Autowire('%kernel.project_dir%/data/market')]
+        private string $baseDataPath,
+    ) {
+    }
+
+    public function getIndicatorData(ChartIndicatorRequestDto $dto): array
+    {
+        // 1. Calculate the required lookback period to ensure indicators are fully "warmed up".
+        $lookback = $this->getMaxLookback($dto->indicators);
+        $this->logger->info('Required lookback period calculated', ['lookback' => $lookback]);
+
+        // 2. Load the necessary slice of OHLCV data, including the lookback period.
+        $ohlcvRecords = $this->loadOhlcvData($dto, $lookback);
+        if (empty($ohlcvRecords)) {
+            return ['ohlcv' => [], 'indicators' => []];
+        }
+        $this->logger->info('Loaded {count} OHLCV records (including lookback).', ['count' => count($ohlcvRecords)]);
+
+        // 3. Prepare dataframes from the loaded records for the indicator calculation engine.
+        $dataframes = new Map(['primary' => $this->createDataframeFromRecords($ohlcvRecords)]);
+        $this->logger->info('Dataframes prepared for calculation.');
+
+        // 4. Instantiate and calculate all requested indicators.
+        $calculatedIndicators = $this->calculateIndicators($dto->indicators, $dataframes);
+        $this->logger->info('{count} indicators calculated.', ['count' => count($calculatedIndicators)]);
+
+        // 5. Trim the lookback data from the series and format the final response for the API.
+        return $this->formatResponse($ohlcvRecords, $calculatedIndicators, $lookback);
+    }
+
+    private function generateFilePath(string $exchangeId, string $symbol, string $timeframe): string
+    {
+        $sanitizedSymbol = str_replace('/', '_', $symbol);
+        return sprintf(
+            '%s/%s/%s/%s.stchx',
+            rtrim($this->baseDataPath, '/'),
+            strtolower($exchangeId),
+            strtoupper($sanitizedSymbol),
+            $timeframe
+        );
+    }
+
+    /**
+     * @param array<IndicatorRequest> $indicators
+     */
+    private function getMaxLookback(array $indicators): int
+    {
+        if (empty($indicators)) {
+            return 0;
+        }
+
+        $maxLookback = 0;
+        foreach ($indicators as $req) {
+            // A more sophisticated mapping could be used here for complex indicators.
+            // For now, we assume the longest 'timePeriod' or 'slowPeriod' is a good proxy.
+            $period = $req->params['slowPeriod'] ?? $req->params['timePeriod'] ?? 0;
+            if ($period > $maxLookback) {
+                $maxLookback = $period;
+            }
+        }
+        // Add a buffer, as some indicators (like MACD) require more than just the longest period.
+        return $maxLookback + 34;
+    }
+
+    private function loadOhlcvData(ChartIndicatorRequestDto $dto, int $lookback): array
+    {
+        $filePath = $this->generateFilePath($dto->exchangeId, $dto->symbol, $dto->timeframe);
+        if (!file_exists($filePath)) {
+            throw new DataFileNotFoundException("Data file not found: {$filePath}");
+        }
+
+        $header = $this->binaryStorage->readHeader($filePath);
+        $recordCount = $header['numRecords'];
+
+        // This logic is a simplified pagination for demonstration. A production system
+        // might use a more robust cursor-based pagination.
+        $endTimestamp = $dto->toTimestamp ?? $this->binaryStorage->readRecordByIndex($filePath, $recordCount - 1)['timestamp'];
+        $startTimestamp = $dto->fromTimestamp ?? 0;
+        $limit = $dto->countback ?? 1000;
+
+        // Fetch the visible window plus the lookback period.
+        $recordsToFetch = $limit + $lookback;
+
+        // Note: For true 'countback', we'd need to find the index of 'endTimestamp'
+        // and read backwards. `readRecordsByTimestampRange` is a forward-only search.
+        // This implementation fetches a window ending at `endTimestamp`.
+        $allRecordsInRange = iterator_to_array(
+            $this->binaryStorage->readRecordsByTimestampRange($filePath, $startTimestamp, $endTimestamp)
+        );
+
+        // We take the tail of the result to simulate `countback`.
+        return array_slice($allRecordsInRange, -$recordsToFetch);
+    }
+
+    private function createDataframeFromRecords(array $records): array
+    {
+        $dataframe = [];
+        foreach (OhlcvEnum::cases() as $case) {
+            $dataframe[$case->value] = new Vector(array_column($records, $case->value));
+        }
+        return $dataframe;
+    }
+
+    /**
+     * @param array<IndicatorRequest> $indicatorRequests
+     * @return array<string, array<string, Series>>
+     */
+    private function calculateIndicators(array $indicatorRequests, Map $dataframes): array
+    {
+        $results = [];
+        foreach ($indicatorRequests as $request) {
+            // For now, we only handle TA-Lib indicators. This can be extended.
+            if ($request->type !== 'talib') {
+                continue;
+            }
+
+            try {
+                $functionEnum = TALibFunctionEnum::from('trader_' . strtolower($request->function));
+                $sourceEnum = AppliedPriceEnum::from($request->source);
+
+                $indicator = new TALibIndicator($functionEnum, $request->params, $sourceEnum);
+                $indicator->calculateBatch($dataframes);
+
+                $results[$request->key] = $indicator->getAllSeries();
+            } catch (\Throwable $e) {
+                $this->logger->error('Failed to calculate indicator {key}: {message}', [
+                    'key' => $request->key,
+                    'message' => $e->getMessage(),
+                    'exception' => $e,
+                ]);
+            }
+        }
+        return $results;
+    }
+
+    /**
+     * @param array<array<string, mixed>> $ohlcvRecords
+     * @param array<string, array<string, Series>> $calculatedIndicators
+     */
+    private function formatResponse(array $ohlcvRecords, array $calculatedIndicators, int $lookback): array
+    {
+        // Trim the lookback period from the OHLCV data.
+        $visibleOhlcv = array_slice($ohlcvRecords, $lookback);
+        $formattedOhlcv = array_map(
+            static fn ($r) => ['time' => $r['timestamp'], 'open' => $r['open'], 'high' => $r['high'], 'low' => $r['low'], 'close' => $r['close']],
+            $visibleOhlcv
+        );
+
+        $formattedIndicators = [];
+        foreach ($calculatedIndicators as $indicatorKey => $seriesMap) {
+            $formattedIndicators[$indicatorKey] = [];
+            foreach ($seriesMap as $seriesKey => $series) {
+                // Also trim the lookback period from each indicator series.
+                $visibleValues = array_slice($series->toArray(), $lookback);
+                $timestamps = array_column($formattedOhlcv, 'time');
+
+                $dataPoints = [];
+                foreach ($timestamps as $index => $ts) {
+                    if (isset($visibleValues[$index]) && $visibleValues[$index] !== null) {
+                        $dataPoints[] = ['time' => $ts, 'value' => $visibleValues[$index]];
+                    }
+                }
+                $formattedIndicators[$indicatorKey][$seriesKey] = $dataPoints;
+            }
+        }
+
+        return [
+            'ohlcv' => $formattedOhlcv,
+            'indicators' => $formattedIndicators,
+        ];
+    }
+}
diff --git a/src/Domain/Chart/Service/ChartLayoutService.php b/src/Domain/Chart/Service/ChartLayoutService.php
new file mode 100644
index 0000000..5d8a623
--- /dev/null
+++ b/src/Domain/Chart/Service/ChartLayoutService.php
@@ -0,0 +1,94 @@
+<?php
+
+namespace Stochastix\Domain\Chart\Service;
+
+use Stochastix\Domain\Chart\Dto\IndicatorRequest;
+use Stochastix\Domain\Chart\Dto\SaveLayoutRequestDto;
+use Stochastix\Domain\Chart\Entity\ChartLayout;
+use Stochastix\Domain\Chart\Repository\ChartLayoutRepository;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+use Symfony\Component\Uid\Ulid;
+
+/**
+ * Service class for handling business logic related to ChartLayout entities.
+ * It acts as an intermediary between controllers and the repository.
+ */
+final readonly class ChartLayoutService
+{
+    public function __construct(private ChartLayoutRepository $repository)
+    {
+    }
+
+    /**
+     * Creates a new ChartLayout entity from a DTO.
+     */
+    public function createLayout(SaveLayoutRequestDto $dto): ChartLayout
+    {
+        $layout = $this->mapDtoToEntity($dto, new ChartLayout());
+        $this->repository->save($layout, true);
+
+        return $layout;
+    }
+
+    /**
+     * Updates an existing ChartLayout entity from a DTO.
+     */
+    public function updateLayout(string $id, SaveLayoutRequestDto $dto): ChartLayout
+    {
+        $layout = $this->findLayoutById($id);
+        $this->mapDtoToEntity($dto, $layout);
+        $this->repository->save($layout, true);
+
+        return $layout;
+    }
+
+    /**
+     * Deletes a ChartLayout entity by its ID.
+     */
+    public function deleteLayout(string $id): void
+    {
+        $layout = $this->findLayoutById($id);
+        $this->repository->remove($layout, true);
+    }
+
+    /**
+     * Finds a single layout by its ULID.
+     *
+     * @throws NotFoundHttpException if the layout does not exist.
+     */
+    public function findLayoutById(string $id): ChartLayout
+    {
+        if (!Ulid::isValid($id)) {
+            throw new NotFoundHttpException('Invalid layout ID format.');
+        }
+
+        $layout = $this->repository->find($id);
+
+        if (!$layout) {
+            throw new NotFoundHttpException('Chart layout not found.');
+        }
+
+        return $layout;
+    }
+
+    /**
+     * Maps data from the request DTO to the Doctrine entity.
+     */
+    private function mapDtoToEntity(SaveLayoutRequestDto $dto, ChartLayout $layout): ChartLayout
+    {
+        $layout->setName($dto->name);
+        $layout->setSymbol($dto->symbol);
+        $layout->setTimeframe($dto->timeframe);
+
+        // Convert array of IndicatorRequest objects to a plain array for JSON serialization.
+        $indicatorConfig = array_map(
+            static fn (IndicatorRequest $indicator) => (array) $indicator,
+            $dto->indicators
+        );
+
+        $layout->setIndicators($indicatorConfig);
+
+        return $layout;
+    }
+}
+
diff --git a/src/Domain/Chart/Service/IndicatorDiscoveryService.php b/src/Domain/Chart/Service/IndicatorDiscoveryService.php
new file mode 100644
index 0000000..cbbb6a6
--- /dev/null
+++ b/src/Domain/Chart/Service/IndicatorDiscoveryService.php
@@ -0,0 +1,125 @@
+<?php
+
+namespace Stochastix\Domain\Chart\Service;
+
+use Stochastix\Domain\Indicator\Attribute\AsIndicator;
+use Stochastix\Domain\Indicator\Model\IndicatorInterface;
+use Stochastix\Domain\Indicator\Model\TALibIndicator;
+use Stochastix\Domain\Strategy\Attribute\Input;
+use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
+
+/**
+ * This service discovers all available indicators, both built-in (TA-Lib)
+ * and custom-defined, and prepares them for the API.
+ */
+final readonly class IndicatorDiscoveryService
+{
+    /**
+     * @var array<string, array<string, mixed>>
+     */
+    private array $builtInIndicatorDefinitions;
+
+    /**
+     * @param iterable<IndicatorInterface> $indicators
+     */
+    public function __construct(
+        #[AutowireIterator(IndicatorInterface::class)]
+        private iterable $indicators
+    ) {
+        $this->builtInIndicatorDefinitions = $this->getBuiltInDefinitions();
+    }
+
+    public function getAvailableIndicators(): array
+    {
+        return [
+            'builtIn' => array_values($this->builtInIndicatorDefinitions),
+            'custom' => $this->discoverCustomIndicators(),
+        ];
+    }
+
+    /**
+     * @return array<string, array<string, mixed>>
+     */
+    private function getBuiltInDefinitions(): array
+    {
+        // This mapping provides user-friendly metadata for common TA-Lib functions.
+        $map = [
+            'sma' => ['name' => 'Simple Moving Average', 'params' => ['timePeriod']],
+            'ema' => ['name' => 'Exponential Moving Average', 'params' => ['timePeriod']],
+            'rsi' => ['name' => 'Relative Strength Index', 'params' => ['timePeriod']],
+            'macd' => ['name' => 'Moving Average Convergence Divergence', 'params' => ['fastPeriod', 'slowPeriod', 'signalPeriod']],
+            'bbands' => ['name' => 'Bollinger Bands', 'params' => ['timePeriod', 'nbDevUp', 'nbDevDn', 'mAType']],
+            'atr' => ['name' => 'Average True Range', 'params' => ['timePeriod']],
+            'stoch' => ['name' => 'Stochastic', 'params' => ['fastK_Period', 'slowK_Period', 'slowK_MAType', 'slowD_Period', 'slowD_MAType']],
+            'adx' => ['name' => 'Average Directional Movement Index', 'params' => ['timePeriod']],
+            'obv' => ['name' => 'On Balance Volume', 'params' => []],
+        ];
+
+        $definitions = [];
+        foreach ($map as $func => $details) {
+            $definitions[$func] = [
+                'name' => $details['name'],
+                'description' => null,
+                'alias' => $func,
+                'type' => 'talib',
+                'inputs' => array_map(fn($p) => ['name' => $p, 'type' => str_contains(strtolower($p), 'period') ? 'integer' : 'number'], $details['params']),
+            ];
+        }
+
+        return $definitions;
+    }
+
+    private function discoverCustomIndicators(): array
+    {
+        $customIndicators = [];
+        foreach ($this->indicators as $indicator) {
+            // We only care about custom implementations, not the generic TALibIndicator itself.
+            if ($indicator instanceof TALibIndicator) {
+                continue;
+            }
+
+            $reflectionClass = new \ReflectionClass($indicator);
+            $asIndicatorAttributes = $reflectionClass->getAttributes(AsIndicator::class);
+
+            if (empty($asIndicatorAttributes)) {
+                continue;
+            }
+
+            /** @var AsIndicator $asIndicator */
+            $asIndicator = $asIndicatorAttributes[0]->newInstance();
+            $inputs = [];
+
+            foreach ($reflectionClass->getProperties(\ReflectionProperty::IS_PUBLIC | \ReflectionProperty::IS_PROTECTED | \ReflectionProperty::IS_PRIVATE) as $property) {
+                $inputAttributes = $property->getAttributes(Input::class);
+                if (empty($inputAttributes)) {
+                    continue;
+                }
+
+                $inputAttr = $inputAttributes[0]->newInstance();
+                $propertyType = $property->getType()?->getName() ?? 'string';
+                $jsonType = match ($propertyType) {
+                    'int' => 'integer',
+                    'float' => 'number',
+                    'bool' => 'boolean',
+                    default => 'string',
+                };
+
+                $inputs[] = [
+                    'name' => $property->getName(),
+                    'description' => $inputAttr->description,
+                    'type' => $jsonType,
+                    'defaultValue' => $property->hasDefaultValue() ? $property->getDefaultValue() : null,
+                ];
+            }
+
+            $customIndicators[] = [
+                'name' => $asIndicator->name,
+                'description' => $asIndicator->description,
+                'alias' => $reflectionClass->getName(), // Use FQCN as the unique identifier
+                'type' => 'custom',
+                'inputs' => $inputs,
+            ];
+        }
+        return $customIndicators;
+    }
+}
diff --git a/src/Domain/Indicator/Attribute/AsIndicator.php b/src/Domain/Indicator/Attribute/AsIndicator.php
new file mode 100644
index 0000000..24a4f27
--- /dev/null
+++ b/src/Domain/Indicator/Attribute/AsIndicator.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace Stochastix\Domain\Indicator\Attribute;
+
+/**
+ * An attribute to mark a class as a custom, user-defined indicator,
+ * making it discoverable by the IndicatorDiscoveryService.
+ */
+#[\Attribute(\Attribute::TARGET_CLASS)]
+final readonly class AsIndicator
+{
+    /**
+     * @param string $name A user-friendly name for the indicator (e.g., "My Custom Oscillator").
+     * @param string|null $description A brief description of what the indicator does.
+     */
+    public function __construct(
+        public string $name,
+        public ?string $description = null,
+    ) {
+    }
+}
diff --git a/src/Domain/Indicator/Model/IndicatorInterface.php b/src/Domain/Indicator/Model/IndicatorInterface.php
index f5d14ab..f1af1a6 100644
--- a/src/Domain/Indicator/Model/IndicatorInterface.php
+++ b/src/Domain/Indicator/Model/IndicatorInterface.php
@@ -5,7 +5,9 @@
 use Ds\Map;
 use Stochastix\Domain\Common\Model\Series;
 use Stochastix\Domain\Plot\PlotDefinition;
+use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
 
+#[AutoconfigureTag]
 interface IndicatorInterface
 {
     public function calculateBatch(Map $dataframes): void;

From d2069ba0a13636fed33691fc2cc3bfd0b37db823 Mon Sep 17 00:00:00 2001
From: William Arin <williamarin.dev@gmail.com>
Date: Sat, 21 Jun 2025 17:25:15 +0800
Subject: [PATCH 3/3] fix: timestampable fields

---
 src/Domain/Chart/Entity/ChartLayout.php | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/Domain/Chart/Entity/ChartLayout.php b/src/Domain/Chart/Entity/ChartLayout.php
index 9e73853..5c0dafe 100644
--- a/src/Domain/Chart/Entity/ChartLayout.php
+++ b/src/Domain/Chart/Entity/ChartLayout.php
@@ -4,7 +4,7 @@
 
 use Doctrine\DBAL\Types\Types;
 use Doctrine\ORM\Mapping as ORM;
-use Gedmo\Timestampable\Traits\Timestampable;
+use Gedmo\Timestampable\Traits\TimestampableEntity;
 use Stochastix\Domain\Chart\Repository\ChartLayoutRepository;
 use Symfony\Component\Uid\Ulid;
 
@@ -12,7 +12,7 @@
 #[ORM\Table(name: 'chart_layout')]
 class ChartLayout
 {
-    use Timestampable;
+    use TimestampableEntity;
 
     #[ORM\Id]
     #[ORM\Column(type: 'ulid', unique: true)]
