From ca77b79931c8f534b4af038e6c6eed94c8dde57b Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 31 May 2026 07:29:20 +0000 Subject: [PATCH 1/6] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/Payel-git-ol/Tradefast/issues/31 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..f80ccb2 --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-05-31T07:29:20.264Z for PR creation at branch issue-31-dcb7bc9e4398 for issue https://github.com/Payel-git-ol/Tradefast/issues/31 \ No newline at end of file From b42dccff8c52c2e86b3917ad088ea90db57e910e Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 31 May 2026 07:34:44 +0000 Subject: [PATCH 2/6] Split backend GraphQL into a graphql/ folder with one class per file and a repository --- .gitkeep | 1 - src/backend/graphql.ts | 185 --------------------- src/backend/graphql/dto/analytics.dto.ts | 32 ++++ src/backend/graphql/dto/run-report.dto.ts | 22 +++ src/backend/graphql/dto/status.dto.ts | 20 +++ src/backend/graphql/dto/strategy.dto.ts | 11 ++ src/backend/graphql/dto/symbol-run.dto.ts | 26 +++ src/backend/graphql/dto/table-count.dto.ts | 11 ++ src/backend/graphql/facade.ts | 19 +++ src/backend/graphql/index.ts | 10 ++ src/backend/graphql/repository.ts | 74 +++++++++ src/backend/graphql/tradefast.resolver.ts | 41 +++++ src/backend/server.ts | 8 +- tests/backend.test.ts | 6 +- 14 files changed, 275 insertions(+), 191 deletions(-) delete mode 100644 .gitkeep delete mode 100644 src/backend/graphql.ts create mode 100644 src/backend/graphql/dto/analytics.dto.ts create mode 100644 src/backend/graphql/dto/run-report.dto.ts create mode 100644 src/backend/graphql/dto/status.dto.ts create mode 100644 src/backend/graphql/dto/strategy.dto.ts create mode 100644 src/backend/graphql/dto/symbol-run.dto.ts create mode 100644 src/backend/graphql/dto/table-count.dto.ts create mode 100644 src/backend/graphql/facade.ts create mode 100644 src/backend/graphql/index.ts create mode 100644 src/backend/graphql/repository.ts create mode 100644 src/backend/graphql/tradefast.resolver.ts diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index f80ccb2..0000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-05-31T07:29:20.264Z for PR creation at branch issue-31-dcb7bc9e4398 for issue https://github.com/Payel-git-ol/Tradefast/issues/31 \ No newline at end of file diff --git a/src/backend/graphql.ts b/src/backend/graphql.ts deleted file mode 100644 index 50e62eb..0000000 --- a/src/backend/graphql.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { Inject } from '@nestjs/common'; -import { Field, Float, Int, Mutation, ObjectType, Query, Resolver } from '@nestjs/graphql'; - -import type { StatusReport } from '../app/tradefast.js'; -import type { RunReport } from '../pipeline/collector.js'; - -export const TRADEFAST_FACADE = Symbol('TRADEFAST_FACADE'); - -export interface TradefastApiFacade { - readonly driver: string; - status(): Promise; - strategies(): { id: string; title: string }[]; - start(): Promise; - update(): Promise; - clear(): Promise; -} - -@ObjectType() -export class TableCountDto { - @Field(() => String) - name!: string; - - @Field(() => Int) - count!: number; -} - -@ObjectType() -export class StrategyDto { - @Field(() => String) - id!: string; - - @Field(() => String) - title!: string; -} - -@ObjectType() -export class AnalyticsDto { - @Field(() => String) - symbol!: string; - - @Field(() => Float) - consensusScore!: number; - - @Field(() => Int) - longCount!: number; - - @Field(() => Int) - shortCount!: number; - - @Field(() => Int) - neutralCount!: number; - - @Field(() => String, { nullable: true }) - strongestStrategy!: string | null; - - @Field(() => Float, { nullable: true }) - strongestStrength!: number | null; - - @Field(() => Float, { nullable: true }) - lastPrice!: number | null; - - @Field(() => Float, { nullable: true }) - atr!: number | null; -} - -@ObjectType() -export class StatusDto { - @Field(() => String) - driver!: string; - - @Field(() => [TableCountDto]) - counts!: TableCountDto[]; - - @Field(() => Int, { nullable: true }) - latestRunId!: number | null; - - @Field(() => [AnalyticsDto]) - latestAnalytics!: AnalyticsDto[]; -} - -@ObjectType() -export class SymbolRunDto { - @Field(() => String) - symbol!: string; - - @Field(() => Int) - candlesAdded!: number; - - @Field(() => Int) - signalsInserted!: number; - - @Field(() => Int) - signalsUpdated!: number; - - @Field(() => Int) - signalsUnchanged!: number; - - @Field(() => Int) - scrapesAdded!: number; - - @Field(() => String) - insight!: string; -} - -@ObjectType() -export class RunReportDto { - @Field(() => Int) - runId!: number; - - @Field(() => String) - kind!: string; - - @Field(() => [SymbolRunDto]) - symbols!: SymbolRunDto[]; - - @Field(() => Int) - searchResults!: number; - - @Field(() => Int) - durationMs!: number; -} - -@Resolver() -export class TradefastResolver { - constructor(@Inject(TRADEFAST_FACADE) private readonly tradefast: TradefastApiFacade) {} - - @Query(() => StatusDto) - async status(): Promise { - const status = await this.tradefast.status(); - return { - driver: status.driver, - counts: Object.entries(status.counts).map(([name, count]) => ({ name, count })), - latestRunId: status.latestRunId ?? null, - latestAnalytics: status.latestAnalytics.map((row) => ({ - symbol: row.symbol, - consensusScore: row.consensusScore, - longCount: row.longCount, - shortCount: row.shortCount, - neutralCount: row.neutralCount, - strongestStrategy: row.strongestStrategy ?? null, - strongestStrength: row.strongestStrength ?? null, - lastPrice: row.lastPrice ?? null, - atr: row.atr ?? null, - })), - }; - } - - @Query(() => [StrategyDto]) - async strategies(): Promise { - return this.tradefast.strategies(); - } - - @Mutation(() => RunReportDto) - async start(): Promise { - return toRunDto(await this.tradefast.start()); - } - - @Mutation(() => RunReportDto) - async update(): Promise { - return toRunDto(await this.tradefast.update()); - } - - @Mutation(() => Int) - async clear(): Promise { - return this.tradefast.clear(); - } -} - -function toRunDto(report: RunReport): RunReportDto { - return { - runId: report.runId, - kind: report.kind, - symbols: report.symbols.map((symbol) => ({ - symbol: symbol.symbol, - candlesAdded: symbol.candlesAdded, - signalsInserted: symbol.signalsInserted, - signalsUpdated: symbol.signalsUpdated, - signalsUnchanged: symbol.signalsUnchanged, - scrapesAdded: symbol.scrapesAdded, - insight: symbol.insight, - })), - searchResults: report.searchResults, - durationMs: report.durationMs, - }; -} diff --git a/src/backend/graphql/dto/analytics.dto.ts b/src/backend/graphql/dto/analytics.dto.ts new file mode 100644 index 0000000..de83532 --- /dev/null +++ b/src/backend/graphql/dto/analytics.dto.ts @@ -0,0 +1,32 @@ +import { Field, Float, Int, ObjectType } from '@nestjs/graphql'; + +/** Per-symbol consensus analytics for the latest run. */ +@ObjectType() +export class AnalyticsDto { + @Field(() => String) + symbol!: string; + + @Field(() => Float) + consensusScore!: number; + + @Field(() => Int) + longCount!: number; + + @Field(() => Int) + shortCount!: number; + + @Field(() => Int) + neutralCount!: number; + + @Field(() => String, { nullable: true }) + strongestStrategy!: string | null; + + @Field(() => Float, { nullable: true }) + strongestStrength!: number | null; + + @Field(() => Float, { nullable: true }) + lastPrice!: number | null; + + @Field(() => Float, { nullable: true }) + atr!: number | null; +} diff --git a/src/backend/graphql/dto/run-report.dto.ts b/src/backend/graphql/dto/run-report.dto.ts new file mode 100644 index 0000000..6bb4f6d --- /dev/null +++ b/src/backend/graphql/dto/run-report.dto.ts @@ -0,0 +1,22 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql'; + +import { SymbolRunDto } from './symbol-run.dto.js'; + +/** The outcome of a `/start` or `/update` collection run. */ +@ObjectType() +export class RunReportDto { + @Field(() => Int) + runId!: number; + + @Field(() => String) + kind!: string; + + @Field(() => [SymbolRunDto]) + symbols!: SymbolRunDto[]; + + @Field(() => Int) + searchResults!: number; + + @Field(() => Int) + durationMs!: number; +} diff --git a/src/backend/graphql/dto/status.dto.ts b/src/backend/graphql/dto/status.dto.ts new file mode 100644 index 0000000..b7179fa --- /dev/null +++ b/src/backend/graphql/dto/status.dto.ts @@ -0,0 +1,20 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql'; + +import { AnalyticsDto } from './analytics.dto.js'; +import { TableCountDto } from './table-count.dto.js'; + +/** A snapshot of the backend's database and the latest run's analytics. */ +@ObjectType() +export class StatusDto { + @Field(() => String) + driver!: string; + + @Field(() => [TableCountDto]) + counts!: TableCountDto[]; + + @Field(() => Int, { nullable: true }) + latestRunId!: number | null; + + @Field(() => [AnalyticsDto]) + latestAnalytics!: AnalyticsDto[]; +} diff --git a/src/backend/graphql/dto/strategy.dto.ts b/src/backend/graphql/dto/strategy.dto.ts new file mode 100644 index 0000000..e4e9886 --- /dev/null +++ b/src/backend/graphql/dto/strategy.dto.ts @@ -0,0 +1,11 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +/** A registered trading strategy, exposed for discovery by clients. */ +@ObjectType() +export class StrategyDto { + @Field(() => String) + id!: string; + + @Field(() => String) + title!: string; +} diff --git a/src/backend/graphql/dto/symbol-run.dto.ts b/src/backend/graphql/dto/symbol-run.dto.ts new file mode 100644 index 0000000..df9213f --- /dev/null +++ b/src/backend/graphql/dto/symbol-run.dto.ts @@ -0,0 +1,26 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql'; + +/** What a single symbol contributed to a collection run. */ +@ObjectType() +export class SymbolRunDto { + @Field(() => String) + symbol!: string; + + @Field(() => Int) + candlesAdded!: number; + + @Field(() => Int) + signalsInserted!: number; + + @Field(() => Int) + signalsUpdated!: number; + + @Field(() => Int) + signalsUnchanged!: number; + + @Field(() => Int) + scrapesAdded!: number; + + @Field(() => String) + insight!: string; +} diff --git a/src/backend/graphql/dto/table-count.dto.ts b/src/backend/graphql/dto/table-count.dto.ts new file mode 100644 index 0000000..907caf8 --- /dev/null +++ b/src/backend/graphql/dto/table-count.dto.ts @@ -0,0 +1,11 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql'; + +/** A single database table together with its current row count. */ +@ObjectType() +export class TableCountDto { + @Field(() => String) + name!: string; + + @Field(() => Int) + count!: number; +} diff --git a/src/backend/graphql/facade.ts b/src/backend/graphql/facade.ts new file mode 100644 index 0000000..759f7e3 --- /dev/null +++ b/src/backend/graphql/facade.ts @@ -0,0 +1,19 @@ +import type { StatusReport } from '../../app/tradefast.js'; +import type { RunReport } from '../../pipeline/collector.js'; + +/** DI token under which the application facade is provided to the resolver. */ +export const TRADEFAST_FACADE = Symbol('TRADEFAST_FACADE'); + +/** + * The slice of the {@link Tradefast} application the GraphQL backend depends on. + * Keeping it an interface lets tests provide a lightweight stand-in and keeps the + * backend decoupled from the full application class. + */ +export interface TradefastApiFacade { + readonly driver: string; + status(): Promise; + strategies(): { id: string; title: string }[]; + start(): Promise; + update(): Promise; + clear(): Promise; +} diff --git a/src/backend/graphql/index.ts b/src/backend/graphql/index.ts new file mode 100644 index 0000000..be4c588 --- /dev/null +++ b/src/backend/graphql/index.ts @@ -0,0 +1,10 @@ +/** Barrel for the backend GraphQL layer: facade, DTOs, repository and resolver. */ +export { TRADEFAST_FACADE, type TradefastApiFacade } from './facade.js'; +export { AnalyticsDto } from './dto/analytics.dto.js'; +export { RunReportDto } from './dto/run-report.dto.js'; +export { StatusDto } from './dto/status.dto.js'; +export { StrategyDto } from './dto/strategy.dto.js'; +export { SymbolRunDto } from './dto/symbol-run.dto.js'; +export { TableCountDto } from './dto/table-count.dto.js'; +export { TradefastRepository, toRunDto } from './repository.js'; +export { TradefastResolver } from './tradefast.resolver.js'; diff --git a/src/backend/graphql/repository.ts b/src/backend/graphql/repository.ts new file mode 100644 index 0000000..59861e6 --- /dev/null +++ b/src/backend/graphql/repository.ts @@ -0,0 +1,74 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import type { RunReport } from '../../pipeline/collector.js'; +import { RunReportDto } from './dto/run-report.dto.js'; +import { StatusDto } from './dto/status.dto.js'; +import { StrategyDto } from './dto/strategy.dto.js'; +import { TRADEFAST_FACADE, type TradefastApiFacade } from './facade.js'; + +/** + * The backend-side repository. It is the single place that talks to the + * application facade and maps its domain reports into GraphQL DTOs, so the + * resolver stays a thin declaration of the schema. Mirrors the frontend + * repository, giving both ends of the `cli → graphql → backend` path one object + * that owns data access. + */ +@Injectable() +export class TradefastRepository { + constructor(@Inject(TRADEFAST_FACADE) private readonly tradefast: TradefastApiFacade) {} + + async status(): Promise { + const status = await this.tradefast.status(); + return { + driver: status.driver, + counts: Object.entries(status.counts).map(([name, count]) => ({ name, count })), + latestRunId: status.latestRunId ?? null, + latestAnalytics: status.latestAnalytics.map((row) => ({ + symbol: row.symbol, + consensusScore: row.consensusScore, + longCount: row.longCount, + shortCount: row.shortCount, + neutralCount: row.neutralCount, + strongestStrategy: row.strongestStrategy ?? null, + strongestStrength: row.strongestStrength ?? null, + lastPrice: row.lastPrice ?? null, + atr: row.atr ?? null, + })), + }; + } + + strategies(): StrategyDto[] { + return this.tradefast.strategies(); + } + + async start(): Promise { + return toRunDto(await this.tradefast.start()); + } + + async update(): Promise { + return toRunDto(await this.tradefast.update()); + } + + clear(): Promise { + return this.tradefast.clear(); + } +} + +/** Map a pipeline {@link RunReport} into its GraphQL DTO. */ +export function toRunDto(report: RunReport): RunReportDto { + return { + runId: report.runId, + kind: report.kind, + symbols: report.symbols.map((symbol) => ({ + symbol: symbol.symbol, + candlesAdded: symbol.candlesAdded, + signalsInserted: symbol.signalsInserted, + signalsUpdated: symbol.signalsUpdated, + signalsUnchanged: symbol.signalsUnchanged, + scrapesAdded: symbol.scrapesAdded, + insight: symbol.insight, + })), + searchResults: report.searchResults, + durationMs: report.durationMs, + }; +} diff --git a/src/backend/graphql/tradefast.resolver.ts b/src/backend/graphql/tradefast.resolver.ts new file mode 100644 index 0000000..e423064 --- /dev/null +++ b/src/backend/graphql/tradefast.resolver.ts @@ -0,0 +1,41 @@ +import { Inject } from '@nestjs/common'; +import { Int, Mutation, Query, Resolver } from '@nestjs/graphql'; + +import { RunReportDto } from './dto/run-report.dto.js'; +import { StatusDto } from './dto/status.dto.js'; +import { StrategyDto } from './dto/strategy.dto.js'; +import { TradefastRepository } from './repository.js'; + +/** + * The GraphQL schema surface. Every field delegates to {@link TradefastRepository} + * so this class stays a thin declaration of queries and mutations. + */ +@Resolver() +export class TradefastResolver { + constructor(@Inject(TradefastRepository) private readonly repository: TradefastRepository) {} + + @Query(() => StatusDto) + status(): Promise { + return this.repository.status(); + } + + @Query(() => [StrategyDto]) + async strategies(): Promise { + return this.repository.strategies(); + } + + @Mutation(() => RunReportDto) + start(): Promise { + return this.repository.start(); + } + + @Mutation(() => RunReportDto) + update(): Promise { + return this.repository.update(); + } + + @Mutation(() => Int) + clear(): Promise { + return this.repository.clear(); + } +} diff --git a/src/backend/server.ts b/src/backend/server.ts index 5a56f24..63f973a 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -5,7 +5,7 @@ import { NestFactory } from '@nestjs/core'; import { ApolloDriver, type ApolloDriverConfig } from '@nestjs/apollo'; import { GraphQLModule } from '@nestjs/graphql'; -import { TRADEFAST_FACADE, TradefastResolver, type TradefastApiFacade } from './graphql.js'; +import { TRADEFAST_FACADE, TradefastRepository, TradefastResolver, type TradefastApiFacade } from './graphql/index.js'; @Module({}) class TradefastBackendModule { @@ -20,7 +20,11 @@ class TradefastBackendModule { path: '/graphql', }), ], - providers: [{ provide: TRADEFAST_FACADE, useValue: facade }, TradefastResolver], + providers: [ + { provide: TRADEFAST_FACADE, useValue: facade }, + TradefastRepository, + TradefastResolver, + ], }; } } diff --git a/tests/backend.test.ts b/tests/backend.test.ts index 0b9ff11..9fcc0d7 100644 --- a/tests/backend.test.ts +++ b/tests/backend.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { TradefastResolver, type TradefastApiFacade } from '../src/backend/graphql.js'; +import { TradefastRepository, TradefastResolver, type TradefastApiFacade } from '../src/backend/graphql/index.js'; import { startTradefastBackend } from '../src/backend/server.js'; const facade: TradefastApiFacade = { @@ -47,7 +47,7 @@ const facade: TradefastApiFacade = { describe('Nest GraphQL resolver', () => { it('maps Tradefast status and strategies into GraphQL DTOs', async () => { - const resolver = new TradefastResolver(facade); + const resolver = new TradefastResolver(new TradefastRepository(facade)); await expect(resolver.strategies()).resolves.toEqual([{ id: 'trend-following', title: 'Trend Following' }]); await expect(resolver.status()).resolves.toMatchObject({ @@ -62,7 +62,7 @@ describe('Nest GraphQL resolver', () => { }); it('exposes start, update, and clear mutations', async () => { - const resolver = new TradefastResolver(facade); + const resolver = new TradefastResolver(new TradefastRepository(facade)); await expect(resolver.start()).resolves.toMatchObject({ runId: 2, kind: 'start' }); await expect(resolver.update()).resolves.toMatchObject({ runId: 3, kind: 'update' }); From 1680c668b657958307d9568f692c54dcf4e7e2aa Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 31 May 2026 07:36:01 +0000 Subject: [PATCH 3/6] Add frontend GraphQL folder: HTTP client, typed documents and repository --- src/cli/graphql/analytics.ts | 25 +++++++ src/cli/graphql/clear.ts | 10 +++ src/cli/graphql/client.ts | 45 +++++++++++++ src/cli/graphql/index.ts | 9 +++ src/cli/graphql/repository.ts | 44 +++++++++++++ src/cli/graphql/run-report.ts | 41 ++++++++++++ src/cli/graphql/status.ts | 29 ++++++++ src/cli/graphql/strategy.ts | 19 ++++++ src/cli/graphql/symbol-run.ts | 21 ++++++ src/cli/graphql/table-count.ts | 5 ++ tests/graphql-repository.test.ts | 110 +++++++++++++++++++++++++++++++ 11 files changed, 358 insertions(+) create mode 100644 src/cli/graphql/analytics.ts create mode 100644 src/cli/graphql/clear.ts create mode 100644 src/cli/graphql/client.ts create mode 100644 src/cli/graphql/index.ts create mode 100644 src/cli/graphql/repository.ts create mode 100644 src/cli/graphql/run-report.ts create mode 100644 src/cli/graphql/status.ts create mode 100644 src/cli/graphql/strategy.ts create mode 100644 src/cli/graphql/symbol-run.ts create mode 100644 src/cli/graphql/table-count.ts create mode 100644 tests/graphql-repository.test.ts diff --git a/src/cli/graphql/analytics.ts b/src/cli/graphql/analytics.ts new file mode 100644 index 0000000..ebb4a7b --- /dev/null +++ b/src/cli/graphql/analytics.ts @@ -0,0 +1,25 @@ +/** Per-symbol consensus analytics for the latest run, as returned by the API. */ +export interface Analytics { + symbol: string; + consensusScore: number; + longCount: number; + shortCount: number; + neutralCount: number; + strongestStrategy: string | null; + strongestStrength: number | null; + lastPrice: number | null; + atr: number | null; +} + +/** The GraphQL selection set shared by queries that embed analytics rows. */ +export const ANALYTICS_FIELDS = ` + symbol + consensusScore + longCount + shortCount + neutralCount + strongestStrategy + strongestStrength + lastPrice + atr +`; diff --git a/src/cli/graphql/clear.ts b/src/cli/graphql/clear.ts new file mode 100644 index 0000000..d7ff0dd --- /dev/null +++ b/src/cli/graphql/clear.ts @@ -0,0 +1,10 @@ +/** Prune outdated runs; returns how many runs were removed. */ +export const CLEAR_MUTATION = ` + mutation Clear { + clear + } +`; + +export interface ClearResult { + clear: number; +} diff --git a/src/cli/graphql/client.ts b/src/cli/graphql/client.ts new file mode 100644 index 0000000..0b11df8 --- /dev/null +++ b/src/cli/graphql/client.ts @@ -0,0 +1,45 @@ +/** + * A tiny GraphQL-over-HTTP client. The frontend talks to the backend purely + * through this transport: it POSTs a query document plus variables and returns + * the typed `data` payload, throwing on transport or GraphQL errors. Keeping the + * client this small means the repository — not the UI — owns every request. + */ +export interface GraphqlError { + message: string; +} + +export class GraphqlRequestError extends Error { + constructor(message: string, readonly errors?: GraphqlError[]) { + super(message); + this.name = 'GraphqlRequestError'; + } +} + +export class GraphqlClient { + constructor( + private readonly url: string, + private readonly fetchImpl: typeof fetch = fetch, + ) {} + + /** Execute a query/mutation document and return its `data` payload. */ + async request(document: string, variables?: Record): Promise { + const response = await this.fetchImpl(this.url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query: document, variables }), + }); + + if (!response.ok) { + throw new GraphqlRequestError(`GraphQL request failed with HTTP ${response.status}`); + } + + const body = (await response.json()) as { data?: TData; errors?: GraphqlError[] }; + if (body.errors && body.errors.length > 0) { + throw new GraphqlRequestError(body.errors.map((error) => error.message).join('; '), body.errors); + } + if (body.data == null) { + throw new GraphqlRequestError('GraphQL response contained no data'); + } + return body.data; + } +} diff --git a/src/cli/graphql/index.ts b/src/cli/graphql/index.ts new file mode 100644 index 0000000..1c8d1c2 --- /dev/null +++ b/src/cli/graphql/index.ts @@ -0,0 +1,9 @@ +/** Barrel for the frontend GraphQL layer: client, typed documents and repository. */ +export { GraphqlClient, GraphqlRequestError, type GraphqlError } from './client.js'; +export { GraphqlTradefastRepository } from './repository.js'; +export type { Analytics } from './analytics.js'; +export type { Status } from './status.js'; +export type { Strategy } from './strategy.js'; +export type { SymbolRun } from './symbol-run.js'; +export type { RunReport } from './run-report.js'; +export type { TableCount } from './table-count.js'; diff --git a/src/cli/graphql/repository.ts b/src/cli/graphql/repository.ts new file mode 100644 index 0000000..89d60e8 --- /dev/null +++ b/src/cli/graphql/repository.ts @@ -0,0 +1,44 @@ +import { CLEAR_MUTATION, type ClearResult } from './clear.js'; +import { GraphqlClient } from './client.js'; +import { START_MUTATION, UPDATE_MUTATION, type RunReport, type StartResult, type UpdateResult } from './run-report.js'; +import { STATUS_QUERY, type Status, type StatusResult } from './status.js'; +import { STRATEGIES_QUERY, type Strategy, type StrategiesResult } from './strategy.js'; + +/** + * The frontend repository. Every API request the CLI makes goes through this + * object, which turns a call like {@link status} into a GraphQL document sent to + * the backend. It is the client end of the `cli → graphql → backend` path and + * the only place in the frontend that knows the API exists. + */ +export class GraphqlTradefastRepository { + private readonly client: GraphqlClient; + + constructor(url: string, fetchImpl: typeof fetch = fetch) { + this.client = new GraphqlClient(url, fetchImpl); + } + + async status(): Promise { + const data = await this.client.request(STATUS_QUERY); + return data.status; + } + + async strategies(): Promise { + const data = await this.client.request(STRATEGIES_QUERY); + return data.strategies; + } + + async start(): Promise { + const data = await this.client.request(START_MUTATION); + return data.start; + } + + async update(): Promise { + const data = await this.client.request(UPDATE_MUTATION); + return data.update; + } + + async clear(): Promise { + const data = await this.client.request(CLEAR_MUTATION); + return data.clear; + } +} diff --git a/src/cli/graphql/run-report.ts b/src/cli/graphql/run-report.ts new file mode 100644 index 0000000..ce820b2 --- /dev/null +++ b/src/cli/graphql/run-report.ts @@ -0,0 +1,41 @@ +import { SYMBOL_RUN_FIELDS, type SymbolRun } from './symbol-run.js'; + +/** The outcome of a `/start` or `/update` collection run. */ +export interface RunReport { + runId: number; + kind: string; + symbols: SymbolRun[]; + searchResults: number; + durationMs: number; +} + +/** The GraphQL selection set shared by the start/update mutations. */ +const RUN_REPORT_FIELDS = ` + runId + kind + searchResults + durationMs + symbols {${SYMBOL_RUN_FIELDS}} +`; + +/** Clear prior run data and analyse afresh. */ +export const START_MUTATION = ` + mutation Start { + start {${RUN_REPORT_FIELDS}} + } +`; + +/** Re-analyse, writing only rows that actually changed. */ +export const UPDATE_MUTATION = ` + mutation Update { + update {${RUN_REPORT_FIELDS}} + } +`; + +export interface StartResult { + start: RunReport; +} + +export interface UpdateResult { + update: RunReport; +} diff --git a/src/cli/graphql/status.ts b/src/cli/graphql/status.ts new file mode 100644 index 0000000..dcaa3c7 --- /dev/null +++ b/src/cli/graphql/status.ts @@ -0,0 +1,29 @@ +import { ANALYTICS_FIELDS, type Analytics } from './analytics.js'; +import type { TableCount } from './table-count.js'; + +/** A snapshot of the backend's database and the latest run's analytics. */ +export interface Status { + driver: string; + counts: TableCount[]; + latestRunId: number | null; + latestAnalytics: Analytics[]; +} + +/** Query the backend status, including the latest run's analytics. */ +export const STATUS_QUERY = ` + query Status { + status { + driver + latestRunId + counts { + name + count + } + latestAnalytics {${ANALYTICS_FIELDS}} + } + } +`; + +export interface StatusResult { + status: Status; +} diff --git a/src/cli/graphql/strategy.ts b/src/cli/graphql/strategy.ts new file mode 100644 index 0000000..e382e70 --- /dev/null +++ b/src/cli/graphql/strategy.ts @@ -0,0 +1,19 @@ +/** A registered trading strategy advertised by the backend. */ +export interface Strategy { + id: string; + title: string; +} + +/** Query the registry of available strategies. */ +export const STRATEGIES_QUERY = ` + query Strategies { + strategies { + id + title + } + } +`; + +export interface StrategiesResult { + strategies: Strategy[]; +} diff --git a/src/cli/graphql/symbol-run.ts b/src/cli/graphql/symbol-run.ts new file mode 100644 index 0000000..e5e203a --- /dev/null +++ b/src/cli/graphql/symbol-run.ts @@ -0,0 +1,21 @@ +/** What a single symbol contributed to a collection run. */ +export interface SymbolRun { + symbol: string; + candlesAdded: number; + signalsInserted: number; + signalsUpdated: number; + signalsUnchanged: number; + scrapesAdded: number; + insight: string; +} + +/** The GraphQL selection set for a symbol's run contribution. */ +export const SYMBOL_RUN_FIELDS = ` + symbol + candlesAdded + signalsInserted + signalsUpdated + signalsUnchanged + scrapesAdded + insight +`; diff --git a/src/cli/graphql/table-count.ts b/src/cli/graphql/table-count.ts new file mode 100644 index 0000000..6f066fb --- /dev/null +++ b/src/cli/graphql/table-count.ts @@ -0,0 +1,5 @@ +/** A database table together with its current row count, as returned by the API. */ +export interface TableCount { + name: string; + count: number; +} diff --git a/tests/graphql-repository.test.ts b/tests/graphql-repository.test.ts new file mode 100644 index 0000000..caec888 --- /dev/null +++ b/tests/graphql-repository.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; + +import type { TradefastApiFacade } from '../src/backend/graphql/index.js'; +import { startTradefastBackend } from '../src/backend/server.js'; +import { GraphqlClient, GraphqlRequestError, GraphqlTradefastRepository } from '../src/cli/graphql/index.js'; + +const facade: TradefastApiFacade = { + driver: 'pglite', + strategies: () => [{ id: 'trend-following', title: 'Trend Following' }], + status: async () => ({ + driver: 'pglite', + counts: { runs: 1, candles: 20 }, + latestRunId: 7, + latestAnalytics: [ + { + symbol: 'BTCUSDT', + consensusScore: 0.42, + longCount: 4, + shortCount: 1, + neutralCount: 8, + strongestStrategy: 'trend-following', + strongestStrength: 0.7, + lastPrice: 60_000, + atr: 120, + }, + ], + }), + start: async () => ({ + runId: 2, + kind: 'start', + symbols: [ + { + symbol: 'BTCUSDT', + analysis: {} as never, + insight: 'flat', + candlesAdded: 5, + signalsInserted: 3, + signalsUpdated: 1, + signalsUnchanged: 0, + scrapesAdded: 0, + assessment: '', + }, + ], + searchResults: 0, + durationMs: 1, + validation: null, + interval: '1h', + }), + update: async () => ({ + runId: 3, + kind: 'update', + symbols: [], + searchResults: 0, + durationMs: 1, + validation: null, + interval: '1h', + }), + clear: async () => 4, +}; + +describe('GraphqlTradefastRepository', () => { + it('drives the real backend through the cli → graphql → backend path', async () => { + const backend = await startTradefastBackend(facade); + const repository = new GraphqlTradefastRepository(backend.url); + + try { + const status = await repository.status(); + expect(status.driver).toBe('pglite'); + expect(status.latestRunId).toBe(7); + expect(status.counts).toEqual([ + { name: 'runs', count: 1 }, + { name: 'candles', count: 20 }, + ]); + expect(status.latestAnalytics[0]).toMatchObject({ symbol: 'BTCUSDT', lastPrice: 60_000 }); + + await expect(repository.strategies()).resolves.toEqual([ + { id: 'trend-following', title: 'Trend Following' }, + ]); + + const started = await repository.start(); + expect(started).toMatchObject({ runId: 2, kind: 'start' }); + expect(started.symbols[0]).toMatchObject({ symbol: 'BTCUSDT', candlesAdded: 5 }); + + await expect(repository.update()).resolves.toMatchObject({ runId: 3, kind: 'update' }); + await expect(repository.clear()).resolves.toBe(4); + } finally { + await backend.close(); + } + }); +}); + +describe('GraphqlClient', () => { + it('throws when the backend returns GraphQL errors', async () => { + const fakeFetch = (async () => + new Response(JSON.stringify({ errors: [{ message: 'boom' }] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })) as unknown as typeof fetch; + const client = new GraphqlClient('http://example.invalid/graphql', fakeFetch); + + await expect(client.request('{ status { driver } }')).rejects.toBeInstanceOf(GraphqlRequestError); + }); + + it('throws on a non-2xx HTTP response', async () => { + const fakeFetch = (async () => new Response('nope', { status: 500 })) as unknown as typeof fetch; + const client = new GraphqlClient('http://example.invalid/graphql', fakeFetch); + + await expect(client.request('{ status { driver } }')).rejects.toThrow(/HTTP 500/u); + }); +}); From d06e3e1d05460528bd95a2494c3da98c302cec14 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 31 May 2026 07:37:17 +0000 Subject: [PATCH 4/6] Route headless status, strategies and clear commands through the GraphQL repository --- src/index.tsx | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index f1087ad..029a739 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -8,6 +8,7 @@ import { render } from 'ink'; import { Tradefast } from './app/tradefast.js'; import { startTradefastBackend, type TradefastBackendHandle } from './backend/server.js'; +import { GraphqlTradefastRepository } from './cli/graphql/index.js'; import { App } from './cli/App.js'; import { renderBannerArt } from './cli/ascii.js'; import { COMMANDS, parseCommand } from './cli/commands.js'; @@ -137,7 +138,11 @@ async function runHeadless(command: string): Promise { } if (name === 'exit') return 0; - const config = loadConfig(name === 'api' ? { apiEnabled: true } : {}); + // These commands map cleanly onto the GraphQL API, so the CLI routes them + // through the backend (cli → graphql → backend) instead of touching the + // application facade directly — the architecture the issue asks for. + const viaGraphql = name === 'strategies' || name === 'status' || name === 'clear'; + const config = loadConfig(name === 'api' || viaGraphql ? { apiEnabled: true } : {}); const app = await Tradefast.create(config); let backend: TradefastBackendHandle | null = null; try { @@ -145,15 +150,19 @@ async function runHeadless(command: string): Promise { backend = await startTradefastBackend(app, { host: config.apiHost, port: config.apiPort }); process.stdout.write(`GraphQL API: ${backend.url}\n`); await waitForShutdown(); - } else if (name === 'strategies') { - for (const s of app.strategies()) process.stdout.write(` ${s.id.padEnd(20)} ${s.title}\n`); - } else if (name === 'status') { - const status = await app.status(); - process.stdout.write(`db: ${status.driver}\n`); - process.stdout.write(`${Object.entries(status.counts).map(([k, v]) => `${k}=${v}`).join(' ')}\n`); - } else if (name === 'clear') { - const pruned = await app.clear(); - process.stdout.write(`Pruned ${pruned} outdated run(s). Search table preserved.\n`); + } else if (viaGraphql) { + backend = await startTradefastBackend(app, { host: config.apiHost, port: config.apiPort }); + const repository = new GraphqlTradefastRepository(backend.url); + if (name === 'strategies') { + for (const s of await repository.strategies()) process.stdout.write(` ${s.id.padEnd(20)} ${s.title}\n`); + } else if (name === 'status') { + const status = await repository.status(); + process.stdout.write(`db: ${status.driver}\n`); + process.stdout.write(`${status.counts.map(({ name: k, count: v }) => `${k}=${v}`).join(' ')}\n`); + } else { + const pruned = await repository.clear(); + process.stdout.write(`Pruned ${pruned} outdated run(s). Search table preserved.\n`); + } } else if (name === 'backtest') { const report = await app.backtest(reportProgress); process.stdout.write(`${renderBacktestLines(report).join('\n')}\n`); From 4bc4d0ef63b7f3c5529ce853e11ceee5cf8c041f Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 31 May 2026 07:39:05 +0000 Subject: [PATCH 5/6] Extract App.tsx selector popups into a cli/components/ folder (one per file) --- src/cli/App.tsx | 273 +----------------------- src/cli/components/CurrencySelector.tsx | 38 ++++ src/cli/components/ExchangeSelector.tsx | 42 ++++ src/cli/components/IntervalSelector.tsx | 42 ++++ src/cli/components/LevelSelector.tsx | 43 ++++ src/cli/components/ModeSelector.tsx | 43 ++++ src/cli/components/PlatformSelector.tsx | 48 +++++ src/cli/components/ThemeSelector.tsx | 39 ++++ src/cli/components/index.ts | 8 + 9 files changed, 313 insertions(+), 263 deletions(-) create mode 100644 src/cli/components/CurrencySelector.tsx create mode 100644 src/cli/components/ExchangeSelector.tsx create mode 100644 src/cli/components/IntervalSelector.tsx create mode 100644 src/cli/components/LevelSelector.tsx create mode 100644 src/cli/components/ModeSelector.tsx create mode 100644 src/cli/components/PlatformSelector.tsx create mode 100644 src/cli/components/ThemeSelector.tsx create mode 100644 src/cli/components/index.ts diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 044b58e..642d046 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -6,9 +6,18 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import type { Tradefast } from '../app/tradefast.js'; import { ChatService } from '../services/chat.js'; import { COMMANDS, completeCommand, parseCommand, suggestCommands, type CommandSpec } from './commands.js'; +import { + ThemeSelector, + ExchangeSelector, + IntervalSelector, + ModeSelector, + CurrencySelector, + LevelSelector, + PlatformSelector, +} from './components/index.js'; import { OutputLine, type OutputItem } from './output.js'; import { saveTheme, saveExchange, saveInterval, saveMode, saveSearchingLevel, saveSearchingPlatforms } from './preferences.js'; -import { getTheme, themeNames, type CliTheme, type ThemeName } from './theme.js'; +import { getTheme, themeNames, type ThemeName } from './theme.js'; import { getExchange, exchangeNames, isKnownExchange, isBinaryOptions, type ExchangeName } from './exchanges.js'; import { getInterval, intervalNames, type IntervalName } from './intervals.js'; import { getMode, modeNames, type ModeName } from './modes.js'; @@ -24,268 +33,6 @@ export interface AppProps { promptOperatingMode?: boolean; } -function ThemeSelector({ - theme, - selectedIndex, -}: { - theme: CliTheme; - selectedIndex: number; -}): React.ReactElement { - return ( - - - Select theme - - {themeNames().map((name, index) => { - const option = getTheme(name); - const selected = index === selectedIndex; - const current = option.name === theme.name; - - return ( - - {selected ? '> ' : ' '} - {option.label.padEnd(8)} - {current ? current : null} - - ); - })} - - ); -} - -function ExchangeSelector({ - theme, - selectedIndex, - current, -}: { - theme: CliTheme; - selectedIndex: number; - current: ExchangeName; -}): React.ReactElement { - return ( - - - Select exchange - - {exchangeNames().map((name, index) => { - const option = getExchange(name); - const selected = index === selectedIndex; - const isCurrent = option.name === current; - - return ( - - {selected ? '> ' : ' '} - {option.label.padEnd(8)} - {isCurrent ? current : null} - - ); - })} - - ); -} - -function IntervalSelector({ - theme, - selectedIndex, - current, -}: { - theme: CliTheme; - selectedIndex: number; - current: IntervalName; -}): React.ReactElement { - return ( - - - Select trading timeframe - - {intervalNames().map((name, index) => { - const option = getInterval(name); - const selected = index === selectedIndex; - const isCurrent = option.name === current; - - return ( - - {selected ? '> ' : ' '} - {option.label.padEnd(10)} - {isCurrent ? current : null} - - ); - })} - - ); -} - -function ModeSelector({ - theme, - selectedIndex, - current, -}: { - theme: CliTheme; - selectedIndex: number; - current: ModeName; -}): React.ReactElement { - return ( - - - Select operating mode - - {modeNames().map((name, index) => { - const option = getMode(name); - const selected = index === selectedIndex; - const isCurrent = option.name === current; - - return ( - - {selected ? '> ' : ' '} - {option.label.padEnd(14)} - {option.description} - {isCurrent ? (current) : null} - - ); - })} - - ); -} - -function CurrencySelector({ - theme, - selectedIndex, - symbols, -}: { - theme: CliTheme; - selectedIndex: number; - symbols: string[]; -}): React.ReactElement { - return ( - - - Select currency for detailed forecast - - {symbols.map((symbol, index) => { - const selected = index === selectedIndex; - return ( - - {selected ? '> ' : ' '} - {symbol.padEnd(10)} - {selected ? press Enter to analyse : null} - - ); - })} - - ); -} - -function LevelSelector({ - theme, - selectedIndex, - current, -}: { - theme: CliTheme; - selectedIndex: number; - current: SearchLevelName; -}): React.ReactElement { - return ( - - - Select research depth - - {searchLevelNames().map((name, index) => { - const option = getSearchLevel(name); - const selected = index === selectedIndex; - const isCurrent = option.name === current; - - return ( - - {selected ? '> ' : ' '} - {option.label.padEnd(8)} - {option.description} - {isCurrent ? (current) : null} - - ); - })} - - ); -} - -function PlatformSelector({ - theme, - cursorIndex, - enabledGroups, -}: { - theme: CliTheme; - cursorIndex: number; - enabledGroups: SourceGroupId[]; -}): React.ReactElement { - return ( - - - Select research platforms - - - Space=toggle, Enter=done, Esc=cancel - - {sourceGroupIds().map((id, index) => { - const group = getSourceGroup(id)!; - const checked = enabledGroups.includes(id); - const focused = index === cursorIndex; - - return ( - - {focused ? '> ' : ' '} - {checked ? '[x]' : '[ ]'} - {' '} - {group.label} - {' '} - {group.description} - - ); - })} - - ); -} - /** * The interactive shell. A static banner and transcript scroll above a single * input line — the same layout as the Gemini CLI. All side effects go through diff --git a/src/cli/components/CurrencySelector.tsx b/src/cli/components/CurrencySelector.tsx new file mode 100644 index 0000000..b0b8e27 --- /dev/null +++ b/src/cli/components/CurrencySelector.tsx @@ -0,0 +1,38 @@ +import { Box, Text } from 'ink'; +import React from 'react'; + +import type { CliTheme } from '../theme.js'; + +export function CurrencySelector({ + theme, + selectedIndex, + symbols, +}: { + theme: CliTheme; + selectedIndex: number; + symbols: string[]; +}): React.ReactElement { + return ( + + + Select currency for detailed forecast + + {symbols.map((symbol, index) => { + const selected = index === selectedIndex; + return ( + + {selected ? '> ' : ' '} + {symbol.padEnd(10)} + {selected ? press Enter to analyse : null} + + ); + })} + + ); +} diff --git a/src/cli/components/ExchangeSelector.tsx b/src/cli/components/ExchangeSelector.tsx new file mode 100644 index 0000000..6c1235a --- /dev/null +++ b/src/cli/components/ExchangeSelector.tsx @@ -0,0 +1,42 @@ +import { Box, Text } from 'ink'; +import React from 'react'; + +import { getExchange, exchangeNames, type ExchangeName } from '../exchanges.js'; +import type { CliTheme } from '../theme.js'; + +export function ExchangeSelector({ + theme, + selectedIndex, + current, +}: { + theme: CliTheme; + selectedIndex: number; + current: ExchangeName; +}): React.ReactElement { + return ( + + + Select exchange + + {exchangeNames().map((name, index) => { + const option = getExchange(name); + const selected = index === selectedIndex; + const isCurrent = option.name === current; + + return ( + + {selected ? '> ' : ' '} + {option.label.padEnd(8)} + {isCurrent ? current : null} + + ); + })} + + ); +} diff --git a/src/cli/components/IntervalSelector.tsx b/src/cli/components/IntervalSelector.tsx new file mode 100644 index 0000000..2fabe3e --- /dev/null +++ b/src/cli/components/IntervalSelector.tsx @@ -0,0 +1,42 @@ +import { Box, Text } from 'ink'; +import React from 'react'; + +import { getInterval, intervalNames, type IntervalName } from '../intervals.js'; +import type { CliTheme } from '../theme.js'; + +export function IntervalSelector({ + theme, + selectedIndex, + current, +}: { + theme: CliTheme; + selectedIndex: number; + current: IntervalName; +}): React.ReactElement { + return ( + + + Select trading timeframe + + {intervalNames().map((name, index) => { + const option = getInterval(name); + const selected = index === selectedIndex; + const isCurrent = option.name === current; + + return ( + + {selected ? '> ' : ' '} + {option.label.padEnd(10)} + {isCurrent ? current : null} + + ); + })} + + ); +} diff --git a/src/cli/components/LevelSelector.tsx b/src/cli/components/LevelSelector.tsx new file mode 100644 index 0000000..867f13a --- /dev/null +++ b/src/cli/components/LevelSelector.tsx @@ -0,0 +1,43 @@ +import { Box, Text } from 'ink'; +import React from 'react'; + +import { searchLevelNames, getSearchLevel, type SearchLevelName } from '../search-level.js'; +import type { CliTheme } from '../theme.js'; + +export function LevelSelector({ + theme, + selectedIndex, + current, +}: { + theme: CliTheme; + selectedIndex: number; + current: SearchLevelName; +}): React.ReactElement { + return ( + + + Select research depth + + {searchLevelNames().map((name, index) => { + const option = getSearchLevel(name); + const selected = index === selectedIndex; + const isCurrent = option.name === current; + + return ( + + {selected ? '> ' : ' '} + {option.label.padEnd(8)} + {option.description} + {isCurrent ? (current) : null} + + ); + })} + + ); +} diff --git a/src/cli/components/ModeSelector.tsx b/src/cli/components/ModeSelector.tsx new file mode 100644 index 0000000..885eba6 --- /dev/null +++ b/src/cli/components/ModeSelector.tsx @@ -0,0 +1,43 @@ +import { Box, Text } from 'ink'; +import React from 'react'; + +import { getMode, modeNames, type ModeName } from '../modes.js'; +import type { CliTheme } from '../theme.js'; + +export function ModeSelector({ + theme, + selectedIndex, + current, +}: { + theme: CliTheme; + selectedIndex: number; + current: ModeName; +}): React.ReactElement { + return ( + + + Select operating mode + + {modeNames().map((name, index) => { + const option = getMode(name); + const selected = index === selectedIndex; + const isCurrent = option.name === current; + + return ( + + {selected ? '> ' : ' '} + {option.label.padEnd(14)} + {option.description} + {isCurrent ? (current) : null} + + ); + })} + + ); +} diff --git a/src/cli/components/PlatformSelector.tsx b/src/cli/components/PlatformSelector.tsx new file mode 100644 index 0000000..9e2d34f --- /dev/null +++ b/src/cli/components/PlatformSelector.tsx @@ -0,0 +1,48 @@ +import { Box, Text } from 'ink'; +import React from 'react'; + +import { sourceGroupIds, getSourceGroup, type SourceGroupId } from '../sources.js'; +import type { CliTheme } from '../theme.js'; + +export function PlatformSelector({ + theme, + cursorIndex, + enabledGroups, +}: { + theme: CliTheme; + cursorIndex: number; + enabledGroups: SourceGroupId[]; +}): React.ReactElement { + return ( + + + Select research platforms + + + Space=toggle, Enter=done, Esc=cancel + + {sourceGroupIds().map((id, index) => { + const group = getSourceGroup(id)!; + const checked = enabledGroups.includes(id); + const focused = index === cursorIndex; + + return ( + + {focused ? '> ' : ' '} + {checked ? '[x]' : '[ ]'} + {' '} + {group.label} + {' '} + {group.description} + + ); + })} + + ); +} diff --git a/src/cli/components/ThemeSelector.tsx b/src/cli/components/ThemeSelector.tsx new file mode 100644 index 0000000..1ed8cbc --- /dev/null +++ b/src/cli/components/ThemeSelector.tsx @@ -0,0 +1,39 @@ +import { Box, Text } from 'ink'; +import React from 'react'; + +import { getTheme, themeNames, type CliTheme } from '../theme.js'; + +export function ThemeSelector({ + theme, + selectedIndex, +}: { + theme: CliTheme; + selectedIndex: number; +}): React.ReactElement { + return ( + + + Select theme + + {themeNames().map((name, index) => { + const option = getTheme(name); + const selected = index === selectedIndex; + const current = option.name === theme.name; + + return ( + + {selected ? '> ' : ' '} + {option.label.padEnd(8)} + {current ? current : null} + + ); + })} + + ); +} diff --git a/src/cli/components/index.ts b/src/cli/components/index.ts new file mode 100644 index 0000000..9f956c3 --- /dev/null +++ b/src/cli/components/index.ts @@ -0,0 +1,8 @@ +/** Barrel for the interactive shell's popup selector components. */ +export { ThemeSelector } from './ThemeSelector.js'; +export { ExchangeSelector } from './ExchangeSelector.js'; +export { IntervalSelector } from './IntervalSelector.js'; +export { ModeSelector } from './ModeSelector.js'; +export { CurrencySelector } from './CurrencySelector.js'; +export { LevelSelector } from './LevelSelector.js'; +export { PlatformSelector } from './PlatformSelector.js'; From 19f5b6aa64224084af0522ec57eae99903deb5b5 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 31 May 2026 07:41:22 +0000 Subject: [PATCH 6/6] Document repository-mediated GraphQL layer and bump version to 0.6.0 --- README.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0a0f5a0..62f7f89 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,13 @@ interactive terminal UI. to hold the directional bet) while every strategy algorithm stays identical. - **In-process NestJS GraphQL backend**: the interactive CLI starts a local GraphQL endpoint without a second service process. +- **Repository-mediated GraphQL layer**: the API surface lives entirely in the + backend. Both ends of the `cli → graphql → backend` path own a repository — the + backend `TradefastRepository` (`src/backend/graphql/`) maps the application + facade onto GraphQL DTOs, and the frontend `GraphqlTradefastRepository` + (`src/cli/graphql/`) is the only place the CLI talks to the API. Each GraphQL + class lives in its own file on both sides. The headless `status`, `strategies` + and `clear` commands make their requests through this path. - **Dockerised**: `docker compose up` brings up PostgreSQL and the CLI. --- diff --git a/package.json b/package.json index f22bb39..a2ae694 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "Tradefast", - "version": "0.5.0", + "version": "0.6.0", "description": "TRADEFΛST — a disciplined crypto market-research CLI: strategies, analytics, risk and AI-assisted research, in the style of Gemini CLI.", "type": "module", "license": "MIT",