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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ data/
.cert
.cert/
CLAUDE.md
brag-output/

# --- DATABASE ---
*.sqlite
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ ApexAlgo is a full-stack algorithmic trading platform for building, backtesting,

---

## Demo

One command to your first bot — 24 s, with sound.

https://github.com/user-attachments/assets/25d03926-1e88-4749-88d4-b97b14ff9c2b





<sub>Player not showing? [Download the MP4](docs/screenshots/demo.mp4?raw=true).</sub>

## Screenshots

**Chart Engine** — TradingView-grade candles with per-bot overlays: indicator lines, historical/live trade markers and the engine's raw buy/sell "thoughts", all toggled per algorithm.
Expand Down
2 changes: 1 addition & 1 deletion backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ async def lifespan(app: FastAPI):
enable_docs = os.getenv("ENABLE_DOCS", "0") == "1"
app = FastAPI(
title="ApexAlgo Engine API",
version="2.1.0",
version="2.1.1",
swagger_ui_init_oauth={"clientId": "test"},
lifespan=lifespan,
docs_url="/docs" if enable_docs else None,
Expand Down
41 changes: 38 additions & 3 deletions backend/routers/bots.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,22 @@ def _resolve_exchange(settings: dict, db: Session) -> str:
return key.exchange
return settings.get("data_exchange", "okx")


def _candle_exchange(settings: dict, key_exchange_by_name: dict) -> str:
"""The exchange this bot's candles (and therefore its signals) come from —
the same rule as engine startup: an API key that actually routes orders
wins, otherwise the configured data exchange."""
settings = settings or {}
if settings.get("api_execution") and settings.get("api_key_name"):
key_exchange = key_exchange_by_name.get(settings["api_key_name"])
if key_exchange:
return key_exchange
return settings.get("data_exchange") or "okx"


def _key_exchanges(db: Session) -> dict:
return {name: ex for name, ex in db.query(ExchangeKey.name, ExchangeKey.exchange).all()}

logger = logging.getLogger("apexalgo.bots")

router = APIRouter(
Expand All @@ -56,13 +72,22 @@ class BotResponse(BotBase):
id: int
is_active: bool
created_at: datetime
# Effective candle/signal exchange (key exchange when orders are routed,
# else data_exchange) — the chart uses it to scope overlays to a dataset
exchange: Optional[str] = None

class Config:
from_attributes = True

@router.get("/", response_model=List[BotResponse])
def get_all_bots(db: Session = Depends(get_db)):
return db.query(BotConfig).all()
key_exchanges = _key_exchanges(db)
out = []
for b in db.query(BotConfig).all():
item = BotResponse.model_validate(b)
item.exchange = _candle_exchange(b.settings, key_exchanges)
out.append(item)
return out


@router.get("/by-id/{bot_id}", response_model=BotResponse)
Expand Down Expand Up @@ -90,6 +115,7 @@ def get_bots_summary(db: Session = Depends(get_db)):
"""Lightweight bot list for polling — excludes full settings/node graph."""
bots = db.query(BotConfig).all()
sandbox_by_key = {k.name: bool(k.is_sandbox) for k in db.query(ExchangeKey.name, ExchangeKey.is_sandbox).all()}
key_exchanges = _key_exchanges(db)
return [
{
"id": b.id,
Expand All @@ -98,6 +124,9 @@ def get_bots_summary(db: Session = Depends(get_db)):
"is_sandbox": b.is_sandbox,
"created_at": b.created_at.isoformat() if b.created_at else None,
"execution_mode": _execution_mode(b.settings, sandbox_by_key),
# Where this bot's candles come from (key exchange when routing
# orders, else data_exchange) — chart-open and Data Vault key on it
"exchange": _candle_exchange(b.settings, key_exchanges),
"settings": {
"timeframe": b.settings.get("timeframe") if b.settings else None,
"symbols": b.settings.get("symbols", []) if b.settings else [],
Expand Down Expand Up @@ -129,12 +158,18 @@ def get_bots_summary(db: Session = Depends(get_db)):


@router.get("/signals")
def get_bot_signals(symbol: str, timeframe: str, limit: int = Query(default=5000, le=200000), since_id: int = Query(default=0, ge=0), db: Session = Depends(get_db)):
def get_bot_signals(symbol: str, timeframe: str, exchange: str = Query(default="okx"), limit: int = Query(default=5000, le=200000), since_id: int = Query(default=0, ge=0), db: Session = Depends(get_db)):
bot_rows = db.query(BotConfig.name, BotConfig.settings).all()
key_exchanges = _key_exchanges(db)
exchange = exchange.lower()

# Same pair + interval on another exchange is a different dataset: only
# bots that trade this exact (exchange, symbol, timeframe) belong on the chart
valid_bot_names = [
name for name, settings in bot_rows
if settings and settings.get('timeframe') == timeframe and (symbol in settings.get('symbols', []) or symbol == settings.get('symbol'))
if settings and settings.get('timeframe') == timeframe
and (symbol in settings.get('symbols', []) or symbol == settings.get('symbol'))
and _candle_exchange(settings, key_exchanges).lower() == exchange
]

if not valid_bot_names:
Expand Down
Binary file added docs/screenshots/demo.mp4
Binary file not shown.
4 changes: 2 additions & 2 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "2.1.0",
"version": "2.1.1",
"license": "AGPL-3.0-only",
"type": "module",
"scripts": {
Expand Down
64 changes: 49 additions & 15 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ export default function App() {
localStorage.setItem('apex_activeView', activeView);
}, [activeView]);

const openChartsRef = useRef(openCharts);
useEffect(() => {
openChartsRef.current = openCharts;
localStorage.setItem('apex_openCharts', JSON.stringify(openCharts));
}, [openCharts]);

Expand Down Expand Up @@ -181,6 +183,28 @@ export default function App() {
};
}, [refetchBots, hasApiKey]);

// Chart tabs live in localStorage, so they outlive their candles when data
// is deleted. Drop any tab whose (exchange, symbol, timeframe) no longer
// exists — on login and whenever the Data Vault deletes something.
useEffect(() => {
if (!hasApiKey) return;
let cancelled = false;
const pruneCharts = async () => {
let rows;
try { rows = (await apiClient.get('/api/data/summary')).data; } catch { return; }
if (cancelled || !Array.isArray(rows)) return;
const have = new Set(rows.map(r => `${(r.exchange || 'okx').toLowerCase()}_${r.symbol}_${r.timeframe}`));
const prev = openChartsRef.current;
const kept = prev.filter(c => have.has(c.id));
if (kept.length === prev.length) return;
setOpenCharts(kept);
setActiveView(v => (prev.some(c => c.id === v) && !kept.some(c => c.id === v)) ? 'home' : v);
};
pruneCharts();
window.addEventListener('data-changed', pruneCharts);
return () => { cancelled = true; window.removeEventListener('data-changed', pruneCharts); };
}, [hasApiKey]);

const handleOpenChart = (dataset) => {
// Exchange is part of the identity: the same pair can be open
// for two exchanges side by side
Expand All @@ -199,7 +223,7 @@ export default function App() {
: (bot.settings?.symbol ? [bot.settings.symbol] : []);

const timeframe = bot.settings?.timeframe || "15m";
const exchange = (bot.settings?.data_exchange || 'okx').toLowerCase();
const exchange = (bot.exchange || bot.settings?.data_exchange || 'okx').toLowerCase();
let updatedCharts = [...openCharts];
let lastOpenedChartId = "";

Expand Down Expand Up @@ -284,16 +308,27 @@ export default function App() {
settings: 'Exchange Configuration',
};

const isChartView = openCharts.some(c => c.id === activeView);
// Home keeps its hero on desktop; with the sidebar collapsed (mobile) every
// non-chart view gets the same slim top bar so the menu button has a home.
const headerTitle = HEADER_TITLES[activeView] || (!sidebarOpen && !isChartView ? 'Dashboard' : null);
const menuButton = (
<button
aria-label="Open sidebar"
className="w-8 h-8 flex items-center justify-center rounded-md border border-border bg-raised text-muted hover:text-accent hover:border-accent transition-colors shrink-0"
onClick={() => setSidebarOpenUser(true)}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /></svg>
</button>
);

return (
<div className="flex h-[100dvh] bg-bg text-text font-sans overflow-hidden relative">

<button
aria-label="Open sidebar"
className={`fixed top-3 left-4 z-[90] p-2 bg-raised border border-border hover:border-accent rounded-md shadow-card text-muted hover:text-accent transition-all duration-300 ${sidebarOpen ? 'opacity-0 pointer-events-none -translate-x-10' : 'opacity-100 translate-x-0'}`}
onClick={() => setSidebarOpenUser(true)}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /></svg>
</button>
{/* Charts have their own toolbar, so the menu button floats there. */}
{!sidebarOpen && isChartView && (
<div className="fixed top-3 left-3 z-[90] shadow-card fade-in">{menuButton}</div>
)}

{sidebarOpen && (
<div className="fixed inset-0 backdrop z-[70] md:hidden fade-in" onClick={() => setSidebarOpen(false)}></div>
Expand All @@ -314,14 +349,13 @@ export default function App() {

<div className={`flex-1 flex flex-col h-full overflow-hidden relative transition-all duration-300 ease-in-out ${sidebarOpen ? 'md:ml-64' : 'ml-0'}`}>

{HEADER_TITLES[activeView] && (
<header className="h-12 bg-raised border-b border-border flex items-center justify-between px-4 md:px-6 shrink-0 relative">
{headerTitle && (
<header className="h-11 md:h-12 bg-raised border-b border-border flex items-center gap-3 px-3 md:px-6 shrink-0 relative">
<div className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-accent/20 to-transparent" />
<div className={`transition-all duration-300 ${!sidebarOpen ? 'ml-12' : 'ml-0'}`}>
<h2 className="text-xs md:text-sm font-semibold text-text tracking-[0.15em] uppercase">
{HEADER_TITLES[activeView]}
</h2>
</div>
{!sidebarOpen && menuButton}
<h2 className="text-xs md:text-sm font-semibold text-text tracking-[0.15em] uppercase truncate">
{headerTitle}
</h2>
</header>
)}

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/BotManagerUI.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -662,7 +662,7 @@ export default function BotManagerUI({ bots = [], refetchBots, backendOk = true
: 'Manage, configure, and deploy automated strategies'}
accentColor="neutral"
action={
<div className="flex items-center gap-2.5">
<div className="flex flex-wrap items-center gap-2.5">
{bots.length > 1 && (
<div className="flex items-center rounded-md border border-border overflow-hidden">
<Button variant="ghost" size="md" icon={IconPlay} loading={bulkBusy === 'start'} disabled={!!bulkBusy || runningCount === bots.length} onClick={startAll} title="Start every stopped bot — they backfill in parallel, no waiting">
Expand Down
26 changes: 23 additions & 3 deletions frontend/src/components/ChartEngine.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ function ChartEngine({ dataset, openDataVault }) {
const [themeTick, setThemeTick] = useState(0); // bumps on apex-theme-changed -> chart re-init with new tokens
const [showMenu, setShowMenu] = useState(false);
const [expandedMenuBot, setExpandedMenuBot] = useState(null);
// Bots on this pair + interval whose candles come from another exchange —
// listed in the menu so "why isn't my bot here?" answers itself
const [otherExchangeBots, setOtherExchangeBots] = useState([]);
const [botConfigs, setBotConfigs] = useState({});

const getSnappedTime = useCallback((rawTime) => {
Expand Down Expand Up @@ -150,10 +153,17 @@ function ChartEngine({ dataset, openDataVault }) {
try {
const botRes = await apiClient.get('/api/bots/');

const validBots = botRes.data.filter(b => {
// A bot only belongs on this chart when exchange, pair AND interval all
// match — its candles (and therefore its signals) come from that exact dataset.
const chartExchange = (dataset.exchange || 'okx').toLowerCase();
const botExchangeOf = (b) => (b.exchange || b.settings?.data_exchange || 'okx').toLowerCase();
const samePairAndInterval = botRes.data.filter(b => {
const hasSymbol = (b.settings?.symbols && b.settings.symbols.includes(dataset.symbol)) || b.settings?.symbol === dataset.symbol;
return hasSymbol && b.settings?.timeframe === dataset.timeframe;
});
const validBots = samePairAndInterval.filter(b => botExchangeOf(b) === chartExchange);
setOtherExchangeBots(samePairAndInterval.filter(b => botExchangeOf(b) !== chartExchange)
.map(b => ({ name: b.name, exchange: botExchangeOf(b) })));

setBotConfigs(prev => {
const newConfigs = { ...prev };
Expand Down Expand Up @@ -196,7 +206,7 @@ function ChartEngine({ dataset, openDataVault }) {
const pollData = async (signal) => {
try {
const safeSymbol = dataset.symbol.replace('/', '-');
const sigParams = { symbol: dataset.symbol, timeframe: dataset.timeframe, limit: 200000 };
const sigParams = { symbol: dataset.symbol, timeframe: dataset.timeframe, exchange: dataset.exchange || 'okx', limit: 200000 };
if (lastSignalIdRef.current > 0) sigParams.since_id = lastSignalIdRef.current;
const cur = tradeCursorRef.current;
const incremental = cur.since !== null;
Expand Down Expand Up @@ -679,7 +689,7 @@ function ChartEngine({ dataset, openDataVault }) {
{showMenu && (
<div className="absolute top-12 right-0 w-[calc(100vw-2rem)] sm:w-80 max-h-[70vh] overflow-y-auto custom-scrollbar bg-overlay/95 backdrop-blur-xl border border-border rounded-lg shadow-pop py-2 z-50">
<div className="px-4 py-3 text-xs font-bold text-muted uppercase border-b border-border mb-1">Algorithm Overlay</div>
{Object.keys(botConfigs).length === 0 ? (
{Object.keys(botConfigs).length === 0 && otherExchangeBots.length === 0 ? (
<div className="px-4 py-3 text-xs text-muted">No algorithms active on this chart.</div>
) : (
Object.keys(botConfigs).map(botName => {
Expand Down Expand Up @@ -718,6 +728,16 @@ function ChartEngine({ dataset, openDataVault }) {
);
})
)}
{otherExchangeBots.length > 0 && (
<div className="px-4 py-3 border-t border-border/50 space-y-1">
<div className="text-3xs md:text-2xs font-bold text-muted uppercase tracking-wider">Same pair, other exchange</div>
{otherExchangeBots.map(b => (
<div key={b.name} className="text-xs text-muted">
<span className="text-text-secondary">{b.name}</span> runs on {b.exchange.toUpperCase()} — open {dataset.symbol} on {b.exchange.toUpperCase()} to see its overlays.
</div>
))}
</div>
)}
</div>
)}
</div>
Expand Down
16 changes: 9 additions & 7 deletions frontend/src/components/DataManager.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ export default function DataManager({ openChart }) {
const keys = new Set();
botsRes.data.filter(b => b.is_active).forEach(b => {
const s = b.settings || {};
const ex = (s.data_exchange || 'okx').toLowerCase();
const ex = (b.exchange || s.data_exchange || 'okx').toLowerCase();
const tf = s.timeframe;
const syms = s.symbols?.length ? s.symbols : (s.symbol ? [s.symbol] : []);
syms.forEach(sym => keys.add(`${ex}|${sym}|${tf}`));
Expand Down Expand Up @@ -196,10 +196,10 @@ export default function DataManager({ openChart }) {
setSyncingSymbol(null);
};

const executeDelete = async (delSymbol, delTimeframe, beforeDateStr) => {
const executeDelete = async (delSymbol, delTimeframe, beforeDateStr, delExchange) => {
setLoading(true);
try {
let endpoint = `/api/data?symbol=${encodeURIComponent(delSymbol)}&timeframe=${delTimeframe}`;
let endpoint = `/api/data?symbol=${encodeURIComponent(delSymbol)}&timeframe=${delTimeframe}&exchange=${encodeURIComponent(delExchange || 'okx')}`;
if (beforeDateStr && beforeDateStr.trim() !== "") {
const isoDate = new Date(beforeDateStr).toISOString();
endpoint += `&before_date=${isoDate}`;
Expand All @@ -209,6 +209,7 @@ export default function DataManager({ openChart }) {
setPruneModalConfig(null);
toast.success(res.data.message);
fetchSummary();
window.dispatchEvent(new CustomEvent('data-changed'));
} catch (err) {
toast.error(humanizeApiError(err, 'Failed to delete candle data.'));
setPruneModalConfig(null);
Expand All @@ -222,7 +223,7 @@ export default function DataManager({ openChart }) {
return;
}
setPruneDate('');
setPruneModalConfig({ symbol: row.symbol, timeframe: row.timeframe });
setPruneModalConfig({ symbol: row.symbol, timeframe: row.timeframe, exchange: row.exchange || 'okx' });
};

const uniqueSymbols = useMemo(() => [...new Set(summary.map(r => r.symbol))], [summary]);
Expand Down Expand Up @@ -263,9 +264,10 @@ export default function DataManager({ openChart }) {
setLoading(true);
try {
await Promise.all(deletable.map(row =>
apiClient.delete(`/api/data?symbol=${encodeURIComponent(row.symbol)}&timeframe=${row.timeframe}`)
apiClient.delete(`/api/data?symbol=${encodeURIComponent(row.symbol)}&timeframe=${row.timeframe}&exchange=${encodeURIComponent(row.exchange || 'okx')}`)
));
fetchSummary();
window.dispatchEvent(new CustomEvent('data-changed'));
toast.success('Successfully deleted all data matching your filters.');
} catch {
toast.error('Failed to delete some data.');
Expand Down Expand Up @@ -372,7 +374,7 @@ export default function DataManager({ openChart }) {
variant="danger"
size="sm"
loading={loading}
onClick={() => executeDelete(pruneModalConfig.symbol, pruneModalConfig.timeframe, '')}
onClick={() => executeDelete(pruneModalConfig.symbol, pruneModalConfig.timeframe, '', pruneModalConfig.exchange)}
>
Delete All
</Button>
Expand All @@ -382,7 +384,7 @@ export default function DataManager({ openChart }) {
loading={loading}
disabled={!pruneDate}
className="!bg-danger !text-danger-ink hover:!bg-danger-hover"
onClick={() => executeDelete(pruneModalConfig.symbol, pruneModalConfig.timeframe, pruneDate)}
onClick={() => executeDelete(pruneModalConfig.symbol, pruneModalConfig.timeframe, pruneDate, pruneModalConfig.exchange)}
>
Prune Date
</Button>
Expand Down
Loading
Loading