Add backtest pipeline, betting_brain filters, score coherence + social v3
betting_brain.py: - HARD_MIN_SAMPLES=50 floor for calibrator bypass - ev_edge < 0 + >= 0.20 hard vetoes - BTTS muted (grid search found no profitable config) - Per-market optimal envelopes (MS, OU25) - Score coherence filter: main_pick must agree with score prediction - HTFT reversal cross-check for MS picks feature_builder.py / data_loader.py: - Real home/away_position from data (was hardcoded 10) - Cup detection wired into UpsetEngine - _estimate_league_position with 300-day season filter New scripts: - diagnostic_backtest.py: per-bet diagnostic backtest with loss patterns - optimize_filters.py: grid search per-market optimal thresholds - analyze_backtest_csv.py: root-cause hypothesis testing on CSV - compare_backtests.py: side-by-side validation with verdict - test_score_coherence.py: smoke test for coherence filter (20/20 pass) Reports: - diagnostic_backtest_20260525_024437 (50-match smoke) - diagnostic_backtest_20260525_035649 (1000-match in-sample) - filter_optimization_patch.json (grid search winners per market) Social poster v3: - satori + resvg HTML/CSS rendering pipeline - Twemoji football/basketball + flag SVGs - caption SEO: 12 curated hashtags per post - image SEO: descriptive filenames + .json metadata sidecar - /health, /preview-png, /run-now endpoints Docs: - mds/SESSION_HANDOFF.md: full session state for cross-machine continuity - mds/SOCIAL_POSTER_SETUP.md: API keys + test commands Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* V2 social card renderer — satori (HTML/CSS → SVG) → resvg (SVG → PNG).
|
||||
* Modern editorial design. Uses React.createElement (h) form for satori's
|
||||
* strict layout requirements (every multi-child node needs explicit display).
|
||||
*/
|
||||
|
||||
import satori from "satori";
|
||||
import { Resvg } from "@resvg/resvg-js";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "public", "predictions");
|
||||
const ARIAL = fs.readFileSync("C:/Windows/Fonts/arial.ttf");
|
||||
const ARIAL_BOLD = fs.readFileSync("C:/Windows/Fonts/arialbd.ttf");
|
||||
const ARIAL_BLACK = fs.existsSync("C:/Windows/Fonts/ariblk.ttf")
|
||||
? fs.readFileSync("C:/Windows/Fonts/ariblk.ttf")
|
||||
: ARIAL_BOLD;
|
||||
|
||||
interface Pick { market: string; pick: string; confidence: number; odds: number; }
|
||||
interface Card {
|
||||
sport: "football" | "basketball";
|
||||
leagueName: string;
|
||||
countryName: string;
|
||||
matchDate: string;
|
||||
homeTeam: string;
|
||||
awayTeam: string;
|
||||
htScore: string;
|
||||
ftScore: string;
|
||||
scoreConfidence: number;
|
||||
picks: Pick[];
|
||||
}
|
||||
|
||||
// satori expects React-element shape: { type, props: { children, style, ... } }
|
||||
type N = string | { type: string; props: { style?: any; children?: any } };
|
||||
const h = (type: string, style: any, ...children: N[]): N => ({
|
||||
type,
|
||||
props: { style, children: children.length === 1 ? children[0] : children },
|
||||
});
|
||||
|
||||
function buildCard(c: Card): N {
|
||||
const isBasket = c.sport === "basketball";
|
||||
const accent = isBasket ? "#F59E0B" : "#22C55E";
|
||||
const accentDark = isBasket ? "#92400E" : "#14532D";
|
||||
const away = "#3B82F6";
|
||||
const awayDark = "#1E3A8A";
|
||||
const sportLabel = isBasket ? "BASKETBOL" : "FUTBOL";
|
||||
const halfLabel = isBasket ? "İLK DEVRE" : "İLK YARI";
|
||||
|
||||
const avatar = (name: string, c1: string, c2: string) =>
|
||||
h("div", {
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
width: 200, height: 200, borderRadius: 200,
|
||||
background: `linear-gradient(135deg, ${c1} 0%, ${c2} 100%)`,
|
||||
fontSize: 120, fontWeight: 900, color: "white",
|
||||
boxShadow: "0 25px 60px rgba(0,0,0,0.55), inset 0 -8px 0 rgba(0,0,0,0.22)",
|
||||
}, (name.match(/\p{L}/u)?.[0] || "?").toUpperCase());
|
||||
|
||||
const teamColumn = (name: string, c1: string, c2: string) =>
|
||||
h("div", {
|
||||
display: "flex", flexDirection: "column", alignItems: "center", width: 300,
|
||||
},
|
||||
avatar(name, c1, c2),
|
||||
h("div", {
|
||||
display: "flex", marginTop: 22, fontSize: 32, fontWeight: 900,
|
||||
color: "white", textAlign: "center", lineHeight: 1.05,
|
||||
maxWidth: 300, justifyContent: "center",
|
||||
}, name),
|
||||
);
|
||||
|
||||
const scoreBlock = h("div", {
|
||||
display: "flex", flexDirection: "column", alignItems: "center",
|
||||
},
|
||||
h("div", {
|
||||
display: "flex", fontSize: 14, color: "rgba(255,255,255,0.4)",
|
||||
letterSpacing: 4, fontWeight: 700, marginBottom: 8,
|
||||
}, "MAÇ SONU"),
|
||||
h("div", {
|
||||
display: "flex", fontSize: 110, fontWeight: 900, color: "white",
|
||||
lineHeight: 1, letterSpacing: -4,
|
||||
}, c.ftScore),
|
||||
h("div", {
|
||||
display: "flex", marginTop: 16, fontSize: 18, color: accent,
|
||||
fontWeight: 800, background: `${accent}22`, padding: "8px 16px",
|
||||
borderRadius: 10, letterSpacing: 1,
|
||||
}, `${halfLabel} ${c.htScore}`),
|
||||
);
|
||||
|
||||
const pickRow = (p: Pick, idx: number) =>
|
||||
h("div", {
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
padding: "20px 26px", marginBottom: 12,
|
||||
background: "rgba(255,255,255,0.045)",
|
||||
borderLeft: `5px solid ${accent}`,
|
||||
borderRadius: 12,
|
||||
},
|
||||
h("div", { display: "flex", alignItems: "center", flexGrow: 1 },
|
||||
h("div", {
|
||||
display: "flex", justifyContent: "center", alignItems: "center",
|
||||
width: 44, height: 44, marginRight: 18, borderRadius: 8,
|
||||
background: `${accent}22`, color: accent, fontSize: 24, fontWeight: 900,
|
||||
}, String(idx + 1)),
|
||||
h("div", { display: "flex", flexDirection: "column" },
|
||||
h("div", { display: "flex", fontSize: 28, fontWeight: 800, color: "white", lineHeight: 1.1 }, p.market),
|
||||
h("div", { display: "flex", marginTop: 4, fontSize: 18, color: "rgba(255,255,255,0.5)" }, `Oran ${p.odds.toFixed(2)}`),
|
||||
),
|
||||
),
|
||||
h("div", {
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontSize: 38, fontWeight: 900, color: accent,
|
||||
background: `${accent}15`,
|
||||
padding: "6px 18px", borderRadius: 10,
|
||||
border: `1px solid ${accent}`,
|
||||
}, `%${p.confidence}`),
|
||||
);
|
||||
|
||||
return h("div", {
|
||||
width: 1080, height: 1080,
|
||||
display: "flex", flexDirection: "column",
|
||||
padding: "50px 60px",
|
||||
background: "linear-gradient(180deg, #0F172A 0%, #020617 100%)",
|
||||
color: "white",
|
||||
fontFamily: "Arial",
|
||||
},
|
||||
// HEADER
|
||||
h("div", {
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
marginBottom: 36,
|
||||
},
|
||||
h("div", { display: "flex", alignItems: "center" },
|
||||
h("div", {
|
||||
display: "flex", width: 8, height: 44, background: accent,
|
||||
marginRight: 16, borderRadius: 4,
|
||||
}),
|
||||
h("div", { display: "flex", flexDirection: "column" },
|
||||
h("div", {
|
||||
display: "flex", fontSize: 14, color: "rgba(255,255,255,0.5)",
|
||||
letterSpacing: 3, fontWeight: 700,
|
||||
}, `${sportLabel} · ${c.countryName.toUpperCase()}`),
|
||||
h("div", {
|
||||
display: "flex", marginTop: 2, fontSize: 26, fontWeight: 900, color: "white",
|
||||
}, c.leagueName),
|
||||
),
|
||||
),
|
||||
h("div", {
|
||||
display: "flex", fontSize: 16, color: "rgba(255,255,255,0.6)",
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
padding: "10px 16px", borderRadius: 10,
|
||||
border: "1px solid rgba(255,255,255,0.1)", fontWeight: 600,
|
||||
}, c.matchDate),
|
||||
),
|
||||
|
||||
// TEAMS + SCORE
|
||||
h("div", {
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
marginBottom: 30, paddingLeft: 10, paddingRight: 10,
|
||||
},
|
||||
teamColumn(c.homeTeam, accent, accentDark),
|
||||
scoreBlock,
|
||||
teamColumn(c.awayTeam, away, awayDark),
|
||||
),
|
||||
|
||||
// PICKS TITLE
|
||||
h("div", {
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
marginBottom: 16, paddingLeft: 6, paddingRight: 6,
|
||||
},
|
||||
h("div", { display: "flex", fontSize: 22, fontWeight: 900, color: "white", letterSpacing: 2 }, "EN İYİ 3 TAHMİN"),
|
||||
h("div", { display: "flex", alignItems: "center" },
|
||||
h("div", { display: "flex", fontSize: 14, color: "rgba(255,255,255,0.55)", fontWeight: 700, marginRight: 8 }, "SKOR GÜVENİ"),
|
||||
h("div", { display: "flex", fontSize: 22, fontWeight: 900, color: accent }, `%${c.scoreConfidence}`),
|
||||
),
|
||||
),
|
||||
|
||||
// PICKS LIST
|
||||
h("div", { display: "flex", flexDirection: "column" },
|
||||
...c.picks.slice(0, 3).map((p, i) => pickRow(p, i)),
|
||||
),
|
||||
|
||||
// FOOTER
|
||||
h("div", {
|
||||
display: "flex", marginTop: "auto", alignItems: "center", justifyContent: "space-between",
|
||||
paddingTop: 22, borderTop: "1px solid rgba(255,255,255,0.08)",
|
||||
},
|
||||
h("div", { display: "flex", fontSize: 16, color: "rgba(255,255,255,0.45)", fontWeight: 600 }, "AI Destekli Maç Tahmini · iddaai"),
|
||||
h("div", { display: "flex", fontSize: 20, color: "white", fontWeight: 900, letterSpacing: 2 }, "iddaai.com"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function renderCard(c: Card, name: string) {
|
||||
const svg = await satori(buildCard(c) as any, {
|
||||
width: 1080,
|
||||
height: 1080,
|
||||
fonts: [
|
||||
{ name: "Arial", data: ARIAL, weight: 400, style: "normal" },
|
||||
{ name: "Arial", data: ARIAL_BOLD, weight: 700, style: "normal" },
|
||||
{ name: "Arial", data: ARIAL_BLACK, weight: 900, style: "normal" },
|
||||
],
|
||||
});
|
||||
const png = new Resvg(svg).render().asPng();
|
||||
const out = path.join(OUTPUT_DIR, `v2-${name}.png`);
|
||||
fs.writeFileSync(out, png);
|
||||
console.log(` → ${out}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
|
||||
await renderCard({
|
||||
sport: "basketball",
|
||||
leagueName: "Şampiyonlar Ligi",
|
||||
countryName: "Avrupa",
|
||||
matchDate: "25 May 2026, 21:00",
|
||||
homeTeam: "Unicaja Malaga",
|
||||
awayTeam: "AEK",
|
||||
htScore: "40-35",
|
||||
ftScore: "88-75",
|
||||
scoreConfidence: 90,
|
||||
picks: [
|
||||
{ market: "Toplam Sayı Üst 160.5", pick: "Üst", confidence: 85, odds: 1.85 },
|
||||
{ market: "Ev Sahibi Toplam Üst 82.5", pick: "Üst", confidence: 83, odds: 1.78 },
|
||||
{ market: "Handikap Ev Sahibi -5.5", pick: "1", confidence: 78, odds: 1.92 },
|
||||
],
|
||||
}, "basket-unicaja-aek");
|
||||
|
||||
await renderCard({
|
||||
sport: "football",
|
||||
leagueName: "Süper Lig",
|
||||
countryName: "Türkiye",
|
||||
matchDate: "26 May 2026, 20:00",
|
||||
homeTeam: "Galatasaray",
|
||||
awayTeam: "Fenerbahçe",
|
||||
htScore: "1-0",
|
||||
ftScore: "2-1",
|
||||
scoreConfidence: 72,
|
||||
picks: [
|
||||
{ market: "Maç Sonucu - Ev Sahibi", pick: "1", confidence: 68, odds: 2.15 },
|
||||
{ market: "Üst 2.5 Gol", pick: "Üst", confidence: 61, odds: 1.92 },
|
||||
{ market: "Karşılıklı Gol", pick: "Var", confidence: 58, odds: 1.75 },
|
||||
],
|
||||
}, "football-gs-fb");
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
Reference in New Issue
Block a user