Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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. |
Expand Down Expand Up @@ -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
Expand All @@ -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. |
Expand All @@ -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`, …).

---

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
19 changes: 18 additions & 1 deletion src/app/tradefast.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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;
Expand All @@ -161,6 +176,7 @@ export class Tradefast {
warmup: options.warmup,
horizon: options.horizon,
accountBalance: this.config.accountBalance,
binary,
}),
);
}
Expand All @@ -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,
Expand Down
28 changes: 11 additions & 17 deletions src/cli/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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],
);
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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.';
Expand Down Expand Up @@ -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(
Expand Down
48 changes: 42 additions & 6 deletions src/cli/exchanges.ts
Original file line number Diff line number Diff line change
@@ -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<string, ExchangeInfo>;

export type ExchangeName = keyof typeof EXCHANGES;
Expand All @@ -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';
}
Loading
Loading