This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Tickonomics is a quantitative-research platform: it ingests market/rates data, computes alpha signals and a composite Liquidity Index, backtests strategies, and exposes results through a Spring API and a Next.js dashboard.
The stated product framing (real-time funding-market intelligence) and the current 6-ETF hardcoded
universe (SPY,QQQ,IWM,TLT,HYG,GLD + SPY benchmark) are placeholders. The real goal, recorded in
ADR-022, is a broad cross-sectional stock universe with
survivorship-bias-free point-in-time data, validated by live/paper trading. Treat the 6-ETF set and SPY
benchmark as scaffolding to be replaced; make universe/benchmark choices config-driven, not hardcoded.
Every architectural decision is captured as an ADR in docs/adr/ (ADR-001 through ADR-026). Read the
relevant ADR before changing a load-bearing choice, and add a new ADR for any change of that magnitude.
The project is a Gradle multi-project build (Java 25 toolchain). Always use the wrapper.
./gradlew build -x :integration-tests:test # full build + unit tests (the CI gate excludes integration-tests here)
./gradlew test # unit tests only
./gradlew lint # Checkstyle + SpotBugs — ADVISORY, never fails the build
./gradlew :integration-tests:test # integration tests (needs Docker for Testcontainers/TimescaleDB)
# Run a single test (module:test --tests "<fully.qualified.Class>" or "<*Spec.method>")
./gradlew :computation:test --tests "com.tickonomics.computation.strategy.BaseStrategySpec"Native TA-Lib is required for the computation module. Run once before the first build or after a
clean checkout of native-libs:
./gradlew :computation:setupTalib # clones + builds TA-Lib C lib + JNA wrapper, installs JAR to ~/.m2Without it, compilation/tests touching TalibAdapter will fail. CI builds it from source in the Dockerfile.
cd analytics
pip install -r requirements.txt
python -m pytest tests -m "not benchmark" # unit tests (CI gate)
python -m pytest tests -m benchmark # benchmark suite (separate; runs in benchmark.yml)
python -m pytest tests/path/test_file.py::test_name # single test
ruff check . # advisory lint (CI: continue-on-error)cd frontend # or landing
npm ci
npm run generate:api # frontend only: regenerates lib/api/types.ts from api-contracts openapi.yaml
npm run build | test | lint | typecheck
npm test -- src/path/to/test # single vitest
npm run test:e2e # Playwright (frontend/ only)frontend/CLAUDE.md and landing/CLAUDE.md delegate to AGENTS.md — read the local Next.js docs in
node_modules/next/dist/docs/ before writing frontend code; this Next.js has breaking changes.
Frontend↔backend wiring (no next.config.ts rewrite/proxy — the dashboard calls the backend directly):
NEXT_PUBLIC_API_BASE_URL (REST, lib/api.ts) and NEXT_PUBLIC_WS_BASE_URL (WS, lib/websocket.ts),
both defaulting to localhost:8080. Auth is a hand-rolled PKCE flow against Keycloak (lib/auth.ts).
Disabling auth is two-sided: set NEXT_PUBLIC_AUTH_DISABLED=true on the frontend and AUTH_DISABLED=true
(=security.auth-disabled) on the backend — setting only one leaves the halves mismatched.
Workflow files are verified locally with act + actionlint (see ADR-026 and the Makefile):
make verify-workflows # full pipeline: actionlint + act -l + hook tests + gate-workflow dry-run
make workflow-lint | workflow-dryrun | workflow-run JOB=<job-id>
make install-hooks # installs pre-commit (actionlint) + pre-push (workflow dry-run) from scripts/git-hooks/cp .env.example .env # fill FRED/FINNHUB/ALPHAVANTAGE keys (all free tiers)
docker compose up # backend(:8080) analytics-worker(:8001) dashboard(:3001) landing(:3000) timescaledb jaeger(:16686)Dev/prod/demo variants: docker-compose.dev.yml, docker-compose.prod.yml, docker-compose.demo.yml.
Health endpoints: backend /actuator/health, worker /health (see docs/runbooks/README.md).
Run just the infra, then the backend and worker directly for fast iteration (point them at the
containerized DB/worker — the SPRING_DATASOURCE_* defaults in application.yml expect localhost:5432,
so override the host):
docker compose up -d timescaledb jaeger # infra only
./gradlew :app:bootRun # backend on :8080 (needs TA-Lib built; see above)
cd analytics && uvicorn app.main:app --port 8001 --reload # worker on :8001
cd frontend && npm run dev # dashboard on :3001 (or landing on :3000)cdm ────────────────── base domain model + source adapters (no dependencies)
├── api-contracts OpenAPI/AsyncAPI YAML + Java client interfaces (AnalyticsWorkerClient, EquityWsClient)
├── persistence NamedParameterJdbcTemplate repos + Flyway migrations + record entities
├── ingestion free data-source clients (FRED, NY Fed, Finnhub WS, Yahoo, Alpha Vantage, Ken French, DataHub, Fed RSS) → CDM → TimescaleDB
├── computation quant engine: 50+ strategies, TalibAdapter, BacktestEngine, ILI, signal generation (native TA-Lib)
└── web REST controllers + WebSocket realtime broadcast + OAuth2/Keycloak security
└── app Spring Boot bootJar (com.tickonomics.TickonomicsApplication) — the deployable; aggregates web + ingestion
└── integration-tests Testcontainers/TimescaleDB end-to-end
analytics/ is a standalone Python FastAPI service, not a Gradle module.
api-contracts/src/main/resources/openapi.yaml is the source of truth for the REST surface — it is
not generated from controllers (there is no springdoc/openapi-generator plugin). Its header calls it the
"Phase 0 contract" and it deliberately lists planned endpoints no controller implements yet. To change an
endpoint: edit openapi.yaml, keep ApiContractsSpec green (it asserts ≥40 paths and the required
endpoint families), then regenerate frontend types with npm run generate:api. The web controllers are
an implementation of this contract, not its definition. asyncapi.yaml is the parallel contract for the
realtime WebSocket surface.
Java is the single system of record for backtesting — one definition of "robust," one BacktestEngine
(ADR-007). Python is the research and ML-training surface. Java calls the worker via
AnalyticsWorkerClient (REST sendAnalysisRequest + Arrow IPC sendArrowRequest); trained models are
intended to cross back to Java for inference via ONNX (planned). Do not build a second backtest path
in Python.
ingestion source clients → CDM adapters (normalize provider-specific raw DTOs into cdm record types)
→ quality checks (DataQualityChecker, ProxyDivergenceGuard) → batched/buffered TimescaleDbWriter
→ TimescaleDB hypertables → continuous aggregates + compression. Resilience4j bulkheads separate
"critical" vs "high-volume" ingestion pools (see resilience4j.bulkhead in application.yml).
Realtime: Finnhub WS trades → PriceWebSocketHandler → dashboard; generated signals → SignalWebSocketHandler.
Ingestion is scheduler-driven, not push: each source client uses
@Scheduled(fixedDelayString = "${monitor.<source>.poll-interval-ms:…}"), armed by @EnableScheduling
on the main class. So poll cadence and enable/disable for a source live entirely in application.yml
under monitor.<source>.poll-interval-ms / monitor.<source>.enabled (the writer flush and guard
eviction run on the same mechanism: writer.flush-interval-ms, guard.evict-interval-ms).
Flyway migrations live in persistence/src/main/resources/db/migration/ (V1…V35). TimescaleDB
hypertables, continuous aggregates, compression/retention policies, and the demo/portfolio tables are all
defined there. Never edit an applied migration — add a new V<N> file.
The strategy catalog is enum-driven and manually registered — three files change for each new strategy:
EquityStrategyType(enum): add a constant carrying a stable ID (EA.01) and aformulaRef(textbook page/equation the strategy implements). This ID is the strategy's persistent identity.BaseEquityStrategy(abstract): extend it and implementcomputeSignal(Map<String,Double> input, StrategyContext ctx). Use the inheritedbuildSignal(...)/mapDirection(...)helpers; don't overridecompute.EquityStrategyRegistry(@Component):register()your instance inregisterDefaults().
Options strategies parallel this via BaseOptionStrategy. The computation/options package is the
options equivalent of computation/equity.
Trust-gate subtlety: BaseEquityStrategy.compute returns a neutral signal when
ctx.irScore() < 0.9 — low intersubjective-reliability data silently produces no signal, by design
(see IntersubjectiveAuditService / IR Score, ADR-011). A strategy that "never fires" may be correct
behaviour, not a bug. StrategyContext carries irScore + confidence.
The ingestion→computation boundary is enforced by CdmAdapter<T,R> (@FunctionalInterface,
R toCdm(T rawData)): the computation engine consumes only CDM-typed objects, so every source maps
its raw shape to CDM at the edge. To add a source:
- Raw DTO in
cdm/adapter/raw/— the provider's response shape (e.g.FredObservation). *CdmAdapter implements CdmAdapter<RawDto, CdmType>incdm/adapter/— useCdmInstrumentMapperto translate source-specific IDs (FRED series, Yahoo symbol) into a CDMCdmInstrumentRef.- Source client in
ingestion/<source>/(a@Component) — inject the adapter, fetch,adapter.toCdm(...), then write viaTimescaleDbWriter.writeXxx(toEntity(cdm)). Cadence and resilience are declarative:@Scheduled(fixedDelayString = "${monitor.<source>.poll-interval-ms:…}")on the poll method, plus@Bulkhead(name="…")/@Retry(name="…")whose names resolve into theresilience4j.{bulkhead,retry}.instancesmap inapplication.yml(e.g.fredApi,criticalIngestion). Per-source config is read with@Value("${monitor.<source>.*}"). - Config — add the
monitor.<source>.*block toapplication.yml(some sources add anenabledflag the client checks; fred/nyfed rely on poll-interval alone).
- Two test styles coexist. Spock/Groovy specs (
*Spec.groovy, ~67 files) and JUnit 5 tests (*Test.java, ~94 files). Both run under JUnit Platform. All tests use Given-When-Then structure.webintegration tests use Spock-Spring. - Static analysis is advisory. Checkstyle and SpotBugs run with
ignoreFailures = true; ruff and Lighthouse run withcontinue-on-error. They surface reports, never fail builds. The./gradlew linttask aggregates Checkstyle + SpotBugs across modules. - Records for DTOs/entities. Domain types in
cdmand persistence entities are Javarecords. - Gradle configuration-cache is on (
org.gradle.configuration-cache=true). Custom tasks inbuild.gradlemust not touchproject/rootProjectat execution time — capture values at configuration time. (Workflow verification no longer lives inbuild.gradle; it runs through git hooks + theMakefile— see ADR-026.) - Config knobs are centralized in
app/src/main/resources/application.yml, namespaced undermonitor.<source>.*(enable flags, poll intervals, symbols) andresilience4j.*(retry/bulkhead per client). Demo/paper-portfolio behavior is undermonitor.demo.*. Override via env vars (*_API_KEY,AUTH_DISABLED,CORS_ALLOWED_ORIGINS,KEYCLOAK_ISSUER_URI). - Source language: Latin characters and English only (no Cyrillic in identifiers/comments), per the global rules. Prefer self-documenting names over comments.
- Terraform (
infra/terraform/, multi-cloud AWS/GCP/Azure) is applied via Atlantis (atlantis.yaml). Terraform PRs use the.github/PULL_REQUEST_TEMPLATE.mdchecklist; runterraform fmt -check -recursive,terraform validate, andtflint(.tflint.hcl) before merging. - Observability: Micrometer + OTLP tracing to Jaeger; Prometheus metrics at
/actuator/prometheus. - Runbooks for incidents, data-source outages, the kill-switch / global safe-mode, and data management
are in
docs/runbooks/. Per-module verification reports are indocs/reviews/.
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
Rules:
- For codebase questions, first run
graphify query "<question>"when graphify-out/graph.json exists. Usegraphify path "<A>" "<B>"for relationships andgraphify explain "<concept>"for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. - If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run
graphify update .to keep the graph current (AST-only, no API cost).