TypeScript SDK
Das offizielle TypeScript-SDK @sharp-api/client bietet typisierten Zugriff auf jeden SharpAPI-Endpoint — mit SSE-Streaming, Zod-Validierung und vollständiger IDE-Autovervollständigung von Anfang an.
Installieren Sie das offizielle SDK: npm install @sharp-api/client — GitHub
SDK-Schnellstart
import { SharpAPI } from '@sharp-api/client'
const api = new SharpAPI('sk_live_...')
// Quoten abrufen
const { data: odds } = await api.odds.get({ league: 'nba' })
// +EV-Gelegenheiten abrufen (Pro+)
const { data: ev } = await api.ev.get({ min_ev: 3 })
// Arbitrage-Gelegenheiten abrufen (Hobby+)
const { data: arbs } = await api.arbitrage.get({ min_profit: 1 })
// Middles abrufen (Pro+)
const { data: middles } = await api.middles.get({ league: 'nba' })
// SSE-Streaming (WebSocket-Add-on)
const stream = api.stream.odds({ league: 'nba' })
stream.on('update', ({ data }) => console.log(data))
stream.connect()
// WebSocket-Streaming (~100ms Latenz)
const ws = api.stream.oddsWs({ sportsbook: ['draftkings'] })
ws.on('odds:update', ({ data, source }) => console.log(source, data))
ws.connect()REST API
Verwenden Sie fetch, um beliebige REST-Endpoints aufzurufen:
const API_URL = 'https://api.sharpapi.io/api/v1';
const API_KEY = 'YOUR_API_KEY';
async function sharpApi<T>(path: string, params?: Record<string, string>): Promise<T> {
const url = new URL(`${API_URL}${path}`);
if (params) {
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
}
const res = await fetch(url, { headers: { 'X-API-Key': API_KEY } });
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
return res.json();
}
// Beispiele
const odds = await sharpApi('/odds', { sport: 'basketball', league: 'nba' });
const ev = await sharpApi('/opportunities/ev', { min_ev: '3.0' });
const arbs = await sharpApi('/opportunities/arbitrage', { min_profit: '1.0' });SSE-Streaming
SSE-Streaming liefert Echtzeit-Quotenupdates und Benachrichtigungen über Gelegenheiten. Dieser Abschnitt beschreibt, wie Sie einen korrekten Client erstellen.
Wichtig: odds:update-Ereignisse sind Deltas — sie enthalten nur Quoten, die sich geändert haben. Ihr Client muss einen lokalen Zustand pflegen und Updates darin zusammenführen. Jedes Ereignis als vollständigen Snapshot zu behandeln, ist die häufigste Ursache für falsche Daten.
Vollständiger TypeScript-Client
const API_URL = 'https://api.sharpapi.io/api/v1';
const API_KEY = 'YOUR_API_KEY';
// ─── Typen ────────────────────────────────────────────────────────────────
interface OddsLine {
id: string;
sportsbook: string;
event_id: string;
sport: string;
league: string;
home_team: string;
away_team: string;
market_type: string;
selection: string;
selection_type: string;
odds_american: number;
odds_decimal: number;
odds_probability: number;
line?: number;
event_start_time: string;
is_live: boolean;
timestamp: string;
player_name?: string; // Nur bei Spieler-Prop-Märkten
stat_category?: string; // Nur bei Spieler-Prop-Märkten
}
// Eine Zeile in `odds:update`: `id` plus die Felder, die sich ändern können
// (odds_american, odds_decimal, odds_probability, line, is_live, timestamp, ...).
// Eine Zeile für eine ID, die Sie noch nicht halten, kommt vollständig mit allen OddsLine-Feldern.
type OddsDelta = Pick<OddsLine, 'id'> & Partial<OddsLine>;
interface EVOpportunity {
id: string;
ev_percentage: number;
odds_american: number;
odds_decimal: number;
selection: string;
market: string;
sportsbook: string;
game: string;
sport: string;
league: string;
is_live: boolean;
confidence_score: number;
kelly_percent: number | null;
possibly_stale: boolean;
oldest_odds_age_seconds: number | null;
warnings: string[];
detected_at: string;
}
interface ArbOpportunity {
id: string;
event_name: string;
sport: string;
market_type: string;
profit_percent: number;
possibly_stale: boolean;
oldest_odds_age_seconds: number | null;
warnings: string[];
legs: Array<{
sportsbook: string;
selection: string;
odds_american: number;
odds_decimal: number;
stake_percent: number;
}>;
detected_at: string;
}
interface LowHoldOpportunity {
id: string;
event_id: string;
event_name: string;
sport: string;
league: string;
market_type: string;
line: number | null;
hold_percentage: number;
side1: LowHoldSide;
side2: LowHoldSide;
side3: LowHoldSide | null; // 3-Wege-Märkte (Fußball, Eishockey)
is_live: boolean;
is_alternate_line: boolean;
all_books: string[];
confidence: number;
odds_age_seconds: number;
possibly_stale: boolean;
detected_at: string;
}
interface LowHoldSide {
selection: string;
books: string[];
line: number | null;
odds: {
american: number;
decimal: number;
implied_probability: number;
fair_probability: number;
};
deep_links: Record<string, string>;
}
// Payload von ev:detected / arb:detected / middles:detected / low_hold:detected.
// Ein Eintrag ist eine neue Gelegenheit ODER eine aktualisierte Version einer bereits gesendeten (gleiche `id`).
interface DetectedEnvelope<T> {
opportunities: T[];
count: number;
type: 'ev' | 'arbitrage' | 'middles' | 'low_hold';
}
// ─── Zustandsverwaltung ───────────────────────────────────────────────────
// Indiziert nach Quoten-Linien-ID (z. B. "draftkings_33483153_moneyline_PHO")
const oddsMap = new Map<string, OddsLine>();
const evMap = new Map<string, EVOpportunity>();
const arbMap = new Map<string, ArbOpportunity>();
const lowHoldMap = new Map<string, LowHoldOpportunity>();
let isReady = false;
let resuming = false; // gesetzt durch `connected` mit `resumed: true` — siehe Wiederverbindung unten
// ─── Verbindung herstellen ────────────────────────────────────────────────
const url = new URL(`${API_URL}/stream`);
url.searchParams.set('channel', 'all');
url.searchParams.set('league', 'nba');
url.searchParams.set('api_key', API_KEY);
const eventSource = new EventSource(url.toString());
// ─── Verbindungslebenszyklus ──────────────────────────────────────────────
function clearState() {
oddsMap.clear();
evMap.clear();
arbMap.clear();
lowHoldMap.clear();
}
eventSource.addEventListener('connected', (e) => {
const data = JSON.parse(e.data);
console.log(`Verbunden: Stream ${data.stream_id}`);
isReady = false;
// Lokalen Zustand nur behalten, wenn der Server den Stream fortgesetzt hat
// (`resumed: true`): Er spielt die verpassten Quoten-Ereignisse nach, statt einen
// Snapshot zu senden. Auf jede andere Verbindung folgt ein vollständiger Snapshot,
// also jetzt löschen. Nur der `odds`-Kanal wird fortgesetzt — auf diesem
// `channel=all`-Stream bekommt jede Wiederverbindung einen Snapshot.
// Nicht `reconnected` verwenden: Es ist auch bei einem Resume `true`.
resuming = data.resumed === true;
if (!resuming) clearState();
});
// ─── Initialer Snapshot (in Chunks) ───────────────────────────────────────
eventSource.addEventListener('snapshot', (e) => {
const data = JSON.parse(e.data);
// Ein Snapshot nach `resumed: true` bedeutet, dass der Server das Resume aufgegeben
// hat: Dieser Snapshot ersetzt Ihren Zustand (sein snapshot:complete meldet `full_resync`)
if (resuming) {
clearState();
resuming = false;
}
// Quoten-Chunks tragen ihre Zeilen unter `odds`; Chunks mit Gelegenheiten
// tragen stattdessen `ev` / `arbitrage` / `middles` / `low_hold`
for (const odds of (data.odds ?? []) as OddsLine[]) {
oddsMap.set(odds.id, odds);
}
// Snapshots der Gelegenheiten
if (data.ev) {
for (const opp of data.ev as EVOpportunity[]) {
evMap.set(opp.id, opp);
}
}
if (data.arbitrage) {
for (const arb of data.arbitrage as ArbOpportunity[]) {
arbMap.set(arb.id, arb);
}
}
if (data.low_hold) {
for (const lh of data.low_hold as LowHoldOpportunity[]) {
lowHoldMap.set(lh.id, lh);
}
}
});
eventSource.addEventListener('snapshot:complete', (e) => {
const { mode } = JSON.parse(e.data); // "resume", "full_resync" oder beim ersten Connect nicht vorhanden
// Ein Full Resync kann ganz ohne `snapshot`-Chunks ankommen (nichts passt mehr
// zu Ihren Filtern), dann lief das Löschen im `snapshot`-Handler nie. Die
// `resuming`-Prüfung verhindert, dass ein schon gespeicherter Snapshot gelöscht wird.
if (mode === 'full_resync' && resuming) clearState();
resuming = false;
isReady = true;
console.log(`Bereit: ${oddsMap.size} Quoten`);
console.log(`${evMap.size} EV-, ${arbMap.size} Arb-, ${lowHoldMap.size} Low-Hold-Gelegenheiten`);
});
// ─── Echtzeit-Quotenupdates (DELTAS — in lokalen Zustand einfügen) ────────
// Wire-Felder gemäß der Streaming-Ereignisreferenz: `odds:update` trägt
// { odds, count, book, partial }, `odds:removed` trägt { ids, count, book },
// und Quotenzeilen nutzen `odds_probability`.
// Die vollständige Ereignisreferenz steht unter /de/api-reference/stream/#oddsupdate.
eventSource.addEventListener('odds:update', (e) => {
const { odds } = JSON.parse(e.data) as { odds: OddsDelta[]; book: string; count: number; partial: boolean };
for (const delta of odds) {
const row = oddsMap.get(delta.id);
if (row) {
// Zusammenführen: Ein Delta enthält nur die Felder, die sich ändern können, daher
// behält die Zuweisung auf die gespeicherte Zeile sportsbook, selection, Teams usw.
Object.assign(row, delta);
} else if (delta.sportsbook !== undefined) {
// Eine Linie, die nach Ihrem Snapshot eröffnet wurde, kommt als vollständige Zeile — speichern
oddsMap.set(delta.id, delta as OddsLine);
}
// Ein kompaktes Delta für eine ID, die Sie nicht halten, hat nichts zum Zusammenführen — überspringen
}
});
// ─── Quoten entfernt (aus lokalem Zustand LÖSCHEN) ────────────────────────
eventSource.addEventListener('odds:removed', (e) => {
const { ids } = JSON.parse(e.data) as { book: string; ids: string[]; count: number };
for (const id of ids) {
oddsMap.delete(id);
}
});
// ─── Ereignisse zu Gelegenheiten ──────────────────────────────────────────
eventSource.addEventListener('ev:detected', (e) => {
const { opportunities } = JSON.parse(e.data) as DetectedEnvelope<EVOpportunity>;
for (const opp of opportunities) {
// Veraltete Gelegenheiten überspringen — und die unter dieser `id` gehaltene Version verwerfen
if (opp.possibly_stale) { evMap.delete(opp.id); continue; }
const isNew = !evMap.has(opp.id);
evMap.set(opp.id, opp); // Upsert — eine bekannte id ist ein Update (neuer Preis / EV%)
if (isNew) console.log(`+EV: ${opp.selection} ${opp.ev_percentage}% bei ${opp.sportsbook}`);
}
});
eventSource.addEventListener('ev:expired', (e) => {
const { expired } = JSON.parse(e.data) as { expired: string[] };
for (const id of expired) {
evMap.delete(id);
}
});
eventSource.addEventListener('arb:detected', (e) => {
const { opportunities } = JSON.parse(e.data) as DetectedEnvelope<ArbOpportunity>;
for (const arb of opportunities) {
if (arb.possibly_stale) { arbMap.delete(arb.id); continue; }
const isNew = !arbMap.has(arb.id);
arbMap.set(arb.id, arb); // Upsert — eine bekannte id ist ein Update (neue Leg-Quoten)
if (isNew) console.log(`Arb: ${arb.profit_percent}% — ${arb.event_name}`);
}
});
eventSource.addEventListener('arb:expired', (e) => {
const { expired } = JSON.parse(e.data) as { expired: string[] };
for (const id of expired) {
arbMap.delete(id);
}
});
eventSource.addEventListener('low_hold:detected', (e) => {
const { opportunities } = JSON.parse(e.data) as DetectedEnvelope<LowHoldOpportunity>;
for (const opp of opportunities) {
if (opp.possibly_stale) { lowHoldMap.delete(opp.id); continue; }
const isNew = !lowHoldMap.has(opp.id);
lowHoldMap.set(opp.id, opp); // Upsert — eine bekannte id ist ein Update (neue Quoten / Hold%)
if (isNew) console.log(`Low Hold: ${opp.hold_percentage}% — ${opp.event_name} (${opp.market_type})`);
}
});
eventSource.addEventListener('low_hold:expired', (e) => {
const { expired } = JSON.parse(e.data) as { expired: string[] };
for (const id of expired) {
lowHoldMap.delete(id);
}
});
// ─── Zustandsüberwachung ──────────────────────────────────────────────────
let lastHeartbeat = Date.now();
eventSource.addEventListener('heartbeat', () => {
lastHeartbeat = Date.now();
});
// Alle 60 Sekunden auf veraltete Verbindungen prüfen
setInterval(() => {
if (Date.now() - lastHeartbeat > 60_000) {
console.warn('Kein Heartbeat seit 60s — Wiederverbindung');
eventSource.close();
// EventSource neu erstellen (Browser verbindet automatisch erneut,
// aber explizites close + reconnect setzt den Zustand sauber zurück)
}
}, 60_000);
// ─── Fehlerbehandlung ─────────────────────────────────────────────────────
eventSource.addEventListener('error', (e) => {
const data = JSON.parse((e as MessageEvent).data);
console.warn(`Streamfehler: ${data.code} — ${data.message}`);
});
eventSource.onerror = () => {
console.log('Verbindung verloren, automatische Wiederverbindung...');
};Node.js mit dem eventsource-Paket
Für die serverseitige Nutzung installieren Sie das eventsource-Paket:
npm install eventsourceimport EventSource from 'eventsource';
const es = new EventSource(
'https://api.sharpapi.io/api/v1/stream?channel=all&league=nba',
{ headers: { 'X-API-Key': 'YOUR_KEY' } }
);
// Gleiche Event-Handler wie im Browser — siehe oben
es.addEventListener('snapshot', (e) => { /* ... */ });
es.addEventListener('odds:update', (e) => { /* ... */ });
// usw.Quotenformat
Alle Quotenwerte werden im amerikanischen Format als primäre Darstellung zurückgegeben, wobei Dezimalformat und implizite Wahrscheinlichkeit enthalten sind:
// Jede OddsLine enthält alle drei Formate:
{
odds_american: -110, // Amerikanische Quoten
odds_decimal: 1.909, // Dezimalquoten
odds_probability: 0.524 // Implizite Wahrscheinlichkeit (0-1)
}Falls Sie selbst zwischen Formaten konvertieren möchten:
function americanToDecimal(american: number): number {
return american > 0
? american / 100 + 1
: 100 / Math.abs(american) + 1;
}
function americanToProbability(american: number): number {
return american > 0
? 100 / (american + 100)
: Math.abs(american) / (Math.abs(american) + 100);
}Veraltungsmetadaten
EV-, Arbitrage- und Low-Hold-Gelegenheitsantworten enthalten Informationen zur Veraltung, die Ihnen helfen, Gelegenheiten basierend auf veralteten Quoten herauszufiltern:
interface EVOpportunity {
// ... weitere Felder ...
possibly_stale: boolean; // true, wenn zugrundeliegende Quoten möglicherweise veraltet sind
oldest_odds_age_seconds: number | null; // Alter der ältesten Quoten-Leg
warnings: string[]; // z. B. ["SINGLE_SHARP_REF", "LIVE_STALE_ODDS"]
}
// Veraltete Gelegenheiten herausfiltern
eventSource.addEventListener('ev:detected', (e) => {
const { opportunities } = JSON.parse(e.data) as { opportunities: EVOpportunity[] };
for (const opp of opportunities) {
if (opp.possibly_stale) {
console.log(`Veraltete EV überspringen: ${opp.id}`);
continue;
}
// Gültige Gelegenheit verarbeiten
}
});Die EV-Engine gibt genau drei Warnungen aus — LIVE_STALE_ODDS, SINGLE_SHARP_REF und SINGLE_SHARP_PERIOD; der Arbitrage-Endpunkt hat seine eigene Taxonomie, einschließlich POTENTIALLY_STALE_ODDS (siehe Arbitrage-Möglichkeiten).
Wiederverbindung
Wenn die Verbindung abbricht, verbindet sich EventSource selbstständig erneut und sendet die ID des zuletzt empfangenen Ereignisses im Last-Event-ID-Header. Auf dem odds-Kanal versucht der Server dann ein Resume: Er spielt die verpassten Quoten-Ereignisse nach, statt einen neuen Snapshot zu senden. Gelingt das nicht, sendet er einen vollständigen Resync: einen frischen Snapshot, der Ihren Zustand ersetzt. Welcher Fall eingetreten ist, meldet er in resumed im connected-Ereignis und in mode im snapshot:complete-Ereignis, das die Wiederverbindung abschließt:
| Sie empfangen | Was passiert ist | Was Ihr Client tut |
|---|---|---|
connected mit resumed: true, nachgespielte odds:update- / odds:removed-Ereignisse mit "replay": true, dann snapshot:complete mit mode: "resume" | Resume: Die verpassten Ereignisse wurden nachgespielt. Es wird kein Snapshot gesendet | Zustand behalten und die nachgespielten Ereignisse wie Live-Ereignisse einfügen |
connected mit resumed: false und einem fallback_reason, snapshot-Chunks, dann snapshot:complete mit mode: "full_resync" | Vollständiger Resync: Der Server konnte nicht fortsetzen | Zustand löschen, dann aus dem Snapshot neu aufbauen |
connected mit resumed: true, einige nachgespielte Ereignisse, dann snapshot-Chunks und snapshot:complete mit mode: "full_resync" | Der Server hat ein Resume begonnen, konnte es nicht abschließen und ist auf einen vollständigen Resync ausgewichen | Zustand löschen, sobald der erste snapshot-Chunk eintrifft — oder, wenn der Resync gar keine Chunks enthält, sobald snapshot:complete mode: "full_resync" meldet — dann aus dem Snapshot neu aufbauen |
connected ohne resumed-Feld, dann snapshot-Chunks | Eine erste Verbindung oder eine Wiederverbindung ohne Last-Event-ID | Alles Gehaltene löschen und aus dem Snapshot aufbauen |
Resume ist Best Effort, kein dauerhaftes Log. Es deckt nur den odds-Kanal ab. Eine gamestate- oder opportunities-Wiederverbindung sendet gar keine Last-Event-ID, startet also einfach mit einem frischen Snapshot neu, und ihr connected-Ereignis trägt weder resumed noch fallback_reason. Auf all setzen die Odds-Ereignisse die Event-ID, die Wiederverbindung legt sie daher vor und der Server lehnt sie mit resumed: false und fallback_reason: "channel_unsupported" ab. Außerdem deckt es nur ein kurzes Fenster jüngster Ereignisse ab, daher kann auch ein längerer Ausfall, eine Serverwartung oder eine Filteränderung in einem vollständigen Resync enden. Der resume-Deskriptor im connected-Ereignis nennt diese Grenzen bei jeder Verbindung, und der Zuverlässigkeitsvertrag listet jeden fallback_reason auf. Behandeln Sie den vollständigen Resync als normalen Recovery-Pfad.
Entscheiden Sie nicht anhand von reconnected. Es ist bei jeder Wiederverbindung mit Last-Event-ID true, auch bei einem erfolgreichen Resume, und wer darauf löscht, wirft den Zustand weg, in den die nachgespielten Ereignisse eingefügt werden.
// `resuming`, `isReady` und `clearState()` wie im Client oben
eventSource.addEventListener('connected', (e) => {
const { resumed, fallback_reason } = JSON.parse(e.data);
isReady = false;
resuming = resumed === true;
if (!resuming) {
// Ein vollständiger Snapshot folgt: erste Verbindung, keine Last-Event-ID oder resumed: false
if (fallback_reason) console.log(`Vollständiger Resync: ${fallback_reason}`);
clearState();
}
});
eventSource.addEventListener('snapshot', (e) => {
if (resuming) {
// Der Server hat das Resume aufgegeben: Dieser Snapshot ersetzt Ihren Zustand
clearState();
resuming = false;
}
// ...den Chunk wie im Client oben speichern
});
eventSource.addEventListener('snapshot:complete', (e) => {
const { mode } = JSON.parse(e.data); // "resume", "full_resync" oder fehlt bei einer ersten Verbindung
// Ein vollständiger Resync kann ganz ohne Snapshot-Chunks kommen, wenn nichts mehr zu Ihren Filtern passt
if (mode === 'full_resync' && resuming) clearState();
resuming = false;
isReady = true;
});Häufige Fallstricke
Dies sind die häufigsten Fehler beim Erstellen eines SSE-Clients. Wenn Sie einen davon falsch machen, kann dies zu Phantom-Arbitrage oder falschen EV-Berechnungen führen.
1. odds:update als vollständigen Snapshot behandeln
odds:update-Ereignisse enthalten nur Quoten, die sich seit dem letzten Ereignis geändert haben. Wenn Sie Ihren gesamten lokalen Zustand mit jedem Update ersetzen, sehen Sie nur 1-2 Sportsbooks gleichzeitig — wodurch jeder Markt wie eine Arbitrage-Gelegenheit aussieht.
Lösung: Updates immer in Ihre Map einfügen, niemals ersetzen.
2. odds:removed-Ereignisse ignorieren
Wenn ein Sportsbook eine Linie zurückzieht (Markt ausgesetzt, Ereignis abgewickelt), senden wir odds:removed mit den zu löschenden IDs. Wenn Sie dies nicht behandeln, sammeln sich veraltete Quoten an und erzeugen Phantom-Arbs zwischen entfernten und frischen Linien.
Lösung: Quoten aus Ihrer Map löschen, wenn Sie odds:removed empfangen.
3. Berechnungen vor snapshot:complete
Der initiale Snapshot wird in mehreren snapshot-Ereignissen aufgeteilt. Wenn Sie während des Snapshot-Ladens mit der Berechnung von Arbs oder EV beginnen, haben Sie ein unvollständiges Bild der verfügbaren Märkte.
Lösung: Setzen Sie ein Flag bei snapshot:complete und beginnen Sie erst danach mit Berechnungen.
4. Zustand bei Wiederverbindung nicht löschen
Wenn eine Wiederverbindung mit einem vollständigen Snapshot endet und Sie Ihren lokalen Zustand nicht gelöscht haben, bleiben Zeilen aus der vorherigen Sitzung unter die neuen Daten gemischt, auch Linien, die während der Unterbrechung entfernt wurden. Bei jeder Wiederverbindung zu löschen ist ebenfalls falsch: Ein Resume sendet keinen Snapshot, danach hätten Sie nur noch die nachgespielten Zeilen.
Lösung: Alle Maps löschen, wenn connected ohne resumed: true eintrifft, und wenn nach resumed: true ein snapshot-Chunk eintrifft. Nicht reconnected verwenden, das auch bei einem Resume true ist. Siehe Wiederverbindung.
5. Quotenformat falsch interpretieren
Wenn Sie amerikanische Quoten (-110) als Dezimalquoten behandeln, liefern Ihre Berechnungen völlig falsche Ergebnisse. Unsere API stellt immer beide Formate bereit — verwenden Sie odds_decimal für Berechnungen.
6. Veraltungswarnungen ignorieren
EV- und Arbitrage-Gelegenheiten enthalten die Felder possibly_stale und oldest_odds_age_seconds. Als veraltet markierte Gelegenheiten können auf Quoten basieren, die mehrere Minuten alt und nicht mehr handelbar sind.
Lösung: Prüfen Sie possibly_stale, bevor Sie auf eine Gelegenheit reagieren.