diff --git a/README.md b/README.md index 2a9ef10..3842ad3 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,12 @@ interactive terminal UI. dependency-injected and testable. - **Works offline or live**: deterministic synthetic market data keeps CI and demos reproducible; Binance, CoinGecko and MEXC adapters can pull real crypto - market rates. + market rates, and a Frankfurter (ECB) adapter pulls live forex rates for the + Pocket Option binary-options venue. +- **Spot and binary options**: crypto exchanges bracket trades with a + take-profit and stop-loss; Pocket Option, a forex binary-options venue, has no + TP/SL — so its forecasts replace those levels with an **expiry time** (how long + 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. - **Dockerised**: `docker compose up` brings up PostgreSQL and the CLI. @@ -119,7 +124,7 @@ TRADEFAST_MARKET_SOURCE=synthetic TRADEFAST_DATA_DIR=:memory: node dist/index.js | `/serching-level [level]` | Set crawl depth/resolution: `normal` (fast, depth 2), `high` (deep, depth 4), or `max` (full graph, depth 8 with comment traversal). Opens a pop-up without argument. | | `/serching-platforms` | Toggle source groups on/off: economic calendars, news portals, crypto news, Reddit communities, crypto communities, exchange communities. Opens a multi-select pop-up. | | `/currency [symbol]` | Run a full forecast for a single symbol with news sentiment and price chart. | -| `/exchange` | Swap the asset being researched — opens a pop-up to select from trading symbols. | +| `/exchange [name]` | Switch the venue/data source: `binance`, `okx`, `bybit`, `mexc` (crypto spot) or `pocketoption` (forex binary options). Pocket Option swaps in forex majors and renders an expiry **Time** column instead of TP/SL. Opens a pop-up without an argument. | | `/ratings` | Show source credibility ratings. Subcommands: `correct`, `incorrect`, `loud-claim`, or a numeric grade (`/ratings "Хабр" -1`). | | `/clear-chat` | Clear the chat transcript and reset AI conversation history, restoring the welcome banner and tips. | | `/api` | Show the in-process GraphQL endpoint. | @@ -239,6 +244,13 @@ Backtest — forecast accuracy (TP before SL) Use it before trading a symbol: a negative expectancy or a profit factor below 1 means the forecasts have *not* held up on that instrument's recent history. +On a binary-options venue (Pocket Option) the same walk-forward harness settles +each trade differently: there is no TP/SL to hit, so a position is held for its +**expiry** (`EXPIRY_BARS` bars of the analysed timeframe) and then scored purely +on direction — a win if price closed beyond the entry the predicted way. Wins +pay the configured binary payout (~0.92R) and losses cost the full stake (−1R), +so the same Win % / expectancy / profit-factor rollup still applies. + --- ## Configuration @@ -249,11 +261,13 @@ All configuration is environment-driven (see `.env.example`): | ------------------------- | ----------------------------- | -------------------------------------------------------------- | | `DATABASE_URL` | _(unset → PGlite)_ | PostgreSQL connection string. Unset uses embedded PGlite. | | `TRADEFAST_DATA_DIR` | `.tradefast/pgdata` | PGlite data directory (`:memory:` for ephemeral). | -| `TRADEFAST_MARKET_SOURCE` | `resilient` | `resilient` \| `live` \| `binance` \| `coingecko` \| `mexc` \| `synthetic`. | +| `TRADEFAST_MARKET_SOURCE` | `resilient` | `resilient` \| `live` \| `binance` \| `coingecko` \| `mexc` \| `pocketoption` \| `synthetic`. | | `TRADEFAST_MARKET_API` | `https://api.binance.com` | Binance REST base URL. | | `TRADEFAST_COINGECKO_API` | `https://api.coingecko.com` | CoinGecko REST base URL. | | `TRADEFAST_MEXC_API` | `https://api.mexc.com` | MEXC REST base URL. | -| `TRADEFAST_SYMBOLS` | `BTCUSDT,ETHUSDT,SOLUSDT` | Comma-separated symbols to analyse. | +| `TRADEFAST_FRANKFURTER_API`| `https://api.frankfurter.dev` | Frankfurter (ECB) forex REST base URL, used by Pocket Option. | +| `TRADEFAST_EXCHANGE` | `bybit` | Venue: `binance` \| `okx` \| `bybit` \| `mexc` \| `pocketoption`. | +| `TRADEFAST_SYMBOLS` | `BTCUSDT,ETHUSDT,SOLUSDT` | Comma-separated symbols to analyse (defaults to forex majors like `EURUSD` when the exchange is `pocketoption`). | | `TRADEFAST_INTERVAL` | `1h` | Candle interval. | | `TRADEFAST_MODE` | `medium-term` | Initial operating mode (`long-term`, `medium-term`, `scalping`). | | `TRADEFAST_CANDLE_LIMIT` | `200` | Number of candles to fetch per symbol. | @@ -279,7 +293,12 @@ The market source falls back gracefully: `resilient` uses live Binance data and transparently switches to deterministic synthetic candles if the network is unreachable. `coingecko` uses `/api/v3/simple/price`; `mexc` uses `/api/v3/ticker/price` and shapes the fetched spot rate into a candle series for -the strategy engine. +the strategy engine. `pocketoption` pulls the live forex rate from Frankfurter +(`/v2/rate/EUR/USD` → `{ "rates": { "USD": 1.15186 } }`) and shapes that single +ECB reference quote into the same candle series, so the unchanged strategy +algorithms run on forex pairs. Selecting Pocket Option via `/exchange` (or +`TRADEFAST_EXCHANGE=pocketoption`) automatically switches the default symbol +universe to forex majors (`EURUSD`, `GBPUSD`, `USDJPY`, …). --- diff --git a/package.json b/package.json index 8d51c05..f22bb39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "Tradefast", - "version": "0.4.1", + "version": "0.5.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", diff --git a/src/app/tradefast.ts b/src/app/tradefast.ts index 056f90c..d75a95d 100644 --- a/src/app/tradefast.ts +++ b/src/app/tradefast.ts @@ -1,7 +1,8 @@ import { createDb, type DbHandle } from '../db/client.js'; import { TradefastStore } from '../db/store.js'; import type { AnalyticsRow } from '../db/store.js'; -import { loadConfig, type TradefastConfig } from '../config.js'; +import { loadConfig, defaultSymbolsForExchange, type TradefastConfig } from '../config.js'; +import { isBinaryOptions } from '../cli/exchanges.js'; import type { Candle } from '../domain/candle.js'; import { CollectionPipeline, type CollectOptions, type ProgressListener, type RunReport } from '../pipeline/collector.js'; import { @@ -86,6 +87,17 @@ export class Tradefast { * Called by the interactive CLI when the user runs /exchange at runtime. */ setExchange(name: string): void { + // When the user has not pinned a custom universe (their symbols still match + // the previous venue's defaults), follow the venue: switching to Pocket + // Option swaps crypto tickers for forex pairs and vice-versa. A customised + // list is left untouched. + const previousDefaults = defaultSymbolsForExchange(this.config.exchange); + const onDefaults = + this.config.symbols.length === previousDefaults.length && + this.config.symbols.every((s, i) => s === previousDefaults[i]); + if (onDefaults) { + (this.config as any).symbols = defaultSymbolsForExchange(name); + } (this.config as any).exchange = name; this.market = createResilientMarketSourceFor(name); this.pipeline = new CollectionPipeline(this.store, this.market); @@ -116,6 +128,7 @@ export class Tradefast { interval: this.config.interval, limit: this.config.candleLimit, accountBalance: this.config.accountBalance, + exchange: this.config.exchange, ...extra, }, onProgress, @@ -131,6 +144,7 @@ export class Tradefast { interval: this.config.interval, limit: this.config.candleLimit, accountBalance: this.config.accountBalance, + exchange: this.config.exchange, ...extra, }, onProgress, @@ -150,6 +164,7 @@ export class Tradefast { const interval = this.config.interval; const limit = this.config.candleLimit; const symbols = this.config.symbols; + const binary = isBinaryOptions(this.config.exchange); const results: BacktestResult[] = []; let step = 0; @@ -161,6 +176,7 @@ export class Tradefast { warmup: options.warmup, horizon: options.horizon, accountBalance: this.config.accountBalance, + binary, }), ); } @@ -177,6 +193,7 @@ export class Tradefast { interval: this.config.interval, limit: this.config.candleLimit, accountBalance: this.config.accountBalance, + exchange: this.config.exchange, ...extra, }, onProgress, diff --git a/src/cli/App.tsx b/src/cli/App.tsx index dbe011c..044b58e 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -9,7 +9,7 @@ import { COMMANDS, completeCommand, parseCommand, suggestCommands, type CommandS 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 { getExchange, exchangeNames, type ExchangeName } from './exchanges.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'; import { searchLevelNames, getSearchLevel, type SearchLevelName } from './search-level.js'; @@ -410,7 +410,10 @@ export function App({ app, version, apiUrl, promptOperatingMode }: AppProps): Re setExchangeSelectorOpen(false); void saveExchange(next.name as ExchangeName); app.setExchange(next.name); - push({ kind: 'text', text: `Exchange: ${next.label} (live data source updated)`, color: theme.colors.info }); + const venueNote = isBinaryOptions(next.name) + ? ` — binary options: forecasts show an expiry time instead of TP/SL (symbols: ${app.config.symbols.join(', ')})` + : ' (live data source updated)'; + push({ kind: 'text', text: `Exchange: ${next.label}${venueNote}`, color: theme.colors.info }); }, [app, push, theme], ); @@ -685,15 +688,11 @@ export function App({ app, version, apiUrl, promptOperatingMode }: AppProps): Re push({ kind: 'text', text: `Current exchange: ${getExchange(app.config.exchange).label}`, color: theme.colors.info }); return; } - const next = getExchange(args[0]); - if (next.name !== args[0].toLowerCase()) { + if (!isKnownExchange(args[0])) { push({ kind: 'error', text: `Unknown exchange "${args[0]}". Available: ${exchangeNames().join(', ')}` }); return; } - setExchange(next.name as ExchangeName); - void saveExchange(next.name as ExchangeName); - app.setExchange(next.name); - push({ kind: 'text', text: `Exchange: ${next.label} (live data source updated)`, color: theme.colors.info }); + applyExchange(getExchange(args[0]).name as ExchangeName); return; } if (name === 'operating-mode') { @@ -927,15 +926,10 @@ export function App({ app, version, apiUrl, promptOperatingMode }: AppProps): Re } case 'run_exchange': { const eName = (toolArgs.name as string) || ''; - if (eName) { + if (eName && isKnownExchange(eName)) { const next = getExchange(eName); - if (next.name === eName.toLowerCase()) { - setExchange(next.name as ExchangeName); - void saveExchange(next.name as ExchangeName); - app.setExchange(next.name); - push({ kind: 'text', text: `Exchange: ${next.label} (live data source updated)`, color: theme.colors.info }); - return `Exchange changed to ${next.label}.`; - } + applyExchange(next.name as ExchangeName); + return `Exchange changed to ${next.label}.`; } openExchangeSelector(); return 'Exchange selector opened — pick an exchange.'; @@ -1095,7 +1089,7 @@ export function App({ app, version, apiUrl, promptOperatingMode }: AppProps): Re setProgress(null); } }, - [apiUrl, app, openThemeSelector, openExchangeSelector, openIntervalSelector, openModeSelector, openCurrencySelector, applyMode, applyLevel, applyPlatforms, searchingLevel, enabledPlatforms, newsCrawlOptions, push, quit, setHistory, theme, version], + [apiUrl, app, openThemeSelector, openExchangeSelector, applyExchange, openIntervalSelector, openModeSelector, openCurrencySelector, applyMode, applyLevel, applyPlatforms, searchingLevel, enabledPlatforms, newsCrawlOptions, push, quit, setHistory, theme, version], ); const onSubmit = useCallback( diff --git a/src/cli/exchanges.ts b/src/cli/exchanges.ts index 3c44be3..1f61771 100644 --- a/src/cli/exchanges.ts +++ b/src/cli/exchanges.ts @@ -1,13 +1,28 @@ +/** + * The kind of trading venue an exchange represents. + * + * - `spot` venues (Binance, Bybit, …) settle a position at a price: trades are + * bracketed with a take-profit and a stop-loss. + * - `binary-options` venues (Pocket Option) have no take-profit or stop-loss at + * all — a trade is simply a directional bet that resolves after a fixed + * **expiry time**. For those venues the Trade Log shows an expiry *time* + * instead of TP/SL levels. + */ +export type ExchangeKind = 'spot' | 'binary-options'; + export interface ExchangeInfo { name: string; label: string; + /** Whether the venue settles on price (spot) or on a timed expiry (binary options). */ + kind: ExchangeKind; } const EXCHANGES = { - binance: { name: 'binance', label: 'Binance' }, - okx: { name: 'okx', label: 'OKX' }, - bybit: { name: 'bybit', label: 'Bybit' }, - mexc: { name: 'mexc', label: 'MEXC' }, + binance: { name: 'binance', label: 'Binance', kind: 'spot' }, + okx: { name: 'okx', label: 'OKX', kind: 'spot' }, + bybit: { name: 'bybit', label: 'Bybit', kind: 'spot' }, + mexc: { name: 'mexc', label: 'MEXC', kind: 'spot' }, + pocketoption: { name: 'pocketoption', label: 'Pocket Option', kind: 'binary-options' }, } satisfies Record; export type ExchangeName = keyof typeof EXCHANGES; @@ -17,6 +32,27 @@ export const DEFAULT_EXCHANGE: ExchangeName = 'bybit'; export const exchangeNames = (): ExchangeName[] => Object.keys(EXCHANGES) as ExchangeName[]; export function getExchange(name?: string): ExchangeInfo { - const normalized = (name ?? DEFAULT_EXCHANGE).toLowerCase(); - return EXCHANGES[normalized as ExchangeName] ?? EXCHANGES[DEFAULT_EXCHANGE]; + const normalized = normalizeExchange(name); + return EXCHANGES[normalized] ?? EXCHANGES[DEFAULT_EXCHANGE]; +} + +/** Normalise loose user input (`pocket-option`, `Pocket Option`, …) to a key. */ +function normalizeExchange(name?: string): ExchangeName { + const raw = (name ?? DEFAULT_EXCHANGE).toLowerCase().replace(/[\s_-]+/g, ''); + return raw as ExchangeName; +} + +/** True when `name` resolves to a known exchange (accepts loose spellings). */ +export function isKnownExchange(name?: string): boolean { + return normalizeExchange(name) in EXCHANGES; +} + +/** The venue kind for an exchange name, defaulting to `spot` for unknown names. */ +export function exchangeKind(name?: string): ExchangeKind { + return getExchange(name).kind; +} + +/** True when the venue resolves trades on a timed expiry rather than TP/SL. */ +export function isBinaryOptions(name?: string): boolean { + return exchangeKind(name) === 'binary-options'; } diff --git a/src/cli/trade-log.ts b/src/cli/trade-log.ts index b8c9b7c..177d0a0 100644 --- a/src/cli/trade-log.ts +++ b/src/cli/trade-log.ts @@ -1,3 +1,4 @@ +import { isBinaryOptions } from './exchanges.js'; import type { RunReport, SymbolReport } from '../pipeline/collector.js'; import { buildForecast } from '../strategies/forecast.js'; @@ -6,13 +7,20 @@ interface TradeLogRow { direction: 'long' | 'short' | ''; tp: number | null; sl: number | null; + expiryMinutes: number | null; entryPrice: number | null; assessment: string; } -export type TradeLogColumnKey = 'currency' | 'direction' | 'tp' | 'sl' | 'entryPrice' | 'assessment'; +export type TradeLogColumnKey = 'currency' | 'direction' | 'tp' | 'sl' | 'expiry' | 'entryPrice' | 'assessment'; -const columns: { key: TradeLogColumnKey; label: string }[] = [ +interface TradeLogColumn { + key: TradeLogColumnKey; + label: string; +} + +/** Spot venues bracket trades with TP/SL. */ +const SPOT_COLUMNS: TradeLogColumn[] = [ { key: 'currency', label: 'Currency' }, { key: 'direction', label: 'Dir' }, { key: 'tp', label: 'TP' }, @@ -21,6 +29,20 @@ const columns: { key: TradeLogColumnKey; label: string }[] = [ { key: 'assessment', label: 'AI' }, ]; +/** Binary-options venues (Pocket Option) have no TP/SL — only an expiry time. */ +const BINARY_COLUMNS: TradeLogColumn[] = [ + { key: 'currency', label: 'Currency' }, + { key: 'direction', label: 'Dir' }, + { key: 'expiry', label: 'Time' }, + { key: 'entryPrice', label: 'Price' }, + { key: 'assessment', label: 'AI' }, +]; + +/** Pick the column layout for a run based on its venue. */ +function columnsFor(report: RunReport): TradeLogColumn[] { + return isBinaryOptions(report.exchange) ? BINARY_COLUMNS : SPOT_COLUMNS; +} + export interface TradeLogCell { key: TradeLogColumnKey; text: string; @@ -37,13 +59,14 @@ function isFinitePrice(value: number | null | undefined): value is number { } /** Map the shared forecast (single source of truth) onto a trade-log row. */ -function buildTradeLogRow(symbol: SymbolReport): TradeLogRow { - const forecast = buildForecast(symbol.analysis); +function buildTradeLogRow(symbol: SymbolReport, interval: string): TradeLogRow { + const forecast = buildForecast(symbol.analysis, { interval }); return { currency: forecast.symbol, direction: forecast.direction, tp: forecast.tp, sl: forecast.sl, + expiryMinutes: forecast.expiryMinutes, entryPrice: forecast.entry, assessment: symbol.assessment, }; @@ -58,6 +81,22 @@ function formatPrice(value: number | null): string { return value.toFixed(12); } +/** Render a binary-options expiry duration (minutes) as a compact `2h` / `30m` / `2d`. */ +function formatDuration(minutes: number | null): string { + if (typeof minutes !== 'number' || !Number.isFinite(minutes) || minutes <= 0) return ''; + if (minutes < 60) return `${trimNumber(minutes)}m`; + if (minutes < 1440) { + const hours = minutes / 60; + return Number.isInteger(hours) ? `${hours}h` : `${trimNumber(minutes)}m`; + } + const days = minutes / 1440; + return Number.isInteger(days) ? `${days}d` : `${trimNumber(minutes / 60)}h`; +} + +function trimNumber(value: number): string { + return Number.isInteger(value) ? String(value) : value.toFixed(1); +} + const MAX_ASSESSMENT_WIDTH = 50; function truncate(text: string, max: number): string { @@ -65,24 +104,28 @@ function truncate(text: string, max: number): string { return text.slice(0, max - 1) + '…'; } -function displayRows(report: RunReport): Record[] { - const rows = report.symbols.map(buildTradeLogRow); - const formatted = rows.map((row) => ({ +type RowStrings = Record; + +function displayRows(report: RunReport): RowStrings[] { + const rows = report.symbols.map((symbol) => buildTradeLogRow(symbol, report.interval ?? '1h')); + const formatted = rows.map((row): RowStrings => ({ currency: row.currency, direction: row.direction, tp: formatPrice(row.tp), sl: formatPrice(row.sl), + expiry: formatDuration(row.expiryMinutes), entryPrice: formatPrice(row.entryPrice), assessment: truncate(row.assessment, MAX_ASSESSMENT_WIDTH), })); return formatted.length > 0 ? formatted - : [{ currency: '', direction: '', tp: '', sl: '', entryPrice: '', assessment: '' }]; + : [{ currency: '', direction: '', tp: '', sl: '', expiry: '', entryPrice: '', assessment: '' }]; } function rowCells( - row: Record, + columns: TradeLogColumn[], + row: RowStrings, widths: Record, ): TradeLogCell[] { return columns.map(({ key }) => ({ @@ -93,6 +136,7 @@ function rowCells( } function borderLine( + columns: TradeLogColumn[], widths: Record, chars: { left: string; join: string; right: string }, ): string { @@ -106,25 +150,27 @@ function cellsLine(cells: readonly TradeLogCell[]): string { /** Render structured table parts so Ink can color individual cells. */ export function renderTradeLogParts(report: RunReport): TradeLogRenderPart[] { + const columns = columnsFor(report); const rows = displayRows(report); const widths = Object.fromEntries( columns.map(({ key, label }) => [key, Math.max(label.length, ...rows.map((row) => row[key].length))]), ) as Record; const header = rowCells( - Object.fromEntries(columns.map(({ key, label }) => [key, label])) as Record, + columns, + Object.fromEntries(columns.map(({ key, label }) => [key, label])) as RowStrings, widths, ); - const top = borderLine(widths, { left: '╭', join: '┬', right: '╮' }); - const separator = borderLine(widths, { left: '├', join: '┼', right: '┤' }); - const bottom = borderLine(widths, { left: '╰', join: '┴', right: '╯' }); + const top = borderLine(columns, widths, { left: '╭', join: '┬', right: '╮' }); + const separator = borderLine(columns, widths, { left: '├', join: '┼', right: '┤' }); + const bottom = borderLine(columns, widths, { left: '╰', join: '┴', right: '╯' }); return [ { kind: 'title', text: 'Trade Log' }, { kind: 'border', text: top }, { kind: 'header', cells: header }, { kind: 'border', text: separator }, - ...rows.map((row): TradeLogRenderPart => ({ kind: 'row', cells: rowCells(row, widths) })), + ...rows.map((row): TradeLogRenderPart => ({ kind: 'row', cells: rowCells(columns, row, widths) })), { kind: 'border', text: bottom }, ]; } diff --git a/src/config.ts b/src/config.ts index b0fcb2a..912afc4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,7 +15,7 @@ export interface TradefastConfig { apiPort: number; } -const DEFAULT_SYMBOLS = [ +export const DEFAULT_SYMBOLS = [ 'BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'XRPUSDT', 'ADAUSDT', 'DOGEUSDT', 'BNBUSDT', 'AVAXUSDT', 'DOTUSDT', 'LINKUSDT', 'LTCUSDT', 'NEARUSDT', 'APTUSDT', 'ARBUSDT', 'SUIUSDT', @@ -25,6 +25,27 @@ const DEFAULT_SYMBOLS = [ 'FETUSDT', 'KASUSDT', ]; +/** + * Pocket Option is a forex binary-options venue, so it trades currency pairs + * (Frankfurter / ECB reference rates) rather than crypto tickers. These majors + * are used as defaults whenever Pocket Option is the selected exchange and the + * user has not pinned their own `TRADEFAST_SYMBOLS`. + */ +export const FOREX_DEFAULT_SYMBOLS = [ + 'EURUSD', 'GBPUSD', 'USDJPY', 'AUDUSD', 'USDCHF', + 'USDCAD', 'NZDUSD', 'EURGBP', 'EURJPY', 'GBPJPY', +]; + +/** True when `exchange` names the Pocket Option binary-options venue. */ +function isPocketOption(exchange?: string): boolean { + return (exchange ?? '').toLowerCase().replace(/[\s_-]+/g, '') === 'pocketoption'; +} + +/** The default symbol universe for an exchange: forex for Pocket Option, crypto otherwise. */ +export function defaultSymbolsForExchange(exchange?: string): string[] { + return isPocketOption(exchange) ? [...FOREX_DEFAULT_SYMBOLS] : [...DEFAULT_SYMBOLS]; +} + function envFlag(name: string, fallback: boolean): boolean { const value = process.env[name]; if (value == null) return fallback; @@ -37,8 +58,10 @@ export function loadConfig(overrides: Partial = {}): TradefastC .map((s) => s.trim().toUpperCase()) .filter(Boolean); + const exchange = overrides.exchange ?? process.env.TRADEFAST_EXCHANGE ?? 'bybit'; + return { - symbols: overrides.symbols ?? (symbols.length > 0 ? symbols : DEFAULT_SYMBOLS), + symbols: overrides.symbols ?? (symbols.length > 0 ? symbols : defaultSymbolsForExchange(exchange)), interval: overrides.interval ?? process.env.TRADEFAST_INTERVAL ?? '1h', candleLimit: overrides.candleLimit ?? Number(process.env.TRADEFAST_CANDLE_LIMIT ?? 200), accountBalance: overrides.accountBalance ?? Number(process.env.TRADEFAST_ACCOUNT_BALANCE ?? 10_000), diff --git a/src/pipeline/collector.ts b/src/pipeline/collector.ts index d6f26bd..19c838d 100644 --- a/src/pipeline/collector.ts +++ b/src/pipeline/collector.ts @@ -13,6 +13,8 @@ export interface CollectOptions { limit?: number; params?: StrategyParameters; accountBalance?: number; + /** The venue this run targets — drives whether the Trade Log shows TP/SL or an expiry time. */ + exchange?: string; /** When true, skip the extra AI validation API call (used when AI chat runs the command itself). */ skipAiValidation?: boolean; } @@ -46,6 +48,10 @@ export interface RunReport { searchResults: number; durationMs: number; validation: ValidationResult | null; + /** The analysed timeframe (e.g. `1h`) — used to render binary-options expiry times. */ + interval: string; + /** The venue this run targeted; `undefined`/spot venues show TP/SL, binary-options show an expiry time. */ + exchange?: string; } export type ProgressListener = (event: ProgressEvent) => void; @@ -169,7 +175,7 @@ export class CollectionPipeline { if (!options.skipAiValidation && hasAiKey && reports.length > 0) { emit({ phase: 'advise', message: 'Running AI validation across all symbols…' }); - const forecasts = reports.map((r) => buildForecast(r.analysis)); + const forecasts = reports.map((r) => buildForecast(r.analysis, { interval })); const newsConsensus = await this.store.getNewsConsensus(30); const allAnalyses = reports.map((r) => r.analysis); @@ -199,6 +205,15 @@ export class CollectionPipeline { await this.store.finishRun(runId, 'completed'); emit({ phase: 'done', message: `Run #${runId} completed for ${symbols.length} symbol(s)` }); - return { runId, kind, symbols: reports, searchResults: searchCount, durationMs: Date.now() - started, validation }; + return { + runId, + kind, + symbols: reports, + searchResults: searchCount, + durationMs: Date.now() - started, + validation, + interval, + exchange: options.exchange, + }; } } diff --git a/src/services/backtest.ts b/src/services/backtest.ts index e1b196d..49bf9d0 100644 --- a/src/services/backtest.ts +++ b/src/services/backtest.ts @@ -1,8 +1,16 @@ import type { Candle } from '../domain/candle.js'; import { DEFAULT_PARAMETERS, type StrategyParameters } from '../domain/signal.js'; -import { buildForecast } from '../strategies/forecast.js'; +import { buildForecast, EXPIRY_BARS } from '../strategies/forecast.js'; import { AnalyticsService } from './analytics.js'; +/** + * Payout per unit staked on a winning binary-options trade (Pocket Option-style, + * ~92%). A loss costs the full stake (−1). Used only by the binary-options + * backtest path, where a trade settles purely on its expiry instead of on a + * take-profit / stop-loss bracket. + */ +export const BINARY_PAYOUT = 0.92; + /** * Walk-forward backtester — the honest answer to "is the system accurate?". * @@ -27,6 +35,14 @@ export interface BacktestOptions { horizon?: number; params?: StrategyParameters; accountBalance?: number; + /** + * Settle trades as binary options — held for a fixed expiry then scored by + * direction — instead of bracketing them with a take-profit / stop-loss. Used + * for Pocket Option, which has no TP/SL, only an expiry time. + */ + binary?: boolean; + /** Bars a binary-options trade is held before it expires. Default {@link EXPIRY_BARS}. */ + expiryBars?: number; } export type TradeOutcome = 'tp' | 'sl' | 'timeout'; @@ -117,6 +133,41 @@ export function simulateTrade( }); } +/** + * Settle a binary-options trade. The position is held for exactly `expiryBars` + * bars and then scored purely on direction: a win if price closed beyond the + * entry in the predicted direction, a loss if it closed against it. There are no + * TP/SL levels — the outcome maps onto `tp` (win) / `sl` (loss) / `timeout` + * (price closed exactly at entry, a push) so it rolls up with the spot metrics. + * A win pays `payout` R, a loss costs the full 1R stake. + */ +export function simulateBinaryTrade( + candles: readonly Candle[], + entryIndex: number, + forecast: { direction: 'long' | 'short'; entry: number }, + expiryBars: number, + payout = BINARY_PAYOUT, +): BacktestTrade { + const { direction, entry } = forecast; + const exitIndex = Math.min(entryIndex + Math.max(1, expiryBars), candles.length - 1); + const exitPrice = candles[exitIndex].close; + const move = direction === 'long' ? exitPrice - entry : entry - exitPrice; + const outcome: TradeOutcome = move > 0 ? 'tp' : move < 0 ? 'sl' : 'timeout'; + return { + entryIndex, + direction, + entry, + // No bracket levels on a binary option — surface the entry as a placeholder. + tp: entry, + sl: entry, + outcome, + exitIndex, + exitPrice, + barsHeld: exitIndex - entryIndex, + rMultiple: outcome === 'tp' ? payout : outcome === 'sl' ? -1 : 0, + }; +} + function summarise(symbol: string, candleCount: number, trades: BacktestTrade[]): BacktestResult { let wins = 0; let losses = 0; @@ -164,6 +215,8 @@ export function backtestSymbol( const horizon = Math.max(1, options.horizon ?? 48); const params = options.params ?? DEFAULT_PARAMETERS; const accountBalance = options.accountBalance ?? 10_000; + const binary = options.binary === true; + const expiryBars = Math.max(1, options.expiryBars ?? EXPIRY_BARS); const analytics = new AnalyticsService(); const trades: BacktestTrade[] = []; @@ -171,9 +224,27 @@ export function backtestSymbol( while (i < candles.length - 1) { const known = candles.slice(0, i + 1); const forecast = buildForecast(analytics.analyze(known, symbol, params, accountBalance)); + const directional = forecast.direction === 'long' || forecast.direction === 'short'; + + if (binary) { + // Binary options need only a direction and an entry — they settle on time. + if (!directional || forecast.entry == null) { + i += 1; + continue; + } + const trade = simulateBinaryTrade( + candles, + i, + { direction: forecast.direction as 'long' | 'short', entry: forecast.entry as number }, + expiryBars, + ); + trades.push(trade); + i = trade.exitIndex + 1; + continue; + } const actionable = - (forecast.direction === 'long' || forecast.direction === 'short') && + directional && forecast.entry != null && forecast.tp != null && forecast.sl != null && diff --git a/src/services/market-data.ts b/src/services/market-data.ts index 21254fa..6023d29 100644 --- a/src/services/market-data.ts +++ b/src/services/market-data.ts @@ -223,6 +223,38 @@ export class OkxMarketData implements MarketDataSource { } } +/** + * Forex rates for the Pocket Option binary-options platform, sourced from the + * public Frankfurter API (https://frankfurter.dev). Frankfurter exposes ECB + * reference rates as a single spot quote per pair (no OHLCV), so — like the + * CoinGecko adapter — this source builds a short, deterministic candle path that + * lands exactly on the fetched rate. That keeps every strategy working unchanged + * while the symbols become forex pairs such as `EURUSD`. + * + * Pocket Option is a binary-options venue: there are no take-profit or + * stop-loss orders, only a directional bet with an expiry time. The price feed + * here is purely what the strategies analyse; the expiry is derived in + * {@link ../strategies/forecast.buildForecast}. + */ +export class FrankfurterMarketData implements MarketDataSource { + readonly name = 'pocketoption'; + + constructor(private readonly baseUrl = process.env.TRADEFAST_FRANKFURTER_API ?? 'https://api.frankfurter.dev') {} + + async getCandles(symbol: string, interval: string, limit: number): Promise { + const { base, quote } = toForexPair(symbol); + const url = `${this.baseUrl}/v2/rate/${base}/${quote}`; + const res = await fetch(url, { signal: AbortSignal.timeout(10_000) }); + if (!res.ok) throw new Error(`Frankfurter responded ${res.status}`); + const json = (await res.json()) as { rates?: Record }; + const rate = Number(json.rates?.[quote]); + if (!Number.isFinite(rate) || rate <= 0) { + throw new Error(`Frankfurter returned no ${base}/${quote} rate`); + } + return candlesFromSpot(symbol, interval, limit, rate); + } +} + /** * Deterministic synthetic candles for offline use and tests. The series is * reproducible from `symbol` so the same input always yields the same data — @@ -271,6 +303,22 @@ function baseAsset(symbol: string): string { return symbol.toUpperCase().replace(/(USDT|USDC|USD)$/u, ''); } +/** + * Splits a 6-letter forex pair such as `EURUSD` into its base and quote ISO + * codes (`EUR`, `USD`). Pocket Option trades currency pairs, so a crypto-style + * ticker like `BTCUSDT` is rejected with a clear message. + */ +export function toForexPair(symbol: string): { base: string; quote: string } { + const cleaned = symbol.toUpperCase().replace(/[^A-Z]/g, ''); + if (cleaned.length !== 6) { + throw new Error( + `Pocket Option expects a 6-letter forex pair like EURUSD, got "${symbol}". ` + + `Set TRADEFAST_SYMBOLS to forex pairs (EURUSD,GBPUSD,…).`, + ); + } + return { base: cleaned.slice(0, 3), quote: cleaned.slice(3, 6) }; +} + function coinGeckoId(symbol: string): string { const base = baseAsset(symbol); return COINGECKO_IDS[base] ?? base.toLowerCase(); @@ -367,13 +415,17 @@ export { MexcMarketData as MexcTickerMarketData }; * - `resilient` (default) → live with synthetic fallback. */ export function createMarketSource(): MarketDataSource { - switch ((process.env.TRADEFAST_MARKET_SOURCE ?? 'resilient').toLowerCase()) { + switch ((process.env.TRADEFAST_MARKET_SOURCE ?? 'resilient').toLowerCase().replace(/[\s_-]+/g, '')) { case 'synthetic': return new SyntheticMarketData(); case 'coingecko': return new CoinGeckoMarketData(); case 'mexc': return new MexcMarketData(); + case 'frankfurter': + case 'forex': + case 'pocketoption': + return new FrankfurterMarketData(); case 'binance': case 'live': return new BinanceMarketData(); @@ -399,14 +451,16 @@ export function withPriceValidation(source: MarketDataSource): MarketDataSource } /** - * Creates a live market data source for the given exchange (Binance, Bybit, OKX, MEXC). + * Creates a live market data source for the given exchange (Binance, Bybit, OKX, + * MEXC, or Pocket Option forex via Frankfurter). * Falls back to Binance if unknown. This is the preferred way when the user has * selected an exchange via /exchange or TRADEFAST_EXCHANGE. * * Prices are validated against realistic floors after every fetch. */ export function createMarketSourceFor(exchange?: ExchangeName | string): MarketDataSource { - const ex = (exchange ?? 'binance').toLowerCase(); + // Strip separators so loose spellings (pocket-option, Pocket_Option) still resolve. + const ex = (exchange ?? 'binance').toLowerCase().replace(/[\s_-]+/g, ''); const inner = (() => { switch (ex) { case 'bybit': @@ -415,6 +469,8 @@ export function createMarketSourceFor(exchange?: ExchangeName | string): MarketD return new OkxMarketData(); case 'mexc': return new MexcMarketData(); + case 'pocketoption': + return new FrankfurterMarketData(); case 'binance': default: return new BinanceMarketData(); diff --git a/src/strategies/forecast.ts b/src/strategies/forecast.ts index 8708dd0..be6b018 100644 --- a/src/strategies/forecast.ts +++ b/src/strategies/forecast.ts @@ -8,23 +8,61 @@ import type { SymbolAnalysis } from '../services/analytics.js'; */ export const REWARD_RISK_RATIO = 2; +/** + * How many candles of the analysed timeframe a binary-options position is held + * before it expires. Binary-options venues (Pocket Option) have no take-profit + * or stop-loss — a trade is a directional bet that settles purely on time — so + * the expiry is derived from the timeframe: it gives the predicted move roughly + * the same number of bars the {@link REWARD_RISK_RATIO}:1 target would need to + * play out on a spot venue. + */ +export const EXPIRY_BARS = REWARD_RISK_RATIO; + +/** Minutes in each supported analysis interval. */ +const INTERVAL_MINUTES: Record = { + '1m': 1, '5m': 5, '10m': 10, '15m': 15, '20m': 20, '30m': 30, '1h': 60, '4h': 240, '1d': 1440, +}; + +/** Resolve an interval string to its duration in minutes (defaults to 1h). */ +export function intervalMinutes(interval: string): number { + return INTERVAL_MINUTES[interval] ?? 60; +} + +/** Binary-options expiry (in minutes) for a given analysis timeframe. */ +export function expiryMinutesFor(interval: string): number { + return intervalMinutes(interval) * EXPIRY_BARS; +} + +/** Options that tune how a forecast is derived. */ +export interface ForecastOptions { + /** The analysed timeframe; drives the binary-options expiry time. */ + interval?: string; +} + /** * The concrete trade Tradefast suggests for a symbol: a direction, an entry, and - * the two bracket levels (take-profit and stop-loss). `direction` is the empty - * string when no risk-approved directional signal exists, in which case the - * bracket levels are null. + * — depending on the venue — both the bracket levels (take-profit and + * stop-loss) and a binary-options expiry time. `direction` is the empty string + * when no risk-approved directional signal exists, in which case the bracket + * levels and expiry are null. */ export interface Forecast { symbol: string; direction: 'long' | 'short' | ''; /** Suggested entry price — the latest close. */ entry: number | null; - /** Take-profit target (2R away from entry). */ + /** Take-profit target (2R away from entry). Used by spot venues. */ tp: number | null; - /** Stop-loss level (1R away from entry). */ + /** Stop-loss level (1R away from entry). Used by spot venues. */ sl: number | null; /** Distance from entry to the stop — the 1R risk unit. */ stopDistance: number | null; + /** + * Binary-options expiry, in minutes: how long to hold the directional bet + * before it settles. Populated whenever there is a directional signal — this + * is what Pocket Option uses *instead* of TP/SL. + */ + expiryMinutes: number | null; } function isFinitePrice(value: number | null | undefined): value is number { @@ -36,11 +74,13 @@ function isFinitePrice(value: number | null | undefined): value is number { * * It picks the strongest risk-approved directional signal, anchors the entry at * the latest price and brackets it with an ATR-derived stop (one stop distance - * away) and a {@link REWARD_RISK_RATIO}:1 take-profit. This is the single source - * of truth shared by the Trade Log table and the backtester, so what users see - * on screen is exactly what gets validated against history. + * away) and a {@link REWARD_RISK_RATIO}:1 take-profit. The same directional + * signal also yields a binary-options expiry time ({@link expiryMinutesFor}) for + * venues that settle on time instead of price. This is the single source of + * truth shared by the Trade Log table and the backtester, so what users see on + * screen is exactly what gets validated against history. */ -export function buildForecast(analysis: SymbolAnalysis): Forecast { +export function buildForecast(analysis: SymbolAnalysis, options: ForecastOptions = {}): Forecast { const entry = analysis.analytics.lastPrice; const candidates = analysis.evaluated .filter((item) => item.position && (item.signal.direction === 'long' || item.signal.direction === 'short')) @@ -54,6 +94,10 @@ export function buildForecast(analysis: SymbolAnalysis): Forecast { selected?.signal.direction === 'long' || selected?.signal.direction === 'short' ? selected.signal.direction : ''; const stopDistance = selected?.position?.stopDistance; + // A directional signal carries an expiry time regardless of whether a spot + // stop distance could be computed — binary options only need the direction. + const expiryMinutes = direction === '' ? null : expiryMinutesFor(options.interval ?? '1h'); + if (!isFinitePrice(entry) || !isFinitePrice(stopDistance)) { return { symbol: analysis.symbol, @@ -62,6 +106,7 @@ export function buildForecast(analysis: SymbolAnalysis): Forecast { sl: null, entry: isFinitePrice(entry) ? entry : null, stopDistance: null, + expiryMinutes, }; } @@ -73,6 +118,7 @@ export function buildForecast(analysis: SymbolAnalysis): Forecast { sl: entry + stopDistance, entry, stopDistance, + expiryMinutes, }; } @@ -83,5 +129,6 @@ export function buildForecast(analysis: SymbolAnalysis): Forecast { sl: entry - stopDistance, entry, stopDistance, + expiryMinutes, }; } diff --git a/tests/backend.test.ts b/tests/backend.test.ts index c267f46..0b9ff11 100644 --- a/tests/backend.test.ts +++ b/tests/backend.test.ts @@ -30,6 +30,8 @@ const facade: TradefastApiFacade = { symbols: [], searchResults: 0, durationMs: 1, + validation: null, + interval: '1h', }), update: async () => ({ runId: 3, @@ -37,6 +39,8 @@ const facade: TradefastApiFacade = { symbols: [], searchResults: 0, durationMs: 1, + validation: null, + interval: '1h', }), clear: async () => 1, }; diff --git a/tests/backtest.test.ts b/tests/backtest.test.ts index 774e793..6529b52 100644 --- a/tests/backtest.test.ts +++ b/tests/backtest.test.ts @@ -4,6 +4,8 @@ import type { Candle } from '../src/domain/candle.js'; import { aggregateBacktests, backtestSymbol, + BINARY_PAYOUT, + simulateBinaryTrade, simulateTrade, type BacktestResult, } from '../src/services/backtest.js'; @@ -93,6 +95,79 @@ describe('simulateTrade', () => { }); }); +describe('simulateBinaryTrade (Pocket Option expiry settlement)', () => { + it('wins when a long closes above entry after the expiry, paying the binary payout', () => { + // Held for 2 bars; the bar at the expiry index closes at 105 (> entry 100). + const candles = [bar(100, 101, 99, 100, 0), bar(100, 106, 99, 102, 1), bar(102, 107, 101, 105, 2)]; + const trade = simulateBinaryTrade(candles, 0, { direction: 'long', entry: 100 }, 2); + + expect(trade.outcome).toBe('tp'); + expect(trade.exitIndex).toBe(2); + expect(trade.exitPrice).toBe(105); + expect(trade.rMultiple).toBeCloseTo(BINARY_PAYOUT, 8); + }); + + it('loses the full stake when a long closes below entry at expiry', () => { + const candles = [bar(100, 101, 99, 100, 0), bar(100, 101, 96, 98, 1), bar(98, 99, 94, 96, 2)]; + const trade = simulateBinaryTrade(candles, 0, { direction: 'long', entry: 100 }, 2); + + expect(trade.outcome).toBe('sl'); + expect(trade.exitPrice).toBe(96); + expect(trade.rMultiple).toBeCloseTo(-1, 8); + }); + + it('scores a short by the mirror rule (win when price closes below entry)', () => { + const candles = [bar(100, 101, 99, 100, 0), bar(100, 101, 94, 96, 1), bar(96, 97, 92, 94, 2)]; + const trade = simulateBinaryTrade(candles, 0, { direction: 'short', entry: 100 }, 2); + + expect(trade.outcome).toBe('tp'); + expect(trade.exitPrice).toBe(94); + expect(trade.rMultiple).toBeCloseTo(BINARY_PAYOUT, 8); + }); + + it('treats a close exactly at entry as a refunded push (no profit, no loss)', () => { + const candles = [bar(100, 101, 99, 100, 0), bar(100, 101, 99, 100, 1), bar(100, 101, 99, 100, 2)]; + const trade = simulateBinaryTrade(candles, 0, { direction: 'long', entry: 100 }, 2); + + expect(trade.outcome).toBe('timeout'); + expect(trade.rMultiple).toBe(0); + }); + + it('clamps the expiry to the last available bar near the end of the series', () => { + const candles = [bar(100, 101, 99, 100, 0), bar(100, 106, 99, 104, 1)]; + const trade = simulateBinaryTrade(candles, 0, { direction: 'long', entry: 100 }, 5); + + expect(trade.exitIndex).toBe(1); + expect(trade.exitPrice).toBe(104); + expect(trade.outcome).toBe('tp'); + }); +}); + +describe('backtestSymbol — binary-options mode', () => { + it('settles every trade on its expiry, so there are no horizon-style timeouts', () => { + const upTrend = series(Array.from({ length: 200 }, (_, i) => 100 + i * 1.5)); + const result = backtestSymbol(upTrend, 'EURUSD', { warmup: 60, binary: true }); + + expect(result.trades).toBeGreaterThan(0); + // A clean uptrend resolves binary longs as wins, not pushes. + expect(result.wins).toBeGreaterThan(result.losses); + for (const trade of result.outcomes) { + // Each trade is held for exactly the expiry window. + expect(trade.barsHeld).toBeGreaterThan(0); + // Win rate book-keeping still adds up. + expect(['tp', 'sl', 'timeout']).toContain(trade.outcome); + } + expect(result.wins + result.losses + result.timeouts).toBe(result.trades); + }); + + it('reports a positive expectancy on a persistent uptrend with the binary payout', () => { + const upTrend = series(Array.from({ length: 200 }, (_, i) => 100 + i * 1.5)); + const result = backtestSymbol(upTrend, 'EURUSD', { warmup: 60, binary: true }); + + expect(result.expectancy).toBeGreaterThan(0); + }); +}); + describe('backtestSymbol', () => { it('returns a zero-trade result for a series too short to warm up', () => { const result = backtestSymbol(series([100, 101, 102, 103]), 'TEST', { warmup: 60 }); diff --git a/tests/cli.test.tsx b/tests/cli.test.tsx index cb802a4..60eeb6b 100644 --- a/tests/cli.test.tsx +++ b/tests/cli.test.tsx @@ -7,7 +7,7 @@ import { App } from '../src/cli/App.js'; import { completeCommand, parseCommand, suggestCommands } from '../src/cli/commands.js'; import { OutputLine } from '../src/cli/output.js'; import { getTheme, themeNames } from '../src/cli/theme.js'; -import { getExchange, exchangeNames, type ExchangeName } from '../src/cli/exchanges.js'; +import { getExchange, exchangeNames, isBinaryOptions, exchangeKind, type ExchangeName } from '../src/cli/exchanges.js'; import { getMode, modeNames } from '../src/cli/modes.js'; import { renderBacktestLines } from '../src/cli/backtest-log.js'; import { renderTradeLogLines } from '../src/cli/trade-log.js'; @@ -275,12 +275,23 @@ describe('cli themes', () => { }); describe('cli exchanges', () => { - it('lists Binance, OKX, Bybit, MEXC and defaults to bybit', () => { - expect(exchangeNames()).toEqual(['binance', 'okx', 'bybit', 'mexc']); + it('lists Binance, OKX, Bybit, MEXC, Pocket Option and defaults to bybit', () => { + expect(exchangeNames()).toEqual(['binance', 'okx', 'bybit', 'mexc', 'pocketoption']); expect(getExchange().name).toBe('bybit'); expect(getExchange('mexc').label).toBe('MEXC'); expect(getExchange('unknown').name).toBe('bybit'); }); + + it('classifies crypto venues as spot and Pocket Option as binary-options', () => { + expect(exchangeKind('bybit')).toBe('spot'); + expect(isBinaryOptions('bybit')).toBe(false); + expect(getExchange('pocketoption').label).toBe('Pocket Option'); + expect(exchangeKind('pocketoption')).toBe('binary-options'); + expect(isBinaryOptions('pocketoption')).toBe(true); + // Tolerates the spaced / hyphenated forms a user might type. + expect(isBinaryOptions('Pocket Option')).toBe(true); + expect(isBinaryOptions('pocket-option')).toBe(true); + }); }); describe('cli operating modes', () => { @@ -309,6 +320,7 @@ describe('run output', () => { searchResults: 3, durationMs: 2340, validation: null, + interval: '1h', symbols: [ { symbol: 'BTCUSDT', @@ -427,6 +439,25 @@ describe('run output', () => { expect(renderTradeLogLines(noActionReport)).toContain('│ BTCUSDT │ │ │ │ 100.00 │ Momentum favours longs │'); }); + + it('shows a binary-options expiry Time column instead of TP/SL for Pocket Option', () => { + const binaryReport: RunReport = { + ...report, + exchange: 'pocketoption', + symbols: [{ ...report.symbols[0], symbol: 'EURUSD', assessment: 'Momentum favours longs' }], + }; + + const lines = renderTradeLogLines(binaryReport); + const header = lines.find((l) => l.includes('Currency')) ?? ''; + // The binary header carries a Time column and drops the spot bracket columns. + expect(header).toContain('Time'); + expect(header).not.toMatch(/\bTP\b/); + expect(header).not.toMatch(/\bSL\b/); + // 1h analysis → 2 bars × 60 min = 120 min, rendered compactly as 2h. + const row = lines.find((l) => l.includes('long')) ?? ''; + expect(row).toContain('2h'); + expect(row).not.toContain('110.00'); + }); }); describe('backtest output', () => { diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..9ec1dde --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + DEFAULT_SYMBOLS, + defaultSymbolsForExchange, + FOREX_DEFAULT_SYMBOLS, + loadConfig, +} from '../src/config.js'; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('defaultSymbolsForExchange', () => { + it('returns forex pairs for Pocket Option (incl. spaced/hyphenated spellings)', () => { + expect(defaultSymbolsForExchange('pocketoption')).toEqual(FOREX_DEFAULT_SYMBOLS); + expect(defaultSymbolsForExchange('Pocket Option')).toEqual(FOREX_DEFAULT_SYMBOLS); + expect(defaultSymbolsForExchange('pocket-option')).toEqual(FOREX_DEFAULT_SYMBOLS); + }); + + it('returns crypto tickers for spot venues and when no exchange is given', () => { + expect(defaultSymbolsForExchange('bybit')).toEqual(DEFAULT_SYMBOLS); + expect(defaultSymbolsForExchange()).toEqual(DEFAULT_SYMBOLS); + }); + + it('hands back a fresh copy so callers cannot mutate the shared default list', () => { + const a = defaultSymbolsForExchange('pocketoption'); + a.push('XXXYYY'); + expect(defaultSymbolsForExchange('pocketoption')).toEqual(FOREX_DEFAULT_SYMBOLS); + }); +}); + +describe('loadConfig symbol defaults', () => { + it('defaults Pocket Option runs to forex pairs when TRADEFAST_SYMBOLS is unset', () => { + vi.stubEnv('TRADEFAST_SYMBOLS', ''); + expect(loadConfig({ exchange: 'pocketoption' }).symbols).toEqual(FOREX_DEFAULT_SYMBOLS); + }); + + it('keeps crypto defaults for spot venues', () => { + vi.stubEnv('TRADEFAST_SYMBOLS', ''); + expect(loadConfig({ exchange: 'bybit' }).symbols).toEqual(DEFAULT_SYMBOLS); + }); + + it('always honours an explicit TRADEFAST_SYMBOLS list over venue defaults', () => { + vi.stubEnv('TRADEFAST_SYMBOLS', 'AUDUSD, NZDUSD'); + expect(loadConfig({ exchange: 'pocketoption' }).symbols).toEqual(['AUDUSD', 'NZDUSD']); + }); +}); diff --git a/tests/forecast.test.ts b/tests/forecast.test.ts index c9eb18f..4503259 100644 --- a/tests/forecast.test.ts +++ b/tests/forecast.test.ts @@ -2,7 +2,13 @@ import { describe, expect, it } from 'vitest'; import type { Candle } from '../src/domain/candle.js'; import { AnalyticsService } from '../src/services/analytics.js'; -import { buildForecast, REWARD_RISK_RATIO } from '../src/strategies/forecast.js'; +import { + buildForecast, + EXPIRY_BARS, + expiryMinutesFor, + intervalMinutes, + REWARD_RISK_RATIO, +} from '../src/strategies/forecast.js'; /** Build a candle series from a list of close prices (tight, well-formed bars). */ const series = (closes: number[]): Candle[] => @@ -68,3 +74,30 @@ describe('buildForecast', () => { expect(forecast.symbol).toBe('XRPUSDT'); }); }); + +describe('binary-options expiry (Pocket Option)', () => { + it('derives an expiry of EXPIRY_BARS bars from the analysed timeframe', () => { + expect(expiryMinutesFor('1h')).toBe(intervalMinutes('1h') * EXPIRY_BARS); + expect(expiryMinutesFor('5m')).toBe(5 * EXPIRY_BARS); + expect(expiryMinutesFor('1d')).toBe(1440 * EXPIRY_BARS); + // Unknown intervals fall back to one hour. + expect(expiryMinutesFor('nonsense')).toBe(60 * EXPIRY_BARS); + }); + + it('populates the expiry time for a directional forecast using the given interval', () => { + const forecast = buildForecast(analytics.analyze(upTrend, 'EURUSD'), { interval: '5m' }); + expect(forecast.direction).toBe('long'); + expect(forecast.expiryMinutes).toBe(expiryMinutesFor('5m')); + }); + + it('defaults the expiry interval to 1h when none is supplied', () => { + const forecast = buildForecast(analytics.analyze(upTrend, 'EURUSD')); + expect(forecast.expiryMinutes).toBe(expiryMinutesFor('1h')); + }); + + it('leaves the expiry null when there is no directional signal', () => { + const forecast = buildForecast(analytics.analyze(series([100, 101, 102]), 'EURUSD'), { interval: '1h' }); + expect(forecast.direction).toBe(''); + expect(forecast.expiryMinutes).toBeNull(); + }); +}); diff --git a/tests/market-data.test.ts b/tests/market-data.test.ts index 2caa679..4fecf43 100644 --- a/tests/market-data.test.ts +++ b/tests/market-data.test.ts @@ -1,6 +1,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { CoinGeckoMarketData, createMarketSource, MexcTickerMarketData } from '../src/services/market-data.js'; +import { + CoinGeckoMarketData, + createMarketSource, + createMarketSourceFor, + FrankfurterMarketData, + MexcTickerMarketData, + toForexPair, +} from '../src/services/market-data.js'; const originalFetch = globalThis.fetch; @@ -55,3 +62,47 @@ describe('spot-price market data sources', () => { expect(createMarketSource()).toBeInstanceOf(MexcTickerMarketData); }); }); + +describe('Pocket Option forex market data (Frankfurter)', () => { + it('queries the Frankfurter rate endpoint and ends candles at the live rate', async () => { + const fetchMock = vi.fn(async () => + Response.json({ amount: 1.0, base: 'EUR', date: '2026-05-30', rates: { USD: 1.15186 } }), + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const candles = await new FrankfurterMarketData('https://api.example.test').getCandles('EURUSD', '1h', 20); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.example.test/v2/rate/EUR/USD', + expect.any(Object), + ); + expect(candles).toHaveLength(20); + expect(candles.at(-1)?.close).toBe(1.15186); + }); + + it('throws a helpful error when the response carries no rate for the pair', async () => { + const fetchMock = vi.fn(async () => Response.json({ amount: 1.0, base: 'EUR', date: '2026-05-30', rates: {} })); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect(new FrankfurterMarketData('https://api.example.test').getCandles('EURUSD', '1h', 20)).rejects.toThrow( + /EUR\/USD rate/, + ); + }); + + it('splits a 6-letter pair into base and quote and rejects non-forex tickers', () => { + expect(toForexPair('EURUSD')).toEqual({ base: 'EUR', quote: 'USD' }); + expect(toForexPair('gbpjpy')).toEqual({ base: 'GBP', quote: 'JPY' }); + expect(() => toForexPair('BTCUSDT')).toThrow(/6-letter forex pair/); + }); + + it('selects the Frankfurter source for Pocket Option (incl. spaced/hyphenated forms)', () => { + // createMarketSourceFor wraps the source in a validation decorator, so its + // venue is identified by `name` rather than the concrete class. + expect(createMarketSourceFor('pocketoption').name).toBe('pocketoption'); + expect(createMarketSourceFor('Pocket Option').name).toBe('pocketoption'); + expect(createMarketSourceFor('pocket-option').name).toBe('pocketoption'); + + vi.stubEnv('TRADEFAST_MARKET_SOURCE', 'pocketoption'); + expect(createMarketSource()).toBeInstanceOf(FrankfurterMarketData); + }); +});