Skip to content
Open
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
32 changes: 30 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ No signup. No credit card. No rate‑limited demo tier. Clone it, `npm install`,
- 🏦 **Macro dashboard** — live US Treasury yield curve, VIX, and major index/commodity proxies
- 💼 **Portfolio tracker** — log buy/sell transactions, track average cost, realized & unrealized P&L (persisted in SQLite)
- 📅 **Calendar** — economic events (Fed, ECB, CPI, NFP and more) with consensus forecast, previous reading and, for the major US/EU releases, the actual outcome; plus a per‑watchlist earnings calendar with click‑through history showing forecast vs. actual EPS for the last several quarters and the stock's next‑day price move
- 🤖 **AI assistant** (optional) — ask questions about the symbol you're looking at, powered by Claude, fully context‑aware of the terminal's current data
- 🤖 **AI assistant** (optional) — ask questions about the symbol you're looking at, powered by Claude, fully context‑aware of the terminal's current data — or run it fully local on an Ollama model, with a picker to switch backend and model on the fly
- ⚡ **Near real‑time updates** — quotes and indexes refresh every second with a subtle flash on change, so you always know what just moved
- ⌨️ **Keyboard shortcuts** everywhere — `⌘K` to search, `⌥1`–`⌥9` to add any widget

Expand Down Expand Up @@ -140,6 +140,26 @@ Two optional knobs for the assistant, both standard for the Anthropic SDK:
- `ANTHROPIC_BASE_URL` — point the assistant at an Anthropic-compatible endpoint (a self-hosted gateway, an EU-hosted relay, etc.) instead of api.anthropic.com.
- `ANTHROPIC_MODEL` — override the model sent in requests (default: `claude-opus-4-8`). Required when the endpoint behind `ANTHROPIC_BASE_URL` serves models under different ids.

### Optional: local models with Ollama

The assistant can also talk to a **local Ollama daemon** instead of Claude — no key, no data leaving your machine. The AI widget has a picker for both the backend and the model; Claude stays the default until you switch.

```bash
# 1. install Ollama and pull a model (https://ollama.com)
ollama pull qwen3:8b

# 2. point the API at the daemon (the default is http://127.0.0.1:11434)
export OLLAMA_BASE_URL=http://127.0.0.1:11434
npm run dev
```

Then open the AI widget and pick **Ollama (local)** and the model to use. The model list is read live from the daemon, so only the models you have pulled show up.

- `OLLAMA_BASE_URL` — where the Ollama daemon listens (default: `http://127.0.0.1:11434` with `npm run dev`; in Docker it already defaults to `http://host.docker.internal:11434`, i.e. the machine running Docker). To use a daemon on another machine, set `OLLAMA_BASE_URL=http://192.168.1.50:11434` — with Docker Compose, put that line in a `.env` file next to `docker-compose.yml` and `docker compose` picks it up automatically.
- `OLLAMA_MODEL` — preselect a model in the picker (default: the first model the daemon reports).

The backend is chosen per request by the widget, so you can switch between Claude and a local model without restarting anything. The browser never talks to Ollama directly — the API server does, and it never accepts a client-supplied URL.

### Security defaults

- The API binds to `127.0.0.1` and only accepts browser requests from `http://localhost:3000` by default — nothing else on your network can reach it out of the box.
Expand All @@ -157,6 +177,13 @@ docker compose up --build

Portfolio data persists in the `terminal-data` volume (SQLite, WAL mode). Ports are published on `127.0.0.1` only by default; see [Security defaults](#security-defaults) to expose it deliberately.

The `api` container reaches an Ollama running on your machine through `http://host.docker.internal:11434`, which is already its default, so a local model works out of the box. To point it at a daemon somewhere else, drop a `.env` file next to `docker-compose.yml` — `docker compose` reads it automatically:

```bash
# .env
OLLAMA_BASE_URL=http://192.168.1.50:11434
```

<br/>

## 🧱 Tech stack
Expand All @@ -167,7 +194,7 @@ Portfolio data persists in the `terminal-data` volume (SQLite, WAL mode). Ports
| Charts | `lightweight-charts` (candles/indicators) · D3 (heatmap treemap) · Recharts (yield curve) |
| Backend | Node.js · Express · TypeScript |
| Database | SQLite (`better-sqlite3`, WAL mode) |
| AI | Anthropic Claude (optional) |
| AI | Anthropic Claude (default) · Ollama local models (optional) |

<br/>

Expand All @@ -178,6 +205,7 @@ Portfolio data persists in the `terminal-data` volume (SQLite, WAL mode). Ports
│ └── src/
│ ├── providers/ # nasdaq, tradingview, yahoo, stooq, fred, econcalendar, coingecko, binance, news
│ ├── routes/ # market, portfolio, ai
│ ├── ai/ # assistant backends: anthropic (Claude), ollama (local)
│ ├── cache.ts # TTL cache with stale-while-revalidate fallback
│ └── db.ts # SQLite (better-sqlite3, WAL)
└── web/ # Next.js 15 + React 19 + Tailwind 4
Expand Down
10 changes: 10 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ services:
- "127.0.0.1:4000:4000"
environment:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
# Local Ollama (optional). Defaults to the Docker host, so an Ollama
# running on this machine is reachable from inside the container.
# Point it somewhere else by setting OLLAMA_BASE_URL in a .env file
# next to this compose file (docker compose reads it automatically),
# e.g. OLLAMA_BASE_URL=http://192.168.1.50:11434
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
- API_KEY=${API_KEY:-}
- API_HOST=0.0.0.0 # safe: still only published to 127.0.0.1 above
- WEB_ORIGIN=${WEB_ORIGIN:-http://localhost:3000}
Expand All @@ -18,6 +24,10 @@ services:
# the web service (see README > Security defaults) — otherwise a caller
# could forge X-Forwarded-For to dodge the /api/ai rate limit.
- TRUST_PROXY=${TRUST_PROXY:-}
extra_hosts:
# Makes "host.docker.internal" resolve on Linux too (Docker Desktop
# resolves it natively).
- "host.docker.internal:host-gateway"
volumes:
- terminal-data:/app/data
restart: unless-stopped
Expand Down
91 changes: 91 additions & 0 deletions server/src/ai/anthropic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import Anthropic from "@anthropic-ai/sdk";
import { AiUnavailableError, type AiProvider, type ChatRequest } from "./types.js";

const DEFAULT_MODEL = "claude-opus-4-8";

// Uses ANTHROPIC_API_KEY (or an `ant auth login` profile) from the environment.
let client: Anthropic | null = null;
function getClient(): Anthropic {
if (!client) client = new Anthropic();
return client;
}

// The SDK's model listing is reached through a structural type on purpose:
// this module must keep working if the SDK reshapes its client surface.
type ModelsApi = {
models?: { list?: () => Promise<{ data?: Array<{ id?: string }> }> };
};

function credentialsConfigured(): boolean {
return Boolean(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN);
}

let modelCache: { at: number; models: string[] } | null = null;
const MODEL_CACHE_MS = 5 * 60_000;

export const anthropicProvider: AiProvider = {
id: "anthropic",
label: "Claude (Anthropic)",
get defaultModel(): string {
return process.env.ANTHROPIC_MODEL || DEFAULT_MODEL;
},

async listModels(): Promise<string[]> {
const fallback = [this.defaultModel];
// Without credentials the SDK can't list anything — don't even try.
if (!credentialsConfigured()) return fallback;
const now = Date.now();
if (modelCache && now - modelCache.at < MODEL_CACHE_MS) return modelCache.models;
try {
const api = (getClient() as unknown as ModelsApi).models;
const page = await api?.list?.();
const ids = (page?.data ?? []).map((m) => m.id).filter((id): id is string => Boolean(id));
const models = ids.length ? ids : fallback;
modelCache = { at: now, models };
return models;
} catch {
// A gateway that doesn't implement /v1/models is fine: the default model
// is still usable, so never let the listing break the picker.
return modelCache?.models ?? fallback;
}
},

async unavailableReason(): Promise<string | null> {
if (credentialsConfigured()) return null;
// No key in the environment. The SDK may still resolve credentials some
// other way (an `ant auth login` profile, a keyless gateway), so this is
// reported as "not configured" rather than "broken" — the chat request
// itself is what ultimately decides.
return "no ANTHROPIC_API_KEY on the server — set one to use Claude";
},

async chat(req: ChatRequest): Promise<string> {
try {
const response = await getClient().messages.create({
model: req.model,
max_tokens: 16000,
thinking: { type: "adaptive" },
system: req.system,
messages: req.messages,
});
if (response.stop_reason === "refusal") {
return "The assistant declined to answer this request.";
}
return response.content
.filter((b) => b.type === "text")
.map((b) => ("text" in b ? b.text : ""))
.join("");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (/api[_ -]?key|authentication|unauthorized|401/i.test(msg)) {
throw new AiUnavailableError("AI assistant unavailable: set ANTHROPIC_API_KEY on the server.");
}
throw err;
}
},
};

/** Exposed for tests only; not part of the provider contract. */
export function resetAnthropicCache(): void {
modelCache = null;
}
50 changes: 50 additions & 0 deletions server/src/ai/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { anthropicProvider } from "./anthropic.js";
import { ollamaProvider } from "./ollama.js";
import type { AiProvider, AiProviderId, ProviderInfo } from "./types.js";

const PROVIDERS: Record<AiProviderId, AiProvider> = {
anthropic: anthropicProvider,
ollama: ollamaProvider,
};

/** Claude stays the default backend; Ollama is opt-in from the UI. */
export const DEFAULT_PROVIDER_ID: AiProviderId = "anthropic";

export function isProviderId(value: unknown): value is AiProviderId {
return value === "anthropic" || value === "ollama";
}

/**
* The backend a request should use: the client's pick, or Claude when the
* client didn't name one. null means "the client named something unknown".
*/
export function pickProvider(value: unknown): AiProviderId | null {
if (value === undefined || value === null || value === "") return DEFAULT_PROVIDER_ID;
return isProviderId(value) ? value : null;
}

export function getProvider(id: AiProviderId): AiProvider {
return PROVIDERS[id];
}

/** Backend catalogue for the picker: what exists, and what works right now. */
export async function describeProviders(): Promise<ProviderInfo[]> {
return Promise.all(
Object.values(PROVIDERS).map(async (provider) => {
let detail: string | undefined;
try {
const reason = await provider.unavailableReason();
if (reason) detail = reason;
} catch (err) {
detail = err instanceof Error ? err.message : String(err);
}
return {
id: provider.id,
label: provider.label,
defaultModel: provider.defaultModel,
available: detail === undefined,
...(detail ? { detail } : {}),
};
})
);
}
125 changes: 125 additions & 0 deletions server/src/ai/ollama.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { AiUnavailableError, type AiProvider, type ChatRequest } from "./types.js";

const DEFAULT_BASE_URL = "http://127.0.0.1:11434";
/** The tag list is a local round-trip: a short timeout is enough. */
const PROBE_TIMEOUT_MS = 2_000;
/** Local models can take a while to load into memory — be generous. */
const CHAT_TIMEOUT_MS = 300_000;
const TAGS_CACHE_MS = 15_000;

/** Where the Ollama daemon lives. Never taken from the client: the browser
* must not be able to make the server fetch an arbitrary URL. */
export function ollamaBaseUrl(): string {
return (process.env.OLLAMA_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, "");
}

type TagsModel = { name?: string; model?: string; capabilities?: string[] };
type TagsResponse = { models?: TagsModel[] };

// Ollama reports "completion" for audio and embedding models too, so those
// would show up as chat candidates and answer with noise. Keep the ones that
// can actually hold a conversation.
const NON_CHAT_MODEL = /whisper|tts|embed|clip|bert|minilm|mms[-:]|parler|melspectro/i;

let tagsCache: { at: number; models: string[] } | null = null;

async function fetchWithTimeout(url: string, init: RequestInit | undefined, timeoutMs: number): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}

/** Model ids of the chat-capable models the local daemon has installed. */
async function fetchTags(): Promise<string[]> {
const now = Date.now();
if (tagsCache && now - tagsCache.at < TAGS_CACHE_MS) return tagsCache.models;

let res: Response;
try {
res = await fetchWithTimeout(`${ollamaBaseUrl()}/api/tags`, undefined, PROBE_TIMEOUT_MS);
} catch {
throw new AiUnavailableError(
`Ollama is not reachable at ${ollamaBaseUrl()} — start it with \`ollama serve\`.`
);
}
if (!res.ok) {
throw new AiUnavailableError(`Ollama at ${ollamaBaseUrl()} answered ${res.status}.`);
}

const data = (await res.json()) as TagsResponse;
const models = (data.models ?? [])
// Older daemons don't report capabilities; only filter when they do.
.filter((m) => !m.capabilities || m.capabilities.includes("completion"))
.map((m) => m.name ?? m.model)
.filter((name): name is string => Boolean(name))
.filter((name) => !NON_CHAT_MODEL.test(name));
const unique = [...new Set(models)].sort();
tagsCache = { at: now, models: unique };
return unique;
}

export const ollamaProvider: AiProvider = {
id: "ollama",
label: "Ollama (local)",
get defaultModel(): string {
return process.env.OLLAMA_MODEL || "";
},

async listModels(): Promise<string[]> {
return fetchTags();
},

async unavailableReason(): Promise<string | null> {
try {
await fetchTags();
return null;
} catch (err) {
return err instanceof Error ? err.message : String(err);
}
},

async chat(req: ChatRequest): Promise<string> {
let res: Response;
try {
res = await fetchWithTimeout(
`${ollamaBaseUrl()}/api/chat`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: req.model,
messages: [{ role: "system", content: req.system }, ...req.messages],
stream: false,
}),
},
CHAT_TIMEOUT_MS
);
} catch {
throw new AiUnavailableError(
`Ollama is not reachable at ${ollamaBaseUrl()} — is the daemon running?`
);
}

if (!res.ok) {
const body = await res.text().catch(() => "");
if (res.status === 404) {
throw new AiUnavailableError(
`Ollama has no model "${req.model}" — pull it with \`ollama pull ${req.model}\`.`
);
}
throw new Error(`Ollama error ${res.status}${body ? `: ${body}` : ""}`);
}

const data = (await res.json()) as { message?: { content?: string } };
return data.message?.content ?? "";
},
};

/** Exposed for tests only; not part of the provider contract. */
export function resetOllamaCache(): void {
tagsCache = null;
}
Loading