Skip to content

Backlog — PortfolioAI

Suivi des features par phase. Mis à jour à chaque session de développement.

Statuts : ⏳ À faire · 🚧 En cours · 🧊 Gelé · ❌ Décommissionné — les ✅ Fait vivent dans journal-livraisons.md, pas ici.

Ce fichier ne liste que ce qui reste à faire et l'état courant des modules gelés / décommissionnés. Pour le journal des features livrées (Phase 0 → Phase 2.5 et dette technique close), voir journal-livraisons.md — format reverse-chronological, lecture comme un changelog Keep-a-Changelog.


Phase 0 — Fondation (terminée, tag v0.1.0)

Le détail des features livrées (et celles qui sont conservées en flow vs décommissionnées) est consigné dans journal-livraisons.md > Phase 0.

❌ Décommissionné — code supprimé en Phase 2.5

Feature Notes
❌ Ingestion RSS Module ingestion/ (Rome, scheduler 15 min, déduplication par guid, parsing robuste DOCTYPE / & nus, 25 sources seedées) supprimé en Phase 2.5. Tables feed_source + feed_article droppées (V6)
❌ Pipeline analyse portfolio LLM AnalysisExecutor, AnalysisContextLoader, ArticleRelevanceScorer, LlmResponseParser, RecommendationValidator, RecommendationPersister supprimés en Phase 2.5. Tables recommendation* + analysis_job droppées (V6). Replacement Phase 6 = PortfolioAggregation au-dessus des snapshots ticker
❌ Pages Recommendations / History features/recommendations/ + features/history/ supprimées en Phase 2.5 (PR2 frontend). Repository AnalysisRepository + adapter retirés, navbar /history retirée
🧊 Bascule Mistral local + timeouts 400 s OllamaClient configuré initialement pour mistral (7B Instruct Q4), timeouts alignés sur 400 s. Le défaut local a depuis basculé sur qwen2.5:3b (Mistral trop lent sur M1, 30-60 s/narratif → timeouts répétés). Le timeout 400 s est aujourd'hui éditable au runtime via le slider llm.timeout-seconds (Phase 2.5 v1.5). Reste activable via llm.provider: ollama mais Claude est le défaut Phase 1

Phase 1 — Pivot ticker (terminée, tag v0.2.0 du 2026-05-02)

Phase clôturée — le détail des features livrées (module market/, pipeline narratif, dossier ticker, settings adaptés Phase 1, tests prioritaires) est consigné dans journal-livraisons.md > Phase 1.


Phase 2 — Profondeur ticker (clôturée 2026-05-06, tag v0.3.0)

Phase clôturée — le détail des features livrées (Twelve Data, settings runtime, multi-timeframe + axes + crosshair, watchlist v1+v2, news, chart analyse v1+v2+v3, news inline, earnings, recommandations analystes, benchmark v1+v2 + sector/custom, sidenav outils chart) est consigné dans journal-livraisons.md > Phase 2.

Note : tous les items ticker sont livrés. Les sujets restants vivent en Phase 2.5 (stabilisation, outils, dette technique) et Phase 3 (observabilité narrative).


Phase 2.5 — Stabilisation et outils (clôturée 2026-05-10, tag v0.4.0)

Phase intermédiaire entre la profondeur ticker (Phase 2) et l'observabilité narrative (Phase 3) — accumule l'outillage runtime et les améliorations UX dashboard qui n'ont pas leur place dans une roadmap « ticker-only » ni « narrative-only ». Pas de scope produit nouveau ; on stabilise et on outille. La phase est dense : config runtime v1+v1.5, décommissionnement Phase 0, CacheTtlListener AFTER_COMMIT, type d'instrument chip 3/3, persistence instrumentType BDD V7 (drop du lazy-lookup burst Twelve Data), sidenav outils chart, exposition analyst.provider / earnings.provider, hint Sector benchmark, lifecycle position OPEN/CLOSED, .env ports configurables stack locale, Swagger UI en profile local, panneau État Ollama + bouton éject VRAM + bouton Pull modèle, clé Anthropic en SECRET runtime, drag-drop portfolios sidebar, décision Ollama option 3 statu quo, Server-Sent Events per-phase pour le narratif (4 PR : backend SSE / frontend bascule / reattach pending / UX phase visible), ./gradlew test + frontend dev-server proxy lisent automatiquement .env, retour audit fin Phase 2.5 (4 findings clôturés en un seul commit avant le tag : boot fragile sans ANTHROPIC_API_KEY, validation jobId/symbol sur le SSE narratif, WatchlistService.add hors @Transactional, sweep LlmTimeoutService dead code). Toutes les notes d'implémentation détaillées sont dans journal-livraisons.md > Phase 2.5.

Note : tous les items Phase 2.5 sont livrés. Les sujets restants vivent en Phase 4 (Authentification), Phase 5 (Déploiement), Phase 6 (DAG unifié + Réintégration Phase 0 + Vision long terme) et dette technique.


Phase 3 — Observabilité narrative (clôturée 2026-05-14)

Phase clôturée — détail des livraisons dans journal-livraisons.md > Phase 3. 4 livraisons en 5 jours : foundation prompt management + scoring (2026-05-10), page observabilité narrative timeline + index (2026-05-13), score de cohérence cross-runs (2026-05-14), détection de biais (2026-05-14). Le ticket #4 « Page Jobs DAG » initialement projeté ici a été déplacé en Phase 6 — il dépend du DAG unifié et son contenu est intrinsèquement lié à l'archi long terme, donc filed avec ses prérequis. Boucle d'audit narrative complète disponible (inspection / cohérence vs précédent / timeline / agrégats corpus). Tag candidat : v0.5.0.


Phase 4 — Authentification (clôturée 2026-05-17)

Phase entièrement livrée. Détails d'implémentation dans journal-livraisons.md > Phase 4 : auth foundation backend + frontend /login + interceptor + guards + navbar gating + Google OIDC + CSRF cookie-based SPA + page /error + secrets refactor .env + DevX toggle BACKEND_AUTH_MODE + migration multi-tenant user_id FK + provider gating UI + logs redaction (convention userId UUID) + Flyway squash V1→V10 en un seul V1__init.sql. Les deux tickets restants liés (GitHub Secrets vault, Hardening secret-management OAuth prod) sont passés en début de Phase 5 parce qu'ils dépendent du choix d'hébergement.


Phase 5 — Déploiement

Aujourd'hui l'app vit uniquement en local via tilt up sur le poste de l'utilisateur — aucun accès depuis un autre appareil, aucun moyen d'autoriser quelqu'un d'autre à l'utiliser. Phase 5 ouvre l'app au-delà du localhost. Provider retenu 2026-05-18 (post-révision après clarification « pas d'Ollama en prod ») : Google Cloud Run + Supabase Postgres, $0/mo durable dans le free tier, région compute Montréal native (northamerica-northeast1), DB Supabase US-East. Fly.io ($10/mo) et Oracle A1 Ampere ($0 + sysadmin léger) restent documentés en fallbacks. Pré-requis bloquant déjà livré : Phase 4 Authentification (OAuth2 Google OIDC + multi-tenant user_id FK). Détail de l'analyse fournisseur, plan phasé, pipeline GitOps Workload Identity Federation et plan de migration sortie : docs/devops/deploiement.md. Discipline non-négociable dès le 1er deploy : zéro SDK Supabase dans le code (juste DATABASE_URL JDBC), backup pg_dump nocturne vers Cloudflare R2, Cloudflare devant Cloud Run pour bypass egress quota.

⏳ À faire

Feature Description Priorité
⏳ Cloudflare devant Cloud Run — custom domain + TLS + cache + bypass egress quota Remplacer l'URL *.run.app par un domaine propre ET absorber le risque quota egress Cloud Run free (1 GB/mo N. America). Cloudflare gratuit fait les deux en un shot. Cible : (1) Acheter / pointer un domaine (e.g. portfolioai.app ou <utilisateur>.dev). (2) Créer un site Cloudflare gratuit, déplacer les nameservers du registrar vers Cloudflare. (3) DNS : CNAME portfolioai.example.com → <service>.run.app proxifié (orange cloud). (4) Soit utiliser Cloud Run custom domain mapping (gratuit, géré côté Google), soit pointer directement via Cloudflare proxy. (5) TLS auto via Cloudflare Universal SSL (Let's Encrypt en backend, transparent). (6) Configurer Cache Rules : assets statiques /static/* cachés agressivement (1 an, immutable), HTML/API path-throughed sans cache. Bypass egress : les hits cache Cloudflare ne consomment pas le quota egress Cloud Run free (1 GB/mo) — critique si on prend du trafic. (7) Mettre à jour APP_FRONTEND_URL=https://portfolioai.example.com côté Cloud Run env vars + redirect URI dans Google Cloud Console OAuth client. Effort estimé : ~1-2 h hors achat du domaine. Pré-requis : Phase 5a bootstrap livré + domaine acheté. 🟡 Moyenne
⏳ Monitoring uptime + Sentry error tracking (free tiers) Visibilité minimale prod sans coût. Cible : (1) Healthcheck.io free tier (ou UptimeRobot) — 1 check toutes les 5 min sur https://portfolioai.example.com/actuator/health, notification email/Slack si > 2 fails consécutifs. (2) Sentry SaaS hobby tier — 5K events/mo gratuits. Backend : ajouter io.sentry:sentry-spring-boot-starter, configurer SENTRY_DSN env var côté Cloud Run secrets, breadcrumbs sur les exceptions Spring + tagging userId UUID (convention auth/CustomOAuth2UserService.findOrCreateUser). Frontend : @sentry/angular SDK, source maps pushées via CLI Sentry dans le workflow build. (3) Configurer alerts Sentry : email si une nouvelle exception apparaît, ou si > 10 errors/h. Effort estimé : ~1-2 h cumulé (healthcheck endpoint vérifié + Sentry backend wiring + Sentry frontend wiring + sources maps + premier crash test). Trigger : après Phase 5a + 5b Cloudflare stables. 🟡 Moyenne
⏳ Précharger la font Material Icons pour éviter le FOUC sur arrivée /login Issue identifiée 2026-05-18 sur le 1er deploy Cloud Run + Supabase : à l'arrivée initiale sur /login (cold-start serveur + 1st request browser, donc avant que la font Material Icons soit DL + parsée), les <mat-icon> rendent leur ligature text en clair (e.g. le mot menu, settings, home) au lieu de l'icône. Flash of Unstyled Content (FOUC) classique Angular Material qui survient typiquement après ~100-300 ms le temps que la font Google Fonts arrive. Une fois cachée, ne réapparaît plus — donc cassé seulement à la première visite. Trois paths de fix possibles (à arbitrer à l'attaque) : (1) <link rel=preload> + <link rel=preconnect> dans frontend/src/index.html pour fonts.googleapis.com + fonts.gstatic.com — DNS resolved en parallèle de la SPA, font DL kicke immédiatement au lieu d'attendre la parse du CSS. ~5 min. Léger gain. (2) font-display: block sur la rule @font-face de Material Icons — au lieu de fallback sur le text plain (FOUC), le browser cache l'icône (invisible) jusqu'à ce que la font soit prête. Pas idéal UX (icônes invisibles ~200 ms) mais évite le FOUC text. ~5 min. (3) Self-host la font Material Icons — copier le woff2 dans frontend/public/fonts/ + update le @font-face + plus de dépendance Google Fonts en runtime (gain : DNS+TLS+DL réseau ~200-500 ms cold-start). ~30 min. (4) MatIconRegistry avec SVG inline — bascule sur le pattern SVG icons (chaque icône est un SVG embedded dans le bundle) plutôt que ligature font. Plus invasif (refactor de tous les <mat-icon> du codebase) mais zero dépendance externe + tree-shaking. ~1-2 j. Ma reco au moment d'attaquer : combiner (1) + (2) pour 10 min de friction zéro régression — c'est suffisant pour single-user. Réserver (3) au moment de couper Cloudflare comme bypass egress, et (4) à un audit complet du bundle frontend qui viendra plus tard si besoin. Effort estimé : ~10-30 min selon le path retenu. Trigger : à grouper avec « Revoir les redirections Angular » ci-dessus dans une session polish frontend prod, idéalement avant d'ouvrir l'app à des testeurs tiers (le FOUC text donne une impression non-pro). 🟡 Moyenne
⏳ Hardening sécurité — secret management OAuth en prod Ticket résiduel de la Phase 4 hardening. Logs redaction livrée Phase 4 (convention userId UUID, jamais l'email — cf. CLAUDE.md + CustomOAuth2UserService.findOrCreateUser). CSRF livré Phase 4 (cookie-based SPA pattern — CookieCsrfTokenRepository.withHttpOnlyFalse() + CsrfTokenResponseFilter). Reste : où stocker le GOOGLE_OAUTH_CLIENT_SECRET et les autres creds boot-time (APP_ADMIN_EMAILS, APP_FRONTEND_URL) une fois en prod ? Dépend directement du provider hébergement retenu (env vars container, secret manager cloud type AWS Secrets Manager / GCP Secret Manager / Vault, ou injection via GitHub Environments — cf. ticket « GitHub Secrets + Environments » ci-dessous qui couvre l'angle CI/CD). Trigger : à attaquer juste après l'analyse hébergement qui décide du provider — la stratégie secret en découle directement. Effort estimé : ~30 min à 2 h selon la solution (env vars natives provider = 30 min, secret manager managé = 1 h pour câbler + tests, Vault self-hosted = pas v1). 🟡 Moyenne
⏳ Hardening sécurité — restreindre server.forward-headers-strategy ou whitelist proxies internes Identifié par code review 2026-05-17 (point « À discuter A » sur le diff Phase 4 wrap). Aujourd'hui application.yml a server.forward-headers-strategy: framework — Spring trust inconditionnellement les headers X-Forwarded-Host/Port/Proto reçus. Nécessaire en dev (le proxy Angular CLI ajoute ces headers via xfwd: true et Spring doit les lire pour construire la redirect_uri OAuth sur le port du SPA, pas du backend). Nécessaire aussi en prod derrière un reverse proxy (nginx/traefik/ALB) pour générer les URLs publiques correctes. Risque : si le backend était jamais exposé directement à internet sans proxy devant, un attaquant pourrait spoofer X-Forwarded-Host → poison de la redirect_uri OAuth envoyée à Google (théorique — la whitelist Google Cloud Console côté authorization server bloquerait toute redirect URI non enregistrée, mais ce serait quand même un trou logique). Cible v2 selon le provider Phase 5 retenu : (a) PaaS managé (Fly.io, Railway, Cloud Run) — le LB est obligatoire, framework reste OK ; documenter explicitement que le backend ne doit jamais être exposé sans LB (réseau privé). (b) VPS self-managed avec nginx/Caddy devant — basculer sur NATIVE + server.tomcat.remoteip.internal-proxies avec la regex IP du proxy local (e.g. 127\.0\.0\.1|::1). Spring Boot supporte les deux strategies via la même property. (c) Documentation dans developpement.md ou architecture.md > Décisions techniques notables : « framework choisi pour simplifier dev + matcher prod derrière LB ; bascule NATIVE requise si VPS auto-géré ». Trigger : à attaquer pendant le ticket « Provisionner et déployer v1 » Phase 5 — la décision dépend du provider retenu. Le commentaire actuel dans application.yml:11-13 documente déjà le risque (« nul tant que le backend n'est pas exposé directement à internet sans proxy devant ») ; ce ticket formalise la résolution. Effort estimé : ~30 min — patch YAML + 1 ligne de doc + test smoke sur l'env retenu. 🟡 Moyenne
⏳ Analyse DNS — trouver un nom de domaine custom pas cher pour la prod Identifié 2026-05-18. État actuel : l'app est servie à https://portfolioai-vybmfauwxq-nn.a.run.app (URL Cloud Run générée). Fonctionnel, mais pas mémorisable, pas branding-friendly, et signale visuellement « projet jetable hébergé chez Google » plus que « SaaS sérieux » → frein perception pro même pour un dev solo qui montre l'app à un proche. Pré-requis du ticket Cloudflare ci-dessus, qui assume « domaine déjà acheté » dans son étape 1 — sans cette analyse, l'achat se fait à la sauvette sur un coup de tête et on prend un TLD/registrar mal choisi. Scope d'analyse à produire : (1) Brainstorm 5-10 noms autour de portfolioai ou variants courts/évocateurs alignés au positionnement « narrateur de marché, pas devin » (e.g. portfolioai, pfai, tickerstory, marketnarrator, dossierticker, etc.) + check dispo via whois ou registrar lookup. (2) TLD comparaison coût-image : .com (~12-15 USD/an, classique mais souvent pris), .app (~14-20 USD/an, Google-owned, HSTS preload list forcée = TLS obligatoire, bonus sécu gratuit), .dev (~12-15 USD/an, idem HSTS), .io (~30-60 USD/an — populaire SaaS techy mais cher), .ai (~80-200 USD/an + géopolitique Anguilla — match brand parfait mais cher), .xyz (~1-10 USD/an — cheapest mais image moins pro), .fr ou .ca (~10-15 USD/an, pertinent vu localisation Montréal + base FR). (3) Registrars compare : Cloudflare Registrar (at-cost pricing, zéro markup, mais TLDs limités — exact list publique), Porkbun (UX propre + auto-renew sain, markup léger), Namecheap (similaire à Porkbun), OVH (registrar français, intéressant si on prend un .fr). Éviter GoDaddy (UX pollutive + upsells aggressifs). (4) Recommandation finale : 1 nom + 1 TLD + 1 registrar + coût annuel total + lien direct vers la page d'achat. Bonus : check WHOIS privacy gratuit chez le registrar choisi (Cloudflare et Porkbun l'incluent par défaut). (5) Validation alignment avec le ticket Cloudflare : le domaine choisi doit être supporté par Cloudflare DNS gratuit (la majorité des TLDs courants le sont — .app, .dev, .io, .com, .xyz OK ; .fr et .ca aussi). Hors-scope v1 : achat du domaine + transfert DNS chez Cloudflare → c'est le ticket Cloudflare qui le fait, ce ticket se contente de produire la reco. Livrable : nouvelle section dans docs/devops/deploiement.md (ou page dédiée docs/devops/dns-analyse.md si la reco devient longue), résumant les options et la reco. Ticket Cloudflare met à jour son étape 1 pour pointer dessus. Effort estimé : ~30 min à 1 h (WebSearches sur prix TLDs courants + 3-5 whois lookups + écriture reco). Trigger : à attaquer avant le ticket Cloudflare (sinon on commence l'achat sans avoir comparé). Faisable en parallèle du workflow Releases / industrialisation versionning — c'est un autre code path. 🟢 Basse
⏳ Persister les préférences utilisateur côté backend (sortir de localStorage) Identifié 2026-05-18. État actuel : trois services frontend persistent dans localStorage — (a) ThemeService clé portfolioai.theme (dark/light), (b) LanguageService (FR/EN), (c) AnnotationRepository.local adapter clés portfolioai.annotation.<SYMBOL> (un blob JSON par ticker avec les annotations user sur le dossier ticker). Limites : (1) Multi-device cassé — un user qui ouvre l'app depuis son téléphone perd theme, langue, et toutes ses annotations ticker. OK en single-user/single-device dev, bloquant dès qu'on connecte un 2e poste ou qu'on ouvre à un testeur tiers. (2) Clear browser data → perte définitive des annotations (les prefs UI se redéfinissent en 2 clics, les annotations sont du contenu non reconstructible). (3) Pas de fallback SSR — si on bascule un jour provideClientHydration, localStorage est inaccessible côté serveur, 1er render n'a pas les prefs → FOUC inévitable. Pré-requis : Phase 4 livré (app_user row par utilisateur authentifié + pattern user_id FK multi-tenant). Cible : (a) Migration Flyway V11 — colonne preferences JSONB sur app_user (plus souple qu'une table user_preference dédiée pour 2-3 prefs UI, et un seul SELECT au /api/me). Shape : { theme: 'dark'|'light', language: 'fr'|'en', ... }. (b) Nouvelle table user_annotation (id UUID PK, user_id UUID FK, symbol VARCHAR, content JSONB, created_at, updated_at, index (user_id, symbol)) — c'est une vraie ressource user, pas une pref UI, table dédiée justifiée. (c) Backend : auth/application/UserPreferencesService (GET/PUT) + UserPreferencesController exposant GET /api/me/preferences + PUT /api/me/preferences. Annotations : repository JPA + endpoint GET/POST/DELETE /api/annotations/{symbol} (placement domaine à arbitrer — sous analysis/ ou nouveau bucket annotation/). (d) Frontend — nouveau service UserPreferencesService (HTTP-backed, primé via provideAppInitializer au boot comme LlmTimeoutService). ThemeService + LanguageService basculent en write-through cache : (i) lecture localStorage synchrone au boot (zéro FOUC, comme aujourd'hui), (ii) hydratation avec la valeur backend au retour de /api/me/preferences (overwrite si différent), (iii) chaque set() écrit en localStorage ET PUT backend (debounced 300 ms pour éviter le spam si toggle rapide). (e) AnnotationRepository — bascule du port sur nouvelle adapter HTTP adapters/annotation.http.ts. Drop l'adapter localStorage (pas d'usage offline-first dans le scope v1). (f) Migration data : pas de migration server-side. Le user récupère ses prefs si jamais il revient sur le même device avec localStorage encore peuplé (write-through hydrate le backend). Côté annotations, le user perd ce qu'il n'a pas réutilisé récemment — acceptable en v0 single-user (annotations rares). Documenter le « one-way breaking change » dans journal-livraisons.md + CHANGELOG.md. (g) FOUC theme — le script inline index.html continue de lire localStorage (sync, pré-Angular). La valeur backend hydrate après boot et l'override est invisible si elle matche, brièvement visible si elle diffère (multi-device 1er login). Acceptable. Alternative anti-flash : poser un cookie theme HttpOnly=false depuis le backend au login pour que le script inline le lise — overkill v1. Tests : (a) backend — UserPreferencesServiceTest (defaults si pas de row, update via PUT, scoping user_id matché à currentUser), (b) frontend — theme.service.spec patché : hydratation backend overrides localStorage, write-through PUT effectué sur set(), (c) intégration end-to-end : login → toggle theme → logout → relogin → theme persisté côté backend, (d) annotations — CRUD complet, scoping per-user. Effort estimé : ~3-4 h (migration Flyway + entités JPA + repositories + services + controllers + endpoints REST + frontend services bascule + write-through cache logic + spec patches + smoke deploy). Trigger : avant d'ouvrir l'app à un testeur tiers ou avant le 1er usage multi-device (typiquement dès qu'on attache un custom domain via Cloudflare et qu'on teste sur mobile). Pas critique pour le dev solo single-device. Décision documentée 2026-05-18 : on attend la fin de Phase 5a (Workflow GitHub Releases + Backup nocturne) pour attaquer ce ticket — la stack deploy doit être stable avant de toucher au schema. 🟡 Moyenne

Reste hors-backlog v1 mais déclenchable Phase 5c si trigger : (a) Migration Supabase → Neon free si Supabase serre (effort ~30 min pg_dump | pg_restore), (b) upgrade Supabase Pro $25/mo si Neon ne tient pas, (c) bascule globale Fly Phase 5a $10/mo comme plan C ultime — tous documentés dans docs/devops/deploiement.md > §5 Phase 5c.


Phase 6 — Vision long terme

Séquencement recommandé (cf. CLAUDE.md « Ordering convention » + arbitrage 2026-05-14 lors du déplacement de #4 Page Jobs depuis Phase 3) : 3 vagues de tickets dépendants entre elles, puis le R&D long terme indépendant.

  • Vague 1 — Foundation DAG (3 tickets séquentiels) : (1) DAG unifié (fondateur, ~3 j) → (2) Réintégration Phase 0 / PortfolioAggregation (~1.5 j, 1er consumer parent qui valide le modèle sur cas réel et ramène la feature majeure perdue en Phase 2.5) → (3) Page Jobs DAG (~2-3 j, l'UI au-dessus, indispensable pour observer ce qu'on vient de bâtir). Les 3 se renforcent — livrer le DAG sans consumer concret = code sans vie ; livrer le consumer sans la Page Jobs = aucun moyen d'observer.
  • Vague 2 — Extensions DAG (1 ticket) : (4) Cron pré-chauffe — 2nd consumer non-UI, simple à câbler une fois DAG + Page Jobs en place.
  • Vague 3 — Surfaces dashboard (2 tickets, indépendants entre eux) : (5) Croisement portfolio × insights ticker, (6) Watchlist alertes. Pas de dépendance stricte au DAG mais bénéficient de la fraîcheur du cache (cron pré-chauffe).
  • Vague 4 — R&D long terme (3 tickets, indépendants) : (7) Paper trading, (8) Multi-broker, (9) Fine-tuning. À attaquer dans l'ordre que l'usage dicte.
Feature Description
Vague 1 — (1) Pipeline d'analyse — modèle DAG unifié (job, parent/child, cache-aware leaves) Ticket fondateur de l'architecture cible décrite dans docs/metier/vision.md > Le pipeline d'analyse et docs/technique/architecture.md > Modèle pipeline d'analyse. Aujourd'hui la seule table async est ticker_narrative_job (Phase 1, après le décommissionnement Phase 0 / V6). Pas de cache visible, pas de parent/enfant, pas de DAG. La vision cible : toute analyse devient un nœud d'un DAG, les feuilles sont les TickerAnalysis(symbol, day) et les parents sont les compositions (PortfolioAggregation, WatchlistDigest, futures). Schéma cible : table job unifiée — colonnes id UUID, kind VARCHAR (TICKER_ANALYSIS / PORTFOLIO_AGGREGATION / MARKET_REFRESH / extensible), parent_id UUID nullable (FK self pour le DAG), status VARCHAR (PENDING / RUNNING / DONE / DONE_CACHED / ERROR / CANCELLED), origin VARCHAR (dashboard / cron / api / parent), cache_key VARCHAR (clé déterministe type TickerAnalysis:NVDA:2026-05-07), target_id UUID nullable (FK vers ticker_narrative_snapshot ou future portfolio_analysis_snapshot), payload JSONB (input du job — symbol+date pour leaf, portfolio_id+date pour parent), result_summary TEXT (« cached snapshot from 09:32 » / « LLM call 8.4 s, retry 0 »), error TEXT, timestamps created_at / started_at / ended_at. Migration : V9 ou suivante, peut soit (a) créer job ex nihilo et migrer les données Phase 1 lignes-par-lignes (clean break), soit (b) garder ticker_narrative_job et créer job comme view union pendant une période transitoire. (a) plus propre, (b) moins risqué. Cache-aware leaves : chaque feuille TickerAnalysis(symbol, day) lookup d'abord ticker_narrative_snapshot.findFreshFor(symbol, day) — si hit, status passe direct à DONE_CACHED sans appel LLM. Politique de fraîcheur à arbitrer : 30 min (cohérent avec dedup actuelle Phase 1) vs même-jour calendaire vs séance de marché. Dedup déterministe : si un job RUNNING existe avec le même cache_key, le nouveau request rejoint ce job au lieu d'en créer un second (extension du dedup window actuel vers une dedup par clé). Trois origines de trigger unifiées sur le même primitif : (1) dashboard — ouverture manuelle de dossier ticker, ou parent kické depuis « Analyser le portefeuille », (2) cron — scheduler quotidien hors heures de bureau qui pré-chauffe les positions OPEN détenues, (3) api — endpoint REST pour scripts externes ou webhooks. Côté Spring : un JobOrchestrator (ou JobRunner, à nommer) avec un ThreadPoolTaskExecutor borné (e.g. 4 threads), méthodes enqueueLeaf(kind, payload) et enqueueParent(kind, payload, childKeys). Le parent reste PENDING tant que tous ses enfants ne sont pas dans un état terminal — implémenté via un listener qui réveille les parents quand un enfant transit. Tests : un cas pin par état (cache hit instant, cache miss → RUNNING → DONE, error → retry depuis l'UI, dedup join existing run, parent waits all children terminal, parent runs even if some children ERROR). Pourquoi pas Temporal / Airflow : single-user, low-throughput, JVM unique — un thread pool Spring + une table job font 95 % du job à 5 % de la complexité. Si SaaS multi-user un jour, Temporal devient pertinent. Effort estimé : ~3 jours pour le modèle + l'orchestrateur + le rebranchement Phase 1 ticker narrative dessus + tests. Prérequis pour les tickets dépendants Vague 1 (#2 Réintégration Phase 0, #3 Page Jobs) et Vague 2 (#4 Cron pré-chauffe). Décision : prérequis bloquant — à livrer avant tous les autres tickets de la Vague 1 et Vague 2
Vague 1 — (2) Réintégration Phase 0 — analyse portefeuille comme parent du DAG Premier consumer concret du DAG unifié (Vague 1 #1). L'ancien AnalysisExecutor Phase 0 (décommissionné en Phase 2.5) faisait UN appel LLM monolithique sur le portfolio entier — coût ~100 s, hallucinations sur les positions, prompt impossible à cacher granulairement (cf. timeout 400 s observé 2026-05-07 sur Ollama cold-start). La nouvelle archi inverse le modèle : l'analyse portefeuille devient un job parent PORTFOLIO_AGGREGATION qui kicke N enfants TICKER_ANALYSIS(symbol, today) (un par position OPEN), attend leur terminaison, puis fait UN appel LLM final court qui digère leurs N narratifs déjà persistés (et leurs analyst recos / earnings / news Phase 2). Coût LLM marginal = M appels où M = nombre de positions non encore analysées ce jour ; si l'utilisateur a ouvert ses dossiers individuels avant, M tend vers 0 et l'analyse portefeuille est quasi-gratuite. Prompt parent : court, contextuel, lit ticker_narrative_snapshot.summary + sentiment + keyPoints × N + agrégats portefeuille (poids, P&L, secteur dominant) ; produit un narratif portefeuille structuré (sentiment global, points saillants cross-positions, alertes éventuelles type « concentration > 40 % sur Tech »). Sortie : nouvelle table portfolio_analysis_snapshot (id, portfolio_id, generated_at, summary, sentiment, key_points JSONB, model_used, prompt_version, included_ticker_snapshot_ids UUID[]) — équivalent du ticker_narrative_snapshot Phase 1 mais à l'échelle portfolio, avec traçabilité explicite de quelles feuilles ont été agrégées. UI : bouton « Analyser le portefeuille » sur le dashboard kicke un parent ; une widget « Pipeline en cours » affiche le DAG en temps réel (cache hits instants, cache miss qui tournent, agrégation finale) avec ETA approximative. Quand le parent est DONE, un panneau « Synthèse du portefeuille du JJ MMM » apparaît avec le narratif. Pas de retour de : le scraping RSS (module supprimé en V6), l'agrégation top-200-articles, les 8 règles validateur legacy, les targetWeight BUY/SELL — l'app ne fait pas de reco actionnable, juste un narratif honnête au-dessus des feuilles ticker. Comparaison historique : la cohérence cross-runs Phase 3 #2 (livrée 2026-05-14) s'applique naturellement à portfolio_analysis_snapshot aussi — le scorer est générique, il suffit de lui passer 2 snapshots du même portfolio. Effort : ~1.5 jour (parent service + agrégateur de narratifs + nouveau snapshot + UI dashboard + tests + prompt v1). Prérequis bloquant : le ticket Vague 1 #1 « DAG unifié » doit être livré avant. Pourquoi avant la Page Jobs (#3) : valider le modèle DAG sur un cas réel utile avant de bâtir l'UI au-dessus — la Page Jobs sans PortfolioAggregation à observer affiche essentiellement les TickerAnalysis qu'on voit déjà via la SSE narrative card
Vague 1 — (3) Page Jobs : visualisation des pipelines async (DAG) UI au-dessus du DAG unifié (Vague 1 #1). Filed initialement comme Phase 3 #4 — déplacé ici 2026-05-14 parce que son intérêt repose à 90 % sur la richesse du DAG (multi-kind, parent/child, cache-hit visible) qui n'existe pas avant Vague 1 #1. Vibe « pipelines GitLab/GitHub Actions » — page qui surface le DAG des jobs (parent / enfants / cache-aware leaves) avec timing par étape, status par nœud, et indicateur cache-hit visuel. v1 read-only : visualisation seule, suffisant pour comprendre ce qui tourne / a tourné / a planté. Modèle de vue : (a) liste des runs (= jobs racines, parent_id null) en reverse-chronological, filtrable par kind (PORTFOLIO_AGGREGATION / TICKER_ANALYSIS standalone / MARKET_REFRESH cron / CSV_IMPORT) et par origin (dashboard / cron / api). (b) Drilldown sur un run : vue arborescente parent → enfants, chaque nœud avec icône status (⏱ running / ⚡ cache hit DONE_CACHED / ✅ done / ⚠ error / ⏸ cancelled), durée, lien vers la ressource produite (snapshot narratif → page dossier ticker en lecture-seule à la date). (c) Stream live via SSE (réutilise JobEventPublisher livré Phase 2.5) tant qu'un nœud non-terminal existe dans le DAG affiché ; arrêt automatique quand tout est terminal. Extensions naturelles au fil des besoins : (1) Retry granulaire — bouton « Retry failed leaves only » sur un parent ERROR, qui re-PENDING uniquement les enfants ratés sans relancer ceux qui ont réussi. (2) Cancel cascade — sur un parent RUNNING, kill remonte aux enfants encore PENDING. (3) Logs structurés — capture stdout/stderr du LLM call (prompt + response truncated), pas juste un error TEXT. Lien vers le prompt_template actif au moment du run (consommateur direct du prompt-management Phase 3 livré 2026-05-10). (4) Métriques agrégées — graphes durée moyenne par kind, taux cache-hit par jour, taux d'erreur par symbol. (5) Alertes — notification (mail / push) quand un kind de job échoue N fois d'affilée. Effort estimé : ~2-3 j pour la vue arborescente + filtres + SSE wiring. Prérequis bloquants : Vague 1 #1 (DAG unifié) et Vague 1 #2 (Réintégration Phase 0) — sans PortfolioAggregation à observer, la page n'a pas de matière intéressante à montrer
Vague 2 — (4) Cron quotidien — pré-chauffe du cache des positions OPEN Job scheduler hors heures de bureau (e.g. 4h du matin local) qui kicke des TICKER_ANALYSIS(symbol, today) pour toutes les positions OPEN de tous les portfolios. Avec origin = cron. Quand le user arrive le matin sur le dashboard, ouvre un dossier ou kicke une analyse portefeuille, tout est en DONE_CACHED instant. Coût LLM = N appels par jour où N = nombre de positions distinctes — borné, prévisible, exécuté hors flow user. Prérequis : Vague 1 #1 DAG unifié + persistence du origin sur la table job pour distinguer cron vs dashboard dans la Page Jobs. Trade-off : si le user n'ouvre pas son dashboard ce jour-là, on a payé N appels pour rien. Acceptable single-user (~10 positions × ~$0.005/call Claude = $0.05/jour soit ~$18/an). À reconsidérer si SaaS multi-user. Effort : ~½ jour (scheduler Spring @Scheduled + appel enqueueLeaf + tests time-shifted)
Vague 3 — (5) Croisement portfolio × insights ticker Sur le Dashboard, afficher pour chaque position le sentiment + alerte si RSI extrême ou drawdown important. Lit les ticker_narrative_snapshot les plus récents par symbol. Pas de dépendance stricte au DAG, mais bénéficie largement de la pré-chauffe Vague 2 #4 (sans elle, l'ouverture du dashboard déclenche N analyses froides à la file)
Vague 3 — (6) Watchlist alertes Seuils déclencheurs (RSI > 70, MA50 cassée, drawdown > 20 %) sur les tickers de la watchlist. Standalone, indépendant du DAG. Notifications via mail / push à arbitrer en même temps
Vague 4 — (7) Paper trading Simulation d'exécution. Gros morceau, indépendant. À cadrer plus tard — décision design ouverte (book order virtuel vs simple ledger d'achats/ventes simulés)
Vague 4 — (8) Multi-broker Ne plus dépendre du seul CSV Wealthsimple. Au minimum : 2e parser CSV (Questrade, IBKR) + abstraction BrokerImporter. À l'extrême : APIs broker (OAuth + REST) — coût ops élevé pour un personal project, à réserver à une bascule SaaS
Vague 4 — (9) Fine-tuning Entraîner un modèle sur les snapshots narratifs personnels + thumbs accumulés via prompt-management Phase 3. Pertinent une fois ~6+ mois d'usage et ~500+ snapshots scoring. R&D long terme — à reconsidérer quand le corpus le justifie
Hors-vague — (10) News in-app — scraping + LLM digest pour lire sans cliquer dehors Identifié 2026-05-18. Frustration actuelle : le module news/ Phase 2 affiche les headlines Finnhub (titre + source + lien) ; cliquer ouvre un nouvel onglet sur le site source → casse le flow d'analyse du dossier ticker (perte de contexte), oblige à se taper cookies/ads/paywalls de chaque média, et chaque clic-out est un mini-deuil de la session d'analyse en cours. User feedback direct : « les news de passer par les liens c'est insupportable ». Cible : amener le contenu (ou un digest) dans l'app pour qu'une lecture rapide se fasse sans clic-out. 4 approches comparables au moment d'attaquer : (a) Scrape full text + display intégral — UX la plus directe mais risque légal (copyright, ToS news sites), storage rapide (10K+ articles × ~50 KB = bloat DB), paywalls (NYT/WSJ/FT/Bloomberg bloquent), brittle (chaque site a un HTML différent). Pas recommandé. (b) Scrape + LLM digestreco — fetch URL, extraction main content (lib type jsoup + Readability ou Mercury Parser), nouveau prompt news-digest qui produit { summary: 100-150 mots, key_points: 3-5 bullets, sentiment, mentions_tickers: [] }. Affichage in-app : card expansible (collapsed = titre+source+date, expanded = digest LLM + petit lien « lire l'original »). Aligné au positionnement « narrateur, pas devin » — le LLM digère et raconte, comme pour le ticker narrative Phase 1. Transformative use → fair use friendly (vs republier le contenu intégral). Cache TTL ~6h (news age fast). (c) Switch news provider vers une API qui sert le full-text (Newsapi.ai, Aylien, NewsCatcher) — subscription payante (~$50+/mo), résout le scraping technique mais pas le problème UX du « cliquer pour lire » si on garde l'archi actuelle. (d) Embedded reader mode (Readability.js / Mercury côté frontend) — extrait le main content du HTML serveur-side et l'affiche en mode lecture clean. Moins ambitieux que (b), garde tout le texte original. Légalement plus risqué que (b) parce que pas de transformation. Scope si on retient (b) : Backend — extension news/ module : (1) adapter scraping WebPageFetcher (HttpClient avec timeout + user-agent rotation + retry exponentiel + per-domain throttle), (2) extraction main content via jsoup + Readability-like (à comparer à l'attaque : goose3 JVM port, dom-distiller, ou implém custom), (3) nouveau prompt template news-digest versionné via prompt-management Phase 3, (4) nouveau service NewsDigestService qui orchestre fetch → extract → LLM → persist, (5) nouvelle table news_digest (news_id PK FK → news_item, summary TEXT, key_points JSONB, sentiment VARCHAR, mentions_tickers VARCHAR[], digested_at, model_used, prompt_version), (6) endpoint GET /api/news/{news_id}/digest (lazy : trigger digest si pas en cache, sinon return cached), (7) fail-soft : si scrape rate >5% sur un domaine, throttle ; si paywall détecté (HTTP 403 / pattern dans le HTML), fall back au lien original avec badge « paywall — ouvrir sur le site ». Frontend — remplacer le news.card actuel par une version expansible : collapsed (title + source + date), expanded (digest LLM en placeholder pendant le fetch async, puis summary + bullets + sentiment + petit lien « lire l'original »). Reuse du pattern SSE déjà câblé Phase 3 (JobEventPublisher) si on veut un live progress sur la génération. Decisions ouvertes à l'attaque : (1) Quand digester — au moment où la news arrive de Finnhub (push proactif, ~5-10 calls LLM par ticker au refresh), ou lazy à la 1re expansion par l'user (économise les digests jamais lus, mais latence visible la 1re fois) ? Reco : lazy v1, batch async (Phase 6 #4 cron) v2 si pertinent. (2) Granularité cache — TTL global 6h, ou immutable (un digest généré = stocké forever) ? La news ne change pas après publication → immutable plus sain, cache pourrait grandir mais bornable via rotation. (3) Filtrer le bruit Finnhub — Finnhub renvoie parfois 20-40 news/ticker dont la moitié sont du PR/relayé. Detection auto (LLM ranking ?) ou filtre éditorial par source (whitelist Reuters / Bloomberg / etc.) ? À cadrer après quelques semaines d'usage. (4) Coût LLM — ~5-10 news par ticker × N tickers × cache : avec Claude Haiku (~$0.001/digest 200 tokens out), c'est 10-50 cents/ticker analysé. Borné. Avec Claude Sonnet (~$0.015/digest), c'est 15× plus → ~$1-5/ticker. Prompt-management Phase 3 permet de switcher modèle facilement. Concerns à documenter dans docs/technique/architecture.md > Décisions notables : (a) légal — transformative use vs republication, position claire écrite, (b) robustness — fail-soft sur paywall + scrape error → fallback au lien (jamais d'écran cassé), (c) provider lock-in — Finnhub donne les URLs, le pipeline scrape+digest est provider-agnostic, (d) rate-limit news sites — per-domain throttle + user-agent realistic, pas du curl-default. Effort estimé : ~5-7 h cumulé (compare libs scrape Kotlin/JVM + impl service + prompt v1 + table + endpoint + UI expansible + tests fail-soft + smoke deploy). Standalone — pas de dépendance Vague 1-4 : ce ticket ne dépend pas du DAG unifié. Peut être attaqué isolément quand l'envie monte, sans bloquer ni être bloqué par les autres tickets Phase 6

Dette technique

Sujets identifiés en cours de session, pas bloquants pour la phase courante mais à traiter quand l'occasion se présente. Les items livrés (cleanup jobs orphelins, ESLint, doc-maintainer, Twelve Data, refacto tests-as-documentation) sont dans journal-livraisons.md > Dette technique — items livrés.

Ordre de lecture (cf. CLAUDE.md « Ordering convention ») : ⏳ À faire en priorité descendante (🔴 → 🟡 → 🟢) en haut. Reordonné le 2026-05-07 lors du split backlog ↔ journal-livraisons.

Sujet Description Priorité
⏳ Onboarding doc — testeur.md non-développeur Audience non-dev qui veut juste cliquer dans l'app sans installer Node/Java en local. Les pré-requis (mocks pour les 4 providers de données + dégraissage developper.mddeveloppement.md + MockLlmClient) sont livrés 2026-05-14 → 2026-05-15 (cf. journal-livraisons.md > Dette technique) — l'app entière tourne sans clé ni daemon. Reste à faire : (1) écrire docs/projet/testeur.md minimaliste : « Docker Desktop + Tilt suffisent, tout est en mock par défaut, le narratif IA aussi ». (2) Trancher entre (a) Dockerfiles dev multi-stage côté backend/ et frontend/ (basculer leurs ressources Tilt en docker_build + dc_resource — « Docker pur ») ou (b) garder l'archi actuelle et documenter explicitement la limitation « Node + Java requis sur l'hôte ». Effort : ~30 min option (b) doc-only, ~2-3 h option (a) Dockerfiles + tests. Trigger : à attaquer si un autre testeur bute, ou avant Phase 5 deploy publique 🟡 Moyenne
⏳ Gestion d'erreur transverse — frontend MatSnackBar + logging backend + exceptions plus honnêtes Trois volets à attaquer ensemble parce que la friction d'usage est la même : l'utilisateur voit aujourd'hui un message vague côté UI alors que le backend a souvent le contexte exact mais le noie dans des logs trop verbeux ou le swallow dans un catch (Exception) générique. Volet 1 — Frontend MatSnackBar centralisé. Aujourd'hui les erreurs UI sont rendues à plusieurs endroits avec des patterns différents : bandeau .error-banner en haut du dashboard (analyse IA, chargement portefeuille / assets), message inline .watchlist-error sous l'input watchlist, surface séparée encore pour le chart / narrative ticker, snackbar ad-hoc isolé sur le PATCH thumbs de la narrative card. Pas de cohérence visuelle ni de règle claire sur la persistance. Cible : service singleton NotificationService dans core/ exposant error(key, params?, options?), info(...), success(...). Les composants l'injectent et appellent notify.error('dashboard.errors.loadPortfolios') (clé i18n) ; le service traduit via TranslateService.instant et dispatche au snackbar avec durée (~5 s défaut, persistant si action requise) et panelClass qui colorise (snack-error rouge, snack-success vert, snack-info neutre — alignés sur les tokens du thème). Bonus : action « Réessayer » sur les erreurs reproductibles (chargement portefeuille / chart / narratif) via Ref.onAction(), action « Annuler » sur les optimistic updates qui échouent (rollback watchlist, thumbs). Migration progressive : Dashboard d'abord (le plus visible), puis Ticker dossier, puis CSV import. Watchlist à part — l'erreur inline est pertinente au moment de la saisie. Trade-off : le bandeau .error-banner actuel est plus visible et persistant qu'un toast — pour les erreurs bloquantes (« impossible de charger les portefeuilles, l'app est inutilisable »), un état dégradé inline reste plus honnête qu'un toast évanescent. Le snackbar est pour les erreurs transient. Coûts cachés : (1) a11y — MatSnackBar est aria-live=polite par défaut, vérifier que les screen readers annoncent ; (2) tests — dashboard.spec, ticker.spec, csv-import.spec asserent aujourd'hui sur le signal error() à reskinner en mock du service. Volet 2 — Logging backend (niveaux + structure). Friction observée : docker compose logs backend est aujourd'hui trop verbeux pour debug efficacement — chaque cache hit, chaque JobEventPublisher.publish, chaque résolution de config, chaque appel tickerService.load log en INFO ce qui noie les vraies erreurs ; à l'inverse, certaines paths critiques (LLM call timeout, fail-soft Finnhub /price-target sur 5xx) sont en DEBUG ou silencieuses. Cible : (a) audit ligne-par-ligne des log.info / log.warn du backend (grep -rn 'log\.\(info\|warn\|debug\)' backend/src/main), reclassifier selon convention claire — DEBUG pour traces dev/runtime (cache hit/miss, dedup, scheduler ticks, SSE register/unregister, body parsing routine), INFO pour milestones business (job kick, snapshot persisté, config bumped, provider switched), WARN pour fail-soft (rate-limit absorbed, fallback null sur /price-target, retry parser, stale-on-switch) qui méritent un signal mais ne cassent pas le flow, ERROR strictement pour les paths qui retournent une erreur user-visible ou interrompent un job. (b) application.yml logging.level ajusté en conséquence — racine INFO, com.portfolioai INFO, packages tiers (org.hibernate.SQL, org.springframework.web, okhttp3) WARN ; application-local.yml peut surcharger en DEBUG pour le dev local. (c) structurer les logs critiques : aujourd'hui log.info("Snapshot persisted for $symbol") perd le contexte (jobId, durée, modèle utilisé). Ajouter MDC (org.slf4j.MDC.put("jobId", id.toString())) sur les entrées de pipeline (TickerNarrativeRunner.run) pour que tous les logs descendants du run portent le jobId — debug 10× plus facile quand on cherche pourquoi un narratif a échoué. Volet 3 — Exceptions plus honnêtes. Friction : catch (Exception) générique dans plusieurs adapters (MockNewsClient, FinnhubAnalystClient.fetchPriceTargetOrNull, WatchlistService.lookupInstrumentType) — utile en fail-soft pour ne pas casser le flow user, mais swallow tout incluant les bugs (NPE Kotlin, IllegalStateException de notre code) sans signal. Aujourd'hui un bug interne se déguise en « provider indisponible ». Cible : (a) resserrer les catch sur les exceptions vraiment attendues (HttpClientErrorException, ResourceAccessException, UpstreamUnavailableException, JsonProcessingException) — laisser propager les RuntimeException génériques pour que GlobalExceptionHandler les attrape et logge ERROR avec stacktrace. (b) enrichir UpstreamUnavailableException d'un cause systématique (déjà fait sur Finnhub fail-soft logging via commit 36e5b60, à étendre à TwelveDataClient qui swallow encore certains JsonProcessingException en fallback null). (c) GlobalExceptionHandler — passer en revue les ResponseStatusException + ApiError DTOs : actuellement on retourne un 503 générique pour toute UpstreamUnavailableException, sans distinguer « provider down » (5xx upstream), « rate-limited » (429 upstream), « auth-failed » (401/403 upstream). Le frontend ne peut pas afficher un message ciblé. Cible : enum MarketUnavailableReason ∈ {PROVIDER_DOWN, RATE_LIMITED, AUTH_FAILED, NOT_FOUND} dans le DTO d'erreur ; le frontend translate vers une i18n key spécifique (errors.market.rateLimited vs errors.market.providerDown). Trigger / coordination : ces trois volets se renforcent mutuellement — un meilleur logging back permet de diagnostiquer pourquoi un snackbar front s'allume, des exceptions plus précises alimentent des keys i18n plus utiles. À attaquer en session dédiée, dans l'ordre : (1) logging audit + niveaux + MDC (~1.5 h, pas d'UI), (2) exceptions resserrées + enum MarketUnavailableReason (~1.5 h, contrats DTO à coordonner), (3) NotificationService + migration UI Dashboard / Ticker (~2 h). Branchage cohérent ; un seul gros commit par volet, pas de séquence morcelée 🟡 Moyenne
⏳ Frontend — convention « Resource builders on the port » : généraliser à tous les repositories core/api/ pour éliminer les subscribe() résiduels Intention : supprimer tous les .subscribe() / firstValueFrom qui restent dans les composants. Aujourd'hui les ports core/api/*.repository.ts exposent Observable<T> à plat, et chaque composant câble son propre rxResource / signal.set(...) après un firstValueFrom — RxJS fuit jusqu'à l'UI alors qu'aucun consommateur n'utilise vraiment d'opérateur. Le pattern cible déplace la plomberie rxResource sur le port : le composant n'invoque plus que des builders qui retournent un Resource<T> (ou un Signal<T> cooked) prêt à consommer — zéro subscribe, zéro ngOnInit, zéro map-emit. Motivation : pilote livré 2026-05-16 sur le bucket portfolio/ (SnapshotRepositoryfrontend/src/app/core/api/portfolio/snapshot.repository.ts:32-58). Le port expose allResource() et positionsCache(trigger) ; le composant Suivi consomme snapshots.value(), loading(), error(), positions().get(id) directement (features/suivi/suivi.ts:21-24). Bénéfice mesurable : −40 lignes sur Suivi, plus de .subscribe(), plus de ngOnInit, plus de map-emit boilerplate. Pattern à appliquer aux 13 autres repositories : (1) allResource() builder quand le repo a une méthode getAll() / list() / findActive(name) que le composant veut consommer en rxResource simple — candidats immédiats : PortfolioRepository, MarketRepository (chart endpoint), WatchlistRepository, NewsRepository, AnalystRepository, EarningsRepository, ConfigRepository, PromptRepository, NarrativeObservabilityRepository, NarrativeBiasRepository. (2) xxxCache(trigger) builder quand le composant accumule des résultats par id (cas positionsCache du pilote) — candidats : potentiellement NewsRepository (per-symbol), AnalystRepository (per-symbol), EarningsRepository (per-symbol), à arbitrer cas par cas selon la sémantique du consumer. Contrainte mocks : test mocks doivent passer en useClass MockXxxRepository extends XxxRepository (le useValue plat ne préserve pas les builders hérités). Migration des specs concernés à faire dans la foulée. Effort : ~3-4 h cumulé si attaqué en bloc, ~20-30 min par repo sinon. Alternative écartée : rxMethod de @ngrx/signals/rxjs-interop — non installé, et effect() du @angular/core couvre déjà le cas (signal-native, auto-cleanup, pas d'aller-retour toObservablepipesubscribe). Doc déjà câblée par le pilote : (a) .claude/skills/angular-signals/SKILL.md > Resource builders live on the port itself pin la convention (allResource, xxxCache, useClass extends pour mocks) ; (b) docs/technique/architecture.md > Décisions techniques notables — Phase 1+ frontend mentionne le pourquoi + alternatives écartées. Supersede partiel : le ticket « Frontend — aligner le type de retour des repositories : Promise vs Observable » ci-dessus devient en partie obsolète — avec ce pattern, le consommateur ne voit plus de Promise<T> ni d'Observable<T> pour les use-cases courants (juste des signals via les builders). Les méthodes abstract HTTP du port restent en Observable<T> (héritage HttpClient) mais ne sont plus appelées directement par les composants. À folder ou clore quand la convention sera adoptée. Pourquoi 🟢 Basse : le pilote a validé la convention end-to-end (code + tests + skills + architecture.md) ; la généralisation aux 13 autres repos n'est plus une question de design mais d'élan. Pas de bénéfice runtime, juste un cleanup d'idiome — à attaquer opportunistically quand on touche déjà un repo pour une autre raison, ou en session dédiée si on veut clore en un coup. Trigger : un repo à la fois pour limiter le diff, ou en bloc si on accepte une grosse PR 🟢 Basse
⏳ Frontend — aligner le type de retour des repositories : Promise ou Signal, plus de firstValueFrom systématique Analyse critique 2026-05-15 findings #F2 + #F3. Aujourd'hui les 14 repositories du core/ retournent Observable<T> (héritage de l'HttpClient). Les consommateurs firstValueFrom-ent immédiatement vers une Promise<T> qu'ils flat dans un signal.set(...) — l'Observable<T> annoncé est une fausse promesse de réactivité : aucun consommateur n'utilise les opérateurs RxJS, et l'app perd toSignal() qui serait l'idiome Angular 21. Deux options : (a) Promise<T> au niveau repo — assumer le pattern existant, retours synchrones (async list(): Promise<Portfolio[]>), firstValueFrom interne aux adapters HTTP, consommateurs await directement. Plus simple, moins de RxJS exposé. (b) Observable<T> au niveau repo + toSignal() côté consommateurreadonly portfolios = toSignal(this.portfolio.list(), { initialValue: [] }) au lieu du pattern signal<T[]>([]) + async load() { … firstValueFrom … set(...) }. Plus puissant pour les flux composés (debounce sur le search dashboard), demande discipline. L'incohérence actuelle est le pire des deux mondes. Recommandation initiale : (a) parce que le projet est signal-first et qu'aucun consommateur n'a vraiment besoin de RxJS sur les repos. Pourquoi 🟢 Basse : démoté 2026-05-16 — la convention « Resource builders on the port » (ticket ci-dessus) supersède partiellement ce ticket. Avec les builders, les consommateurs ne voient plus Promise<T> ni Observable<T> du tout. Reste à trancher le type de retour des méthodes abstract HTTP (héritage HttpClient), mais c'est devenu un point cosmétique — pas un sujet structurant. Effort : ~2-3 h (refacto des 14 repos + tous les firstValueFrom call-sites, ~30-50 sites à toucher). Impact skills : folders-structure-frontend à clarifier (type de retour des *.repository.ts), angular-signals à patcher (promouvoir le pattern retenu, supprimer le firstValueFrom du « Service State Pattern »). Trigger : à attaquer en session dédiée si on veut clore en un coup, ou à clore quand la convention Resource builders aura été appliquée aux 13 autres repos 🟢 Basse
⏳ Stratégie de cache : homogénéiser les six caches sur le pattern service-sans-préfixe Audit 2026-05-06 fin Phase 2 finding #3. Option (b) « documenter explicitement les deux modèles » livrée 2026-05-14 (cf. journal-livraisons). Reste l'option (a) — homogénéisation : drop le préfixe 'twelvedata|' côté TwelveDataClient, déplacer @Cacheable du niveau adapter vers le niveau service applicatif (mirroir de NewsService / AnalystRecommendationService / EarningsService / SectorClassifierService). Tous les six caches partageraient alors le même contrat : clé symbol.toUpperCase(), provider absent, staleness ~15 min post-switch acceptée. Pourquoi 🟢 Basse : démoté 2026-05-16 — cosmétique, alignement mental, pas de bug runtime. L'option (b) doc-only a déjà capturé 80 % du bénéfice. Coût : ~1-2 h dont un test d'intégration qui vérifie que le cache market-chart survit un toggle mock → twelvedata (sinon on perd silencieusement la valeur cachée du mode actuel). Trigger : à attaquer dans une session « refacto cache » dédiée — peu d'impact runtime, mais simplifie le mental model et permet d'aligner la KDoc de RoutingMarketChartClient sur les autres routings 🟢 Basse
⏳ Provider gating — refuser de sélectionner un provider live sans la clé configurée Symptôme observé 2026-05-17 : après suppression de TWELVEDATA_API_KEY du .env (refacto secrets Phase 4), un dossier ticker a renvoyé 503 avec message « Twelve Data API key is missing ». Cause : market.provider=twelvedata était dans app_config (override DB d'un switch UI précédent), la clé n'était plus nulle part, le RoutingMarketChartClient a routé vers TwelveDataClient qui a throw. Le mock par défaut aurait protégé un fresh clone, mais pas un setup qui a déjà switché vers un live provider et perdu sa clé ensuite. Cible : (1) Backend validation dans ConfigController.set (ou AppConfigService) — refus 400 avec message i18n actionable si le user essaie de set market.provider=twelvedata alors que market.twelvedata.api-key est vide/null en BDD ET pas d'env var. Même règle pour news.provider=finnhub, analyst.provider=finnhub, earnings.provider=finnhub (tous → market.finnhub.api-key), llm.provider=claude (→ anthropic.api.key), llm.provider=ollama (→ daemon Ollama joignable, pas une clé mais l'invariant équivalent — déjà partiellement testé via le panneau État Ollama Phase 2.5). Mapping provider value → required key à déclarer en Kotlin (enum sealed class ou map immutable dans config/application/). (2) DTO AllowedValueDto étendu avec disabledReason: String? (i18n key, e.g. configurationPage.providerDisabled.missingKey) — le backend annote chaque allowedValue indisponible. (3) Frontend : sur les 5 toggles providers (market.provider, news.provider, analyst.provider, earnings.provider, llm.provider) dans /settings/configuration, l'option live disabled quand disabledReason non-null + tooltip Material qui surface le reason traduit (« Configurez la clé market.twelvedata.api-key d'abord »). (4) Tests : (a) backend — set vers un live sans clé → 400 + message contient le path de la clé manquante ; (b) backend — set vers live avec clé présente → 200 ; (c) backend — set vers mock → 200 toujours (mock n'exige rien) ; (d) frontend — UI affiche le bouton disabled + tooltip si reason ; (e) frontend — pas de PUT envoyé si user click dessus quand même (defensive). Trigger : Avant Phase 5 deploy public ou si un autre user rejoint et trébuche dessus. Pas critique single-user — moi je sais que mes clés doivent être configurées. Effort estimé : ~1-1.5 h (mapping + ConfigController validation + DTO enrichi + 5 toggles UI patches + 5 tests). 🟡 Moyenne
⏳ Multi-provider OAuth — ajouter GitHub OAuth comme 2e provider Si l'app s'ouvre un jour à un public qui n'a pas tous un compte Gmail, ajouter GitHub OAuth en deuxième provider. Câble : 2e registration spring.security.oauth2.client.registration.github + handler dans CustomOAuth2UserService qui mappe les claims GitHub (email, id, login) sur le même User (l'email reste la clé naturelle, donc un user qui se connecte via Google puis GitHub avec le même email récupère la même row — provider et provider_id sont juste tracés au last login). UI : 2 boutons sur /login. Effort estimé : ~1 h. Trigger : seulement si on identifie une cible utilisateur qui n'a pas Gmail. Hors Phase 4 + Phase 5 — le user a tranché 2026-05-17 qu'on ne ferait pas ça avant. À garder filed comme option future. 🟢 Basse
⏳ Migrer google-github-actions/auth@v2 + setup-gcloud@v2 vers Node 24 (v3 quand dispo) Avertissement observé 2026-05-18 sur le run #1 du workflow smoke-wif.yml : Node.js 20 actions are deprecated. The following actions are running on Node.js 20 and may not work as expected: google-github-actions/auth@v2, google-github-actions/setup-gcloud@v2. Actions will be forced to run with Node.js 24 by default starting June 2nd, 2026. Node.js 20 will be removed from the runner on September 16th, 2026. Cible : (1) Vérifier la dispo des versions @v3 ou successeur natif Node 24 sur https://github.com/google-github-actions/auth/releases (probablement disponible d'ici fin mai 2026, Google bumpe rapidement). (2) Bump dans smoke-wif.yml + futur deploy.yml Provisionner v1 + futur backup-postgres.yml (tous les workflows qui consomment les actions Google). (3) Re-déclencher le smoke test pour valider que la nouvelle version fonctionne avec notre Workload Identity Federation existant. Effort : ~10 min (bump + smoke test + commit). Trigger : (a) le 2 juin 2026 quand Node 20 devient default-disabled (le workaround FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true deviendrait alors une dette à porter), OU (b) à la sortie de @v3 officielle (avant juin probablement), OU (c) si on touche déjà .github/workflows/ pour autre chose. Risque : faible — les majeures google-github-actions/* sont backward-compatible sur les inputs WIF/SA. 🟢 Basse
⏳ Migrer GitHub Actions CodeQL v3 → v4 Avertissement observé 2026-05-17 dans la pipeline CI : Warning: CodeQL Action v3 will be deprecated in December 2026. Please update all occurrences of the CodeQL Action in your workflow files to v4. Source : https://github.blog/changelog/2025-10-28-upcoming-deprecation-of-codeql-action-v3/. Cible : grep github/codeql-action/*@v3 dans .github/workflows/codeql.yml (3 steps : init@v3, analyze@v3, et autobuild@v3 si présent), remplacer par @v4. Tester le workflow sur une PR pour vérifier que CodeQL produit toujours le rapport SARIF + onglet Security. Effort : ~5 min de patch + ~2-3 min de validation CI. Trigger : pas urgent — la deadline est décembre 2026 (~7 mois de marge). À grouper avec un autre touch de .github/workflows/ (e.g. l'ajout du workflow deploy Phase 5), ou à faire en standalone si on remarque que d'autres deprecations s'accumulent. Risque : faible — les majors GitHub Actions sont généralement backward-compatible sur les outputs, mais une breaking change cachée sur l'input build-mode ou trap-caching pourrait surface au moment du flip ; d'où la validation CI obligatoire. 🟢 Basse
⏳ Coverage IntelliJ — pas d'affichage line-by-line dans l'éditeur quand on lance « Run with Coverage » Symptôme : depuis l'IDE, click droit sur un test backend → « Run with Coverage » → les tests passent mais la gutter de l'éditeur ne montre pas le coverage line-by-line (lignes vertes / rouges / jaunes habituelles d'IntelliJ). Idem sur les composants Angular en mode IDE. Cause probable : (a) côté backend Kotlin, le projet utilise Kover comme outil de coverage (configuré dans backend/build.gradle.kts avec id("org.jetbrains.kotlinx.kover") version "0.9.8" + kover { reports { filters { excludes { … } } } }). Kover génère ses propres rapports (HTML/XML/SARIF) via les tasks Gradle koverHtmlReport / koverXmlReport. IntelliJ a son propre coverage engine (« IntelliJ IDEA » runner) et le runner JaCoCo natif ; ni l'un ni l'autre ne consomme les rapports Kover automatiquement, donc la gutter ne s'allume pas. La feature « Run with Coverage » de l'IDE lance les tests via l'engine choisi (IntelliJ par défaut), pas via Kover. (b) côté frontend Angular, Vitest peut produire un rapport coverage via @vitest/coverage-v8 mais le projet ne l'a pas câblé — npm run test n'émet pas de coverage data exploitable par IntelliJ. Cibles : (1) Backend : trois options par effort croissant : (a) doc-only — ajouter un paragraphe dans developpement.md > Tests qui dit « pour le coverage line-by-line, lance ./gradlew koverHtmlReport et ouvre backend/build/reports/kover/html/index.html dans le navigateur ; la gutter IntelliJ n'est pas alimentée par Kover ». ~10 min. (b) switcher Kover → JaCoCo pour interop IDE natif (JaCoCo est ce que la « Run with Coverage » IntelliJ consomme nativement). Coût : refacto build.gradle.kts + perte de l'instrumentation Kover (qui est optimisée pour Kotlin coroutines), + le pipeline CI/SARIF actuel à ré-adapter à JaCoCo. Effort ~1-2 h, perte technique. (c) garder Kover + plugin IntelliJ Kover (si dispo dans la marketplace JetBrains, à vérifier) — option idéale mais dépend de la disponibilité du plugin. (2) Frontend : (a) doc-only — npm run test -- --coverage (à câbler dans le package.json script si pas déjà) → rapport généré dans frontend/coverage/, à ouvrir manuellement. (b) configurer @vitest/coverage-v8 + plugin IntelliJ Vitest qui consomme l'output. Recommandation : option (a) doc-only des deux côtés comme premier pas (~20 min total, débloque le user immédiatement). Si la friction persiste, attaquer (c) backend (plugin Kover) et (b) frontend (Vitest coverage IDE-friendly). Trigger : à attaquer quand un autre dev a besoin du coverage IDE, ou pendant une session « DX dev tooling ». Effort : ~20 min option doc-only, ~2-3 h pour la version intégrée IDE 🟢 Basse
⏳ Coutures post-audit pré-v0.5.1 — 6 Important non patchés (2026-05-16) Punch-list issue de l'audit docs/projet/audits/2026-05-16-pre-v0.5.1.md (3 subagents en parallèle, 19 commits couverts). Les 2 Critiques et 3 Important doc-set ont été patchés avant tag ; restent 6 Important defensive à attaquer à l'occasion. (1) JobEventPublisher.kt:36Clock seam fragile : default Kotlin sur @Component sans @Bean Clock déclaré ailleurs. Spring 6 + Kotlin reflection honore le défaut aujourd'hui mais l'invariant casse silencieusement si un futur dev ajoute un @Bean Clock. Patch : soit un commentaire d'invariant 2 lignes au-dessus du constructor, soit déclarer un @Bean fun clock(): Clock = Clock.systemUTC() dans analysis/AnalysisConfig.kt et basculer en injection explicite. (2) OllamaStatusService.pullModel:173-179 — asymétrie 404 vs 5xx : pullModel ne gère pas la branche 404 spécifique « model not pulled » contrairement à unloadModel/deleteModel. Aujourd'hui Ollama renvoie 500 + error: model not found mais si l'API s'aligne un jour sur 404, le user verra « HTTP 404 from Ollama » au lieu du hint utile. Patch : 1 if défensif + 1 test qui pin la branche 404. (3) WatchlistService.lookupInstrumentType:127-139@Suppress("TooGenericExceptionCaught") : catch (e: Exception) autour de tickerService.load(...). Resserrement déjà inscrit dans le ticket dette « Gestion d'erreur transverse » volet 3 ; ajouter un TODO inline pour que le lecteur soit prévenu que ce site est ciblé. (4) snapshot.repository.ts:51-55effect() capture le DestroyRef de l'injection context du caller : OK pour le pilote Suivi (field initialiser de composant), à contraindre explicitement avant la généralisation du pattern aux 13 autres repos. Doc-debt minimum : KDoc du builder + section angular-signals/SKILL.md > Resource builders live on the port itself. Idéal : assertInInjectionContext() au début du builder pour fail-fast côté runtime. (5) Pas de snapshot.repository.spec.ts dédié : les builders allResource / positionsCache sont testés indirectement via suivi.spec.ts. Pour un pattern qui va être généralisé, un spec qui pin les builders contre une fake extends SnapshotRepository (id rotation, cache hit, undefined trigger → idle, accumulator effect) limiterait la dette quand les autres repos copieront la convention. ~30 min. (6) configuration.spec.ts:204, :233 — mocks Promise hardcodés : mockResolvedValue(undefined) retourne Promise alors que prod retourne Observable depuis 22fa6f5. C'est exactement le pattern qui a permis le Critique frontend du jour (refresh() sans subscribe()). Le mock timeoutServiceMock.refresh a été corrigé en patch v0.5.1 ; restent les mocks OllamaStatusService (refresh / unload / delete) à harmoniser sur vi.fn(() => of(undefined)). Trigger : à grouper en 1 session de 30 min ou à attaquer en lots opportunistically. Effort : ~30-45 min cumulé. Référence : audit complet sous docs/projet/audits/2026-05-16-pre-v0.5.1.md 🟢 Basse
⏳ Coutures post-livraison Phase 3 — résidus review 2026-05-14 Punch-list issue de la code review fin Phase 3 (3 subagents general-purpose en parallèle, 2026-05-14). Le bloquant TickerNarrativePromptService.lookupOrFallback qui cachait le FALLBACK_TEMPLATE est patché en direct + test fallback is never cached so a transient empty-DB state self-heals on the next request dans TickerNarrativePromptServiceTest. Restent 8 résidus polish, regroupés ici plutôt que de salir les livraisons. (1) PromptScoreService.setThumbs race théorique — pas d'unique constraint sur prompt_score(snapshot_id), donc 2 PATCH concurrents sur le même snapshot peuvent passer le findFirstBySnapshotId == null check et insérer 2 rows. Académique single-user mais à blinder via V9 future UNIQUE INDEX idx_prompt_score_snapshot_unique ON prompt_score(snapshot_id) WHERE snapshot_id IS NOT NULL. (2) NarrativeBiasService.computeCalibration fan-out séquentielassociateWith { chartClient.fetchChart(symbol) } enchaîne les 10 fetches en série sur cold cache. Acceptable single-user (~10 symbols typiques, cache Caffeine souvent warm), mais à parallelStream ou runBlocking { withContext(Dispatchers.IO) { … } } quand la page sera ouverte plus souvent ou quand PortfolioAggregation Phase 6 appellera ce service depuis un thread request. (3) Bias flag rounding edgeBIAS_THRESHOLD = "0.6000" testé sur 60/100 mais pas sur 5995/10000 (qui flag par rounding HALF_UP). 1 test à ajouter pour documenter le contrat « HALF_UP at scale 4 » sur un cas fractionnaire. (4) V8 migration : commentaire claim de robustesse à corriger — la migration claim que le backfill UPDATE … WHERE prompt_version='v2' est sûr ; en réalité un narratif persisté pendant l'exécution de la migration tomberait dans le gap. Single-user ⇒ académique ; soit dropper la claim, soit ajouter un ACCESS EXCLUSIVE LOCK ON ticker_narrative_snapshot au début de V8 (no-op single-user mais documente l'intention). (5) NarrativeObservabilityQuery:81 LIMIT $MAX_ROWS interpolé au lieu de bound — pas une faille (Int constant), mais incohérent avec le reste de la query qui bind tout via setParameter. Idem MAX_TICKERS ligne 131. Fix : setParameter("limit", MAX_ROWS). Idem dans NarrativeBiasQuery (MAX_RAW_ROWS). (6) Tokenizer biais — stopwords dupliquésNarrativeBiasService.STOPWORDS contient "his" × 2 et "side" × 2. setOf dedupe silencieusement donc cosmétique, mais signale que la liste n'a pas été review end-to-end. Réviser + dédupliquer. Sujet adjacent : "price" est stopworded — incohérent avec le DTO comment qui claim que « never said price » serait un signal pertinent. Trancher : strip OU surface, et aligner le commentaire. (7) buildFilter + nextDayIso dupliqués entre bias.ts et observability.ts — extraire shared/filter-window/filter-window.ts (10 lignes, sibling de core/ / features/, drawer pure helpers ouvert 2026-05-16 ; un sous-dossier par concept), refacto sans impact runtime. (8) mapper = jacksonObjectMapper() instancié dans 2 services (NarrativeObservabilityService, NarrativeBiasService) — Spring expose déjà un ObjectMapper bean configuré (cohérent date-format, nullability). Injecter au lieu d'instancier. (9) NarrativeBiasQuery.normalizeInstant duplique le helper de NarrativeObservabilityQuery — extraire en utility commune quand un 3ᵉ usage apparaît. Trigger : à grouper en 1-2 sessions de polish au sortir de Phase 6 #1 (DAG) qui touchera de toute façon ces fichiers — ou à attaquer en lots de 30 min entre features. Effort : ~2 h cumulé si attaqué d'un bloc, ~10-30 min par item sinon 🟢 Basse
⏳ Coutures post-livraison analyst (recommandations) — résidus Audit 2026-05-06 fin Phase 2 finding #2. Le sous-#1 prioritaire (FinnhubAnalystClientTest MockWebServer absent) est livré 2026-05-08 — voir journal-livraisons.md > Dette technique. Restent 4 sous-findings de polish, regroupés ici plutôt que de salir la livraison. (1) fetchPriceTargetOrNull swallow tout silencieusement — un 5xx ou un timeout sur /price-target finit en null sans signal, indistinguable de "Finnhub n'a pas de target pour ce symbole". Le 401/403 est attendu (free tier), mais 5xx/network mériterait un signal différent. Cible v2 : soit une métrique compteur incrémentée à chaque fallback null, soit un champ DTO distinct (priceTargetUnavailable: boolean) pour permettre au front d'afficher "objectif temporairement indisponible" plutôt que "pas d'objectif". (2) MockAnalystClient utilise LocalDate.now() directement — l'history se base sur today = LocalDate.now(), donc tester l'égalité d'une période exacte (2026-04-01) deviendrait flakey à chaque mois. Aucun test actuel ne pin une période exacte (les invariants sont relatifs out.asOf == out.history.last().period), donc pas de bug aujourd'hui. Cible : injecter un Clock (overkill v1) si on étend les tests à des assertions absolues. (3) AnalystSnapshotDto.consensus: String perd la sécurité de type — le backend produit enum.name (4 valeurs hard) ; le front type en 'BUY' \| 'HOLD' \| 'SELL' \| 'MIXED' mais TS ne peut pas valider à runtime. Si demain on ajoute STRONG_BUY côté domain, le contrat dérive silencieusement. Cible : soit garder l'enum côté DTO (Jackson sérialise les enum Kotlin proprement), soit ajouter un test contractuel qui vérifie la liste des valeurs exposées vs la liste front. (4) Nits SCSS — segments .analyst-bar-segment à width 0 affichent quand même 1 px de border-left, multiplié par 5 segments = 4-5 px fantômes ; .analyst-bar height en 12px hardcodé là où le reste du fichier utilise var(--radius-sm). Cible : [hidden] sur les segments à 0 + variable CSS pour la hauteur. Trigger : à grouper avec un autre commit du module ; aucun blocant 🟢 Basse
🧊 News inline v2 — LLM-as-summarizer pour étendre le résumé Finnhub Gelé 2026-05-16 — aucun signal utilisateur que le résumé Finnhub brut (~150-200 chars) est trop court ; à dégeler si un comportement « user ouvre quand même l'onglet externe à chaque expand » est observé. Suite logique de la livraison News inline v1 minimaliste (Phase 2 ✅) : le body de l'accordéon affiche aujourd'hui le summary Finnhub brut (~150-200 chars) + un lien vers la source. Si à l'usage ce résumé se révèle trop court pour rester sur le dossier (le user ouvre quand même l'onglet externe à chaque fois), basculer sur un summarizer LLM côté backend. Cible : nouveau service NewsSummarizerService qui prend {headline, summary Finnhub, source, publishedAt} en input et génère un digest 3-5 phrases via Claude (déjà câblé pour le narratif ticker). Pourquoi pas le scraping : ToS Reuters/Bloomberg fragiles, paywalls, JS-heavy sites — trop de cas d'échec et risque légal pour un personal project. LLM-as-summarizer prend le summary Finnhub comme contexte (pas comme seul input) — ça réduit l'hallucination par rapport à passer juste la url (où le LLM devine ce que dit l'article sans l'avoir vu). Cache long : 24 h+ par couple (news.id) parce qu'un article ne change pas après publication ; opt-in par item via expand (le user a déjà cliqué pour l'ouvrir, donc cohérent). Coût estimé : ~$0.001 par appel Claude Haiku 4.5 sur ~200 input + 150 output tokens, donc négligeable même sans cache (~$0.01/dossier × 10 expand). Tradeoff principal : (1) hallucination — le LLM peut inventer du contexte au-delà du summary Finnhub. Mitigation : prompt explicite « stay strictly within the provided summary, do not infer ». (2) Qualité variable — l'effet « narratif IA » lecture creuse risque de se reproduire. Mitigation : tester sur ~10 dépêches en mode preview avant de wire en prod, valider qualitativement. Dépendances Phase 3 : ce travail recoupe l'observabilité narrative (sait-on de quel article le LLM s'est inspiré ?) et le prompt management — à attaquer une fois Phase 3 démarrée pour profiter de l'infra A/B et scoring 🟢 Basse
⏳ Cache Vitest en CI (à mesurer avant) Aujourd'hui la suite frontend tourne en ~30-60 s en CI dont une fraction de Vitest pur — pas mesuré précisément. Le cache Vitest (transform TS→JS) vit dans node_modules/.vite/ par défaut, donc wipé à chaque npm ci. Pour le rendre cacheable il faut (1) déplacer le cache via cacheDir: '.vitest-cache' dans vitest.config.ts (ou via angular.json si on passe par ng test), (2) ajouter une 3e step actions/cache@v5 sur ce dossier dans frontend.yml, key bicéphale package-lock.json + vitest.config. Trigger d'arbitrage : à attaquer si npm test en CI dépasse les 30 s sur la partie Vitest seule (mesurer via les logs du job, pas le total). Tant qu'on est en dessous, le bruit de maintenance (clé de cache à invalider, dépendance sur la config Vitest) coûte plus que le gain. Préreq avant code : ajouter une step time autour du npm test dans frontend.yml pour publier la durée Vitest brute sur 5-10 runs avant de décider 🟢 Basse
⏳ Découper ticker.scss en sub-components quand il retape le budget Angular Stopgap acté 2026-05-13 : le warning anyComponentStyle exceeded maximum budget. Budget 24.00 kB was not met by 2.28 kB a été silencé en bumpant le budget de 24 kB → 32 kB (warning) et 48 kB → 64 kB (error) dans angular.json. À la prochaine alerte (≥ 32 kB), interdiction de re-bumper — c'est le signal pour découper. ticker.scss pèse aujourd'hui 1823 lignes / 26.28 kB pour la dossier ticker entière : chart + sidenav outils + indicateurs + Fondamentaux (analyst recos + earnings) + news inline + narrative card + filter bar ajoutée en 2026-05-13. Cible du split : extraire chaque section en sub-component avec sa propre feuille de styles encapsulée — candidats naturels par ordre de gain (a) <app-ticker-chart> (probable ~600 lignes : SVG, brush, overlays, annotations, measure), (b) <app-ticker-fundamentals> (analyst + earnings, ~200 lignes), (c) <app-ticker-narrative> (card narrative + thumbs + history link, ~150 lignes), (d) <app-ticker-news> (accordion + état vide, ~100 lignes). Bénéfice annexe : ticker.ts (2291 lignes aujourd'hui) bénéficiera du même split — la logique du chart en particulier est isolable. Effort : ~½-1 j pour chaque sub-component sorti, à étaler entre features. Trade-off du bump : on accepte de ne pas voir grossir ticker.scss jusqu'à 32 kB sans signal, mais on doit refacto dès qu'on touche le seuil — sinon on retape la même décision dans 3 mois et la dette s'enracine 🟢 Basse
⏳ Sweep ::ng-deep côté frontend ::ng-deep est officiellement déprécié par Angular depuis longtemps mais reste l'unique échappatoire stable pour styler les internes Material rendus dans le CDK overlay (panel autocomplete, form-field wrapper). Aujourd'hui utilisé à plusieurs endroits : ticker.scss (.benchmark-autocomplete pour collapser le padding du mat-mdc-text-field-wrapper et la hauteur du mat-mdc-form-field-infix), styles.scss (panels autocomplete .watchlist-autocomplete-panel + .benchmark-autocomplete-panel). Cible : passer à un système plus pérenne — soit ::part() quand Material l'expose pour le composant ciblé, soit un wrap dans un :host-context + classe parent custom, soit un thème Material custom (mat.define-typography-config + density tokens) qui évite d'avoir à chirurger dans les internes. Prérequis : auditer toutes les occurrences (grep -rn "::ng-deep" frontend/src) et catégoriser : (a) celles qui peuvent disparaître avec un density token Material 21, (b) celles qui ont besoin d'un selector officiel mais où il existe (e.g. .mat-mdc-option), (c) celles qui restent inévitables. Pour les inévitables, encapsuler dans un mixin SCSS @mixin material-internal($selector) qui documente la dépendance dans son nom et localise la dette. Trigger : à attaquer si Angular casse ::ng-deep dans une release majeure (peu probable à court terme — c'est officiellement supporté tant qu'il n'y a pas d'alternative pour shadow DOM piercing) ou si Material 22+ expose une API density plus riche. Pas urgent 🟢 Basse
⏳ Documenter les choix techniques par fiche Créer docs/technique/stack/ (ou decisions/) avec une fiche par technologie / choix structurant : pourquoi on l'a retenue, alternatives écartées, comment la brancher / configurer, pièges connus. Format léger (style ADR mais avec une section "configuration" en plus). architecture.md garde l'overview modules + schéma global et linke vers chaque fiche. Candidats v1 : Caffeine (TTL, cache key, rebuild dynamique), Twelve Data (quirks API, mapping erreurs, quotas), JdkClientHttpRequestFactory (vs SimpleClientHttpRequestFactory, cookies, headers strippés), zoneless Angular (signaux + change detection), ngx-translate (vs Angular i18n, choix runtime), Spotless ktfmt, MockWebServer, mockito-kotlin, Flyway (repair-on-migrate en dev), Tilt + Docker Compose (Tiltfile). Bénéfice : éviter qu'architecture.md enfle à chaque ajout, et capturer le pourquoi + le comment opérer au même endroit 🟢 Basse
⏳ Doc-set MkDocs — numérotation des sections + TOC intégré Friction observée (2026-05-14) : certaines docs sont longues (architecture.md ~300 lignes, developper.md ~230 lignes, audits ~300+ lignes) et le lecteur perd vite le fil quand il scrolle. Aujourd'hui Material rend bien le TOC à droite mais (a) il ne le rend que sur écrans larges, (b) les titres ne sont pas numérotés donc « va voir la section sur les providers » force un Cmd+F. Cible : (1) Numérotation auto des h2/h3 via CSS counter-reset / counter-increment sur .md-content h2 / h3 injecté via docs/stylesheets/extra.css ré-injecté dans mkdocs.yml > extra_css. Avantage : la numérotation apparaît uniquement sur le site rendu, les sources .md restent propres (lisibles aussi sur GitHub). Format suggéré : 1. Démarrage / 1.1 Conflit de port / 1.2 Swagger UI etc. (2) TOC intégré dans la nav gauche sur petits écrans : ajouter toc.integrate dans theme.features du mkdocs.yml — le TOC de la page courante se déplie sous la nav. (3) toc: Markdown extension : ajouter pymdownx.toc avec permalink: true + toc_depth: 3 pour que chaque titre porte un permalink cliquable (ancre copy-link), utile pour les liens croisés architecture.md#section. Effort : ~1 h cumulé (CSS counter ~30 min + mkdocs.yml ~10 min + relecture rendu sur 5-6 pages représentatives ~20 min). Trigger : à attaquer lors d'une session de polish doc-set, idéalement après une livraison de phase qui ajoute du contenu (la valeur monte avec le volume). Pas urgent 🟢 Basse
🧊 Traduction anglaise du doc-set (i18n) Gelé 2026-05-16 — aucun contributeur externe ne se manifeste, et la Phase 5 deploy publique reste à plusieurs mois. À dégeler si un des 3 triggers déclenche : contributeur externe, deploy publique, ou besoin de visibilité recruteur. Friction anticipée : le doc-set est aujourd'hui 100 % FR, alors que le code (commits, KDoc, TSDoc, i18n keys d'erreur) est intégralement EN. Un contributeur externe (non-francophone) qui clone le repo bute sur la barrière linguistique sans pouvoir lire architecture.md, developper.md, etc. À l'inverse, l'utilisateur principal pense en FR — basculer tout en EN unilateralement perdrait la nuance. Cible : doc bilingue via mkdocs-static-i18n plugin (defaults to FR, switcher EN visible en haut du site). Chaque .md a un voisin .en.md ; le plugin sert la version par défaut quand le .en.md manque (graceful fallback). Ordre de traduction recommandé par priorité usage externe : (1) README.md racine (point d'entrée GitHub), (2) developper.md (onboarding contributeurs), (3) architecture.md + developpement.md (référence), (4) metier/vision.md + fonctionnalites.md (compréhension produit), (5) projet/backlog.md + journal-livraisons.md (pour les contributeurs qui veulent attaquer un ticket). Audits + CHANGELOG restent FR (snapshots historiques, peu de valeur à traduire). Trade-off : maintenance doublée — chaque PR doc qui touche un .md doit toucher son jumeau .en.md. Solution partielle : un doc-translator subagent (à l'image du doc-maintainer) qui détecte les drifts FR↔EN et propose les patches EN à partir des changements FR fraîchement appliqués. Effort initial : ~1 j pour traduire les 5 docs prioritaires en EN + setup plugin + theming switcher (i18n plugin demande peu de config, ~30 min). Effort récurrent : ~5-10 min par PR doc qui touche un .md couvert. Trigger : à attaquer (a) si un contributeur externe potentiel se manifeste, (b) avant la Phase 5 deploy publique (le projet devient visible, EN aide la visibilité), (c) si un recruteur futur regarde le repo et la barrière FR pose problème. Pas urgent v1 🟢 Basse